Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e32dd8ada | ||
|
|
803671de14 | ||
|
|
ebea84d077 | ||
|
|
8fdea07fbd | ||
|
|
a993908ebc | ||
|
|
59874ca749 | ||
|
|
1c88fe55cb | ||
|
|
9e0450b15b | ||
|
|
494efe0493 | ||
|
|
88f53948b2 | ||
|
|
478c5a5e7f | ||
|
|
9a55fd75fa | ||
|
|
ea11e3e348 | ||
|
|
c6acfdfa40 | ||
|
|
6640170aee | ||
|
|
825fa6d854 | ||
|
|
8263506713 | ||
|
|
48ec492209 | ||
|
|
f87065f58f | ||
|
|
abd0d39aeb | ||
|
|
e974d7ef60 | ||
|
|
6b4ffd8e61 | ||
|
|
155823fe69 | ||
|
|
a6077a4ce2 | ||
|
|
645cc38e55 | ||
|
|
0c7e2e49f5 | ||
|
|
6ace244401 | ||
|
|
c1716a15ae | ||
|
|
52e0037a75 | ||
|
|
e01732ecd2 | ||
|
|
8eecab5a00 | ||
|
|
3db69def36 | ||
|
|
0a43d8ab56 | ||
|
|
acd30f345f | ||
|
|
4f7ba383e6 | ||
|
|
9a8546ef09 | ||
|
|
b090354d50 | ||
|
|
bbc535d39e | ||
|
|
85a1dfe4be | ||
|
|
0e49ccef8e | ||
|
|
e8059ca6fd | ||
|
|
5ae6e60db9 |
24
CHANGELOG.md
24
CHANGELOG.md
@ -1,5 +1,27 @@
|
||||
### Next
|
||||
> Changes here are not considered stable and are only in pre-releases.
|
||||
|
||||
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
|
||||
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
|
||||
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
|
||||
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
|
||||
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
|
||||
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
|
||||
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
|
||||
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
||||
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
||||
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
||||
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
|
||||
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
|
||||
- Added validation for machine renaming to prevent invalid submissions (closes [#192](https://github.com/tale/headplane/issues/192)).
|
||||
- Unmanaged (non-OIDC) users cannot have a role assigned to them so the menu option was disabled.
|
||||
- Support Docker container discovery through labels (via [#194](https://github.com/tale/headplane/pull/194)).
|
||||
- AAAA records are now supported on the DNS page (closes [#189](https://github.com/tale/headplane/issues/189)).
|
||||
- Add support for `dns.extra_records_path` in the Headscale config (closes [#144](https://github.com/tale/headplane/issues/144)).
|
||||
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
|
||||
|
||||
### 0.5.10 (April 4, 2025)
|
||||
- Fix an issue where other prefernences to skip onboarding affected every user.
|
||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||
|
||||
### 0.5.9 (April 3, 2025)
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@ -1,3 +1,15 @@
|
||||
FROM golang:1.24 AS agent-build
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY agent/ ./agent
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o /app/hp_agent ./agent/cmd/hp_agent
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
@ -11,8 +23,13 @@ COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN mkdir -p /var/lib/headplane
|
||||
RUN mkdir -p /usr/libexec/headplane
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent
|
||||
RUN chmod +x /usr/libexec/headplane/agent
|
||||
CMD [ "node", "./build/server/index.js" ]
|
||||
|
||||
@ -2,39 +2,34 @@ package main
|
||||
|
||||
import (
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/tale/headplane/agent/config"
|
||||
"github.com/tale/headplane/agent/tsnet"
|
||||
"github.com/tale/headplane/agent/hpagent"
|
||||
"log"
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/hpagent"
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
type Register struct {
|
||||
Type string
|
||||
ID string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := util.GetLogger()
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %s", err)
|
||||
log.Fatal("Failed to load config: %s", err)
|
||||
}
|
||||
|
||||
agent := tsnet.NewAgent(
|
||||
cfg.Hostname,
|
||||
cfg.TSControlURL,
|
||||
cfg.TSAuthKey,
|
||||
cfg.Debug,
|
||||
)
|
||||
log.SetDebug(cfg.Debug)
|
||||
agent := tsnet.NewAgent(cfg)
|
||||
|
||||
agent.StartAndFetchID()
|
||||
agent.Connect()
|
||||
defer agent.Shutdown()
|
||||
|
||||
ws, err := hpagent.NewSocket(
|
||||
agent,
|
||||
cfg.HPControlURL,
|
||||
cfg.HPAuthKey,
|
||||
cfg.Debug,
|
||||
)
|
||||
log.Msg(&Register{
|
||||
Type: "register",
|
||||
ID: agent.ID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create websocket: %s", err)
|
||||
}
|
||||
|
||||
defer ws.StopListening()
|
||||
ws.StartListening()
|
||||
hpagent.FollowMaster(agent)
|
||||
}
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Represents messages from the Headplane master
|
||||
type RecvMessage struct {
|
||||
NodeIDs []string `json:omitempty`
|
||||
}
|
||||
|
||||
// Starts listening for messages from the Headplane master
|
||||
func (s *Socket) StartListening() {
|
||||
for {
|
||||
_, message, err := s.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("error reading message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var msg RecvMessage
|
||||
err = json.Unmarshal(message, &msg)
|
||||
if err != nil {
|
||||
log.Printf("error unmarshalling message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
log.Printf("got message: %s", message)
|
||||
}
|
||||
|
||||
if len(msg.NodeIDs) == 0 {
|
||||
log.Printf("got a message with no node IDs? %s", message)
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate the results since we invoke via gofunc
|
||||
results := make(map[string]*tailcfg.HostinfoView)
|
||||
mu := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
for _, nodeID := range msg.NodeIDs {
|
||||
wg.Add(1)
|
||||
go func(nodeID string) {
|
||||
defer wg.Done()
|
||||
result, err := s.Agent.GetStatusForPeer(nodeID)
|
||||
if err != nil {
|
||||
log.Printf("error getting status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
results[nodeID] = result
|
||||
mu.Unlock()
|
||||
}(nodeID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Send the results back to the Headplane master
|
||||
err = s.SendStatus(results)
|
||||
if err != nil {
|
||||
log.Printf("error sending status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
log.Printf("sent status: %s", results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stops listening for messages from the Headplane master
|
||||
func (s *Socket) StopListening() {
|
||||
s.Close()
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Sends the status to the Headplane master
|
||||
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
|
||||
err := s.WriteJSON(status)
|
||||
return err
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/tale/headplane/agent/tsnet"
|
||||
)
|
||||
|
||||
type Socket struct {
|
||||
*websocket.Conn
|
||||
Debug bool
|
||||
Agent *tsnet.TSAgent
|
||||
}
|
||||
|
||||
// Creates a new websocket connection to the Headplane server.
|
||||
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
|
||||
wsURL, err := httpToWs(controlURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
|
||||
|
||||
auth := fmt.Sprintf("Bearer %s", authKey)
|
||||
headers.Add("Authorization", auth)
|
||||
|
||||
log.Printf("dialing websocket at %s", wsURL)
|
||||
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Socket{ws, debug, agent}, nil
|
||||
}
|
||||
|
||||
// We need to convert the control URL to a websocket URL
|
||||
func httpToWs(controlURL string) (string, error) {
|
||||
u, err := url.Parse(controlURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if u.Scheme == "http" {
|
||||
u.Scheme = "ws"
|
||||
} else if u.Scheme == "https" {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
|
||||
}
|
||||
|
||||
// We also need to append /_dial to the path
|
||||
if u.Path[len(u.Path)-1] != '/' {
|
||||
u.Path += "/"
|
||||
}
|
||||
|
||||
u.Path += "_dial"
|
||||
return u.String(), nil
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
import "os"
|
||||
|
||||
// Config represents the configuration for the agent.
|
||||
type Config struct {
|
||||
@ -12,8 +8,7 @@ type Config struct {
|
||||
Hostname string
|
||||
TSControlURL string
|
||||
TSAuthKey string
|
||||
HPControlURL string
|
||||
HPAuthKey string
|
||||
WorkDir string
|
||||
}
|
||||
|
||||
const (
|
||||
@ -21,8 +16,7 @@ const (
|
||||
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
||||
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
||||
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
||||
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
|
||||
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
|
||||
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
|
||||
)
|
||||
|
||||
// Load reads the agent configuration from environment variables.
|
||||
@ -32,8 +26,7 @@ func Load() (*Config, error) {
|
||||
Hostname: os.Getenv(HostnameEnv),
|
||||
TSControlURL: os.Getenv(TSControlURLEnv),
|
||||
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
||||
HPControlURL: os.Getenv(HPControlURLEnv),
|
||||
HPAuthKey: os.Getenv(HPAuthKeyEnv),
|
||||
WorkDir: os.Getenv(WorkDirEnv),
|
||||
}
|
||||
|
||||
if os.Getenv(DebugEnv) == "true" {
|
||||
@ -48,9 +41,5 @@ func Load() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateHPReady(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
@ -16,16 +16,12 @@ func validateRequired(config *Config) error {
|
||||
return fmt.Errorf("%s is required", TSControlURLEnv)
|
||||
}
|
||||
|
||||
if config.HPControlURL == "" {
|
||||
return fmt.Errorf("%s is required", HPControlURLEnv)
|
||||
}
|
||||
|
||||
if config.TSAuthKey == "" {
|
||||
return fmt.Errorf("%s is required", TSAuthKeyEnv)
|
||||
}
|
||||
|
||||
if config.HPAuthKey == "" {
|
||||
return fmt.Errorf("%s is required", HPAuthKeyEnv)
|
||||
if config.WorkDir == "" {
|
||||
return fmt.Errorf("%s is required", WorkDirEnv)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -38,8 +34,7 @@ func validateTSReady(config *Config) error {
|
||||
testURL = testURL[:len(testURL)-1]
|
||||
}
|
||||
|
||||
// TODO: Consequences of switching to /health (headscale only)
|
||||
testURL = fmt.Sprintf("%s/key?v=109", testURL)
|
||||
testURL = fmt.Sprintf("%s/health", testURL)
|
||||
resp, err := http.Get(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect to TS control server: %s", err)
|
||||
@ -51,23 +46,3 @@ func validateTSReady(config *Config) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pings the Headplane server to make sure it's up and running
|
||||
func validateHPReady(config *Config) error {
|
||||
testURL := config.HPControlURL
|
||||
if strings.HasSuffix(testURL, "/") {
|
||||
testURL = testURL[:len(testURL)-1]
|
||||
}
|
||||
|
||||
testURL = fmt.Sprintf("%s/healthz", testURL)
|
||||
resp, err := http.Get(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect to HP control server: %s", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
87
agent/internal/hpagent/handler.go
Normal file
87
agent/internal/hpagent/handler.go
Normal file
@ -0,0 +1,87 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Represents messages from the Headplane master
|
||||
type RecvMessage struct {
|
||||
NodeIDs []string
|
||||
}
|
||||
|
||||
type SendMessage struct {
|
||||
Type string
|
||||
Data any
|
||||
}
|
||||
|
||||
// Starts listening for messages from stdin
|
||||
func FollowMaster(agent *tsnet.TSAgent) {
|
||||
log := util.GetLogger()
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
var msg RecvMessage
|
||||
err := json.Unmarshal(line, &msg)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal message: %s", err)
|
||||
log.Debug("Full Error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Recieved message from master: %v", line)
|
||||
|
||||
if len(msg.NodeIDs) == 0 {
|
||||
log.Debug("Message recieved had no node IDs")
|
||||
log.Debug("Full message: %s", line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate the results since we invoke via gofunc
|
||||
results := make(map[string]*tailcfg.HostinfoView)
|
||||
mu := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
for _, nodeID := range msg.NodeIDs {
|
||||
wg.Add(1)
|
||||
go func(nodeID string) {
|
||||
defer wg.Done()
|
||||
result, err := agent.GetStatusForPeer(nodeID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get status for node %s: %s", nodeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
log.Debug("No status for node %s", nodeID)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
results[nodeID] = result
|
||||
mu.Unlock()
|
||||
}(nodeID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Send the results back to the Headplane master
|
||||
log.Debug("Sending status back to master: %v", results)
|
||||
log.Msg(&SendMessage{
|
||||
Type: "status",
|
||||
Data: results,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatal("Error reading from stdin: %s", err)
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,11 @@ package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
|
||||
@ -13,26 +15,41 @@ import (
|
||||
|
||||
// Returns the raw hostinfo for a peer based on node ID.
|
||||
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if !strings.HasPrefix(id, "nodekey:") {
|
||||
log.Debug("Node ID with missing prefix: %s", id)
|
||||
return nil, fmt.Errorf("invalid node ID: %s", id)
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
log.Printf("querying peer state for %s", id)
|
||||
}
|
||||
|
||||
log.Debug("Querying status of peer: %s", id)
|
||||
status, err := s.Lc.Status(context.Background())
|
||||
if err != nil {
|
||||
log.Debug("Failed to get status: %s", err)
|
||||
return nil, fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
|
||||
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
|
||||
// We need to convert from 64 char hex to 32 byte raw.
|
||||
bytes, err := hex.DecodeString(id[8:])
|
||||
if err != nil {
|
||||
log.Debug("Failed to decode hex: %s", err)
|
||||
return nil, fmt.Errorf("failed to decode hex: %w", err)
|
||||
}
|
||||
|
||||
raw := mem.B(bytes)
|
||||
if raw.Len() != 32 {
|
||||
log.Debug("Invalid node ID length: %d", raw.Len())
|
||||
return nil, fmt.Errorf("invalid node ID length: %d", raw.Len())
|
||||
}
|
||||
|
||||
nodeKey := key.NodePublicFromRaw32(raw)
|
||||
peer := status.Peer[nodeKey]
|
||||
if peer == nil {
|
||||
// Check if we are on Self.
|
||||
if status.Self.PublicKey == nodeKey {
|
||||
peer = status.Self
|
||||
} else {
|
||||
log.Debug("Peer not found in status: %s", id)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@ -40,8 +57,10 @@ func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
|
||||
ip := peer.TailscaleIPs[0].String()
|
||||
whois, err := s.Lc.WhoIs(context.Background(), ip)
|
||||
if err != nil {
|
||||
log.Debug("Failed to get whois: %s", err)
|
||||
return nil, fmt.Errorf("failed to get whois: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Got whois for peer %s: %v", id, whois)
|
||||
return &whois.Node.Hostinfo, nil
|
||||
}
|
||||
77
agent/internal/tsnet/server.go
Normal file
77
agent/internal/tsnet/server.go
Normal file
@ -0,0 +1,77 @@
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
// Wrapper type so we can add methods to the server.
|
||||
type TSAgent struct {
|
||||
*tsnet.Server
|
||||
Lc *tailscale.LocalClient
|
||||
ID string
|
||||
}
|
||||
|
||||
// Creates a new tsnet agent and returns an instance of the server.
|
||||
func NewAgent(cfg *config.Config) *TSAgent {
|
||||
log := util.GetLogger()
|
||||
|
||||
dir, err := filepath.Abs(cfg.WorkDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get absolute path: %s", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Fatal("Cannot create agent work directory: %s", err)
|
||||
}
|
||||
|
||||
server := &tsnet.Server{
|
||||
Dir: dir,
|
||||
Hostname: cfg.Hostname,
|
||||
ControlURL: cfg.TSControlURL,
|
||||
AuthKey: cfg.TSAuthKey,
|
||||
Logf: func(string, ...any) {}, // Disabled by default
|
||||
UserLogf: log.Info,
|
||||
}
|
||||
|
||||
if cfg.Debug {
|
||||
server.Logf = log.Debug
|
||||
}
|
||||
|
||||
return &TSAgent{server, nil, ""}
|
||||
}
|
||||
|
||||
// Starts the tsnet agent and sets the node ID.
|
||||
func (s *TSAgent) Connect() {
|
||||
log := util.GetLogger()
|
||||
|
||||
// Waits until the agent is up and running.
|
||||
status, err := s.Up(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to Tailnet: %s", err)
|
||||
}
|
||||
|
||||
s.Lc, err = s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize local Tailscale client: %s", err)
|
||||
}
|
||||
|
||||
id, err := status.Self.PublicKey.MarshalText()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to marshal public key: %s", err)
|
||||
}
|
||||
|
||||
log.Info("Connected to Tailnet (PublicKey: %s)", status.Self.PublicKey)
|
||||
s.ID = string(id)
|
||||
}
|
||||
|
||||
// Shuts down the tsnet agent.
|
||||
func (s *TSAgent) Shutdown() {
|
||||
s.Close()
|
||||
}
|
||||
117
agent/internal/util/logger.go
Normal file
117
agent/internal/util/logger.go
Normal file
@ -0,0 +1,117 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LevelInfo LogLevel = "info"
|
||||
LevelDebug LogLevel = "debug"
|
||||
LevelError LogLevel = "error"
|
||||
LevelFatal LogLevel = "fatal"
|
||||
LevelMsg LogLevel = "msg"
|
||||
)
|
||||
|
||||
type LogMessage struct {
|
||||
Level LogLevel
|
||||
Time string
|
||||
Message any
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
debugEnabled bool
|
||||
encoder *json.Encoder
|
||||
pool *sync.Pool
|
||||
}
|
||||
|
||||
var logger = NewLogger()
|
||||
|
||||
func GetLogger() *Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
func NewLogger() *Logger {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
return &Logger{
|
||||
encoder: enc,
|
||||
pool: &sync.Pool{
|
||||
New: func() any {
|
||||
return &LogMessage{}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) SetDebug(enabled bool) {
|
||||
if enabled {
|
||||
l.debugEnabled = true
|
||||
l.Info("Enabling Debug logging for headplane-agent")
|
||||
l.Info("Be careful, this will spam a lot of information")
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level LogLevel, format string, v ...any) {
|
||||
msg := fmt.Sprintf(format, v...)
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
|
||||
// Manually construct compact JSON line for performance
|
||||
line := `{"Level":"` + string(level) +
|
||||
`","Time":"` + timestamp +
|
||||
`","Message":"` + escapeString(msg) + `"}` + "\n"
|
||||
|
||||
if level == LevelError || level == LevelFatal {
|
||||
os.Stderr.WriteString(line)
|
||||
}
|
||||
|
||||
// Always write to stdout but also write to stderr for errors
|
||||
os.Stdout.WriteString(line)
|
||||
if level == LevelFatal {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(format string, v ...any) {
|
||||
if l.debugEnabled {
|
||||
l.log(LevelDebug, format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
|
||||
func (l *Logger) Error(format string, v ...any) { l.log(LevelError, format, v...) }
|
||||
func (l *Logger) Fatal(format string, v ...any) { l.log(LevelFatal, format, v...) }
|
||||
|
||||
func (l *Logger) Msg(obj any) {
|
||||
entry := l.pool.Get().(*LogMessage)
|
||||
defer l.pool.Put(entry)
|
||||
|
||||
entry.Level = LevelMsg
|
||||
entry.Time = time.Now().Format(time.RFC3339)
|
||||
entry.Message = obj
|
||||
|
||||
// Because the encoder is tied to STDOUT we get a message
|
||||
_ = l.encoder.Encode(entry)
|
||||
|
||||
// Reset the entry for reuse
|
||||
entry.Level = ""
|
||||
entry.Time = ""
|
||||
entry.Message = nil
|
||||
}
|
||||
|
||||
func escapeString(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, `\"`,
|
||||
`\`, `\\`,
|
||||
"\n", `\n`,
|
||||
"\t", `\t`,
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
// Wrapper type so we can add methods to the server.
|
||||
type TSAgent struct {
|
||||
*tsnet.Server
|
||||
Lc *tailscale.LocalClient
|
||||
ID string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// Creates a new tsnet agent and returns an instance of the server.
|
||||
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
|
||||
s := &tsnet.Server{
|
||||
Hostname: hostname,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
Logf: func(string, ...interface{}) {}, // Disabled by default
|
||||
}
|
||||
|
||||
if debug {
|
||||
s.Logf = log.New(
|
||||
os.Stderr,
|
||||
fmt.Sprintf("[DBG:%s] ", hostname),
|
||||
log.LstdFlags,
|
||||
).Printf
|
||||
}
|
||||
|
||||
return &TSAgent{s, nil, "", debug}
|
||||
}
|
||||
|
||||
// Starts the tsnet agent and sets the node ID.
|
||||
func (s *TSAgent) StartAndFetchID() {
|
||||
// Waits until the agent is up and running.
|
||||
status, err := s.Up(context.Background())
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start agent: %v", err)
|
||||
}
|
||||
|
||||
s.Lc, err = s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create local client: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
|
||||
s.ID = string(status.Self.ID)
|
||||
}
|
||||
|
||||
// Shuts down the tsnet agent.
|
||||
func (s *TSAgent) Shutdown() {
|
||||
s.Close()
|
||||
}
|
||||
@ -1,46 +1,54 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Check, Copy, Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
isCopyable?: boolean;
|
||||
link?: string;
|
||||
suppressHydrationWarning?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({
|
||||
name,
|
||||
value,
|
||||
link,
|
||||
tooltip,
|
||||
isCopyable,
|
||||
suppressHydrationWarning,
|
||||
}: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex items-center w-full gap-x-1">
|
||||
<dt className="font-semibold w-1/4 shrink-0 text-sm">
|
||||
{link ? (
|
||||
<a className="hover:underline" href={link}>
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
name
|
||||
<dl className="flex gap-1 items-center text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
tooltip ? 'flex items-center gap-1' : undefined,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<Info className="size-4" />
|
||||
<Tooltip.Body>{tooltip}</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
<dd
|
||||
suppressHydrationWarning={suppressHydrationWarning}
|
||||
className={cn(
|
||||
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
|
||||
'flex items-center gap-x-1',
|
||||
'focus-within:outline-none focus-within:ring-2',
|
||||
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
|
||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
||||
...(isCopyable
|
||||
? [
|
||||
'cursor-pointer hover:shadow-sm',
|
||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
||||
]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-x-1 outline-none"
|
||||
className="flex items-center gap-1.5 relative min-w-0 w-full"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
@ -48,7 +56,7 @@ export default function Attribute({
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast('Copied to clipboard');
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
@ -57,17 +65,20 @@ export default function Attribute({
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<p
|
||||
suppressHydrationWarning={suppressHydrationWarning}
|
||||
className="truncate"
|
||||
>
|
||||
<div suppressHydrationWarning className="truncate">
|
||||
{value}
|
||||
</p>
|
||||
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
||||
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
|
||||
</div>
|
||||
{isCopyable ? (
|
||||
<div>
|
||||
<Check className="size-4 hidden data-[copied]:block" />
|
||||
<Copy className="size-4 block data-[copied]:hidden" />
|
||||
</div>
|
||||
) : undefined}
|
||||
</button>
|
||||
) : (
|
||||
value
|
||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@ -17,10 +17,10 @@ export default function Chip({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
'bg-headplane-100 dark:bg-headplane-700',
|
||||
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
|
||||
'inline-flex items-center gap-x-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -8,7 +8,7 @@ interface Props {
|
||||
type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
function getMessage(error: Error | unknown): {
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
message: string;
|
||||
} {
|
||||
@ -45,10 +45,7 @@ function getMessage(error: Error | unknown): {
|
||||
|
||||
// If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
return {
|
||||
title: 'Errors',
|
||||
message: rootError.errors.map((error) => error.message).join('\n'),
|
||||
};
|
||||
throw new Error('Unhandled AggregateError');
|
||||
}
|
||||
|
||||
return {
|
||||
@ -60,7 +57,7 @@ function getMessage(error: Error | unknown): {
|
||||
export function ErrorPopup({ type = 'full' }: Props) {
|
||||
const error = useRouteError();
|
||||
const routing = isRouteErrorResponse(error);
|
||||
const { title, message } = getMessage(error);
|
||||
const { title, message } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -81,7 +78,7 @@ export function ErrorPopup({ type = 'full' }: Props) {
|
||||
<Card.Text
|
||||
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||
>
|
||||
{routing ? error.data.message : message}
|
||||
{routing ? error.data : message}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,49 +1,71 @@
|
||||
import { CircleX } from 'lucide-react';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface FooterProps {
|
||||
url: string;
|
||||
debug: boolean;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({ url, debug }: FooterProps) {
|
||||
export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 z-40 w-full h-14',
|
||||
'flex flex-col justify-center gap-1 shadow-inner',
|
||||
'bg-headplane-100 dark:bg-headplane-950',
|
||||
'text-headplane-800 dark:text-headplane-200',
|
||||
'fixed w-full bottom-0 left-0 z-40 h-12',
|
||||
'flex items-center justify-center',
|
||||
'bg-headplane-50 dark:bg-headplane-950',
|
||||
'dark:border-t dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<p className="container text-xs">
|
||||
Headplane is entirely free to use. If you find it useful, consider{' '}
|
||||
<Link
|
||||
to="https://github.com/sponsors/tale"
|
||||
name="Aarnav's GitHub Sponsors"
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-rows-1 items-center container mx-auto',
|
||||
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
|
||||
>
|
||||
donating
|
||||
</Link>{' '}
|
||||
to support development.{' '}
|
||||
</p>
|
||||
<p className="container text-xs opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' — '}
|
||||
Connecting to{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0} // Allows keyboard focus
|
||||
className={cn(
|
||||
'blur-sm hover:blur-none focus:blur-none transition',
|
||||
'focus:outline-none focus:ring-2 rounded-sm',
|
||||
)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
<p>
|
||||
Headplane is free. Please consider{' '}
|
||||
<Link
|
||||
to="https://github.com/sponsors/tale"
|
||||
name="Aarnav's GitHub Sponsors"
|
||||
>
|
||||
donating
|
||||
</Link>{' '}
|
||||
to support development.{' '}
|
||||
</p>
|
||||
<p className="opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' — '}
|
||||
Connecting to{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0} // Allows keyboard focus
|
||||
className={cn(
|
||||
'blur-sm hover:blur-none focus:blur-none transition',
|
||||
'focus:outline-none focus:ring-2 rounded-sm',
|
||||
)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
</div>
|
||||
{!healthy ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
|
||||
'bg-red-500 text-white font-semibold',
|
||||
)}
|
||||
>
|
||||
<CircleX size={16} strokeWidth={3} />
|
||||
<p className="text-nowrap">Headscale is unreachable</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -71,14 +71,14 @@ export default function Input(props: InputProps) {
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{isInvalid && (
|
||||
{isInvalid ? (
|
||||
<div
|
||||
{...errorMessageProps}
|
||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,45 @@
|
||||
import { CircleSlash2 } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleSlash2,
|
||||
LucideProps,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: 'default' | 'error' | 'warning';
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({ children }: NoticeProps) {
|
||||
export default function Notice({
|
||||
children,
|
||||
title,
|
||||
variant,
|
||||
icon,
|
||||
}: NoticeProps) {
|
||||
return (
|
||||
<Card className="flex w-full max-w-full gap-4 font-semibold">
|
||||
<CircleSlash2 />
|
||||
{children}
|
||||
<Card variant="flat" className="max-w-2xl my-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? (
|
||||
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||
) : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case 'warning':
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,7 +78,9 @@ function Select(props: SelectProps) {
|
||||
className={cn(
|
||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
props.isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="p-0.5" />
|
||||
|
||||
32
app/components/tags/ExitNode.tsx
Normal file
32
app/components/tags/ExitNode.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export interface ExitNodeTagProps {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this
|
||||
from the "Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
42
app/components/tags/Expiry.tsx
Normal file
42
app/components/tags/Expiry.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export interface ExpiryTagProps {
|
||||
variant: 'expired' | 'no-expiry';
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text={
|
||||
variant === 'expired'
|
||||
? `Expired ${formatter.format(new Date(expiry!))}`
|
||||
: 'No expiry'
|
||||
}
|
||||
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{variant === 'expired' ? (
|
||||
<>
|
||||
This machine is expired and will not be able to connect to the
|
||||
network. Re-authenticate with Tailscale on the machine to re-enable
|
||||
it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This machine has key expiry disabled and will never need to
|
||||
re-authenticate.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
20
app/components/tags/HeadplaneAgent.tsx
Normal file
20
app/components/tags/HeadplaneAgent.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export function HeadplaneAgentTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn(
|
||||
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine is running the Headplane agent, which allows it to provide
|
||||
host information in the web UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
32
app/components/tags/Subnet.tsx
Normal file
32
app/components/tags/Subnet.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export interface SubnetTagProps {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the
|
||||
"Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { ServerCrash } from 'lucide-react';
|
||||
import {
|
||||
type LoaderFunctionArgs,
|
||||
isRouteErrorResponse,
|
||||
redirect,
|
||||
useRouteError,
|
||||
} from 'react-router';
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import Card from '~/components/Card';
|
||||
import { ErrorPopup, getErrorMessage } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
@ -37,29 +44,8 @@ export async function loader({
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { healthy } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!healthy ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 right-0 z-50 w-fit h-14',
|
||||
'flex flex-col justify-center gap-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
|
||||
'border rounded-lg text-white bg-red-500',
|
||||
'border-red-600 dark:border-red-400 shadow-sm',
|
||||
)}
|
||||
>
|
||||
<XCircleFillIcon className="w-4 h-4 text-white" />
|
||||
Headscale is unreachable
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@ -116,6 +116,7 @@ export async function loader({
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
healthy: await context.client.healthcheck(),
|
||||
};
|
||||
} catch {
|
||||
// No session, so we can just return
|
||||
|
||||
@ -6,7 +6,7 @@ export default [
|
||||
route('/healthz', 'routes/util/healthz.ts'),
|
||||
|
||||
// Authentication Routes
|
||||
route('/login', 'routes/auth/login.tsx'),
|
||||
route('/login', 'routes/auth/login/page.tsx'),
|
||||
route('/logout', 'routes/auth/logout.ts'),
|
||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||
@ -28,7 +28,8 @@ export default [
|
||||
|
||||
...prefix('/settings', [
|
||||
index('routes/settings/overview.tsx'),
|
||||
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
|
||||
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'),
|
||||
route('/restrictions', 'routes/settings/restrictions/overview.tsx'),
|
||||
// route('/local-agent', 'routes/settings/local-agent.tsx'),
|
||||
]),
|
||||
]),
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
|
||||
interface NoticeViewProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function NoticeView({ children, title }: NoticeViewProps) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-2xl my-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorViewProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function ErrorView({ children }: ErrorViewProps) {
|
||||
const [title, ...rest] = children.split(':');
|
||||
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
|
||||
|
||||
return (
|
||||
<Card variant="flat" className="max-w-2xl mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
{title.trim() ?? 'Error'}
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Could not apply changes to the ACL policy:
|
||||
<br />
|
||||
<span className="font-mono">{formattedMessage}</span>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -17,7 +17,6 @@ import toast from '~/utils/toast';
|
||||
import { aclAction } from './acl-action';
|
||||
import { aclLoader } from './acl-loader';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
import { ErrorView, NoticeView } from './components/error';
|
||||
|
||||
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
|
||||
return aclLoader(request);
|
||||
@ -57,19 +56,19 @@ export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
{!access ? (
|
||||
<NoticeView title="ACL Policy restricted">
|
||||
<Notice title="ACL Policy restricted" variant="warning">
|
||||
You do not have the necessary permissions to edit the Access Control
|
||||
List policy. Please contact your administrator to request access or to
|
||||
make changes to the ACL policy.
|
||||
</NoticeView>
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<NoticeView title="Read-only ACL Policy">
|
||||
<Notice title="Read-only ACL Policy" variant="error">
|
||||
The ACL policy mode is most likely set to <Code>file</Code> in your
|
||||
Headscale configuration. This means that the ACL file cannot be edited
|
||||
through the web interface. In order to resolve this, you'll need to
|
||||
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
|
||||
configuration.
|
||||
</NoticeView>
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
||||
<p className="mb-4 max-w-prose">
|
||||
@ -91,7 +90,13 @@ export default function Page() {
|
||||
.
|
||||
</p>
|
||||
{fetcher.data?.error !== undefined ? (
|
||||
<ErrorView>{fetcher.data.error}</ErrorView>
|
||||
<Notice
|
||||
variant="error"
|
||||
title={fetcher.data.error.split(':')[0] ?? 'Error'}
|
||||
>
|
||||
{fetcher.data.error.split(':').slice(1).join(': ') ??
|
||||
'An unknown error occurred while trying to update the ACL policy.'}
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs.Item
|
||||
@ -150,7 +155,6 @@ export default function Page() {
|
||||
}
|
||||
onPress={() => {
|
||||
const formData = new FormData();
|
||||
console.log(codePolicy);
|
||||
formData.append('policy', codePolicy);
|
||||
fetcher.submit(formData, { method: 'PATCH' });
|
||||
}}
|
||||
|
||||
@ -1,186 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
useSearchParams,
|
||||
} from 'react-router';
|
||||
import { Form, useActionData, useLoaderData } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Key } from '~/types';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const state = qp.get('s');
|
||||
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
|
||||
if (context.oidc && disableApiKeyLogin) {
|
||||
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
|
||||
// Since logging out would just log back in based on the redirects
|
||||
|
||||
if (state !== 'logout') {
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
disableApiKeyLogin,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const formData = await request.formData();
|
||||
const oidcStart = formData.get('oidc-start');
|
||||
const session = await context.sessions.getOrCreate(request);
|
||||
|
||||
if (oidcStart) {
|
||||
if (!context.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
const apiKey = String(formData.get('api-key'));
|
||||
|
||||
// Test the API key
|
||||
try {
|
||||
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
|
||||
if (!key) {
|
||||
return {
|
||||
error: 'Invalid API key',
|
||||
};
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
const expiresIn = expiry.getTime() - Date.now();
|
||||
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
|
||||
|
||||
session.set('state', 'auth');
|
||||
session.set('api_key', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: key.prefix,
|
||||
email: `${expiresDays.toString()} days`,
|
||||
});
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session, {
|
||||
maxAge: expiresIn,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
error: 'Invalid API key',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// State is a one time thing, we need to remove it after it has
|
||||
// been consumed to prevent logic loops.
|
||||
if (state !== null) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
searchParams.delete('s');
|
||||
|
||||
// Replacing because it's not a navigation, just a cleanup of the URL
|
||||
// We can't use the useSearchParams method since it revalidates
|
||||
// which will trigger a full reload
|
||||
const newUrl = searchParams.toString()
|
||||
? `{${window.location.pathname}?${searchParams.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
}, [state, params]);
|
||||
|
||||
if (state === 'logout') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
|
||||
<Card.Title>You have been logged out</Card.Title>
|
||||
<Card.Text>
|
||||
You can now close this window. If you would like to log in again,
|
||||
please refresh the page.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
{!disableApiKeyLogin ? (
|
||||
<Form method="post">
|
||||
<Card.Text>
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running <Code>headscale apikeys create</Code> in your
|
||||
terminal.
|
||||
</Card.Text>
|
||||
|
||||
{actionData?.error ? (
|
||||
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
|
||||
) : undefined}
|
||||
<Input
|
||||
isRequired
|
||||
labelHidden
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
className="mt-4 mb-2"
|
||||
/>
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
{oidc ? (
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="oidc-start" value="true" />
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
variant={disableApiKeyLogin ? 'heavy' : 'light'}
|
||||
type="submit"
|
||||
>
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
app/routes/auth/login/action.ts
Normal file
106
app/routes/auth/login/action.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Key } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function loginAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const formData = await request.formData();
|
||||
const apiKey = formData.has('api_key')
|
||||
? String(formData.get('api_key'))
|
||||
: undefined;
|
||||
|
||||
if (apiKey === undefined) {
|
||||
log.warn('auth', 'Request made without API key');
|
||||
log.warn(
|
||||
'auth',
|
||||
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
|
||||
);
|
||||
throw data('Missing `api_key`', { status: 400 });
|
||||
}
|
||||
|
||||
if (apiKey.length === 0) {
|
||||
log.warn('auth', 'Request made with empty API key');
|
||||
log.warn(
|
||||
'auth',
|
||||
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
|
||||
);
|
||||
throw data('Received an empty `api_key`', { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
|
||||
if (!lookup) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API key was not found in the Headscale database',
|
||||
};
|
||||
}
|
||||
|
||||
if (lookup.expiration === null || lookup.expiration === undefined) {
|
||||
log.error('auth', 'Got an API key without an expiration');
|
||||
throw data('API key is malformed', { status: 500 });
|
||||
}
|
||||
|
||||
const expiry = new Date(lookup.expiration);
|
||||
if (expiry.getTime() < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API key has expired',
|
||||
};
|
||||
}
|
||||
|
||||
// Set the session
|
||||
const session = await context.sessions.getOrCreate(request);
|
||||
const expiresDays = Math.round(
|
||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
||||
);
|
||||
|
||||
session.set('state', 'auth');
|
||||
session.set('api_key', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
});
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session, {
|
||||
maxAge: expiry.getTime() - Date.now(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
// TODO: What in gods name is wrong with the headscale API?
|
||||
if (
|
||||
error.status === 401 ||
|
||||
error.status === 403 ||
|
||||
(error.status === 500 && error.response.trim() === 'Unauthorized')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API key is invalid (it may be incorrect or expired)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
log.error('auth', 'Error while validating API key: %s', error);
|
||||
log.debug('auth', 'Error details: %o', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Error while validating API key (see logs for details)',
|
||||
};
|
||||
}
|
||||
}
|
||||
15
app/routes/auth/login/logout.tsx
Normal file
15
app/routes/auth/login/logout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import Card from '~/components/Card';
|
||||
|
||||
export default function Logout() {
|
||||
return (
|
||||
<div className="flex w-screen h-screen items-center justify-center">
|
||||
<Card className="max-w-md m-4 sm:m-0">
|
||||
<Card.Title>You have been logged out</Card.Title>
|
||||
<Card.Text>
|
||||
You can now close this window. If you would like to log in again,
|
||||
please refresh the page.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
app/routes/auth/login/page.tsx
Normal file
134
app/routes/auth/login/page.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useSearchParams,
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import { loginAction } from './action';
|
||||
import Logout from './logout';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const state = qp.get('s') ?? undefined;
|
||||
|
||||
// OIDC config cannot be undefined if an OIDC client is set
|
||||
// Also check if we are in a logout state and skip redirect if we are
|
||||
const ssoOnly = context.config.oidc?.disable_api_key_login;
|
||||
if (state !== 'logout' && ssoOnly) {
|
||||
// This shouldn't be possible, but still a safe sanity check
|
||||
if (!context.oidc) {
|
||||
throw data(
|
||||
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return loginAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { state, oidc } = useLoaderData<typeof loader>();
|
||||
const formData = useActionData<typeof action>();
|
||||
const [params] = useSearchParams();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
useEffect(() => {
|
||||
// This page does NOT need stale while revalidate logic
|
||||
pause();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// State is a one time thing, we need to remove it after it has
|
||||
// been consumed to prevent logic loops.
|
||||
if (state !== null) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
searchParams.delete('s');
|
||||
|
||||
// Replacing because it's not a navigation, just a cleanup of the URL
|
||||
// We can't use the useSearchParams method since it revalidates
|
||||
// which will trigger a full reload
|
||||
const newUrl = searchParams.toString()
|
||||
? `{${window.location.pathname}?${searchParams.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
}, [state, params]);
|
||||
|
||||
if (state === 'logout') {
|
||||
return <Logout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-screen h-screen items-center justify-center">
|
||||
<Card className="max-w-md m-4 sm:m-0">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
<Form method="POST">
|
||||
<Card.Text>
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running <Code>headscale apikeys create</Code> in your
|
||||
terminal.
|
||||
</Card.Text>
|
||||
<Input
|
||||
isRequired
|
||||
labelHidden
|
||||
label="API Key"
|
||||
name="api_key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
className="mt-8 mb-2"
|
||||
/>
|
||||
{formData?.success === false ? (
|
||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||
{formData.message}
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
{oidc ? (
|
||||
<RemixLink to="/oidc/start">
|
||||
<Button variant="light" className="w-full mt-2">
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
) : undefined}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -17,7 +17,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p>
|
||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||
only <Code>A</Code> records are supported.{' '}
|
||||
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
name="Headscale DNS Records documentation"
|
||||
@ -34,19 +34,19 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
<TableList.Item key={`${record.name}-${record.value}`}>
|
||||
<div className="flex gap-24 items-center">
|
||||
<div className="flex gap-4 items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono text-sm font-bold py-1 px-2 rounded-md',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30',
|
||||
)}
|
||||
>
|
||||
{record.type}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono text-sm font-bold py-1 px-2 rounded-md text-center',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 min-w-12',
|
||||
)}
|
||||
>
|
||||
{record.type}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_record" />
|
||||
|
||||
@ -2,12 +2,14 @@ import { useMemo, useState } from 'react';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import Select from '~/components/Select';
|
||||
|
||||
interface Props {
|
||||
records: { name: string; type: 'A' | string; value: string }[];
|
||||
records: { name: string; type: 'A' | 'AAAA' | string; value: string }[];
|
||||
}
|
||||
|
||||
export default function AddRecord({ records }: Props) {
|
||||
const [type, setType] = useState<'A' | 'AAAA' | string>('A');
|
||||
const [name, setName] = useState('');
|
||||
const [ip, setIp] = useState('');
|
||||
|
||||
@ -22,14 +24,30 @@ export default function AddRecord({ records }: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add DNS record</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Panel
|
||||
onSubmit={() => {
|
||||
setName('');
|
||||
setIp('');
|
||||
}}
|
||||
>
|
||||
<Dialog.Title>Add DNS record</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<input type="hidden" name="action_id" value="add_record" />
|
||||
<input type="hidden" name="record_type" value="A" />
|
||||
<Select
|
||||
isRequired
|
||||
label="Record Type"
|
||||
name="record_type"
|
||||
defaultInputValue={type}
|
||||
onSelectionChange={(v) => {
|
||||
if (v) setType(v.toString() as 'A' | 'AAAA');
|
||||
}}
|
||||
>
|
||||
<Select.Item key="A">A</Select.Item>
|
||||
<Select.Item key="AAAA">AAAA</Select.Item>
|
||||
</Select>
|
||||
<Input
|
||||
isRequired
|
||||
label="Domain"
|
||||
@ -41,7 +59,9 @@ export default function AddRecord({ records }: Props) {
|
||||
<Input
|
||||
isRequired
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
placeholder={
|
||||
type === 'AAAA' ? '2001:db8::ff00:42:8329' : '101.101.101.101'
|
||||
}
|
||||
name="record_value"
|
||||
onChange={setIp}
|
||||
isInvalid={isDuplicate}
|
||||
|
||||
@ -194,16 +194,16 @@ async function removeRecord(formData: FormData, context: LoadContext) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records.filter(
|
||||
(i) => i.name !== recordName || i.type !== recordType,
|
||||
);
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: '',
|
||||
});
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
@ -218,15 +218,15 @@ async function addRecord(formData: FormData, context: LoadContext) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records;
|
||||
records.push({ name: recordName, type: recordType, value: recordValue });
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ export async function loader({
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
extraRecords: config.dns.extra_records,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -4,105 +4,54 @@ import { Link } from 'react-router';
|
||||
import Chip from '~/components/Chip';
|
||||
import Menu from '~/components/Menu';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import type { HostInfo, Machine, Route, User } from '~/types';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
||||
import { SubnetTag } from '~/components/tags/Subnet';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface Props {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
stats?: HostInfo;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
machine,
|
||||
routes,
|
||||
node,
|
||||
users,
|
||||
isAgent,
|
||||
magic,
|
||||
stats,
|
||||
isDisabled,
|
||||
}: Props) {
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired');
|
||||
}
|
||||
|
||||
const prefix = magic?.startsWith('[user]')
|
||||
? magic.replace('[user]', machine.user.name)
|
||||
: magic;
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce<{
|
||||
exit: Route[];
|
||||
subnetApproved: Route[];
|
||||
subnet: Route[];
|
||||
}>(
|
||||
(acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route);
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (route.enabled) {
|
||||
acc.subnetApproved.push(route);
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.subnet.push(route);
|
||||
return acc;
|
||||
},
|
||||
{ exit: [], subnetApproved: [], subnet: [] },
|
||||
);
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false;
|
||||
return exit[0].enabled && exit[1].enabled;
|
||||
}, [exit]);
|
||||
|
||||
if (exitEnabled) {
|
||||
tags.unshift('Exit Node');
|
||||
}
|
||||
|
||||
if (subnetApproved.length > 0) {
|
||||
tags.unshift('Subnets');
|
||||
}
|
||||
|
||||
if (isAgent) {
|
||||
tags.unshift('Headplane Agent');
|
||||
}
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, isAgent);
|
||||
return tags;
|
||||
}, [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
|
||||
return machine.ipAddresses;
|
||||
}, [magic, machine.ipAddresses]);
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
key={node.id}
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
>
|
||||
<td className="pl-0.5 py-2 focus-within:ring">
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
to={`/machines/${node.id}`}
|
||||
className={cn('group/link h-full focus:outline-none')}
|
||||
>
|
||||
<p
|
||||
@ -112,11 +61,12 @@ export default function MachineRow({
|
||||
'group-hover/link:dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm font-mono opacity-50">{machine.name}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map((tag) => (
|
||||
<p className="text-sm opacity-50">{node.user.name}</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
@ -124,7 +74,7 @@ export default function MachineRow({
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{machine.ipAddresses[0]}
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
@ -157,11 +107,13 @@ export default function MachineRow({
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{stats !== undefined ? (
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(node.hostInfo)}
|
||||
</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(stats)}
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@ -177,20 +129,19 @@ export default function MachineRow({
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={machine.online && !expired}
|
||||
isOnline={node.online && !node.expired}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{machine.online && !expired
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(machine.lastSeen).toLocaleString()}
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
node={node}
|
||||
users={users}
|
||||
magic={magic}
|
||||
isDisabled={isDisabled}
|
||||
@ -199,3 +150,64 @@ export default function MachineRow({
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
uiTags.push('expired');
|
||||
}
|
||||
|
||||
if (node.expiry === null) {
|
||||
uiTags.push('no-expiry');
|
||||
}
|
||||
|
||||
if (node.customRouting.exitRoutes.length > 0) {
|
||||
if (node.customRouting.exitApproved) {
|
||||
uiTags.push('exit-approved');
|
||||
} else {
|
||||
uiTags.push('exit-waiting');
|
||||
}
|
||||
}
|
||||
|
||||
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
||||
uiTags.push('subnet-waiting');
|
||||
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
||||
uiTags.push('subnet-approved');
|
||||
}
|
||||
|
||||
if (isAgent === true) {
|
||||
uiTags.push('headplane-agent');
|
||||
}
|
||||
|
||||
return uiTags;
|
||||
}
|
||||
|
||||
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||
return uiTags.map((tag) => {
|
||||
switch (tag) {
|
||||
case 'exit-approved':
|
||||
case 'exit-waiting':
|
||||
return <ExitNodeTag key={tag} isEnabled={tag === 'exit-approved'} />;
|
||||
|
||||
case 'subnet-approved':
|
||||
case 'subnet-waiting':
|
||||
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />;
|
||||
|
||||
case 'expired':
|
||||
case 'no-expiry':
|
||||
return (
|
||||
<ExpiryTag
|
||||
key={tag}
|
||||
variant={tag}
|
||||
expiry={node.expiry ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'headplane-agent':
|
||||
return <HeadplaneAgentTag />;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Cog, Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import Delete from '../dialogs/delete';
|
||||
import Expire from '../dialogs/expire';
|
||||
import Move from '../dialogs/move';
|
||||
@ -11,8 +12,7 @@ import Routes from '../dialogs/routes';
|
||||
import Tags from '../dialogs/tags';
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
magic?: string;
|
||||
isFullButton?: boolean;
|
||||
@ -22,27 +22,18 @@ interface MenuProps {
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
|
||||
export default function MachineMenu({
|
||||
machine,
|
||||
routes,
|
||||
node,
|
||||
magic,
|
||||
users,
|
||||
isFullButton,
|
||||
isDisabled,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'remove'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@ -51,7 +42,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'move' && (
|
||||
<Move
|
||||
machine={machine}
|
||||
machine={node}
|
||||
users={users}
|
||||
isOpen={modal === 'move'}
|
||||
setIsOpen={(isOpen) => {
|
||||
@ -61,7 +52,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
machine={machine}
|
||||
machine={node}
|
||||
magic={magic}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
@ -71,8 +62,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'routes' && (
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
node={node}
|
||||
isOpen={modal === 'routes'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@ -81,16 +71,16 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'tags' && (
|
||||
<Tags
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'tags'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{expired && modal === 'expire' ? undefined : (
|
||||
{node.expired && modal === 'expire' ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'expire'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@ -116,7 +106,10 @@ export default function MachineMenu({
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
)}
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Panel
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
disabledKeys={node.expired ? ['expire'] : []}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
||||
@ -124,13 +117,9 @@ export default function MachineMenu({
|
||||
<Menu.Item key="move">Change owner</Menu.Item>
|
||||
</Menu.Section>
|
||||
<Menu.Section>
|
||||
{expired ? (
|
||||
<></>
|
||||
) : (
|
||||
<Menu.Item key="expire" textValue="Expire">
|
||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="expire" textValue="Expire">
|
||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="remove" textValue="Remove">
|
||||
<p className="text-red-500 dark:text-red-400">Remove</p>
|
||||
</Menu.Item>
|
||||
|
||||
@ -22,8 +22,8 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
||||
This machine will be permanently removed from your network. To re-add
|
||||
it, you will need to reauthenticate to your tailnet from the device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="delete" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -16,8 +16,8 @@ export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
|
||||
This will disconnect the machine from your Tailnet. In order to
|
||||
reconnect, you will need to re-authenticate from the device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="expire" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="expire" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -17,11 +17,11 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="move" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="reassign" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<Select
|
||||
label="Owner"
|
||||
name="to"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
defaultSelectedKey={machine.user.id}
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Computer, KeySquare } from 'lucide-react';
|
||||
import { Computer, FileKey2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
@ -12,6 +12,7 @@ export interface NewMachineProps {
|
||||
server: string;
|
||||
users: User[];
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: string[];
|
||||
}
|
||||
|
||||
export default function NewMachine(data: NewMachineProps) {
|
||||
@ -29,14 +30,13 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
||||
your device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<input type="hidden" name="action_id" value="register" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
name="mkey"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
/>
|
||||
<Select
|
||||
@ -51,7 +51,7 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu isDisabled={data.isDisabled}>
|
||||
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
@ -74,7 +74,7 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
</Menu.Item>
|
||||
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeySquare className="w-4" />
|
||||
<FileKey2 className="w-4" />
|
||||
Generate Pre-auth Key
|
||||
</div>
|
||||
</Menu.Item>
|
||||
|
||||
@ -27,14 +27,45 @@ export default function Rename({
|
||||
This name is shown in the admin panel, in Tailscale clients, and used
|
||||
when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="rename" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
validationBehavior="native"
|
||||
name="name"
|
||||
defaultValue={machine.givenName}
|
||||
onChange={setName}
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return 'Cannot be empty';
|
||||
}
|
||||
|
||||
// DNS hostname validation
|
||||
if (value.toLowerCase() !== value) {
|
||||
return 'Cannot contain uppercase letters';
|
||||
}
|
||||
|
||||
if (value.length > 63) {
|
||||
return 'DNS hostnames cannot be 64+ characters';
|
||||
}
|
||||
|
||||
// Test for invalid characters
|
||||
if (!/^[a-z0-9-]+$/.test(value)) {
|
||||
return 'Cannot contain special characters';
|
||||
}
|
||||
|
||||
// Test for leading/trailing hyphens
|
||||
if (value.startsWith('-') || value.endsWith('-')) {
|
||||
return 'Cannot start or end with a hyphen';
|
||||
}
|
||||
|
||||
// Test for consecutive hyphens
|
||||
if (value.includes('--')) {
|
||||
return 'Cannot contain consecutive hyphens';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
|
||||
@ -1,55 +1,30 @@
|
||||
import { GlobeLock, RouteOff } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Link from '~/components/Link';
|
||||
import Switch from '~/components/Switch';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { Machine, Route } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
|
||||
interface RoutesProps {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({
|
||||
machine,
|
||||
routes,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: RoutesProps) {
|
||||
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet } = routes.reduce<{
|
||||
exit: Route[];
|
||||
subnet: Route[];
|
||||
}>(
|
||||
(acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route);
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.subnet.push(route);
|
||||
return acc;
|
||||
},
|
||||
{ exit: [], subnet: [] },
|
||||
);
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false;
|
||||
return exit[0].enabled && exit[1].enabled;
|
||||
}, [exit]);
|
||||
const subnets = [
|
||||
...node.customRouting.subnetApprovedRoutes,
|
||||
...node.customRouting.subnetWaitingRoutes,
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant="unactionable">
|
||||
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
|
||||
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on by advertising
|
||||
@ -62,7 +37,7 @@ export default function Routes({
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<TableList className="mt-4">
|
||||
{subnet.length === 0 ? (
|
||||
{subnets.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<RouteOff />
|
||||
<p className="font-semibold">
|
||||
@ -70,7 +45,7 @@ export default function Routes({
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
{subnet.map((route) => (
|
||||
{subnets.map((route) => (
|
||||
<TableList.Item key={route.id}>
|
||||
<p>{route.prefix}</p>
|
||||
<Switch
|
||||
@ -78,9 +53,9 @@ export default function Routes({
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'routes');
|
||||
form.set('route', route.id);
|
||||
form.set('action_id', 'update_routes');
|
||||
form.set('node_id', node.id);
|
||||
form.set('routes', [route.id].join(','));
|
||||
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
@ -102,7 +77,7 @@ export default function Routes({
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<TableList className="mt-4">
|
||||
{exit.length === 0 ? (
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<GlobeLock />
|
||||
<p className="font-semibold">This machine is not an exit node</p>
|
||||
@ -111,13 +86,18 @@ export default function Routes({
|
||||
<TableList.Item>
|
||||
<p>Use as exit node</p>
|
||||
<Switch
|
||||
defaultSelected={exitEnabled}
|
||||
defaultSelected={node.customRouting.exitApproved}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'exit-node');
|
||||
form.set('routes', exit.map((route) => route.id).join(','));
|
||||
form.set('action_id', 'update_routes');
|
||||
form.set('node_id', node.id);
|
||||
form.set(
|
||||
'routes',
|
||||
node.customRouting.exitRoutes
|
||||
.map((route) => route.id)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
|
||||
@ -33,8 +33,8 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="tags" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="update_tags" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import { type ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { data400, data403, data404, send } from '~/utils/res';
|
||||
|
||||
// TODO: Clean this up like dns-actions and user-actions
|
||||
export async function machineAction({
|
||||
request,
|
||||
context,
|
||||
@ -16,14 +13,33 @@ export async function machineAction({
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
const apiKey = session.get('api_key')!;
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.get('api_key')!;
|
||||
|
||||
// TODO: Rename this to 'action_id' and 'node_id'
|
||||
const action = formData.get('_method')?.toString();
|
||||
const nodeId = formData.get('id')?.toString();
|
||||
if (!action || !nodeId) {
|
||||
return data400('Missing required parameters: _method and id');
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === 'register') {
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage machines', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return registerMachine(formData, apiKey, context);
|
||||
}
|
||||
|
||||
// Check if the user has permission to manage this machine
|
||||
const nodeId = formData.get('node_id')?.toString();
|
||||
if (!nodeId) {
|
||||
throw data('Missing `node_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
@ -33,215 +49,192 @@ export async function machineAction({
|
||||
|
||||
const node = nodes.find((node) => node.id === nodeId);
|
||||
if (!node) {
|
||||
return data404(`Node with ID ${nodeId} not found`);
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const subject = session.get('user')!.subject;
|
||||
if (node.user.providerId?.split('/').pop() !== subject) {
|
||||
if (!check) {
|
||||
return data403('You do not have permission to act on this machine');
|
||||
}
|
||||
if (
|
||||
node.user.providerId?.split('/').pop() !== session.get('user')!.subject &&
|
||||
!check
|
||||
) {
|
||||
throw data('You do not have permission to act on this machine', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Split up into methods
|
||||
switch (action) {
|
||||
case 'rename': {
|
||||
return renameMachine(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
|
||||
return { message: 'Machine removed' };
|
||||
return deleteMachine(apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/expire`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
return { message: 'Machine expired' };
|
||||
return expireMachine(apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!formData.has('name')) {
|
||||
return send(
|
||||
{ message: 'No name provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/rename/${name}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
return { message: 'Machine renamed' };
|
||||
case 'update_tags': {
|
||||
return updateTags(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!formData.has('route') || !formData.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const route = String(formData.get('route'));
|
||||
const enabled = formData.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await context.client.post(
|
||||
`v1/routes/${route}/${postfix}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
return { message: 'Route updated' };
|
||||
case 'update_routes': {
|
||||
return updateRoutes(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'exit-node': {
|
||||
if (!formData.has('routes') || !formData.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const routes = formData.get('routes')?.toString().split(',') ?? [];
|
||||
const enabled = formData.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await Promise.all(
|
||||
routes.map(async (route) => {
|
||||
await context.client.post(
|
||||
`v1/routes/${route}/${postfix}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return { message: 'Exit node updated' };
|
||||
case 'reassign': {
|
||||
return reassignMachine(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!formData.has('to')) {
|
||||
return send(
|
||||
{ message: 'No destination provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const to = String(formData.get('to'));
|
||||
|
||||
try {
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/user`,
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: to,
|
||||
},
|
||||
);
|
||||
|
||||
return { message: `Moved node ${nodeId} to ${to}` };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return send(
|
||||
{ message: `Failed to move node ${nodeId} to ${to}` },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags =
|
||||
formData
|
||||
.get('tags')
|
||||
?.toString()
|
||||
.split(',')
|
||||
.filter((tag) => tag.trim() !== '') ?? [];
|
||||
|
||||
try {
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/tags`,
|
||||
session.get('api_key')!,
|
||||
{
|
||||
tags,
|
||||
},
|
||||
);
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
} catch (error) {
|
||||
log.debug('api', 'Failed to update tags: %s', error);
|
||||
return send(
|
||||
{ message: 'Failed to update tags' },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = formData.get('mkey')?.toString();
|
||||
const user = formData.get('user')?.toString();
|
||||
|
||||
if (!key) {
|
||||
return send(
|
||||
{ message: 'No machine key provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return send(
|
||||
{ message: 'No user provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', key);
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
await context.client.post(url, session.get('api_key')!, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Machine registered',
|
||||
};
|
||||
} catch {
|
||||
return send(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to register machine',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return send(
|
||||
{ message: 'Invalid method' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', registrationKey);
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
|
||||
user,
|
||||
key: registrationKey,
|
||||
});
|
||||
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
async function renameMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
async function deleteMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.delete(`v1/node/${nodeId}`, apiKey);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
async function expireMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
async function updateTags(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
});
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
|
||||
async function updateRoutes(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const postfix = enabled === 'true' ? 'enable' : 'disable';
|
||||
await Promise.all(
|
||||
allRoutes.map(async (route) => {
|
||||
await context.client.post(`v1/routes/${route}/${postfix}`, apiKey);
|
||||
}),
|
||||
);
|
||||
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
async function reassignMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import { mapTag } from 'yaml/util';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
@ -12,6 +13,9 @@ import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
import { machineAction } from './machine-actions';
|
||||
@ -33,7 +37,7 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
const [machine, { routes }, { users }] = await Promise.all([
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.get('api_key')!,
|
||||
@ -45,16 +49,15 @@ export async function loader({
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
const [node] = mapNodes([machine.node], routes);
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter((route) => route.node.id === params.id),
|
||||
users: users.users,
|
||||
node,
|
||||
users,
|
||||
magic,
|
||||
// TODO: Fix agent
|
||||
agent: false,
|
||||
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
|
||||
// machine.node.id,
|
||||
// ),
|
||||
agent: context.agents?.agentID(),
|
||||
stats: lookup?.[node.nodeKey],
|
||||
};
|
||||
}
|
||||
|
||||
@ -63,62 +66,13 @@ export async function action(request: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users, agent } =
|
||||
useLoaderData<typeof loader>();
|
||||
const { node, magic, users, agent, stats } = useLoaderData<typeof loader>();
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired');
|
||||
}
|
||||
|
||||
if (agent) {
|
||||
tags.unshift('Headplane Agent');
|
||||
}
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce<{
|
||||
exit: Route[];
|
||||
subnet: Route[];
|
||||
subnetApproved: Route[];
|
||||
}>(
|
||||
(acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route);
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (route.enabled) {
|
||||
acc.subnetApproved.push(route);
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.subnet.push(route);
|
||||
return acc;
|
||||
},
|
||||
{ exit: [], subnetApproved: [], subnet: [] },
|
||||
);
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false;
|
||||
return exit[0].enabled && exit[1].enabled;
|
||||
}, [exit]);
|
||||
|
||||
if (exitEnabled) {
|
||||
tags.unshift('Exit Node');
|
||||
}
|
||||
|
||||
if (subnetApproved.length > 0) {
|
||||
tags.unshift('Subnets');
|
||||
}
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||
return tags;
|
||||
}, [node, agent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -127,7 +81,7 @@ export default function Page() {
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{machine.givenName}
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
@ -136,17 +90,10 @@ export default function Page() {
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle isOnline={node.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
isFullButton
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
<MenuOptions isFullButton node={node} users={users} magic={magic} />
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||
@ -161,29 +108,23 @@ export default function Page() {
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<UserCircle />
|
||||
{machine.user.name}
|
||||
{node.user.name}
|
||||
</div>
|
||||
</div>
|
||||
{tags.length > 0 ? (
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
isOpen={showRouting}
|
||||
setIsOpen={setShowRouting}
|
||||
/>
|
||||
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
|
||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||
@ -214,11 +155,11 @@ export default function Page() {
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnetApproved.length === 0 ? (
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnetApproved.map((route) => (
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route.id}>{route.prefix}</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -246,11 +187,11 @@ export default function Page() {
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnet.length === 0 ? (
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnet.map((route) => (
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route.id}>{route.prefix}</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -277,9 +218,9 @@ export default function Page() {
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{exit.length === 0 ? (
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : exitEnabled ? (
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||||
Allowed
|
||||
@ -302,36 +243,156 @@ export default function Page() {
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
<Attribute name="Creator" value={machine.user.name} />
|
||||
<Attribute name="Node ID" value={machine.id} />
|
||||
<Attribute name="Node Name" value={machine.givenName} />
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Expiry"
|
||||
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
|
||||
/>
|
||||
{magic ? (
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection
|
||||
issues.
|
||||
</p>
|
||||
<Card
|
||||
variant="flat"
|
||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute name="Creator" value={node.user.name} />
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
name="OS hostname"
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
name="ID"
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${magic}`}
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
name="Node key"
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(node.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={
|
||||
node.online
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()
|
||||
}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={
|
||||
node.expiry !== null
|
||||
? new Date(node.expiry).toLocaleString()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="uppercase text-sm font-semibold opacity-75">
|
||||
Addresses
|
||||
</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
name="Tailscale IPv4"
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
name="Tailscale IPv6"
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||
name="Short domain"
|
||||
value={node.givenName}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||
name="Full domain"
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
{stats ? (
|
||||
<>
|
||||
<p className="uppercase text-sm font-semibold opacity-75 mt-4">
|
||||
Client Connectivity
|
||||
</p>
|
||||
<Attribute
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||
name="Varies"
|
||||
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||
name="Hairpinning"
|
||||
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
name="IPv6"
|
||||
value={stats.NetInfo?.WorkingIPv6 ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
name="UDP"
|
||||
value={stats.NetInfo?.WorkingUDP ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
name="UPnP"
|
||||
value={stats.NetInfo?.UPnP ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute name="PCP" value={stats.NetInfo?.PCP ? 'Yes' : 'No'} />
|
||||
<Attribute
|
||||
name="NAT-PMP"
|
||||
value={stats.NetInfo?.PMP ? 'Yes' : 'No'}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIpv4Address(addresses: string[]) {
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith('100.')) {
|
||||
// Return the first CGNAT address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
|
||||
function getIpv6Address(addresses: string[]) {
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith('fd')) {
|
||||
// Return the first IPv6 address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
@ -40,7 +41,7 @@ export async function loader({
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
const [{ nodes }, { routes }, { users }] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
@ -59,16 +60,23 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, routes, stats);
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
populatedNodes,
|
||||
nodes,
|
||||
routes,
|
||||
users,
|
||||
magic,
|
||||
server: context.config.headscale.url,
|
||||
publicServer: context.config.headscale.public_url,
|
||||
agents: context.agents?.tailnetIDs(),
|
||||
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
||||
agent: context.agents?.agentID(),
|
||||
writable: writablePermission,
|
||||
preAuth: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
),
|
||||
subject: user.subject,
|
||||
};
|
||||
}
|
||||
@ -99,6 +107,7 @@ export default function Page() {
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
isDisabled={!data.writable}
|
||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
@ -124,7 +133,7 @@ export default function Page() {
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{data.agents !== undefined ? (
|
||||
{data.agent !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
@ -136,19 +145,13 @@ export default function Page() {
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{data.nodes.map((machine) => (
|
||||
{data.populatedNodes.map((machine) => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
machine={machine}
|
||||
routes={data.routes.filter(
|
||||
(route) => route.node.id === machine.id,
|
||||
)}
|
||||
node={machine}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
// If we pass undefined, the column will not be rendered
|
||||
// This is useful for when there are no agents configured
|
||||
isAgent={data.agents?.includes(machine.id)}
|
||||
stats={data.stats?.[machine.nodeKey]}
|
||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
||||
isDisabled={
|
||||
data.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
@ -161,7 +164,3 @@ export default function Page() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
@ -1,216 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import { send } from '~/utils/res';
|
||||
import AuthKeyRow from './components/key';
|
||||
import AddPreAuthKey from './dialogs/new';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const users = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
const preAuthKeys = await Promise.all(
|
||||
users.users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
|
||||
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
|
||||
users: users.users,
|
||||
server: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const data = await request.formData();
|
||||
|
||||
// Expiring a pre-auth key
|
||||
if (request.method === 'DELETE') {
|
||||
const key = data.get('key');
|
||||
const user = data.get('user');
|
||||
|
||||
if (!key || !user) {
|
||||
return send(
|
||||
{ message: 'Missing parameters' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey/expire',
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: user,
|
||||
key: key,
|
||||
},
|
||||
);
|
||||
|
||||
return { message: 'Pre-auth key expired' };
|
||||
}
|
||||
|
||||
// Creating a new pre-auth key
|
||||
if (request.method === 'POST') {
|
||||
const user = data.get('user');
|
||||
const expiry = data.get('expiry');
|
||||
const reusable = data.get('reusable');
|
||||
const ephemeral = data.get('ephemeral');
|
||||
|
||||
if (!user || !expiry || !reusable || !ephemeral) {
|
||||
return send(
|
||||
{ message: 'Missing parameters' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
},
|
||||
);
|
||||
|
||||
return { message: 'Pre-auth key created', key };
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { keys, users, server } = useLoaderData<typeof loader>();
|
||||
const [user, setUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState('active');
|
||||
|
||||
const filteredKeys = keys.filter((key) => {
|
||||
if (user !== '__headplane_all' && key.user !== user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status !== 'all') {
|
||||
const now = new Date();
|
||||
const expiry = new Date(key.expiration);
|
||||
|
||||
if (status === 'active') {
|
||||
return !(expiry < now) && (!key.used || key.reusable);
|
||||
}
|
||||
|
||||
if (status === 'expired') {
|
||||
return key.used || expiry < now;
|
||||
}
|
||||
|
||||
if (status === 'reusable') {
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
if (status === 'ephemeral') {
|
||||
return key.ephemeral;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// TODO: Fix the selects
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add
|
||||
devices to your Tailnet. To learn more about using pre-authentication
|
||||
keys, visit the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddPreAuthKey users={users} />
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
label="Filter by User"
|
||||
placeholder="Select a user"
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...users.map((user) => (
|
||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
label="Filter by status"
|
||||
placeholder="Select a status"
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{filteredKeys.length === 0 ? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">No pre-auth keys</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} server={server} />
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
app/routes/settings/auth-keys/actions.ts
Normal file
118
app/routes/settings/auth-keys/actions.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { PreAuthKey } from '~/types';
|
||||
|
||||
export async function authKeysAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage pre-auth keys', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.get('api_key')!;
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_preauthkey':
|
||||
return await addPreAuthKey(formData, apiKey, context);
|
||||
case 'expire_preauthkey':
|
||||
return await expirePreAuthKey(formData, apiKey, context);
|
||||
default:
|
||||
return data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function addPreAuthKey(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiry = formData.get('expiry')?.toString();
|
||||
if (!expiry) {
|
||||
return data('Missing `expiry` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const reusable = formData.get('reusable')?.toString();
|
||||
if (!reusable) {
|
||||
return data('Missing `reusable` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const ephemeral = formData.get('ephemeral')?.toString();
|
||||
if (!ephemeral) {
|
||||
return data('Missing `ephemeral` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
apiKey,
|
||||
{
|
||||
user: user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
},
|
||||
);
|
||||
|
||||
return data('Pre-auth key created');
|
||||
}
|
||||
|
||||
async function expirePreAuthKey(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const key = formData.get('key')?.toString();
|
||||
if (!key) {
|
||||
return data('Missing `key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
@ -1,50 +1,46 @@
|
||||
import type { PreAuthKey } from '~/types';
|
||||
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import toast from '~/utils/toast';
|
||||
import ExpireKey from '../dialogs/expire';
|
||||
import ExpireAuthKey from './dialogs/expire-auth-key';
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey;
|
||||
server: string;
|
||||
user: User;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
||||
<Attribute name="User" value={authKey.user} isCopyable />
|
||||
<Attribute name="User" value={user.name} isCopyable />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
<Attribute suppressHydrationWarning name="Created" value={createdAt} />
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Expiration"
|
||||
value={expiration}
|
||||
/>
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<p className="mb-1 mt-4">
|
||||
To use this key, run the following command on your device:
|
||||
</p>
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server {server} --authkey {authKey.key}
|
||||
tailscale up --login-server={url} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
||||
{(authKey.used && !authKey.reusable) ||
|
||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||
<ExpireKey authKey={authKey} />
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
className="my-4"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server ${server} --authkey ${authKey.key}`,
|
||||
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
|
||||
);
|
||||
|
||||
toast('Copied command to clipboard');
|
||||
@ -7,12 +7,12 @@ import Select from '~/components/Select';
|
||||
import Switch from '~/components/Switch';
|
||||
import type { User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
interface AddAuthKeyProps {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddPreAuthKey(data: Props) {
|
||||
export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
|
||||
@ -21,13 +21,14 @@ export default function AddPreAuthKey(data: Props) {
|
||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_preauthkey" />
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
label="User"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
className="mb-2"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||
@ -47,7 +48,7 @@ export default function AddPreAuthKey(data: Props) {
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
@ -64,7 +65,7 @@ export default function AddPreAuthKey(data: Props) {
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
27
app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx
Normal file
27
app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
|
||||
interface ExpireAuthKeyProps {
|
||||
authKey: PreAuthKey;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<input type="hidden" name="action_id" value="expire_preauthkey" />
|
||||
{/* TODO: Why is Headscale using email as the user ID here?
|
||||
https://github.com/juanfont/headscale/issues/2520 */}
|
||||
<input type="hidden" name="user" value={user.name} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it from
|
||||
being used to authenticate new devices. This action cannot be undone.
|
||||
</Dialog.Text>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
255
app/routes/settings/auth-keys/overview.tsx
Normal file
255
app/routes/settings/auth-keys/overview.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import { FileKey2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { authKeysAction } from './actions';
|
||||
import AuthKeyRow from './auth-key-row';
|
||||
import AddAuthKey from './dialogs/add-auth-key';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
const preAuthKeys = await Promise.all(
|
||||
users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map(async (user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
|
||||
try {
|
||||
const { preAuthKeys } = await context.client.get<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
preAuthKeys,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
|
||||
return {
|
||||
success: false,
|
||||
user,
|
||||
error,
|
||||
preAuthKeys: [] as PreAuthKey[],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const keys = preAuthKeys
|
||||
.filter(({ success }) => success)
|
||||
.map(({ user, preAuthKeys }) => ({
|
||||
user,
|
||||
preAuthKeys,
|
||||
}));
|
||||
|
||||
const missing = preAuthKeys
|
||||
.filter(({ success }) => !success)
|
||||
.map(({ user, error }) => ({
|
||||
user,
|
||||
error,
|
||||
}));
|
||||
|
||||
return {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
access: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
),
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return authKeysAction(request);
|
||||
}
|
||||
|
||||
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
||||
export default function Page() {
|
||||
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
|
||||
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState<Status>('active');
|
||||
const isDisabled =
|
||||
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const now = new Date();
|
||||
return keys
|
||||
.filter(({ user }) => {
|
||||
if (selectedUser === '__headplane_all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id === selectedUser;
|
||||
})
|
||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||
.filter((key) => {
|
||||
if (status === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 'ephemeral') {
|
||||
return key.ephemeral;
|
||||
}
|
||||
|
||||
if (status === 'reusable') {
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
if (status === 'expired') {
|
||||
// Expired keys are either used or expired
|
||||
// BUT only used if they are not reusable
|
||||
if (key.used && !key.reusable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiry < now;
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
// Active keys are either not expired or reusable
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!key.used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return key.reusable;
|
||||
}
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:w-2/3">
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to generate pre-auth keys.
|
||||
Please contact your administrator to request access or to generate a
|
||||
pre-auth key for you.
|
||||
</Notice>
|
||||
) : missing.length > 0 ? (
|
||||
<Notice title="Missing authentication keys" variant="error">
|
||||
An error occurred while fetching the authentication keys for the
|
||||
following users:{' '}
|
||||
{missing.map(({ user }, index) => (
|
||||
<>
|
||||
<Code key={user.name}>{user.name}</Code>
|
||||
{index < missing.length - 1 ? ', ' : '. '}
|
||||
</>
|
||||
))}
|
||||
Their keys may not be listed correctly. Please check the server logs
|
||||
for more information.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add
|
||||
devices to your Tailnet. To learn more about using pre-authentication
|
||||
keys, visit the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey users={users} />
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
label="User"
|
||||
placeholder="Select a user"
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
onSelectionChange={(value) =>
|
||||
setSelectedUser(value?.toString() ?? '')
|
||||
}
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Select a status"
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
onSelectionChange={(value) =>
|
||||
setStatus((value?.toString() ?? 'active') as Status)
|
||||
}
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys have been created yet.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys match the selected filters.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
filteredKeys.map((key) => {
|
||||
// TODO: Why is Headscale using email as the user ID here?
|
||||
// https://github.com/juanfont/headscale/issues/2520
|
||||
const user = users.find((user) => user.email === key.user);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} url={url} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { PreAuthKey } from '~/types';
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey;
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Expire Key</Dialog.Button>
|
||||
<Dialog.Panel method="DELETE" variant="destructive">
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it from
|
||||
being used to authenticate new devices. This action cannot be undone.
|
||||
</Dialog.Text>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,22 @@
|
||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
import { LoadContext } from '~/server';
|
||||
|
||||
import AgentSection from './components/agent';
|
||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
oidc: context.oidc,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { config, oidc } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className="flex flex-col w-2/3">
|
||||
@ -37,7 +47,34 @@ export default function Page() {
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
{/**<AgentSection />**/}
|
||||
{config && oidc ? (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow
|
||||
certain email domains, groups, or users to authenticate. This can
|
||||
be used to limit access to your Tailnet to only certain users or
|
||||
groups and Headplane will also respect these settings when
|
||||
authenticating.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
name="Headscale OIDC documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/restrictions">
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Restrictions
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
221
app/routes/settings/restrictions/actions.ts
Normal file
221
app/routes/settings/restrictions/actions.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
|
||||
export async function restrictionAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.configure_iam,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to modify IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
throw data('The Headscale configuration file is not editable.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('No action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_domain': {
|
||||
return addDomain(formData, context);
|
||||
}
|
||||
|
||||
case 'remove_domain': {
|
||||
return removeDomain(formData, context);
|
||||
}
|
||||
|
||||
case 'add_group': {
|
||||
return addGroup(formData, context);
|
||||
}
|
||||
|
||||
case 'remove_group': {
|
||||
return removeGroup(formData, context);
|
||||
}
|
||||
|
||||
case 'add_user': {
|
||||
return addUser(formData, context);
|
||||
}
|
||||
|
||||
case 'remove_user': {
|
||||
return removeUser(formData, context);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw data('Invalid action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addDomain(formData: FormData, context: LoadContext) {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Domain added successfully.');
|
||||
}
|
||||
|
||||
async function removeDomain(formData: FormData, context: LoadContext) {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Domain removed successfully.');
|
||||
}
|
||||
|
||||
async function addUser(formData: FormData, context: LoadContext) {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('User added successfully.');
|
||||
}
|
||||
|
||||
async function removeUser(formData: FormData, context: LoadContext) {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('User removed successfully.');
|
||||
}
|
||||
|
||||
async function addGroup(formData: FormData, context: LoadContext) {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Group added successfully.');
|
||||
}
|
||||
|
||||
async function removeGroup(formData: FormData, context: LoadContext) {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Group removed successfully.');
|
||||
}
|
||||
64
app/routes/settings/restrictions/dialogs/add-domain.tsx
Normal file
64
app/routes/settings/restrictions/dialogs/add-domain.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface AddDomainProps {
|
||||
domains: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
||||
const [domain, setDomain] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (!domain || domain.trim().length === 0) {
|
||||
// Empty domain is invalid, but no error shown
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domains.includes(domain.trim())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if domain is a valid FQDN
|
||||
const url = new URL(`http://${domain.trim()}`);
|
||||
return url.hostname !== domain.trim();
|
||||
} catch (e) {
|
||||
// If URL constructor fails, it's not a valid domain
|
||||
return true;
|
||||
}
|
||||
}, [domain, domains]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add domain</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add domain</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
Add this domain to a list of allowed email domains that can
|
||||
authenticate with Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_domain" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Domain"
|
||||
description={
|
||||
domain.trim().length > 0
|
||||
? `Matches users with <user>@${domain.trim()}`
|
||||
: 'Enter a domain to match users with their email addresses.'
|
||||
}
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
onChange={setDomain}
|
||||
isInvalid={domain.trim().length === 0 || isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
The domain you entered is invalid or already exists in the list.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
51
app/routes/settings/restrictions/dialogs/add-group.tsx
Normal file
51
app/routes/settings/restrictions/dialogs/add-group.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface AddGroupProps {
|
||||
groups: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
||||
const [group, setGroup] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (!group || group.trim().length === 0) {
|
||||
// Empty group is invalid, but no error shown
|
||||
return false;
|
||||
}
|
||||
|
||||
if (groups.includes(group.trim())) {
|
||||
return true;
|
||||
}
|
||||
}, [group, groups]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add group</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add group</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
Add this group to a list of allowed groups that can authenticate with
|
||||
Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_group" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Group"
|
||||
description="The group to allow for OIDC authentication."
|
||||
placeholder="admin"
|
||||
name="group"
|
||||
onChange={setGroup}
|
||||
isInvalid={group.trim().length === 0 || isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
The group you entered already exists in the list of allowed groups.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
51
app/routes/settings/restrictions/dialogs/add-user.tsx
Normal file
51
app/routes/settings/restrictions/dialogs/add-user.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface AddUserProps {
|
||||
users: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddUser({ users, isDisabled }: AddUserProps) {
|
||||
const [user, setUser] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (!user || user.trim().length === 0) {
|
||||
// Empty user is invalid, but no error shown
|
||||
return false;
|
||||
}
|
||||
|
||||
if (users.includes(user.trim())) {
|
||||
return true;
|
||||
}
|
||||
}, [user, users]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add user</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
Add this user to a list of allowed users that can authenticate with
|
||||
Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_user" />
|
||||
<Input
|
||||
isRequired
|
||||
label="User"
|
||||
description="The user to allow for OIDC authentication."
|
||||
placeholder="john_doe"
|
||||
name="user"
|
||||
onChange={setUser}
|
||||
isInvalid={user.trim().length === 0 || isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
The user you entered already exists in the list of allowed users.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
118
app/routes/settings/restrictions/overview.tsx
Normal file
118
app/routes/settings/restrictions/overview.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { restrictionAction } from './actions';
|
||||
import AddDomain from './dialogs/add-domain';
|
||||
import AddGroup from './dialogs/add-group';
|
||||
import AddUser from './dialogs/add-user';
|
||||
import RestrictionTable from './table';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to view IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.c?.oidc) {
|
||||
throw data('OIDC is not configured on this Headscale instance.', {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
access: await context.sessions.check(request, Capabilities.configure_iam),
|
||||
writable: context.hs.writable(),
|
||||
settings: {
|
||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return restrictionAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { access, writable, settings } = useLoaderData<typeof loader>();
|
||||
const isDisabled = writable ? !access : true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-screen-lg">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice
|
||||
title="Authentication permissions restricted"
|
||||
variant="warning"
|
||||
>
|
||||
You do not have the necessary permissions to edit the Authentication
|
||||
Restrictions settings. Please contact your administrator to request
|
||||
access or to make changes to these settings.
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<Notice title="Configuration Locked" variant="error">
|
||||
The Headscale configuration file is not editable through the web
|
||||
interface. Please ensure that you have correctly given Headplane
|
||||
write access to the file.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2 mt-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow
|
||||
certain email domains, groups, or users to authenticate. This can be
|
||||
used to limit access to your Tailnet to only certain users or groups
|
||||
and Headplane will also respect these settings when authenticating.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
name="Headscale OIDC documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable
|
||||
type="domain"
|
||||
values={settings.domains}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
type="group"
|
||||
values={settings.groups}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
type="user"
|
||||
values={settings.users}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddUser users={settings.users} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
app/routes/settings/restrictions/table.tsx
Normal file
85
app/routes/settings/restrictions/table.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { GlobeLock, Group, User2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Form } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import TableList from '~/components/TableList';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface RestrictionProps {
|
||||
children: React.ReactNode;
|
||||
type: 'domain' | 'group' | 'user';
|
||||
values: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RestrictionTable({
|
||||
children,
|
||||
type,
|
||||
values,
|
||||
isDisabled,
|
||||
}: RestrictionProps) {
|
||||
return (
|
||||
<div className="w-2/3">
|
||||
<h2 className="text-2xl font-medium mt-8">
|
||||
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</h2>
|
||||
<TableList className="my-4">
|
||||
{values.length > 0 ? (
|
||||
values.map((value) => (
|
||||
<TableList.Item key={`${type}-${value}`}>
|
||||
{type === 'domain' ? (
|
||||
<p>
|
||||
<span className="text-headplane-600 dark:text-headplane-300">
|
||||
{'<user>'}
|
||||
</span>
|
||||
<span className="font-bold">@</span>
|
||||
<span>{value}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p>{value}</p>
|
||||
)}
|
||||
<Form method="POST">
|
||||
<input
|
||||
type="hidden"
|
||||
name="action_id"
|
||||
value={`remove_${type}`}
|
||||
/>
|
||||
<input type="hidden" name={type} value={value} />
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</TableList.Item>
|
||||
))
|
||||
) : (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
{iconForType(type)}
|
||||
<p className="font-semibold">
|
||||
All {type}s are permitted to authenticate.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
)}
|
||||
</TableList>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForType(type: 'domain' | 'group' | 'user') {
|
||||
if (type === 'domain') {
|
||||
return <GlobeLock />;
|
||||
}
|
||||
|
||||
if (type === 'group') {
|
||||
return <Group />;
|
||||
}
|
||||
|
||||
return <User2 />;
|
||||
}
|
||||
@ -61,7 +61,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
: 'You can add, remove, and rename users here.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CreateUser isDisabled={isDisabled} />
|
||||
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -48,7 +48,7 @@ export default function UserMenu({ user }: MenuProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
|
||||
<Menu.IconButton
|
||||
label="Machine Options"
|
||||
className={cn(
|
||||
|
||||
@ -2,11 +2,12 @@ import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface CreateUserProps {
|
||||
isOidc?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Support image upload for user avatars
|
||||
export default function CreateUser({ isDisabled }: CreateUserProps) {
|
||||
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
|
||||
@ -15,21 +16,49 @@ export default function CreateUser({ isDisabled }: CreateUserProps) {
|
||||
<Dialog.Text className="mb-6">
|
||||
Enter a username to create a new user. Usernames can be addressed when
|
||||
managing ACL policies.
|
||||
{isOidc ? (
|
||||
<>
|
||||
{' '}
|
||||
Manually created users are given administrative access to
|
||||
Headplane unless they become linked to an OIDC user in Headscale.
|
||||
</>
|
||||
) : undefined}
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="create_user" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
name="username"
|
||||
type="text"
|
||||
label="Username"
|
||||
placeholder="my-new-user"
|
||||
validationBehavior="native"
|
||||
validate={(value) => {
|
||||
if (value.trim().length === 0) {
|
||||
return 'Username is required';
|
||||
}
|
||||
|
||||
if (value.includes(' ')) {
|
||||
return 'Usernames cannot contain spaces';
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
name="display_name"
|
||||
type="text"
|
||||
label="Display Name"
|
||||
placeholder="John Doe"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="name@example.com"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input name="email" label="Email" placeholder="name@example.com" />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
|
||||
@ -30,7 +30,7 @@ export async function userAction({
|
||||
case 'rename_user':
|
||||
return renameUser(formData, apiKey, context);
|
||||
case 'reassign_user':
|
||||
return reassignUser(formData, apiKey, context, session);
|
||||
return reassignUser(formData, apiKey, context);
|
||||
default:
|
||||
throw data400('Invalid `action_id` provided.');
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ type T = NonNullable<HeadplaneConfig['integration']>['docker'];
|
||||
export default class DockerIntegration extends Integration<T> {
|
||||
private maxAttempts = 10;
|
||||
private client: Client | undefined;
|
||||
private containerId: string | undefined;
|
||||
|
||||
get name() {
|
||||
return 'Docker';
|
||||
@ -56,22 +57,13 @@ export default class DockerIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
// Perform a basic check to see if any of the required properties are set
|
||||
if (
|
||||
this.context.container_name.length === 0 &&
|
||||
!this.context.container_label
|
||||
) {
|
||||
log.error('config', 'Docker container name and label are both empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.context.container_name.length > 0 &&
|
||||
!this.context.container_label
|
||||
) {
|
||||
// Basic configuration check, the name overrides the container_label
|
||||
// selector because of legacy support.
|
||||
const { container_name, container_label } = this.context;
|
||||
if (container_name.length === 0 && container_label.length === 0) {
|
||||
log.error(
|
||||
'config',
|
||||
'Docker container name and label are mutually exclusive',
|
||||
'Missing a Docker `container_name` or `container_label`',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@ -127,40 +119,73 @@ export default class DockerIntegration extends Integration<T> {
|
||||
socketPath: url.pathname,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.client === undefined) {
|
||||
log.error('config', 'Failed to create Docker client');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.context.container_name.length === 0) {
|
||||
try {
|
||||
if (this.context.container_label === undefined) {
|
||||
log.error('config', 'Docker container label is not defined');
|
||||
return false;
|
||||
}
|
||||
const containerName = await this.getContainerName(
|
||||
this.context.container_label.name,
|
||||
this.context.container_label.value,
|
||||
);
|
||||
if (containerName.length === 0) {
|
||||
log.error(
|
||||
'config',
|
||||
'No Docker containers found matching label: %s=%s',
|
||||
this.context.container_label.name,
|
||||
this.context.container_label.value,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.context.container_name = containerName;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to get Docker container name: %s', error);
|
||||
return false;
|
||||
}
|
||||
const qp = new URLSearchParams({
|
||||
filters: JSON.stringify(
|
||||
container_name.length > 0
|
||||
? { name: container_name }
|
||||
: { label: [container_label] },
|
||||
),
|
||||
});
|
||||
|
||||
const res = await this.client.request({
|
||||
method: 'GET',
|
||||
path: `/v1.30/containers/json?${qp.toString()}`,
|
||||
});
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
log.error('config', 'Could not request available Docker containers');
|
||||
log.debug('config', 'Error Details: %o', await res.body.json());
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info('config', 'Using container: %s', this.context.container_name);
|
||||
const data = (await res.body.json()) as DockerContainer[];
|
||||
if (data.length > 1) {
|
||||
if (container_name.length > 0) {
|
||||
log.error(
|
||||
'config',
|
||||
`Found multiple containers with name ${container_name}`,
|
||||
);
|
||||
} else {
|
||||
log.error(
|
||||
'config',
|
||||
`Found multiple containers with label ${container_label}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.client !== undefined;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
if (container_name.length > 0) {
|
||||
log.error(
|
||||
'config',
|
||||
`No container found with the name ${container_name}`,
|
||||
);
|
||||
} else {
|
||||
log.error(
|
||||
'config',
|
||||
`No container found with the label ${container_label}`,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.containerId = data[0].Id;
|
||||
log.info(
|
||||
'config',
|
||||
'Using container: %s (ID: %s)',
|
||||
data[0].Names[0],
|
||||
this.containerId,
|
||||
);
|
||||
|
||||
return this.client !== undefined && this.containerId !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
@ -175,13 +200,13 @@ export default class DockerIntegration extends Integration<T> {
|
||||
log.debug(
|
||||
'config',
|
||||
'Restarting container: %s (attempt %d)',
|
||||
this.context.container_name,
|
||||
this.containerId,
|
||||
attempts,
|
||||
);
|
||||
|
||||
const response = await this.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${this.context.container_name}/restart`,
|
||||
path: `/v1.30/containers/${this.containerId}/restart`,
|
||||
});
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
@ -217,11 +242,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'config',
|
||||
'Missed restart deadline for %s',
|
||||
this.context.container_name,
|
||||
);
|
||||
log.error('config', 'Missed restart deadline for %s', this.containerId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export async function loadIntegration(context: HeadplaneConfig['integration']) {
|
||||
try {
|
||||
const res = await integration.isAvailable();
|
||||
if (!res) {
|
||||
log.error('config', 'Integration %s is not available', integration);
|
||||
log.error('config', 'Integration %s is not available', integration.name);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -162,9 +162,11 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
try {
|
||||
log.debug('config', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (data.includes('headscale')) {
|
||||
return pid;
|
||||
if (!data.includes('headscale') && !data.includes('serve')) {
|
||||
throw new Error('Found PID without Headscale serve command');
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch (error) {
|
||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
|
||||
@ -38,9 +38,11 @@ export default class ProcIntegration extends Integration<T> {
|
||||
try {
|
||||
log.debug('config', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (data.includes('headscale')) {
|
||||
return pid;
|
||||
if (!data.includes('headscale') && !data.includes('serve')) {
|
||||
throw new Error('Found PID without Headscale serve command');
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
const stringToBool = type('string | boolean').pipe((v) => Boolean(v));
|
||||
const stringToBool = type('string | boolean').pipe((v) => {
|
||||
if (typeof v === 'string') {
|
||||
if (v === '1' || v === 'true' || v === 'yes') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (v === '0' || v === 'false' || v === 'no') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid string value for boolean: ${v}`);
|
||||
}
|
||||
|
||||
return Boolean(v);
|
||||
});
|
||||
|
||||
const serverConfig = type({
|
||||
host: 'string.ip',
|
||||
port: type('string | number.integer').pipe((v) => Number(v)),
|
||||
cookie_secret: '32 <= string <= 32',
|
||||
cookie_secure: stringToBool,
|
||||
agent: type({
|
||||
authkey: 'string = ""',
|
||||
ttl: 'number.integer = 180000', // Default to 3 minutes
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
})
|
||||
.onDeepUndeclaredKey('reject')
|
||||
.default(() => ({
|
||||
authkey: '',
|
||||
ttl: 180000,
|
||||
cache_path: '/var/lib/headplane/agent_cache.json',
|
||||
})),
|
||||
});
|
||||
|
||||
const oidcConfig = type({
|
||||
@ -39,18 +43,24 @@ const headscaleConfig = type({
|
||||
public_url: 'string.url?',
|
||||
config_path: 'string?',
|
||||
config_strict: stringToBool,
|
||||
dns_records_path: 'string?',
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const containerLabel = type({
|
||||
name: 'string',
|
||||
value: 'string',
|
||||
}).optional();
|
||||
const agentConfig = type({
|
||||
enabled: stringToBool.default(false),
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string = ""',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
});
|
||||
|
||||
const dockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string',
|
||||
container_name: 'string = ""',
|
||||
container_label: 'string = "me.tale.headplane.target=headscale"',
|
||||
socket: 'string = "unix:///var/run/docker.sock"',
|
||||
container_label: containerLabel,
|
||||
});
|
||||
|
||||
const kubernetesConfig = type({
|
||||
@ -67,12 +77,14 @@ const integrationConfig = type({
|
||||
'docker?': dockerConfig,
|
||||
'kubernetes?': kubernetesConfig,
|
||||
'proc?': procConfig,
|
||||
'agent?': agentConfig,
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialIntegrationConfig = type({
|
||||
'docker?': dockerConfig.partial(),
|
||||
'kubernetes?': kubernetesConfig.partial(),
|
||||
'proc?': procConfig.partial(),
|
||||
'agent?': agentConfig.partial(),
|
||||
}).partial();
|
||||
|
||||
export const headplaneConfig = type({
|
||||
|
||||
@ -1,8 +1,132 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { data } from 'react-router';
|
||||
import { Agent, Dispatcher, request } from 'undici';
|
||||
import { errors } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import ResponseError from './api-error';
|
||||
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
const keys = Object.keys(error as Record<string, unknown>);
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
keys.includes('code') &&
|
||||
keys.includes('errno')
|
||||
);
|
||||
}
|
||||
|
||||
function friendlyError(givenError: unknown) {
|
||||
let error: unknown = givenError;
|
||||
if (error instanceof AggregateError) {
|
||||
error = error.errors[0];
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error instanceof errors.BodyTimeoutError:
|
||||
case error instanceof errors.ConnectTimeoutError:
|
||||
case error instanceof errors.HeadersTimeoutError:
|
||||
return data('Timed out waiting for a response from the Headscale API', {
|
||||
statusText: 'Request Timeout',
|
||||
status: 408,
|
||||
});
|
||||
|
||||
case error instanceof errors.SocketError:
|
||||
case error instanceof errors.SecureProxyConnectionError:
|
||||
case error instanceof errors.ClientClosedError:
|
||||
case error instanceof errors.ClientDestroyedError:
|
||||
case error instanceof errors.RequestAbortedError:
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
|
||||
case error instanceof errors.InvalidArgumentError:
|
||||
case error instanceof errors.InvalidReturnValueError:
|
||||
case error instanceof errors.NotSupportedError:
|
||||
return data('Unable to make a request (this is most likely a bug)', {
|
||||
statusText: 'Internal Server Error',
|
||||
status: 500,
|
||||
});
|
||||
|
||||
case error instanceof errors.HeadersOverflowError:
|
||||
case error instanceof errors.RequestContentLengthMismatchError:
|
||||
case error instanceof errors.ResponseContentLengthMismatchError:
|
||||
case error instanceof errors.ResponseExceededMaxSizeError:
|
||||
return data('The Headscale API returned a malformed response', {
|
||||
statusText: 'Bad Gateway',
|
||||
status: 502,
|
||||
});
|
||||
|
||||
case isNodeNetworkError(error):
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'EAI_AGAIN') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
return data('Timed out waiting for a response from the Headscale API', {
|
||||
statusText: 'Request Timeout',
|
||||
status: 408,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'EPIPE') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENETUNREACH') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENETRESET') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
|
||||
default:
|
||||
return data((error as Error).message ?? 'An unknown error occurred', {
|
||||
statusText: 'Internal Server Error',
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApiClient(base: string, certPath?: string) {
|
||||
if (!certPath) {
|
||||
return new ApiClient(new Agent(), base);
|
||||
@ -37,21 +161,34 @@ export class ApiClient {
|
||||
const method = options?.method ?? 'GET';
|
||||
log.debug('api', '%s %s', method, url);
|
||||
|
||||
return await request(new URL(url, this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
try {
|
||||
const res = await request(new URL(url, this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error: unknown) {
|
||||
throw friendlyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async healthcheck() {
|
||||
try {
|
||||
const res = await this.defaultFetch('/health');
|
||||
const res = await request(new URL('/health', this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.statusCode === 200;
|
||||
} catch (error) {
|
||||
log.debug('api', 'Healthcheck failed %o', error);
|
||||
|
||||
127
app/server/headscale/config-dns.ts
Normal file
127
app/server/headscale/config-dns.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export interface DNSRecord {
|
||||
type: 'A' | 'AAAA' | (string & {});
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// This class is solely for DNS records that are out of tree in the main
|
||||
// Headscale config file. If you are using dns.extra_records_path, it will
|
||||
// be managed here and not in the main config file.
|
||||
//
|
||||
// All DNS insertions and deletions are handled by the main config manager,
|
||||
// but are passed through to here if the extra file is being used.
|
||||
export class HeadscaleDNSConfig {
|
||||
private records: DNSRecord[];
|
||||
private access: 'rw' | 'ro' | 'no';
|
||||
private path?: string;
|
||||
private writeLock = false;
|
||||
|
||||
constructor(
|
||||
access: 'rw' | 'ro' | 'no',
|
||||
records?: DNSRecord[],
|
||||
path?: string,
|
||||
) {
|
||||
this.access = access;
|
||||
this.records = records ?? [];
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
readable() {
|
||||
return this.access !== 'no';
|
||||
}
|
||||
|
||||
writable() {
|
||||
return this.access === 'rw';
|
||||
}
|
||||
|
||||
get r() {
|
||||
return this.records;
|
||||
}
|
||||
|
||||
async patch(records: DNSRecord[]) {
|
||||
if (!this.path || !this.readable() || !this.writable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.records = records;
|
||||
log.debug(
|
||||
'config',
|
||||
'Patching DNS records (%d -> %d)',
|
||||
this.records.length,
|
||||
records.length,
|
||||
);
|
||||
|
||||
return this.write();
|
||||
}
|
||||
|
||||
private async write() {
|
||||
if (!this.path || !this.writable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
log.debug('config', 'Writing updated DNS configuration to %s', this.path);
|
||||
const data = JSON.stringify(this.records, null, 4);
|
||||
await writeFile(this.path, data);
|
||||
this.writeLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHeadscaleDNS(path?: string) {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Loading Headscale DNS configuration file: %s', path);
|
||||
const { w, r } = await validateConfigPath(path);
|
||||
if (!r) {
|
||||
return new HeadscaleDNSConfig('no');
|
||||
}
|
||||
|
||||
const records = await loadConfigFile(path);
|
||||
if (!records) {
|
||||
return new HeadscaleDNSConfig('no');
|
||||
}
|
||||
|
||||
return new HeadscaleDNSConfig(w ? 'rw' : 'ro', records, path);
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.R_OK);
|
||||
log.info('config', 'Found a valid Headscale DNS file at %s', path);
|
||||
} catch (error) {
|
||||
log.error('config', 'Unable to read a Headscale DNS file at %s', path);
|
||||
log.error('config', '%s', error);
|
||||
return { w: false, r: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch (error) {
|
||||
log.warn('config', 'Headscale DNS file at %s is not writable', path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFile(path: string) {
|
||||
log.debug('config', 'Reading Headscale DNS file at %s', path);
|
||||
try {
|
||||
const data = await readFile(path, 'utf8');
|
||||
const records = JSON.parse(data) as DNSRecord[];
|
||||
return records;
|
||||
} catch (e) {
|
||||
log.error('config', 'Error reading Headscale DNS file at %s', path);
|
||||
log.error('config', '%s', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { exit } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { type } from 'arktype';
|
||||
import { Document, parseDocument } from 'yaml';
|
||||
import log from '~/utils/log';
|
||||
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from './config-dns';
|
||||
import { headscaleConfig } from './config-schema';
|
||||
|
||||
interface PatchConfig {
|
||||
@ -19,9 +21,11 @@ class HeadscaleConfig {
|
||||
private access: 'rw' | 'ro' | 'no';
|
||||
private path?: string;
|
||||
private writeLock = false;
|
||||
private dns?: HeadscaleDNSConfig;
|
||||
|
||||
constructor(
|
||||
access: 'rw' | 'ro' | 'no',
|
||||
dns?: HeadscaleDNSConfig,
|
||||
config?: typeof headscaleConfig.infer,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
@ -30,6 +34,7 @@ class HeadscaleConfig {
|
||||
this.config = config;
|
||||
this.document = document;
|
||||
this.path = path;
|
||||
this.dns = dns;
|
||||
}
|
||||
|
||||
readable() {
|
||||
@ -44,6 +49,14 @@ class HeadscaleConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
get d() {
|
||||
if (this.dns) {
|
||||
return this.dns.r;
|
||||
}
|
||||
|
||||
return this.config?.dns.extra_records ?? [];
|
||||
}
|
||||
|
||||
async patch(patches: PatchConfig[]) {
|
||||
if (!this.path || !this.document || !this.readable() || !this.writable()) {
|
||||
return;
|
||||
@ -115,9 +128,106 @@ class HeadscaleConfig {
|
||||
this.writeLock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record to the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param record The DNS record to add.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async addDNS(record: DNSRecord) {
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug('config', 'DNS config is not writable');
|
||||
return;
|
||||
}
|
||||
|
||||
const records = this.dns.r;
|
||||
if (
|
||||
records.some((i) => i.name === record.name && i.type === record.type)
|
||||
) {
|
||||
log.debug('config', 'DNS record already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
return this.dns.patch([...records, record]);
|
||||
}
|
||||
|
||||
// If we get here, we need to add to the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
if (
|
||||
existing.some((i) => i.name === record.name && i.type === record.type)
|
||||
) {
|
||||
log.debug('config', 'DNS record already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.patch([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: Array.from(new Set([...existing, record])),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a DNS record from the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param records The DNS record to remove.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async removeDNS(record: DNSRecord) {
|
||||
// In this case we need to check both the main config and the DNS config
|
||||
// to see if the record exists, and if it does, we need to remove it
|
||||
// from both places.
|
||||
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug('config', 'DNS config is not writable');
|
||||
return;
|
||||
}
|
||||
|
||||
const records = this.dns.r.filter(
|
||||
(i) => i.name !== record.name || i.type !== record.type,
|
||||
);
|
||||
|
||||
return this.dns.patch(records);
|
||||
}
|
||||
|
||||
// If we get here, we need to remove from the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
const filtered = existing.filter(
|
||||
(i) => i.name !== record.name || i.type !== record.type,
|
||||
);
|
||||
|
||||
// If the length of the existing records is the same as the filtered
|
||||
// records, then we don't need to do anything
|
||||
if (existing.length === filtered.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.patch([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: existing.filter(
|
||||
(i) => i.name !== record.name || i.type !== record.type,
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHeadscaleConfig(path?: string, strict = true) {
|
||||
export async function loadHeadscaleConfig(
|
||||
path?: string,
|
||||
strict = true,
|
||||
dnsPath?: string,
|
||||
) {
|
||||
if (!path) {
|
||||
log.debug('config', 'No Headscale configuration file was provided');
|
||||
return new HeadscaleConfig('no');
|
||||
@ -137,6 +247,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true) {
|
||||
if (!strict) {
|
||||
return new HeadscaleConfig(
|
||||
w ? 'rw' : 'ro',
|
||||
new HeadscaleDNSConfig('no'),
|
||||
augmentUnstrictConfig(document.toJSON()),
|
||||
document,
|
||||
path,
|
||||
@ -148,7 +259,35 @@ export async function loadHeadscaleConfig(path?: string, strict = true) {
|
||||
return new HeadscaleConfig('no');
|
||||
}
|
||||
|
||||
return new HeadscaleConfig(w ? 'rw' : 'ro', config, document, path);
|
||||
if (config.dns.extra_records && config.dns.extra_records_path) {
|
||||
log.warn(
|
||||
'config',
|
||||
'Both extra_records and extra_records_path are set, Headscale will crash',
|
||||
);
|
||||
|
||||
log.warn('config', 'Please remove one of them from the configuration file');
|
||||
return new HeadscaleConfig('no');
|
||||
}
|
||||
|
||||
const dns = await loadHeadscaleDNS(dnsPath);
|
||||
if (dns && !config.dns.extra_records_path) {
|
||||
log.error(
|
||||
'config',
|
||||
'Using separate DNS config file but dns.extra_records_path is not set in Headscale config',
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'Please set `dns.extra_records_path` in the Headscale config',
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'Or remove `headscale.dns_records_path` from the Headplane config',
|
||||
);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return new HeadscaleConfig(w ? 'rw' : 'ro', dns, config, document, path);
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
|
||||
@ -114,7 +114,8 @@ export const headscaleConfig = type({
|
||||
type: 'string | "A"',
|
||||
})
|
||||
.array()
|
||||
.default(() => []),
|
||||
.optional(),
|
||||
extra_records_path: 'string?',
|
||||
},
|
||||
|
||||
unix_socket: 'string?',
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { env, versions } from 'node:process';
|
||||
import type { UpgradeWebSocket } from 'hono/ws';
|
||||
import { createHonoServer } from 'react-router-hono-server/node';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import log from '~/utils/log';
|
||||
import { configureConfig, configureLogger, envVariables } from './config/env';
|
||||
import { loadIntegration } from './config/integration';
|
||||
@ -38,6 +37,7 @@ const appLoadContext = {
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
|
||||
// TODO: Better cookie options in config
|
||||
@ -57,11 +57,9 @@ const appLoadContext = {
|
||||
),
|
||||
|
||||
agents: await loadAgentSocket(
|
||||
config.server.agent.authkey,
|
||||
config.server.agent.cache_path,
|
||||
config.server.agent.ttl,
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
),
|
||||
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
|
||||
};
|
||||
@ -71,7 +69,6 @@ declare module 'react-router' {
|
||||
}
|
||||
|
||||
export default createHonoServer({
|
||||
useWebSocket: true,
|
||||
overrideGlobalObjects: true,
|
||||
port: config.server.port,
|
||||
hostname: config.server.host,
|
||||
@ -85,20 +82,6 @@ export default createHonoServer({
|
||||
return appLoadContext;
|
||||
},
|
||||
|
||||
configure(app, { upgradeWebSocket }) {
|
||||
const agentManager = appLoadContext.agents;
|
||||
if (agentManager) {
|
||||
app.get(
|
||||
`${__PREFIX__}/_dial`,
|
||||
// We need this since we cannot pass the WSEvents context
|
||||
// Also important to not pass the callback directly
|
||||
// since we need to retain `this` context
|
||||
(upgradeWebSocket as UpgradeWebSocket<WebSocket>)((c) =>
|
||||
agentManager.configureSocket(c),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
listeningListener(info) {
|
||||
log.info('server', 'Running on %s:%s', info.address, info.port);
|
||||
},
|
||||
|
||||
@ -1,159 +1,287 @@
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { open, readFile, writeFile } from 'node:fs/promises';
|
||||
import { constants, access, open, readFile, writeFile } from 'node:fs/promises';
|
||||
import { exit } from 'node:process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
import { type } from 'arktype';
|
||||
import type { Context } from 'hono';
|
||||
import type { WSContext, WSEvents } from 'hono/ws';
|
||||
import { WebSocket } from 'ws';
|
||||
import { HostInfo } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config/schema';
|
||||
|
||||
interface LogResponse {
|
||||
Level: 'info' | 'debug' | 'error' | 'fatal';
|
||||
Message: string;
|
||||
}
|
||||
|
||||
interface RegisterMessage {
|
||||
Type: 'register';
|
||||
ID: string;
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
Type: 'status';
|
||||
Data: Record<string, HostInfo>;
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
Level: 'msg';
|
||||
Message: RegisterMessage | StatusMessage;
|
||||
}
|
||||
|
||||
type AgentResponse = LogResponse | MessageResponse;
|
||||
|
||||
export async function loadAgentSocket(
|
||||
authkey: string,
|
||||
path: string,
|
||||
ttl: number,
|
||||
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||
headscaleUrl: string,
|
||||
) {
|
||||
if (authkey.length === 0) {
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.pre_authkey.trim().length === 0) {
|
||||
log.error('agent', 'Agent `pre_authkey` is not set');
|
||||
log.warn('agent', 'The agent will not run until resolved');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await open(path, 'w');
|
||||
log.info('agent', 'Using agent cache file at %s', path);
|
||||
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||
log.debug('config', 'Using agent work dir at %s', config.work_dir);
|
||||
} catch (error) {
|
||||
log.info('config', 'Agent work dir not accessible at %s', config.work_dir);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await open(config.cache_path, 'a+');
|
||||
log.info('agent', 'Using agent cache file at %s', config.cache_path);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info('agent', 'Agent cache file not accessible at %s', path);
|
||||
log.info(
|
||||
'agent',
|
||||
'Agent cache file not accessible at %s',
|
||||
config.cache_path,
|
||||
);
|
||||
log.debug('agent', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = new TimedCache<HostInfo>(ttl, path);
|
||||
return new AgentManager(cache, authkey);
|
||||
const cache = new TimedCache<HostInfo>(config.cache_ttl, config.cache_path);
|
||||
return new AgentManager(cache, config, headscaleUrl);
|
||||
}
|
||||
|
||||
class AgentManager {
|
||||
private static readonly MAX_RESTARTS = 5;
|
||||
private restartCounter = 0;
|
||||
|
||||
private cache: TimedCache<HostInfo>;
|
||||
private agents: Map<string, WSContext>;
|
||||
private timers: Map<string, NodeJS.Timeout>;
|
||||
private authkey: string;
|
||||
private headscaleUrl: string;
|
||||
private config: NonNullable<
|
||||
NonNullable<HeadplaneConfig['integration']>['agent']
|
||||
>;
|
||||
|
||||
constructor(cache: TimedCache<HostInfo>, authkey: string) {
|
||||
private spawnProcess: ChildProcess | null;
|
||||
private agentId: string | null;
|
||||
|
||||
constructor(
|
||||
cache: TimedCache<HostInfo>,
|
||||
config: NonNullable<NonNullable<HeadplaneConfig['integration']>['agent']>,
|
||||
headscaleUrl: string,
|
||||
) {
|
||||
this.cache = cache;
|
||||
this.authkey = authkey;
|
||||
this.agents = new Map();
|
||||
this.timers = new Map();
|
||||
this.config = config;
|
||||
this.headscaleUrl = headscaleUrl;
|
||||
this.spawnProcess = null;
|
||||
this.agentId = null;
|
||||
this.startAgent();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
this.spawnProcess?.kill();
|
||||
exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
this.spawnProcess?.kill();
|
||||
exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
tailnetIDs() {
|
||||
return Array.from(this.agents.keys());
|
||||
/**
|
||||
* Used by the UI to indicate why the agent is not running.
|
||||
* Exhaustion requires a manual restart of the agent.
|
||||
* (Which can be invoked via the UI)
|
||||
* @returns true if the agent is exhausted
|
||||
*/
|
||||
exhausted() {
|
||||
return this.restartCounter >= AgentManager.MAX_RESTARTS;
|
||||
}
|
||||
|
||||
lookup(nodeIds: string[]) {
|
||||
/*
|
||||
* Called by the UI to manually force a restart of the agent.
|
||||
*/
|
||||
deExhaust() {
|
||||
this.restartCounter = 0;
|
||||
this.startAgent();
|
||||
}
|
||||
|
||||
/*
|
||||
* Stored agent ID for the current process. This is caught by the agent
|
||||
* when parsing the stdout on agent startup.
|
||||
*/
|
||||
agentID() {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
private startAgent() {
|
||||
if (this.spawnProcess) {
|
||||
log.debug('agent', 'Agent already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.exhausted()) {
|
||||
log.error('agent', 'Agent is exhausted, cannot start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot be detached since we want to follow our process lifecycle
|
||||
// We also need to be able to send data to the process by using stdin
|
||||
log.info(
|
||||
'agent',
|
||||
'Starting agent process (attempt %d)',
|
||||
this.restartCounter,
|
||||
);
|
||||
this.spawnProcess = spawn(this.config.executable_path, [], {
|
||||
detached: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
HOME: process.env.HOME,
|
||||
HEADPLANE_EMBEDDED: 'true',
|
||||
HEADPLANE_AGENT_WORK_DIR: this.config.work_dir,
|
||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
|
||||
HEADPLANE_AGENT_HOSTNAME: this.config.host_name,
|
||||
HEADPLANE_AGENT_TS_SERVER: this.headscaleUrl,
|
||||
HEADPLANE_AGENT_TS_AUTHKEY: this.config.pre_authkey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.spawnProcess?.pid) {
|
||||
log.error('agent', 'Failed to start agent process');
|
||||
this.restartCounter++;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.spawnProcess.stdin === null || this.spawnProcess.stdout === null) {
|
||||
log.error('agent', 'Failed to connect to agent process');
|
||||
this.restartCounter++;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const rlStdout = createInterface({
|
||||
input: this.spawnProcess.stdout,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
rlStdout.on('line', (line) => {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as AgentResponse;
|
||||
if (parsed.Level === 'msg') {
|
||||
switch (parsed.Message.Type) {
|
||||
case 'register':
|
||||
this.agentId = parsed.Message.ID;
|
||||
break;
|
||||
case 'status':
|
||||
for (const [key, value] of Object.entries(parsed.Message.Data)) {
|
||||
// Mark the agent as the one that is running
|
||||
// We store it in the cache so that it shows
|
||||
// itself later
|
||||
if (key === this.agentId) {
|
||||
value.HeadplaneAgent = true;
|
||||
}
|
||||
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (parsed.Level) {
|
||||
case 'info':
|
||||
case 'debug':
|
||||
case 'error':
|
||||
log[parsed.Level]('agent', parsed.Message);
|
||||
break;
|
||||
case 'fatal':
|
||||
log.error('agent', parsed.Message);
|
||||
break;
|
||||
default:
|
||||
log.debug('agent', 'Unknown agent response: %s', line);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug('agent', 'Failed to parse agent response: %s', error);
|
||||
log.debug('agent', 'Raw data: %s', line);
|
||||
}
|
||||
});
|
||||
|
||||
this.spawnProcess.on('error', (error) => {
|
||||
log.error('agent', 'Failed to start agent process: %s', error);
|
||||
this.restartCounter++;
|
||||
this.spawnProcess = null;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
});
|
||||
|
||||
this.spawnProcess.on('exit', (code) => {
|
||||
log.error('agent', 'Agent process exited with code %d', code ?? -1);
|
||||
this.restartCounter++;
|
||||
this.spawnProcess = null;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
});
|
||||
}
|
||||
|
||||
async lookup(nodeIds: string[]) {
|
||||
const entries = this.cache.toJSON();
|
||||
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
|
||||
if (missing.length > 0) {
|
||||
this.requestData(missing);
|
||||
await this.requestData(missing);
|
||||
}
|
||||
|
||||
return entries;
|
||||
return Object.entries(entries).reduce<Record<string, HostInfo>>(
|
||||
(acc, [key, value]) => {
|
||||
if (nodeIds.includes(key)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
// Request data from all connected agents
|
||||
// This does not return anything, but caches the data which then needs to be
|
||||
// queried by the caller separately.
|
||||
private requestData(nodeList: string[]) {
|
||||
const NodeIDs = [...new Set(nodeList)];
|
||||
NodeIDs.map((node) => {
|
||||
log.debug('agent', 'Requesting agent data for %s', node);
|
||||
});
|
||||
|
||||
for (const agent of this.agents.values()) {
|
||||
agent.send(JSON.stringify({ NodeIDs }));
|
||||
// Request data from the internal agent by sending a message to the process
|
||||
// via stdin. This is a blocking call, so it will wait for the agent to
|
||||
// respond before returning.
|
||||
private async requestData(nodeList: string[]) {
|
||||
if (this.exhausted()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Since we are using Node, Hono is built on 'ws' WebSocket types.
|
||||
configureSocket(c: Context): WSEvents<WebSocket> {
|
||||
return {
|
||||
onOpen: (_, ws) => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Rejecting an agent WebSocket connection without a tailnet ID',
|
||||
);
|
||||
ws.close(1008, 'ERR_INVALID_TAILNET_ID');
|
||||
return;
|
||||
}
|
||||
// Wait for the process to be spawned, busy waiting is gross
|
||||
while (this.spawnProcess === null) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
const auth = c.req.header('authorization');
|
||||
if (auth !== `Bearer ${this.authkey}`) {
|
||||
log.warn('agent', 'Rejecting an unauthorized WebSocket connection');
|
||||
|
||||
const info = getConnInfo(c);
|
||||
if (info.remote.address) {
|
||||
log.warn('agent', 'Agent source IP: %s', info.remote.address);
|
||||
}
|
||||
|
||||
ws.close(1008, 'ERR_UNAUTHORIZED');
|
||||
return;
|
||||
}
|
||||
|
||||
const pinger = setInterval(() => {
|
||||
if (ws.readyState !== 1) {
|
||||
clearInterval(pinger);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.raw?.ping();
|
||||
}, 30000);
|
||||
|
||||
this.agents.set(id, ws);
|
||||
this.timers.set(id, pinger);
|
||||
},
|
||||
|
||||
onClose: () => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.timers.get(id));
|
||||
this.agents.delete(id);
|
||||
},
|
||||
|
||||
onError: (event, ws) => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.timers.get(id));
|
||||
if (event instanceof ErrorEvent) {
|
||||
log.error('agent', 'WebSocket error: %s', event.message);
|
||||
}
|
||||
|
||||
log.debug('agent', 'Closing agent WebSocket connection');
|
||||
ws.close(1011, 'ERR_INTERNAL_ERROR');
|
||||
},
|
||||
|
||||
// This is where we receive the data from the agent
|
||||
// Requests are made in the AgentManager.requestData function
|
||||
onMessage: (event, ws) => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(event.data.toString());
|
||||
log.debug('agent', 'Received agent data from %s', id);
|
||||
for (const [node, info] of Object.entries<HostInfo>(data)) {
|
||||
this.cache.set(node, info);
|
||||
log.debug('agent', 'Cached HostInfo for %s', node);
|
||||
}
|
||||
},
|
||||
};
|
||||
// Send the request to the agent, without waiting for a response.
|
||||
// The live data invalidator will re-request the data if it is not
|
||||
// available in the cache anyways.
|
||||
const data = JSON.stringify({ NodeIDs: nodeList });
|
||||
this.spawnProcess.stdin?.write(`${data}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -75,76 +75,88 @@ export async function createOidcClient(
|
||||
}
|
||||
|
||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||
const oidc = await client.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
secret,
|
||||
clientAuthMethod(config.token_endpoint_auth_method)(secret),
|
||||
);
|
||||
|
||||
const metadata = oidc.serverMetadata();
|
||||
if (!metadata.authorization_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `authorization_endpoint`',
|
||||
try {
|
||||
const oidc = await client.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
secret,
|
||||
clientAuthMethod(config.token_endpoint_auth_method)(secret),
|
||||
);
|
||||
log.error('config', 'OIDC server does not support authorization code flow');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.token_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||
log.error('config', 'OIDC server does not support token exchange');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this field is missing, assume the server supports all response types
|
||||
// and that we can continue safely.
|
||||
if (metadata.response_types_supported) {
|
||||
if (!metadata.response_types_supported.includes('code')) {
|
||||
const metadata = oidc.serverMetadata();
|
||||
if (!metadata.authorization_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `response_types_supported` does not include `code`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support code flow');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.token_endpoint_auth_methods_supported) {
|
||||
if (
|
||||
!metadata.token_endpoint_auth_methods_supported.includes(
|
||||
config.token_endpoint_auth_method,
|
||||
)
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
|
||||
config.token_endpoint_auth_method,
|
||||
'Issuer discovery did not return `authorization_endpoint`',
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support %s',
|
||||
config.token_endpoint_auth_method,
|
||||
'OIDC server does not support authorization code flow',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.userinfo_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `userinfo_endpoint`');
|
||||
log.error('config', 'OIDC server does not support userinfo endpoint');
|
||||
return;
|
||||
}
|
||||
if (!metadata.token_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||
log.error('config', 'OIDC server does not support token exchange');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'OIDC client created successfully');
|
||||
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
metadata.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
|
||||
return oidc;
|
||||
// If this field is missing, assume the server supports all response types
|
||||
// and that we can continue safely.
|
||||
if (metadata.response_types_supported) {
|
||||
if (!metadata.response_types_supported.includes('code')) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `response_types_supported` does not include `code`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support code flow');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.token_endpoint_auth_methods_supported) {
|
||||
if (
|
||||
!metadata.token_endpoint_auth_methods_supported.includes(
|
||||
config.token_endpoint_auth_method,
|
||||
)
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
|
||||
config.token_endpoint_auth_method,
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support %s',
|
||||
config.token_endpoint_auth_method,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.userinfo_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `userinfo_endpoint`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support userinfo endpoint');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'OIDC client created successfully');
|
||||
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
metadata.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
|
||||
return oidc;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to discover OIDC issuer');
|
||||
log.error('config', 'Error: %s', error);
|
||||
log.debug('config', 'Error details: %o', error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export const Capabilities = {
|
||||
// Write feature configuration, for example, enable Taildrop (unimplemented)
|
||||
write_feature: 1 << 6,
|
||||
|
||||
// Configure user & group provisioning (unimplemented)
|
||||
// Configure user & group provisioning
|
||||
configure_iam: 1 << 7,
|
||||
|
||||
// Read machines, for example, see machine names and status
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
|
||||
|
||||
export interface HostInfo {
|
||||
/**
|
||||
* Custom identifier we use to determine if its an agent or not
|
||||
*/
|
||||
HeadplaneAgent?: boolean;
|
||||
|
||||
/** Version of this code (in version.Long format) */
|
||||
IPNVersion?: string;
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ export interface Machine {
|
||||
|
||||
user: User;
|
||||
lastSeen: string;
|
||||
expiry: string;
|
||||
expiry: string | null;
|
||||
|
||||
preAuthKey?: unknown; // TODO
|
||||
|
||||
|
||||
58
app/utils/node-info.ts
Normal file
58
app/utils/node-info.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { HostInfo, Machine, Route } from '~/types';
|
||||
|
||||
export interface PopulatedNode extends Machine {
|
||||
routes: Route[];
|
||||
hostInfo?: HostInfo;
|
||||
expired: boolean;
|
||||
customRouting: {
|
||||
exitRoutes: Route[];
|
||||
exitApproved: boolean;
|
||||
subnetApprovedRoutes: Route[];
|
||||
subnetWaitingRoutes: Route[];
|
||||
};
|
||||
}
|
||||
|
||||
export function mapNodes(
|
||||
nodes: Machine[],
|
||||
routes: Route[],
|
||||
stats?: Record<string, HostInfo> | undefined,
|
||||
): PopulatedNode[] {
|
||||
return nodes.map((node) => {
|
||||
const nodeRoutes = routes.filter((route) => route.node.id === node.id);
|
||||
const customRouting = nodeRoutes.reduce<PopulatedNode['customRouting']>(
|
||||
(acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exitRoutes.push(route);
|
||||
if (route.enabled) {
|
||||
acc.exitApproved = true;
|
||||
}
|
||||
} else {
|
||||
if (route.enabled) {
|
||||
acc.subnetApprovedRoutes.push(route);
|
||||
} else {
|
||||
acc.subnetWaitingRoutes.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
exitRoutes: [],
|
||||
exitApproved: false,
|
||||
subnetApprovedRoutes: [],
|
||||
subnetWaitingRoutes: [],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...node,
|
||||
routes: nodeRoutes,
|
||||
hostInfo: stats?.[node.nodeKey],
|
||||
customRouting,
|
||||
expired:
|
||||
node.expiry === null
|
||||
? false
|
||||
: new Date(node.expiry).getTime() < Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -11,7 +11,7 @@ services:
|
||||
image: "headscale/headscale:0.25.1"
|
||||
container_name: "headscale"
|
||||
labels:
|
||||
- com.headplane.selector=headscale
|
||||
me.tale.headplane.target: headscale
|
||||
restart: "unless-stopped"
|
||||
command: "serve"
|
||||
networks:
|
||||
|
||||
@ -42,23 +42,65 @@ headscale:
|
||||
# If you want to disable this validation, set this to false.
|
||||
config_strict: true
|
||||
|
||||
# If you are using `dns.extra_records_path` in your Headscale
|
||||
# configuration, you need to set this to the path for Headplane
|
||||
# to be able to read the DNS records.
|
||||
#
|
||||
# Pass it in if using Docker and ensure that the file is both
|
||||
# readable and writable to the Headplane process.
|
||||
# When using this, Headplane will no longer need to automatically
|
||||
# restart Headscale for DNS record changes.
|
||||
# dns_records_path: "/var/lib/headplane/extra_records.json"
|
||||
|
||||
# Integration configurations for Headplane to interact with Headscale
|
||||
# Only one of these should be enabled at a time or you will get errors
|
||||
integration:
|
||||
agent:
|
||||
# The Headplane agent allows retrieving information about nodes
|
||||
# This allows the UI to display version, OS, and connectivity data
|
||||
# You will see the Headplane agent in your Tailnet as a node when
|
||||
# it connects.
|
||||
enabled: false
|
||||
# To connect to your Tailnet, you need to generate a pre-auth key
|
||||
# This can be done via the web UI or through the `headscale` CLI.
|
||||
pre_authkey: "<your-preauth-key>"
|
||||
# Optionally change the name of the agent in the Tailnet.
|
||||
# host_name: "headplane-agent"
|
||||
|
||||
# Configure different caching settings. By default, the agent will store
|
||||
# caches in the path below for a maximum of 1 minute. If you want data
|
||||
# to update faster, reduce the TTL, but this will increase the frequency
|
||||
# of requests to Headscale.
|
||||
# cache_ttl: 60
|
||||
# cache_path: /var/lib/headplane/agent_cache.json
|
||||
|
||||
# Do not change this unless you are running a custom deployment.
|
||||
# The work_dir represents where the agent will store its data to be able
|
||||
# to automatically reauthenticate with your Tailnet. It needs to be
|
||||
# writable by the user running the Headplane process.
|
||||
# work_dir: "/var/lib/headplane/agent"
|
||||
|
||||
# Only one of these should be enabled at a time or you will get errors
|
||||
# This does not include the agent integration (above), which can be enabled
|
||||
# at the same time as any of these and is recommended for the best experience.
|
||||
docker:
|
||||
enabled: false
|
||||
# Preferred method: use container_label to dynamically discover the Headscale container.
|
||||
container_label:
|
||||
name: "com.headplane.selector"
|
||||
value: "headscale"
|
||||
# Optional fallback: directly specify the container name (or ID)
|
||||
# of the container running Headscale
|
||||
|
||||
# By default we check for the presence of a container label (see the docs)
|
||||
# to determine the container to signal when changes are made to DNS settings.
|
||||
container_label: "me.tale.headplane.target=headscale"
|
||||
|
||||
# HOWEVER, you can fallback to a container name if you desire, but this is
|
||||
# not recommended as its brittle and doesn't work with orchestrators that
|
||||
# automatically assign container names.
|
||||
#
|
||||
# If `container_name` is set, it will override any label checks.
|
||||
# container_name: "headscale"
|
||||
|
||||
# The path to the Docker socket (do not change this if you are unsure)
|
||||
# Docker socket paths must start with unix:// or tcp:// and at the moment
|
||||
# https connections are not supported.
|
||||
socket: "unix:///var/run/docker.sock"
|
||||
|
||||
# Please refer to docs/integration/Kubernetes.md for more information
|
||||
# on how to configure the Kubernetes integration. There are requirements in
|
||||
# order to allow Headscale to be controlled by Headplane in a cluster.
|
||||
|
||||
@ -30,7 +30,7 @@ Clone the Headplane repository, install dependencies, and build the project:
|
||||
```sh
|
||||
git clone https://github.com/tale/headplane
|
||||
cd headplane
|
||||
git checkout 0.5.10 # Or whatever tag you want to use
|
||||
git checkout v0.6.0 # Or whatever tag you want to use
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
# Headplane Agent
|
||||
|
||||
> This is currently not available in Headplane.
|
||||
> It is incomplete and will land within the next few releases.
|
||||
|
||||
The Headplane agent is a lightweight service that runs alongside the Headscale server.
|
||||
It's used to interface with devices on your network locally, unlocking the following:
|
||||
|
||||
- **Node Information/Status**: View Tailscale versions, OS versions, and connection details.
|
||||
- **SSH via Web (Soon)**: Connect to devices via SSH directly from the Headplane UI.
|
||||
|
||||
It's built on top of [tsnet](https://tailscale.com/kb/1244/tsnet), the official
|
||||
set of libraries published by Tailscale for creating local services that can
|
||||
join the tailnet.
|
||||
While it isn't required to run the agent, it's highly recommended to get the
|
||||
closest experience to the SaaS version of Tailscale. This is paired with the
|
||||
integrations provided by Headplane to manage DNS and Headscale settings.
|
||||
|
||||
### Installation
|
||||
The agent can either be ran as a standalone binary or as a Docker container.
|
||||
Agent binaries are available on the [releases](https://github.com/tale/headplane/releases) page.
|
||||
The Docker image is available through the `ghcr.io/tale/headplane-agent` tag.
|
||||
|
||||
The agent requires the following environment variables to be set:
|
||||
- **`HEADPLANE_AGENT_DEBUG`**: Enable debug logging if `true`.
|
||||
- **`HEADPLANE_AGENT_HOSTNAME`**: A hostname you want to use for the agent.
|
||||
- **`HEADPLANE_AGENT_TS_SERVER`**: The URL to your Headscale instance.
|
||||
- **`HEADPLANE_AGENT_TS_AUTHKEY`**: An authorization key to authenticate with Headscale (see below).
|
||||
- **`HEADPLANE_AGENT_HP_SERVER`**: The URL to your Headplane instance, including the subpath (eg. `https://headplane.example.com/admin`).
|
||||
- **`HEADPLANE_AGENT_HP_AUTHKEY`**: The generated auth key to authenticate with Headplane.
|
||||
|
||||
If you already have Headplane setup, you can generate all of these values within
|
||||
the Headplane UI. Navigate to the `Settings` page and click `Agent` to get started.
|
||||
@ -34,7 +34,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.10
|
||||
image: ghcr.io/tale/headplane:0.6.0
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@ -44,6 +44,10 @@ services:
|
||||
# This should match headscale.config_path in your config.yaml
|
||||
- './headscale-config/config.yaml:/etc/headscale/config.yaml'
|
||||
|
||||
# If using dns.extra_records in Headscale (recommended), this should
|
||||
# match the headscale.dns_records_path in your config.yaml
|
||||
- './headscale-config/dns_records.json:/etc/headscale/dns_records.json'
|
||||
|
||||
# Headplane stores its data in this directory
|
||||
- './headplane-data:/var/lib/headplane'
|
||||
|
||||
@ -54,6 +58,9 @@ services:
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
labels:
|
||||
# This is needed for Headplane to find it and signal it
|
||||
me.tale.headplane.target: headscale
|
||||
ports:
|
||||
- '8080:8080'
|
||||
volumes:
|
||||
@ -154,7 +161,7 @@ spec:
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:0.5.10
|
||||
image: ghcr.io/tale/headplane:0.6.0
|
||||
env:
|
||||
# Set these if the pod name for Headscale is not static
|
||||
# We will use the downward API to get the pod name instead
|
||||
|
||||
@ -19,7 +19,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.10
|
||||
image: ghcr.io/tale/headplane:0.6.0
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
10
go.mod
10
go.mod
@ -4,6 +4,12 @@ go 1.23.1
|
||||
|
||||
toolchain go1.23.4
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13
|
||||
tailscale.com v1.78.3
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
@ -39,12 +45,10 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/csrf v1.7.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/illarion/gonotify/v2 v2.0.3 // indirect
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
@ -71,7 +75,6 @@ require (
|
||||
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
|
||||
@ -86,5 +89,4 @@ require (
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect
|
||||
tailscale.com v1.78.3 // indirect
|
||||
)
|
||||
|
||||
71
go.sum
71
go.sum
@ -1,9 +1,15 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw=
|
||||
@ -34,19 +40,33 @@ github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
|
||||
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
|
||||
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
|
||||
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
|
||||
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
|
||||
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
|
||||
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
@ -59,6 +79,8 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@ -67,16 +89,17 @@ github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
|
||||
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
@ -89,6 +112,12 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
@ -101,15 +130,29 @@ github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
||||
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
||||
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
@ -126,10 +169,18 @@ github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVL
|
||||
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
|
||||
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
|
||||
github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
|
||||
github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
|
||||
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
|
||||
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
|
||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||
@ -145,6 +196,10 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
@ -173,9 +228,21 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
|
||||
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
|
||||
honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I=
|
||||
honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
tailscale.com v1.78.3 h1:2BJepIEYA0ba0ZXn2rOuZjYzIV4Az+X9RS5XJF007Ug=
|
||||
tailscale.com v1.78.3/go.mod h1:gT7ALbLFCr2YIu0kgc9Q3tBVaTlod65D2N6jMLH11Bk=
|
||||
|
||||
@ -3,7 +3,7 @@ buildGoModule {
|
||||
pname = "hp_agent";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
src = ../.;
|
||||
vendorHash = "sha256-G0kahv3mPTL/mxU2U+0IytJaFVPXMbMBktbLMfM0BO8=";
|
||||
vendorHash = "sha256-5TmX9ZUotNC3ZnNWRlyugAmzQG/WSZ66jFfGljql/ww=";
|
||||
ldflags = ["-s" "-w"];
|
||||
env.CGO_ENABLED = 0;
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
pnpmDeps = pnpm_10.fetchDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
hash = "sha256-GtpwQz7ngLpP+BubH6uaG1uUZsZdCQzvTI1WKBYU2T4=";
|
||||
hash = "sha256-OOWgYaGwa5PtWhFEEkRCojCDmkPIR6tJ5cfFMOLND3I=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "headplane",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "0.5.10",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@ -17,7 +17,6 @@
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/inter": "^5.1.1",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@kubernetes/client-node": "^0.22.3",
|
||||
"@primer/octicons-react": "^19.14.0",
|
||||
"@react-aria/toast": "3.0.0-beta.18",
|
||||
@ -43,13 +42,12 @@
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.4.0",
|
||||
"react-router-hono-server": "^2.11.0",
|
||||
"react-router-hono-server": "^2.13.0",
|
||||
"react-stately": "^3.35.0",
|
||||
"remix-utils": "^8.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"undici": "^7.2.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"ws": "^8.18.1",
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@ -58,7 +56,6 @@
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@react-router/dev": "^7.4.0",
|
||||
"@types/websocket": "^1.0.10",
|
||||
"@types/ws": "^8.5.13",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
|
||||
"lefthook": "^1.10.9",
|
||||
|
||||
@ -1,54 +1,34 @@
|
||||
diff --git a/dist/adapters/node.d.ts b/dist/adapters/node.d.ts
|
||||
index 68742808892c1282ccff1e3321167862196d1229..f9a9249e1d1e573018d7ff3d3b967c4a1667d6ca 100644
|
||||
--- a/dist/adapters/node.d.ts
|
||||
+++ b/dist/adapters/node.d.ts
|
||||
@@ -50,6 +50,10 @@ interface HonoNodeServerOptions<E extends Env = BlankEnv> extends HonoServerOpti
|
||||
/**
|
||||
* Callback executed just after `serve` from `@hono/node-server`
|
||||
*/
|
||||
+ /**
|
||||
+ * Customize the hostname of the node server
|
||||
+ */
|
||||
+ hostname?: string;
|
||||
onServe?: (server: ServerType) => void;
|
||||
/**
|
||||
* The Node.js Adapter rewrites the global Request/Response and uses a lightweight Request/Response to improve performance.
|
||||
diff --git a/dist/adapters/node.js b/dist/adapters/node.js
|
||||
index 481dec801537f6ccf7f7a8a8e2294f4b0f20bb7d..980fecf219dd0c501ed415e36985ec56d997f14f 100644
|
||||
index 966604f94ca8528b684ef95fe7891c2e6352561b..8222cf31333668f8c2ebe65986b6ab9a3711b587 100644
|
||||
--- a/dist/adapters/node.js
|
||||
+++ b/dist/adapters/node.js
|
||||
@@ -46,16 +46,22 @@ async function createHonoServer(options) {
|
||||
@@ -46,16 +46,25 @@ async function createHonoServer(options) {
|
||||
}
|
||||
await mergedOptions.beforeAll?.(app);
|
||||
app.use(
|
||||
- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
|
||||
+ `${__PREFIX__}/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
|
||||
+ `/${__PREFIX__}${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
|
||||
cache(60 * 60 * 24 * 365),
|
||||
// 1 year
|
||||
- serveStatic({ root: clientBuildPath })
|
||||
- serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets })
|
||||
+ serveStatic({
|
||||
+ root: clientBuildPath,
|
||||
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
||||
+ })
|
||||
+ root: clientBuildPath,
|
||||
+ ...mergedOptions.serveStaticOptions?.clientAssets,
|
||||
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
||||
+ })
|
||||
);
|
||||
+ app.use(__PREFIX__, (c) => c.redirect(`${__PREFIX__}/`));
|
||||
app.use(
|
||||
- "*",
|
||||
+ `${__PREFIX__}/assets/*`,
|
||||
+ `${__PREFIX__}/*`,
|
||||
cache(60 * 60),
|
||||
// 1 hour
|
||||
- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public" })
|
||||
- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets })
|
||||
+ serveStatic({
|
||||
+ root: PRODUCTION ? clientBuildPath : "./public",
|
||||
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
||||
+ })
|
||||
+ root: PRODUCTION ? clientBuildPath : "./public",
|
||||
+ ...mergedOptions.serveStaticOptions?.publicAssets,
|
||||
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
||||
+ })
|
||||
);
|
||||
if (mergedOptions.defaultLogger) {
|
||||
app.use("*", logger());
|
||||
@@ -86,6 +92,7 @@ async function createHonoServer(options) {
|
||||
...app,
|
||||
...mergedOptions.customNodeServer,
|
||||
port: mergedOptions.port,
|
||||
+ hostname: mergedOptions.hostname,
|
||||
overrideGlobalObjects: mergedOptions.overrideGlobalObjects
|
||||
},
|
||||
mergedOptions.listeningListener
|
||||
|
||||
@ -9,7 +9,7 @@ patchedDependencies:
|
||||
hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211
|
||||
path: patches/@shopify__lang-jsonc@1.0.0.patch
|
||||
react-router-hono-server:
|
||||
hash: c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e
|
||||
hash: 6549978df41006e07f1335bfe4ca86224ea36ed40d3f08dfef33143bad54005c
|
||||
path: patches/react-router-hono-server.patch
|
||||
|
||||
importers:
|
||||
@ -31,9 +31,6 @@ importers:
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1
|
||||
'@hono/node-server':
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0(hono@4.7.5)
|
||||
'@kubernetes/client-node':
|
||||
specifier: ^0.22.3
|
||||
version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
@ -110,8 +107,8 @@ importers:
|
||||
specifier: ^7.4.0
|
||||
version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react-router-hono-server:
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))
|
||||
specifier: ^2.13.0
|
||||
version: 2.13.0(patch_hash=6549978df41006e07f1335bfe4ca86224ea36ed40d3f08dfef33143bad54005c)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))
|
||||
react-stately:
|
||||
specifier: ^3.35.0
|
||||
version: 3.35.0(react@19.0.0)
|
||||
@ -127,9 +124,6 @@ importers:
|
||||
usehooks-ts:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(react@19.0.0)
|
||||
ws:
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
yaml:
|
||||
specifier: ^2.7.0
|
||||
version: 2.7.0
|
||||
@ -149,9 +143,6 @@ importers:
|
||||
'@types/websocket':
|
||||
specifier: ^1.0.10
|
||||
version: 1.0.10
|
||||
'@types/ws':
|
||||
specifier: ^8.5.13
|
||||
version: 8.5.13
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
@ -457,8 +448,8 @@ packages:
|
||||
'@codemirror/commands@6.7.1':
|
||||
resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==}
|
||||
|
||||
'@codemirror/commands@6.8.0':
|
||||
resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==}
|
||||
'@codemirror/commands@6.8.1':
|
||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||
|
||||
'@codemirror/language@6.10.8':
|
||||
resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
|
||||
@ -980,15 +971,15 @@ packages:
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hono/node-ws@1.1.0':
|
||||
resolution: {integrity: sha512-uHaz1EPguJqsUmA+Jmhdi/DTRAMs2Fvcy7qno9E48rlK3WBtyGQw4u4DKlc+o18Nh1DGz2oA1n9hCzEyhVBeLw==}
|
||||
'@hono/node-ws@1.1.1':
|
||||
resolution: {integrity: sha512-iFJrAw5GuBTstehBzLY2FyW5rRlXmO3Uwpijpm4Liv75owNP/UjZe3KExsLuEK4w+u+xhvHqOoQUyEKWUvyghw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
'@hono/node-server': ^1.11.1
|
||||
hono: ^4.6.0
|
||||
|
||||
'@hono/vite-dev-server@0.17.0':
|
||||
resolution: {integrity: sha512-EvGOIj1MoY9uV94onXXz88yWaTxzUK+Mv8LiIEsR/9eSFoVUnHVR0B7l7iNIsxfHYRN7tbPDMWBSnD2RQun3yw==}
|
||||
'@hono/vite-dev-server@0.19.0':
|
||||
resolution: {integrity: sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: '*'
|
||||
@ -1826,9 +1817,6 @@ packages:
|
||||
'@types/node@20.17.16':
|
||||
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
|
||||
|
||||
'@types/node@22.10.1':
|
||||
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
|
||||
|
||||
'@types/node@22.10.7':
|
||||
resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
|
||||
|
||||
@ -1843,9 +1831,6 @@ packages:
|
||||
'@types/websocket@1.0.10':
|
||||
resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==}
|
||||
|
||||
'@types/ws@8.5.13':
|
||||
resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==}
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.23.7':
|
||||
resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==}
|
||||
peerDependencies:
|
||||
@ -2270,8 +2255,8 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hono@4.7.5:
|
||||
resolution: {integrity: sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ==}
|
||||
hono@4.7.6:
|
||||
resolution: {integrity: sha512-564rVzELU+9BRqqx5k8sT2NFwGD3I3Vifdb6P7CmM6FiarOSY+fDC+6B+k9wcCb86ReoayteZP2ki0cRLN1jbw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hosted-git-info@6.1.3:
|
||||
@ -2755,18 +2740,18 @@ packages:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router-hono-server@2.11.0:
|
||||
resolution: {integrity: sha512-zn0kJUUamgxYS7mMDLv0kHCJE1UTX0bYNdfJeBLjw0xr/gnre0ttEZ2LTsFM8re1P2iMQ64mftpnSyeXIPijOA==}
|
||||
react-router-hono-server@2.13.0:
|
||||
resolution: {integrity: sha512-YcxmFpphZL9Jc4CnOgsKw35wcurmiOpTg3vBzjOROIUhEo6rKvUHOg7AM2QJi2Fa8BRJK6MvHaN4haedYo1iiA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20241112.0
|
||||
'@cloudflare/workers-types': ^4.20250317.0
|
||||
'@react-router/dev': ^7.2.0
|
||||
'@types/react': ^18.3.10 || ^19.0.0
|
||||
miniflare: ^3.20241205.0
|
||||
react-router: ^7.2.0
|
||||
vite: ^5.1.0 || ^6.0.0
|
||||
wrangler: ^3.91.0
|
||||
vite: ^6.0.0
|
||||
wrangler: ^4.2.0
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
@ -3679,7 +3664,7 @@ snapshots:
|
||||
'@codemirror/view': 6.36.1
|
||||
'@lezer/common': 1.2.3
|
||||
|
||||
'@codemirror/commands@6.8.0':
|
||||
'@codemirror/commands@6.8.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.10.8
|
||||
'@codemirror/state': 6.5.0
|
||||
@ -4015,23 +4000,23 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@hono/node-server@1.14.0(hono@4.7.5)':
|
||||
'@hono/node-server@1.14.0(hono@4.7.6)':
|
||||
dependencies:
|
||||
hono: 4.7.5
|
||||
hono: 4.7.6
|
||||
|
||||
'@hono/node-ws@1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10)':
|
||||
'@hono/node-ws@1.1.1(@hono/node-server@1.14.0(hono@4.7.6))(bufferutil@4.0.9)(hono@4.7.6)(utf-8-validate@5.0.10)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.14.0(hono@4.7.5)
|
||||
hono: 4.7.5
|
||||
'@hono/node-server': 1.14.0(hono@4.7.6)
|
||||
hono: 4.7.6
|
||||
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@hono/vite-dev-server@0.17.0(hono@4.7.5)':
|
||||
'@hono/vite-dev-server@0.19.0(hono@4.7.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.14.0(hono@4.7.5)
|
||||
hono: 4.7.5
|
||||
'@hono/node-server': 1.14.0(hono@4.7.6)
|
||||
hono: 4.7.6
|
||||
minimatch: 9.0.5
|
||||
|
||||
'@internationalized/date@3.6.0':
|
||||
@ -5325,10 +5310,6 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.19.8
|
||||
|
||||
'@types/node@22.10.1':
|
||||
dependencies:
|
||||
undici-types: 6.20.0
|
||||
|
||||
'@types/node@22.10.7':
|
||||
dependencies:
|
||||
undici-types: 6.20.0
|
||||
@ -5345,10 +5326,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.10.7
|
||||
|
||||
'@types/ws@8.5.13':
|
||||
dependencies:
|
||||
'@types/node': 22.10.1
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.23.7(@codemirror/autocomplete@6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3)
|
||||
@ -5552,7 +5529,7 @@ snapshots:
|
||||
codemirror@6.0.1(@lezer/common@1.2.3):
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3)
|
||||
'@codemirror/commands': 6.8.0
|
||||
'@codemirror/commands': 6.8.1
|
||||
'@codemirror/language': 6.10.8
|
||||
'@codemirror/lint': 6.8.2
|
||||
'@codemirror/search': 6.5.7
|
||||
@ -5821,7 +5798,7 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hono@4.7.5: {}
|
||||
hono@4.7.6: {}
|
||||
|
||||
hosted-git-info@6.1.3:
|
||||
dependencies:
|
||||
@ -6255,15 +6232,15 @@ snapshots:
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
|
||||
react-router-hono-server@2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)):
|
||||
react-router-hono-server@2.13.0(patch_hash=6549978df41006e07f1335bfe4ca86224ea36ed40d3f08dfef33143bad54005c)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)):
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.11.0
|
||||
'@hono/node-server': 1.14.0(hono@4.7.5)
|
||||
'@hono/node-ws': 1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10)
|
||||
'@hono/vite-dev-server': 0.17.0(hono@4.7.5)
|
||||
'@hono/node-server': 1.14.0(hono@4.7.6)
|
||||
'@hono/node-ws': 1.1.1(@hono/node-server@1.14.0(hono@4.7.6))(bufferutil@4.0.9)(hono@4.7.6)(utf-8-validate@5.0.10)
|
||||
'@hono/vite-dev-server': 0.19.0(hono@4.7.6)
|
||||
'@react-router/dev': 7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)
|
||||
'@types/react': 19.0.2
|
||||
hono: 4.7.5
|
||||
hono: 4.7.6
|
||||
react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
|
||||
transitivePeerDependencies:
|
||||
|
||||
@ -5,6 +5,7 @@ import { reactRouterHonoServer } from 'react-router-hono-server/dev';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const prefix = process.env.__INTERNAL_PREFIX || '/admin';
|
||||
if (prefix.endsWith('/')) {
|
||||
@ -18,9 +19,17 @@ if (!version) {
|
||||
throw new Error('Unable to read version from package.json');
|
||||
}
|
||||
|
||||
// Load the config without any environment variables (not needed here)
|
||||
const config = await readFile('config.example.yaml', 'utf-8');
|
||||
const { server } = parse(config);
|
||||
|
||||
export default defineConfig(({ isSsrBuild }) => ({
|
||||
base: isSsrBuild ? `${prefix}/` : undefined,
|
||||
plugins: [reactRouterHonoServer(), reactRouter(), tsconfigPaths()],
|
||||
server: {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user