mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0866ced767 | |||
| 40a2eb4186 | |||
| cc0c7fa61d | |||
| d25e5fd883 | |||
| 769674ab07 | |||
| 0276a500c7 | |||
| d6fbc3b5ad | |||
| 662c38ea44 | |||
| c827f56b6b | |||
| 753a403aed | |||
| ffce20e88a | |||
| eda8ed0b3e | |||
| 59b32c0842 | |||
| 68bd6200fc | |||
| f56d4e9d9f | |||
| 602e55a94d | |||
| 5e60ac1208 | |||
| b1da29b2fb | |||
| 0808299369 | |||
| 39d6602263 | |||
| df4f250e0f | |||
| 129e4f648b | |||
| 2a4d23d70f | |||
| f4da4c6428 | |||
| 7f5e0fe42d | |||
| 021ecabd3c | |||
| 81feb2f19a | |||
| abedf40690 | |||
| c9874966d2 | |||
| 887834efd9 | |||
| 6f2a0773ba | |||
| 8c01fcfe89 | |||
| f93acd8ef1 | |||
| a510796e38 | |||
| 69d97ea585 | |||
| 000e9326fb | |||
| 8299aac94f | |||
| 08ee0eff42 | |||
| d1f395acfd | |||
| 6bc20bd0e6 | |||
| 130b603d58 | |||
| ae0aadd83f | |||
| 6be55ec686 | |||
| fb3bc35b40 | |||
| dedec8f317 | |||
| 7eda09bae8 | |||
| ed4041073c | |||
| c5e6a6c407 | |||
| 608fcacdb1 | |||
| 3dca401837 | |||
| 785d5a663f | |||
| ece304638f | |||
| 1a2b1ad3f2 | |||
| 169a7b14d0 | |||
| 3a5ab68432 |
@@ -4,7 +4,3 @@ node_modules
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
|
||||
# Nix stuff
|
||||
/result
|
||||
.direnv
|
||||
|
||||
+26
-1
@@ -1,5 +1,30 @@
|
||||
### 0.6.0 (May 25, 2025)
|
||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
||||
- Older versions of Headscale will not work with Headplane.
|
||||
|
||||
- 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)).
|
||||
- Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
|
||||
|
||||
### 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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+53
-31
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Key, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Select from '~/components/Select';
|
||||
import type { Machine, User } from '~/types';
|
||||
@@ -10,6 +11,8 @@ interface MoveProps {
|
||||
}
|
||||
|
||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
const [userId, setUserId] = useState<Key | null>(null);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
@@ -17,13 +20,18 @@ 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} />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="to"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
defaultSelectedKey={machine.user.id}
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
|
||||
@@ -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,17 +45,17 @@ export default function Routes({
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
{subnet.map((route) => (
|
||||
<TableList.Item key={route.id}>
|
||||
<p>{route.prefix}</p>
|
||||
{subnets.map((route) => (
|
||||
<TableList.Item key={route}>
|
||||
<p>{route}</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
defaultSelected={node.approvedRoutes.includes(route)}
|
||||
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].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)
|
||||
.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,218 @@ 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 { node } = await context.client.get<{ node: Machine }>(
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const newApproved = node.approvedRoutes;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
|
||||
routes: newApproved,
|
||||
});
|
||||
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
async function reassignMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
|
||||
+193
-131
@@ -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';
|
||||
@@ -10,8 +11,11 @@ import Link from '~/components/Link';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { Machine, 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,28 +37,27 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
const [machine, { users }] = await Promise.all([
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ routes: Route[] }>(
|
||||
'v1/routes',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
||||
const [node] = mapNodes([machine.node], lookup);
|
||||
const tags = Array.from(
|
||||
new Set([...node.validTags, ...node.forcedTags]),
|
||||
).sort();
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter((route) => route.node.id === params.id),
|
||||
users: users.users,
|
||||
node,
|
||||
tags,
|
||||
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,14 @@ export async function action(request: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users, agent } =
|
||||
const { node, tags, 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 +82,7 @@ export default function Page() {
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{machine.givenName}
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -136,17 +91,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 +109,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)}
|
||||
{tags.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,12 +156,12 @@ 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) => (
|
||||
<li key={route.id}>{route.prefix}</li>
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -246,12 +188,12 @@ 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) => (
|
||||
<li key={route.id}>{route.prefix}</li>
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -277,9 +219,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 +244,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 '—';
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { InfoIcon } from '@primer/octicons-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { Machine, 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,15 +40,11 @@ export async function loader({
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
const [{ nodes }, { users }] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ routes: Route[] }>(
|
||||
'v1/routes',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
@@ -59,16 +55,22 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
populatedNodes,
|
||||
nodes,
|
||||
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 +101,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 +127,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 +139,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 +158,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>
|
||||
);
|
||||
}
|
||||
@@ -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_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` 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,
|
||||
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_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
+11
-15
@@ -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');
|
||||
+15
-9
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Key, useState } from 'react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Link from '~/components/Link';
|
||||
@@ -7,30 +7,36 @@ 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);
|
||||
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<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" />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<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"
|
||||
onSelectionChange={(value) => {
|
||||
setUserId(value);
|
||||
}}
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<NumberInput
|
||||
@@ -47,7 +53,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 +70,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">
|
||||
+8
-6
@@ -1,17 +1,19 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { PreAuthKey } from '~/types';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
interface ExpireAuthKeyProps {
|
||||
authKey: PreAuthKey;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Expire Key</Dialog.Button>
|
||||
<Dialog.Panel method="DELETE" variant="destructive">
|
||||
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="action_id" value="expire_preauthkey" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it from
|
||||
@@ -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.id);
|
||||
|
||||
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.id === key.user.id);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} url={url} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,78 @@ 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] },
|
||||
),
|
||||
});
|
||||
|
||||
log.debug(
|
||||
'config',
|
||||
'Requesting Docker containers with filters: %s',
|
||||
qp.toString(),
|
||||
);
|
||||
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 +205,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 +247,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);
|
||||
}
|
||||
|
||||
+64
-195
@@ -1,5 +1,5 @@
|
||||
import { constants, access, readFile } from 'node:fs/promises';
|
||||
import { env } from 'node:process';
|
||||
import { env, exit } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import { configDotenv } from 'dotenv';
|
||||
import { parseDocument } from 'yaml';
|
||||
@@ -7,56 +7,10 @@ import log from '~/utils/log';
|
||||
import { EnvOverrides, envVariables } from './env';
|
||||
import {
|
||||
HeadplaneConfig,
|
||||
PartialHeadplaneConfig,
|
||||
headplaneConfig,
|
||||
partialHeadplaneConfig,
|
||||
} from './schema';
|
||||
|
||||
// A whitelist of known secret paths that should be processed
|
||||
// and loaded from files.
|
||||
const SECRET_PATHS = new Set([
|
||||
'server.cookie_secret_path',
|
||||
'server.agent.authkey_path',
|
||||
'oidc.client_secret_path',
|
||||
'oidc.headscale_api_key_path',
|
||||
'headscale.api_key_path',
|
||||
'headscale.tls_cert_path',
|
||||
'headscale.tls_key_path',
|
||||
]);
|
||||
|
||||
// Custom Error for configuration issues
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for environment variable interpolation in file paths
|
||||
function interpolateEnvVars(filePath: string): string {
|
||||
const interpolatedPath = filePath.replace(
|
||||
/\$\{(.*?)\}/g,
|
||||
(match, varName) => {
|
||||
const value = env[varName];
|
||||
if (value === undefined) {
|
||||
throw new ConfigError(
|
||||
`Environment variable "${varName}" not found for path interpolation in "${filePath}"`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
);
|
||||
if (interpolatedPath !== filePath) {
|
||||
log.debug(
|
||||
'config',
|
||||
'Interpolated path "%s" to "%s"',
|
||||
filePath,
|
||||
interpolatedPath,
|
||||
);
|
||||
}
|
||||
return interpolatedPath;
|
||||
}
|
||||
|
||||
// loadConfig is a has a lifetime of the entire application and is
|
||||
// used to load the configuration for Headplane. It is called once.
|
||||
//
|
||||
@@ -64,44 +18,35 @@ function interpolateEnvVars(filePath: string): string {
|
||||
// But this may not be necessary as a use-case anyways
|
||||
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
||||
log.debug('config', 'Loading configuration file: %s', path);
|
||||
|
||||
const isValidPath = await validateConfigPath(path);
|
||||
if (!isValidPath) {
|
||||
throw new ConfigError(`Config file not accessible: "${path}"`);
|
||||
const valid = await validateConfigPath(path);
|
||||
if (!valid) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const rawData = await loadConfigFile(path);
|
||||
if (typeof rawData !== 'object' || rawData === null) {
|
||||
throw new ConfigError('Loaded configuration data is not a valid object.');
|
||||
const data = await loadConfigFile(path);
|
||||
if (!data) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Initial validation
|
||||
const initialValidatedConfig = validateConfig({
|
||||
...(rawData as Record<string, unknown>),
|
||||
debug: log.debugEnabled,
|
||||
});
|
||||
|
||||
// Deep clone before mutation by loadSecretsFromFiles
|
||||
let configObject = JSON.parse(JSON.stringify(initialValidatedConfig));
|
||||
|
||||
// Process *_path fields from the YAML file itself, regardless of env loading
|
||||
await loadSecretsFromFiles(configObject);
|
||||
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
||||
if (!config) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!loadEnv) {
|
||||
log.debug('config', 'Environment variable overrides are disabled');
|
||||
log.debug('config', 'This also disables the loading of a .env file');
|
||||
return validateConfig(configObject); // Re-validate after potential modifications by loadSecretsFromFiles
|
||||
return config;
|
||||
}
|
||||
|
||||
log.info('config', 'Loading a .env file (if available)');
|
||||
configDotenv({ override: true });
|
||||
config = coalesceEnv(config);
|
||||
if (!config) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
configObject = coalesceEnv(configObject);
|
||||
|
||||
// Processes any new or modified secret paths that came from environment variables
|
||||
await loadSecretsFromFiles(configObject);
|
||||
|
||||
return validateConfig(configObject);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function hp_loadConfig() {
|
||||
@@ -123,15 +68,14 @@ export async function hp_loadConfig() {
|
||||
// }
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string): Promise<boolean> {
|
||||
async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.R_OK);
|
||||
log.info('config', 'Found a valid configuration file at %s', path);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
} catch (error) {
|
||||
log.error('config', 'Unable to read a configuration file at %s', path);
|
||||
log.error('config', '%s', message);
|
||||
log.error('config', '%s', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -143,14 +87,11 @@ async function loadConfigFile(path: string): Promise<unknown> {
|
||||
const configYaml = parseDocument(data);
|
||||
if (configYaml.errors.length > 0) {
|
||||
log.error('config', 'Cannot parse configuration file at %s', path);
|
||||
let errorMessages = '';
|
||||
for (const error of configYaml.errors) {
|
||||
log.error('config', ` - ${error.toString()}`);
|
||||
errorMessages += `${error.toString()}\n`;
|
||||
}
|
||||
throw new ConfigError(
|
||||
`YAML parsing error in "${path}":\n${errorMessages}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (configYaml.warnings.length > 0) {
|
||||
@@ -165,39 +106,47 @@ async function loadConfigFile(path: string): Promise<unknown> {
|
||||
}
|
||||
|
||||
return configYaml.toJSON() as unknown;
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
} catch (e) {
|
||||
log.error('config', 'Error reading configuration file at %s', path);
|
||||
log.error('config', '%s', message);
|
||||
if (e instanceof ConfigError) throw e;
|
||||
throw new ConfigError(`File read error for "${path}": ${message}`);
|
||||
log.error('config', '%s', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown): HeadplaneConfig {
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug('config', 'Validating Headplane configuration');
|
||||
const result = headplaneConfig(config);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
log.error('config', 'Error validating Headplane configuration:');
|
||||
let errorSummary = '';
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
errorSummary += `(${number}): ${error.toString()}\n`;
|
||||
}
|
||||
throw new ConfigError(`Configuration validation failed:\n${errorSummary}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function coalesceEnv(config: HeadplaneConfig): HeadplaneConfig {
|
||||
function coalesceEnv(config: HeadplaneConfig) {
|
||||
const envConfig: Record<string, unknown> = {};
|
||||
const rootKeys: string[] = Object.values(envVariables);
|
||||
|
||||
// Typescript is still insanely stupid at nullish filtering
|
||||
const vars = Object.entries(env).filter(([key, value]) => {
|
||||
if (!value) return false;
|
||||
if (!key.startsWith('HEADPLANE_')) return false;
|
||||
if (rootKeys.includes(key)) return false;
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!key.startsWith('HEADPLANE_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out the rootEnv configurations
|
||||
if (rootKeys.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as [string, string][];
|
||||
|
||||
@@ -208,125 +157,45 @@ function coalesceEnv(config: HeadplaneConfig): HeadplaneConfig {
|
||||
'config',
|
||||
` - ${key}=${new Array(value.length).fill('*').join('')}`,
|
||||
);
|
||||
|
||||
let current = envConfig;
|
||||
while (configPath.length > 1) {
|
||||
const pathPart = configPath.shift() as string;
|
||||
if (!(pathPart in current)) current[pathPart] = {};
|
||||
current = current[pathPart] as Record<string, unknown>;
|
||||
const path = configPath.shift() as string;
|
||||
if (!(path in current)) {
|
||||
current[path] = {};
|
||||
}
|
||||
|
||||
current = current[path] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[configPath[0]] = value;
|
||||
}
|
||||
|
||||
// coalesceConfig will throw ConfigError if validation of env vars fails.
|
||||
// If it succeeds, toMerge will be a valid PartialHeadplaneConfig.
|
||||
const toMerge = coalesceConfig(envConfig);
|
||||
if (!toMerge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If coalesceConfig did not throw, proceed to merge.
|
||||
return deepMerge(config, toMerge as DeepPartial<HeadplaneConfig>);
|
||||
// Deep merge the environment variables into the configuration
|
||||
// This will overwrite any existing values in the configuration
|
||||
return deepMerge(config, toMerge);
|
||||
}
|
||||
|
||||
export function coalesceConfig(config: unknown): PartialHeadplaneConfig {
|
||||
export function coalesceConfig(config: unknown) {
|
||||
log.debug('config', 'Revalidating config after coalescing variables');
|
||||
const out = partialHeadplaneConfig(config);
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'Error parsing environment variables into partial config:',
|
||||
);
|
||||
let errorSummary = '';
|
||||
log.error('config', 'Error parsing variables:');
|
||||
for (const [number, error] of out.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
errorSummary += `(${number}): ${error.toString()}\n`;
|
||||
}
|
||||
throw new ConfigError(
|
||||
`Environment variable validation failed:\n${errorSummary}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Safely interpolates environment variables with error handling
|
||||
function safeInterpolateEnvVars(value: string, fullKeyPath: string): string {
|
||||
try {
|
||||
return interpolateEnvVars(value);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ConfigError) throw e;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
log.error('config', 'Interpolation error for %s: %s', fullKeyPath, message);
|
||||
throw new ConfigError(`Interpolation error for ${fullKeyPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Processes a secret path field by reading the file and setting the value
|
||||
async function processSecretPath(
|
||||
configObject: Record<string, unknown>,
|
||||
key: string,
|
||||
value: string,
|
||||
fullKeyPath: string,
|
||||
): Promise<void> {
|
||||
const valueKey = key.substring(0, key.length - '_path'.length);
|
||||
const processedPath = safeInterpolateEnvVars(value, fullKeyPath);
|
||||
|
||||
log.debug(
|
||||
'config',
|
||||
'Loading value for "%s" from file (via %s): %s',
|
||||
valueKey,
|
||||
fullKeyPath,
|
||||
processedPath,
|
||||
);
|
||||
|
||||
try {
|
||||
const secretContent = await readFile(processedPath, 'utf8');
|
||||
configObject[valueKey] = secretContent.trim();
|
||||
delete configObject[key];
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to read file "%s" for %s: %s',
|
||||
processedPath,
|
||||
fullKeyPath,
|
||||
message,
|
||||
);
|
||||
throw new ConfigError(
|
||||
`File read error for ${fullKeyPath} (path: ${processedPath}): ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively processes the config tree to find and evaluate secret paths
|
||||
// and loads their file contents into the config.
|
||||
async function loadSecretsFromFiles(
|
||||
currentConfigLevel: Record<string, unknown>,
|
||||
currentPathPrefix = '',
|
||||
): Promise<void> {
|
||||
for (const key in currentConfigLevel) {
|
||||
if (!Object.prototype.hasOwnProperty.call(currentConfigLevel, key)) {
|
||||
continue; // Skip inherited properties
|
||||
}
|
||||
|
||||
const value = currentConfigLevel[key];
|
||||
const fullKeyPath = currentPathPrefix ? `${currentPathPrefix}.${key}` : key;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (SECRET_PATHS.has(fullKeyPath)) {
|
||||
await processSecretPath(currentConfigLevel, key, value, fullKeyPath);
|
||||
} else {
|
||||
currentConfigLevel[key] = safeInterpolateEnvVars(value, fullKeyPath);
|
||||
}
|
||||
}
|
||||
// Recurse through the nested object
|
||||
else if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
await loadSecretsFromFiles(value as Record<string, unknown>, fullKeyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DeepPartial<T> =
|
||||
| {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
@@ -340,7 +209,7 @@ function deepMerge<T>(target: T, source: DeepPartial<T>): T {
|
||||
|
||||
for (const key in source) {
|
||||
const val = source[key];
|
||||
if (val === undefined) {
|
||||
if (val === undefined || val === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+66
-410
@@ -1,288 +1,85 @@
|
||||
import { Type, type } from 'arktype';
|
||||
import { type } from 'arktype';
|
||||
|
||||
// Configuration Schema for Headplane
|
||||
//
|
||||
// For OIDC client secret, Headplane supports two ways to provide it:
|
||||
// 1. Directly in the config file or environment variable (client_secret)
|
||||
// 2. As a path to a file containing the secret (client_secret_path)
|
||||
//
|
||||
// Only one of these should be set. If client_secret_path is provided,
|
||||
// Headplane will read the secret from that file during startup.
|
||||
|
||||
/**
|
||||
* Generates an ArkType schema for a field that can be provided either as a direct value
|
||||
* or via a file path. Enforces mutual exclusivity and optional overall mandatoriness.
|
||||
*
|
||||
* @param key The base name for the field (e.g., "client_secret").
|
||||
* @param options Configuration options:
|
||||
* - mandatory: If true, ensures either the direct value or the path is provided.
|
||||
* - valueType: ArkType string for the direct value (e.g., "string", "32 <= string <= 32"). Defaults to "string".
|
||||
* @returns An ArkType Type definition for the value/path pair.
|
||||
*/
|
||||
function valueOrPath<Key extends string>(
|
||||
key: Key,
|
||||
options?: { mandatory?: boolean; valueType?: string },
|
||||
) {
|
||||
const pathKey = `${key}_path` as const;
|
||||
const valueTypeString = options?.valueType || 'string';
|
||||
|
||||
return type({
|
||||
[key]: valueTypeString,
|
||||
[pathKey]: 'string',
|
||||
}).narrow((obj: unknown, ctx) => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return ctx.reject('Expected an object');
|
||||
const stringToBool = type('string | boolean').pipe((v) => {
|
||||
if (typeof v === 'string') {
|
||||
if (v === '1' || v === 'true' || v === 'yes') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const valProperty = (obj as Record<string, unknown>)[key];
|
||||
const pathProperty = (obj as Record<string, unknown>)[pathKey];
|
||||
|
||||
const hasVal =
|
||||
valProperty !== undefined &&
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== undefined &&
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`);
|
||||
if (v === '0' || v === 'false' || v === 'no') {
|
||||
return false;
|
||||
}
|
||||
if (options?.mandatory && !hasVal && !hasPath) {
|
||||
return ctx.reject(
|
||||
`Either "${key}" or "${pathKey}" must be provided for ${key}.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const stringToBool = type('string | boolean').pipe((v) => Boolean(v));
|
||||
throw new Error(`Invalid string value for boolean: ${v}`);
|
||||
}
|
||||
|
||||
// --- Agent Config (defined separately for clarity in partial) ---
|
||||
const agentObjectDefinition = type({
|
||||
'authkey?': '(string | null)',
|
||||
'authkey_path?': '(string | null)',
|
||||
ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
})
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ArkType context object
|
||||
.narrow((obj, ctx: any) => {
|
||||
const key = 'authkey';
|
||||
const pathKey = 'authkey_path';
|
||||
const valProperty = obj[key];
|
||||
const pathProperty = obj[pathKey];
|
||||
return Boolean(v);
|
||||
});
|
||||
|
||||
const hasVal =
|
||||
valProperty !== undefined &&
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== undefined &&
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
|
||||
console.log(
|
||||
`Agent Narrow Check: authkey="${valProperty}", authkey_path="${pathProperty}", hasVal=${hasVal}, hasPath=${hasPath}`,
|
||||
);
|
||||
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(
|
||||
`Only one of agent "${key}" or "${pathKey}" may be set.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialAgentConfig = type({
|
||||
'authkey??': '(string | null)',
|
||||
'authkey_path??': '(string | null)',
|
||||
'ttl??': 'number.integer',
|
||||
'cache_path??': 'string',
|
||||
})
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ArkType context object
|
||||
.narrow((obj, ctx: any) => {
|
||||
const key = 'authkey?';
|
||||
const pathKey = 'authkey_path?';
|
||||
const valProperty = obj[key];
|
||||
const pathProperty = obj[pathKey];
|
||||
if (valProperty !== undefined && pathProperty !== undefined) {
|
||||
const hasVal =
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(
|
||||
`Only one of agent "${key}" or "${pathKey}" may be set in partial config.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const agentConfig = agentObjectDefinition.default(() => ({
|
||||
authkey: null,
|
||||
ttl: 180000,
|
||||
cache_path: '/var/lib/headplane/agent_cache.json',
|
||||
}));
|
||||
|
||||
// --- Main Configurations ---
|
||||
const serverConfig = type({
|
||||
host: 'string.ip',
|
||||
port: type('string | number.integer').pipe((v) => Number(v)),
|
||||
'cookie_secret?': '((32 <= string <= 32) | null)',
|
||||
'cookie_secret_path?': 'string',
|
||||
cookie_secret: '32 <= string <= 32',
|
||||
cookie_secure: stringToBool,
|
||||
agent: agentConfig,
|
||||
}).narrow((obj, ctx: any) => {
|
||||
const key = 'cookie_secret';
|
||||
const pathKey = 'cookie_secret_path';
|
||||
const valProperty = obj[key];
|
||||
const pathProperty = obj[pathKey];
|
||||
|
||||
const hasVal =
|
||||
valProperty !== undefined &&
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== undefined &&
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`);
|
||||
}
|
||||
if (!hasVal && !hasPath) {
|
||||
return ctx.reject(
|
||||
`Either "${key}" or "${pathKey}" must be provided for ${key}.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const oidcConfig = type({
|
||||
issuer: 'string.url',
|
||||
client_id: 'string',
|
||||
'client_secret?': '(string | null)',
|
||||
'client_secret_path?': 'string',
|
||||
'headscale_api_key?': '(string | null)',
|
||||
'headscale_api_key_path?': 'string',
|
||||
client_secret: 'string?',
|
||||
client_secret_path: 'string?',
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
|
||||
'redirect_uri?': 'string.url',
|
||||
user_storage_file: type('string').default('/var/lib/headplane/users.json'),
|
||||
redirect_uri: 'string.url?',
|
||||
user_storage_file: 'string = "/var/lib/headplane/users.json"',
|
||||
disable_api_key_login: stringToBool,
|
||||
headscale_api_key: 'string',
|
||||
strict_validation: stringToBool.default(true),
|
||||
})
|
||||
.narrow((obj, ctx: any) => {
|
||||
const clientSecretKey = 'client_secret';
|
||||
const clientSecretPathKey = 'client_secret_path';
|
||||
const clientSecretVal = obj[clientSecretKey];
|
||||
const clientSecretPathVal = obj[clientSecretPathKey];
|
||||
const hasClientSecretVal =
|
||||
clientSecretVal !== undefined &&
|
||||
clientSecretVal !== null &&
|
||||
(typeof clientSecretVal === 'string' ? clientSecretVal !== '' : true);
|
||||
const hasClientSecretPath =
|
||||
clientSecretPathVal !== undefined &&
|
||||
clientSecretPathVal !== null &&
|
||||
typeof clientSecretPathVal === 'string' &&
|
||||
clientSecretPathVal !== '';
|
||||
|
||||
if (hasClientSecretVal && hasClientSecretPath) {
|
||||
return ctx.reject(
|
||||
`Only one of "${clientSecretKey}" or "${clientSecretPathKey}" may be set.`,
|
||||
);
|
||||
}
|
||||
if (obj.issuer && obj.client_id) {
|
||||
if (!hasClientSecretVal && !hasClientSecretPath) {
|
||||
return ctx.reject(
|
||||
`Either "${clientSecretKey}" or "${clientSecretPathKey}" must be provided for client_secret if OIDC is configured.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hsApiKey = 'headscale_api_key';
|
||||
const hsApiKeyPath = 'headscale_api_key_path';
|
||||
const hsApiVal = obj[hsApiKey];
|
||||
const hsApiPathVal = obj[hsApiKeyPath];
|
||||
const hasHsApiVal =
|
||||
hsApiVal !== undefined &&
|
||||
hsApiVal !== null &&
|
||||
(typeof hsApiVal === 'string' ? hsApiVal !== '' : true);
|
||||
const hasHsApiPath =
|
||||
hsApiPathVal !== undefined &&
|
||||
hsApiPathVal !== null &&
|
||||
typeof hsApiPathVal === 'string' &&
|
||||
hsApiPathVal !== '';
|
||||
|
||||
if (hasHsApiVal && hasHsApiPath) {
|
||||
return ctx.reject(
|
||||
`Only one of "${hsApiKey}" or "${hsApiKeyPath}" may be set.`,
|
||||
);
|
||||
}
|
||||
if (obj.issuer && obj.client_id) {
|
||||
if (!hasHsApiVal && !hasHsApiPath) {
|
||||
return ctx.reject(
|
||||
`Either "${hsApiKey}" or "${hsApiKeyPath}" must be provided for oidc.headscale_api_key if OIDC is configured.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const headscaleConfig = type({
|
||||
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
||||
'api_key?': 'string',
|
||||
'api_key_path?': 'string',
|
||||
'tls_cert_path?': '(string | null)',
|
||||
'public_url?': 'string.url',
|
||||
'config_path?': 'string',
|
||||
tls_cert_path: 'string?',
|
||||
public_url: 'string.url?',
|
||||
config_path: 'string?',
|
||||
config_strict: stringToBool,
|
||||
})
|
||||
.narrow((obj, ctx: any) => {
|
||||
const key = 'api_key';
|
||||
const pathKey = 'api_key_path';
|
||||
const valProperty = obj[key];
|
||||
const pathProperty = obj[pathKey];
|
||||
const hasVal =
|
||||
valProperty !== undefined &&
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== undefined &&
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
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 partialAgentConfig = type({
|
||||
enabled: stringToBool,
|
||||
host_name: 'string | undefined',
|
||||
pre_authkey: 'string | undefined',
|
||||
cache_ttl: 'number.integer | undefined',
|
||||
cache_path: 'string | undefined',
|
||||
executable_path: 'string | undefined',
|
||||
work_dir: 'string | undefined',
|
||||
}).partial();
|
||||
|
||||
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 partialDockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string | undefined',
|
||||
container_label: 'string | undefined',
|
||||
socket: 'string | undefined',
|
||||
}).partial();
|
||||
|
||||
const kubernetesConfig = type({
|
||||
enabled: stringToBool,
|
||||
pod_name: 'string',
|
||||
@@ -297,171 +94,30 @@ const integrationConfig = type({
|
||||
'docker?': dockerConfig,
|
||||
'kubernetes?': kubernetesConfig,
|
||||
'proc?': procConfig,
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
'agent?': agentConfig,
|
||||
});
|
||||
|
||||
const partialIntegrationConfig = type({
|
||||
'docker?': partialDockerConfig,
|
||||
'kubernetes?': kubernetesConfig.partial(),
|
||||
'proc?': procConfig.partial(),
|
||||
'agent?': partialAgentConfig,
|
||||
}).partial();
|
||||
|
||||
export const headplaneConfig = type({
|
||||
debug: stringToBool.default(false),
|
||||
debug: stringToBool,
|
||||
server: serverConfig,
|
||||
'oidc?': oidcConfig,
|
||||
'integration?': integrationConfig,
|
||||
headscale: headscaleConfig,
|
||||
}).onDeepUndeclaredKey('delete');
|
||||
|
||||
// --- Partial Configurations (Explicitly defined field by field) ---
|
||||
|
||||
const partialServerConfig = type({
|
||||
'host?': 'string.ip',
|
||||
'port?': type('string | number.integer').pipe((v) => Number(v)),
|
||||
'cookie_secret?': '((32 <= string <= 32) | null)',
|
||||
'cookie_secret_path?': 'string',
|
||||
'cookie_secure?': stringToBool,
|
||||
'agent?': partialAgentConfig,
|
||||
}).narrow((obj, ctx: any) => {
|
||||
const key = 'cookie_secret';
|
||||
const pathKey = 'cookie_secret_path';
|
||||
const valProperty = obj[key];
|
||||
const pathProperty = obj[pathKey];
|
||||
const hasVal =
|
||||
valProperty !== undefined &&
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== undefined &&
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`);
|
||||
}
|
||||
if (Object.keys(obj).length > 0 && !obj.agent && !obj.cookie_secure) {
|
||||
if (!hasVal && !hasPath) {
|
||||
return ctx.reject(
|
||||
`Either "${key}" or "${pathKey}" must be provided for cookie_secret if server section is present.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const partialOidcConfig = type({
|
||||
'issuer?': 'string.url',
|
||||
'client_id?': 'string',
|
||||
'client_secret?': '(string | null)',
|
||||
'client_secret_path?': 'string',
|
||||
'token_endpoint_auth_method?':
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
|
||||
'redirect_uri?': 'string.url',
|
||||
'user_storage_file?': 'string',
|
||||
'disable_api_key_login?': stringToBool,
|
||||
'headscale_api_key?': '(string | null)',
|
||||
'headscale_api_key_path?': 'string',
|
||||
'strict_validation?': stringToBool,
|
||||
})
|
||||
.narrow((obj, ctx: any) => {
|
||||
const clientSecretKey = 'client_secret';
|
||||
const clientSecretPathKey = 'client_secret_path';
|
||||
const clientSecretVal = obj[clientSecretKey];
|
||||
const clientSecretPathVal = obj[clientSecretPathKey];
|
||||
const hasClientSecretVal =
|
||||
clientSecretVal !== undefined &&
|
||||
clientSecretVal !== null &&
|
||||
(typeof clientSecretVal === 'string' ? clientSecretVal !== '' : true);
|
||||
const hasClientSecretPath =
|
||||
clientSecretPathVal !== undefined &&
|
||||
clientSecretPathVal !== null &&
|
||||
typeof clientSecretPathVal === 'string' &&
|
||||
clientSecretPathVal !== '';
|
||||
if (hasClientSecretVal && hasClientSecretPath) {
|
||||
return ctx.reject(
|
||||
`Only one of "${clientSecretKey}" or "${clientSecretPathKey}" may be set.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
obj.issuer &&
|
||||
obj.client_id &&
|
||||
!hasClientSecretVal &&
|
||||
!hasClientSecretPath
|
||||
) {
|
||||
return ctx.reject(
|
||||
`Either "${clientSecretKey}" or "${clientSecretPathKey}" must be provided if issuer and client_id are set in partial OIDC config.`,
|
||||
);
|
||||
}
|
||||
|
||||
const hsApiKey = 'headscale_api_key';
|
||||
const hsApiKeyPath = 'headscale_api_key_path';
|
||||
const hsApiVal = obj[hsApiKey];
|
||||
const hsApiPathVal = obj[hsApiKeyPath];
|
||||
const hasHsApiVal =
|
||||
hsApiVal !== undefined &&
|
||||
hsApiVal !== null &&
|
||||
(typeof hsApiVal === 'string' ? hsApiVal !== '' : true);
|
||||
const hasHsApiPath =
|
||||
hsApiPathVal !== undefined &&
|
||||
hsApiPathVal !== null &&
|
||||
typeof hsApiPathVal === 'string' &&
|
||||
hsApiPathVal !== '';
|
||||
if (hasHsApiVal && hasHsApiPath) {
|
||||
return ctx.reject(
|
||||
`Only one of "${hsApiKey}" or "${hsApiKeyPath}" may be set.`,
|
||||
);
|
||||
}
|
||||
if (obj.issuer && obj.client_id && !hasHsApiVal && !hasHsApiPath) {
|
||||
return ctx.reject(
|
||||
`Either "${hsApiKey}" or "${hsApiKeyPath}" must be provided for headscale_api_key if issuer and client_id are set in partial OIDC config.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialHeadscaleConfig = type({
|
||||
'url?': type('string.url').pipe((v) =>
|
||||
v.endsWith('/') ? v.slice(0, -1) : v,
|
||||
),
|
||||
'api_key?': 'string',
|
||||
'api_key_path?': 'string',
|
||||
'tls_cert_path?': '(string | null)',
|
||||
'public_url?': 'string.url',
|
||||
'config_path?': 'string',
|
||||
'config_strict?': stringToBool,
|
||||
})
|
||||
.narrow((obj, ctx: any) => {
|
||||
const key = 'api_key';
|
||||
const pathKey = 'api_key_path';
|
||||
const valProperty = obj[key];
|
||||
const pathProperty = obj[pathKey];
|
||||
const hasVal =
|
||||
valProperty !== undefined &&
|
||||
valProperty !== null &&
|
||||
(typeof valProperty === 'string' ? valProperty !== '' : true);
|
||||
const hasPath =
|
||||
pathProperty !== undefined &&
|
||||
pathProperty !== null &&
|
||||
typeof pathProperty === 'string' &&
|
||||
pathProperty !== '';
|
||||
if (hasVal && hasPath) {
|
||||
return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialDockerConfig = dockerConfig.partial();
|
||||
const partialKubernetesConfig = kubernetesConfig.partial();
|
||||
const partialProcConfig = procConfig.partial();
|
||||
|
||||
const partialIntegrationConfig = type({
|
||||
'docker?': partialDockerConfig,
|
||||
'kubernetes?': partialKubernetesConfig,
|
||||
'proc?': partialProcConfig,
|
||||
}).partial();
|
||||
|
||||
export const partialHeadplaneConfig = type({
|
||||
'debug?': stringToBool,
|
||||
'server?': partialServerConfig,
|
||||
'oidc?': partialOidcConfig,
|
||||
'integration?': integrationConfig.partial(),
|
||||
'headscale?': partialHeadscaleConfig,
|
||||
debug: stringToBool,
|
||||
server: serverConfig.partial(),
|
||||
'oidc?': oidcConfig.partial(),
|
||||
'integration?': partialIntegrationConfig,
|
||||
headscale: headscaleConfig.partial(),
|
||||
}).partial();
|
||||
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?',
|
||||
|
||||
+4
-21
@@ -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);
|
||||
},
|
||||
|
||||
+277
-123
@@ -1,163 +1,317 @@
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { open, readFile, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
constants,
|
||||
access,
|
||||
mkdir,
|
||||
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 | null,
|
||||
path: string,
|
||||
ttl: number,
|
||||
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||
headscaleUrl: string,
|
||||
) {
|
||||
if (authkey === null || authkey.length === 0) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Agent authkey is not configured or is empty, agent support will be disabled.',
|
||||
);
|
||||
return undefined;
|
||||
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) {
|
||||
// Try to create the directory just in case
|
||||
try {
|
||||
await mkdir(config.work_dir, { recursive: true });
|
||||
log.debug('config', 'Created agent work dir at %s', config.work_dir);
|
||||
log.info(
|
||||
'config',
|
||||
'Created missing agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
|
||||
return;
|
||||
} catch (innerError) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to create agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.info(
|
||||
'config',
|
||||
'Agent work dir not accessible at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
log.debug('config', 'Create error details: %s', innerError);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+73
-61
@@ -31,7 +31,7 @@ async function loadClientSecret(path: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
return secret;
|
||||
return secret.trim();
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read client secret from %s', path);
|
||||
log.error('config', 'Error: %s', error);
|
||||
@@ -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
|
||||
|
||||
@@ -26,4 +26,9 @@ export interface Machine {
|
||||
validTags: string[];
|
||||
givenName: string;
|
||||
online: boolean;
|
||||
|
||||
// Added in Headscale 0.26+
|
||||
approvedRoutes: string[];
|
||||
availableRoutes: string[];
|
||||
subnetRoutes: string[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { User } from './User';
|
||||
|
||||
export interface PreAuthKey {
|
||||
id: string;
|
||||
key: string;
|
||||
user: string;
|
||||
user: User;
|
||||
reusable: boolean;
|
||||
ephemeral: boolean;
|
||||
used: boolean;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { HostInfo, Machine } from '~/types';
|
||||
|
||||
export interface PopulatedNode extends Machine {
|
||||
routes: string[];
|
||||
hostInfo?: HostInfo;
|
||||
expired: boolean;
|
||||
customRouting: {
|
||||
exitRoutes: string[];
|
||||
exitApproved: boolean;
|
||||
subnetApprovedRoutes: string[];
|
||||
subnetWaitingRoutes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function mapNodes(
|
||||
nodes: Machine[],
|
||||
stats?: Record<string, HostInfo> | undefined,
|
||||
): PopulatedNode[] {
|
||||
return nodes.map((node) => {
|
||||
const customRouting = {
|
||||
exitRoutes: node.availableRoutes.filter(
|
||||
(route) => route === '::/0' || route === '0.0.0.0/0',
|
||||
),
|
||||
exitApproved: node.approvedRoutes.some(
|
||||
(route) => route === '::/0' || route === '0.0.0.0/0',
|
||||
),
|
||||
subnetApprovedRoutes: node.approvedRoutes.filter(
|
||||
(route) =>
|
||||
route !== '::/0' &&
|
||||
route !== '0.0.0.0/0' &&
|
||||
node.availableRoutes.includes(route),
|
||||
),
|
||||
subnetWaitingRoutes: node.availableRoutes.filter(
|
||||
(route) =>
|
||||
route !== '::/0' &&
|
||||
route !== '0.0.0.0/0' &&
|
||||
!node.approvedRoutes.includes(route),
|
||||
),
|
||||
} satisfies PopulatedNode['customRouting'];
|
||||
|
||||
return {
|
||||
...node,
|
||||
routes: Array.from(new Set(node.availableRoutes)),
|
||||
hostInfo: stats?.[node.nodeKey],
|
||||
customRouting,
|
||||
expired:
|
||||
node.expiry === '0001-01-01 00:00:00' ||
|
||||
node.expiry === '0001-01-01T00:00:00Z' ||
|
||||
node.expiry === null
|
||||
? false
|
||||
: new Date(node.expiry).getTime() < Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -28,9 +28,6 @@
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+3
-2
@@ -8,10 +8,10 @@ networks:
|
||||
driver: "bridge"
|
||||
services:
|
||||
headscale:
|
||||
image: "headscale/headscale:0.25.1"
|
||||
image: "headscale/headscale:0.26.0-debug"
|
||||
container_name: "headscale"
|
||||
labels:
|
||||
- com.headplane.selector=headscale
|
||||
me.tale.headplane.target: headscale
|
||||
restart: "unless-stopped"
|
||||
command: "serve"
|
||||
networks:
|
||||
@@ -21,5 +21,6 @@ services:
|
||||
- "./test:/etc/headscale"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "9090:9090"
|
||||
environment:
|
||||
TZ: "America/New_York"
|
||||
|
||||
+49
-7
@@ -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.
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ different needs.
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.25 or newer (already deployed)
|
||||
- Headscale 0.26 or newer (already deployed)
|
||||
- Node.js 22 LTS or newer
|
||||
- [PNPM](https://pnpm.io/installation) 10.x
|
||||
- A finished configuration file (config.yaml)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -11,45 +11,6 @@ Headplane also stores stuff in the `/var/lib/headplane` directory by default.
|
||||
This can be configured on a per-section basis in the configuration file, but
|
||||
it is very important this directory is persistent and writable by Headplane.
|
||||
|
||||
## Sensitive Values
|
||||
Headplane supports a dual-mode pattern for providing certain sensitive configuration values, such as secrets, keys, and private certificates. For each such field, you can either:
|
||||
|
||||
1. Set the value directly in the configuration file (e.g., `cookie_secret: "your-32-character-long-secret"`)
|
||||
2. Provide a path to a file containing the value using the `_path` suffixed key (e.g., `cookie_secret_path: "/path/to/your/cookie_secret_file"`)
|
||||
|
||||
When using a `_path` option, the content of the specified file will be read and used as the value for the setting. These paths can include environment variable interpolation (e.g., `${CREDENTIALS_DIRECTORY}/my_secret_file`), which is useful for integration with tools like systemd's `LoadCredential`.
|
||||
|
||||
**Important Rules for Dual-Mode Fields:**
|
||||
- You **cannot** set both the direct value (e.g., `cookie_secret`) and its corresponding `_path` (e.g., `cookie_secret_path`) simultaneously. Doing so will result in a configuration error.
|
||||
- If a `_path` is provided, the corresponding direct value field (if also present and not null) will usually be ignored or may cause validation errors depending on the specific field and loader logic. It's best to provide only one.
|
||||
- The same dual-mode pattern works with environment variables. You can set `HEADPLANE_OIDC__CLIENT_SECRET` for a direct value or `HEADPLANE_OIDC__CLIENT_SECRET_PATH` to specify a path to a file containing the secret.
|
||||
|
||||
**Note on Path Processing:**
|
||||
Headplane uses a whitelist approach to determine which `_path` fields should have their content loaded as secrets. Only the explicitly whitelisted paths below will have their file content read and used as configuration values. All other paths with a `_path` suffix will have environment variables interpolated but will not have their content loaded.
|
||||
|
||||
The following configuration options in Headplane are treated as secret paths:
|
||||
|
||||
- **Server Settings (`server.*`):**
|
||||
- `cookie_secret_path` (for web session encoding)
|
||||
- `agent.authkey_path` (for the server's internal agent functionality)
|
||||
- *Note:* These are optional. If neither `authkey` nor `authkey_path` are provided for the server's internal agent, or if they resolve to null/empty, Headplane will log a message indicating that its internal agent support features are disabled and will proceed without error.
|
||||
|
||||
- **Headscale Connection Settings (`headscale.*`):**
|
||||
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
|
||||
- `tls_key_path` (custom TLS private key for connecting to Headscale)
|
||||
- `api_key_path` (Headscale API key for Headplane to connect to Headscale)
|
||||
|
||||
- **OIDC Settings (`oidc.*`):**
|
||||
- `client_secret_path` (OIDC client secret)
|
||||
- `headscale_api_key_path` (Headscale API key used by Headplane during the OIDC authentication flow)
|
||||
|
||||
**Distinction for Non-Secret Paths:**
|
||||
Other configuration fields that end with `_path` are treated as regular paths and will not have their content loaded. For example:
|
||||
- `server.agent.cache_path` (default: `/var/lib/headplane/agent_cache.json`): This is a direct file path where Headplane will *store* its agent cache data.
|
||||
- `headscale.config_path`: This is an optional path to Headscale's `config.yaml` file, which Headplane might read or use for validation.
|
||||
|
||||
All path fields (both secret and non-secret) support environment variable interpolation using the `${VAR_NAME}` syntax.
|
||||
|
||||
## Environment Variables
|
||||
It is also possible to override the configuration file using environment variables.
|
||||
These changes get merged *after* the configuration file is loaded, so they will take precedence.
|
||||
@@ -57,10 +18,6 @@ Environment variables follow this pattern: **`HEADPLANE_<SECTION>__<KEY_NAME>`**
|
||||
For example, to override `oidc.client_secret`, you would set `HEADPLANE_OIDC__CLIENT_SECRET`
|
||||
to the value that you want.
|
||||
|
||||
The path-based secret loading mechanism also works with environment variables. For instance:
|
||||
- `HEADPLANE_OIDC__CLIENT_SECRET_PATH=/path/to/secret/file` will load the OIDC client secret from the specified file
|
||||
- `HEADPLANE_SERVER__COOKIE_SECRET_PATH=${CREDENTIALS_DIRECTORY}/cookie_secret` will use environment variable interpolation in the path
|
||||
|
||||
Here are a few more examples:
|
||||
|
||||
- `HEADPLANE_HEADSCALE__URL`: `headscale.url`
|
||||
|
||||
@@ -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.
|
||||
+12
-5
@@ -26,7 +26,7 @@ experience.
|
||||
|
||||
Requirements:
|
||||
- Docker and Docker Compose
|
||||
- Headscale 0.25 or newer
|
||||
- Headscale 0.26 or newer
|
||||
- A finished configuration file (config.yaml)
|
||||
|
||||
Here is what a sample Docker Compose deployment would look like:
|
||||
@@ -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,16 +44,23 @@ 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'
|
||||
|
||||
# If you are using the Docker integration, mount the Docker socket
|
||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||
headscale:
|
||||
image: headscale/headscale:0.25.1
|
||||
image: headscale/headscale:0.26.0
|
||||
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
|
||||
@@ -171,7 +178,7 @@ spec:
|
||||
mountPath: /var/lib/headplane
|
||||
|
||||
- name: headscale
|
||||
image: headscale/headscale:0.25.1
|
||||
image: headscale/headscale:0.26.0
|
||||
command: ['serve']
|
||||
volumeMounts:
|
||||
- name: headscale-data
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ out [**Integrated Mode**](/docs/Integrated-Mode.md).
|
||||
|
||||
Requirements:
|
||||
- Docker and Docker Compose
|
||||
- Headscale 0.25 or newer (already deployed)
|
||||
- Headscale 0.26 or newer (already deployed)
|
||||
- A finished configuration file (config.yaml)
|
||||
|
||||
Here is what a sample Docker Compose deployment would look like:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
pnpmDeps = pnpm_10.fetchDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
hash = "sha256-GEtnKC8UcsudQNVAvO4aPpYaECu2dBTcfSp2m+RnBgc=";
|
||||
hash = "sha256-OOWgYaGwa5PtWhFEEkRCojCDmkPIR6tJ5cfFMOLND3I=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
+5
-12
@@ -2,15 +2,14 @@
|
||||
"name": "headplane",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "0.5.10",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"build": "react-router build",
|
||||
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
|
||||
"start": "node build/server/index.js",
|
||||
"typecheck": "tsc",
|
||||
"test": "vitest run"
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -18,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",
|
||||
@@ -30,7 +28,7 @@
|
||||
"@uiw/codemirror-theme-github": "^4.23.7",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.8",
|
||||
"@uiw/react-codemirror": "^4.23.7",
|
||||
"arktype": "^2.1.20",
|
||||
"arktype": "^2.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"isbot": "^5.1.19",
|
||||
@@ -44,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,12 +55,9 @@
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@react-router/dev": "^7.4.0",
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/websocket": "^1.0.10",
|
||||
"@types/ws": "^8.5.13",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lefthook": "^1.10.9",
|
||||
"postcss": "^8.5.3",
|
||||
"react-router-dom": "^7.4.0",
|
||||
@@ -75,8 +69,7 @@
|
||||
"vite": "^6.2.2",
|
||||
"vite-node": "^3.0.8",
|
||||
"vite-plugin-babel": "^1.3.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.1.3"
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.0",
|
||||
"engines": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user