Compare commits

..

74 Commits

Author SHA1 Message Date
Aarnav Tale 0866ced767 chore: update changelog version 2025-05-25 11:02:19 -04:00
Aarnav Tale 40a2eb4186 fix: show tags on machine page 2025-05-25 11:02:19 -04:00
Aarnav Tale cc0c7fa61d fix: i am stupid 2025-05-25 11:02:19 -04:00
Aarnav Tale d25e5fd883 fix: properly support default/fallback in arktype 2025-05-25 11:02:19 -04:00
Aarnav Tale 769674ab07 fix: check for invalid go timestamp on expiry again 2025-05-25 11:02:19 -04:00
Aarnav Tale 0276a500c7 fix: attempt to auto create agent work_dir if missing 2025-05-25 11:02:19 -04:00
Aarnav Tale d6fbc3b5ad fix: use corrected user ids for submission fields on the api 2025-05-25 11:02:19 -04:00
Aarnav Tale 662c38ea44 fix: handle newlines in provided secret paths 2025-05-25 11:02:19 -04:00
Aarnav Tale c827f56b6b chore: update changelog to reflect 0.26+ 2025-05-25 11:02:19 -04:00
Aarnav Tale 753a403aed fix: use full users for preauthkey lookup 2025-05-25 11:02:19 -04:00
Aarnav Tale ffce20e88a chore: update docs to match 0.26+ criteria 2025-05-25 11:02:19 -04:00
Aarnav Tale eda8ed0b3e feat: support 0.26+ routes only 2025-05-25 11:02:19 -04:00
Aarnav Tale 59b32c0842 fix: resolve kubernetes import issue 2025-05-25 11:02:19 -04:00
Aarnav Tale 68bd6200fc chore: update changelog 2025-05-25 11:02:19 -04:00
Aarnav Tale f56d4e9d9f chore: tighten pid logic 2025-05-25 11:02:19 -04:00
Giorgi Lekveishvili 602e55a94d fix: indentation 2025-05-25 11:02:19 -04:00
Giorgi Lekveishvili 5e60ac1208 fix: improve headscale proc detection
Check if command line also includes 'serve'.
Move command line processing logic into a helper function.
2025-05-25 11:02:19 -04:00
Aarnav Tale b1da29b2fb chore: use single string docker labels 2025-05-25 11:02:19 -04:00
Aarnav Tale 0808299369 fix: use AAAA and A info the record dialog 2025-05-25 11:02:19 -04:00
Aarnav Tale 39d6602263 fix: use descriptive error messages 2025-05-25 11:02:19 -04:00
Aarnav Tale df4f250e0f fix: mention AAAA in description and fix records table layout 2025-05-25 11:02:19 -04:00
Aarnav Tale 129e4f648b fix: when not using dns_records_path, use config dns editor 2025-05-25 11:02:19 -04:00
Aarnav Tale 2a4d23d70f fix: extra_records should be optional 2025-05-25 11:02:19 -04:00
Aarnav Tale f4da4c6428 chore: update docs 2025-05-25 11:02:19 -04:00
Aarnav Tale 7f5e0fe42d fix: resolve some type errors 2025-05-25 11:02:19 -04:00
Aarnav Tale 021ecabd3c feat: add support for extra_records_path in hs config 2025-05-25 11:02:19 -04:00
Aarnav Tale 81feb2f19a feat: add support for AAAA records 2025-05-25 11:02:19 -04:00
Aarnav Tale abedf40690 feat: use host info on the machines page 2025-05-25 11:02:19 -04:00
Aarnav Tale c9874966d2 fix: make docker container name optional 2025-05-25 11:02:19 -04:00
Aarnav Tale 887834efd9 chore: update changelog 2025-05-25 11:02:19 -04:00
Aarnav Tale 6f2a0773ba feat: disable role changing on unmanaged users 2025-05-25 11:02:19 -04:00
Aarnav Tale 8c01fcfe89 fix: only return queried machines from an agent 2025-05-25 11:02:19 -04:00
Aarnav Tale f93acd8ef1 feat: redesign chip attribute 2025-05-25 11:02:19 -04:00
Aarnav Tale a510796e38 fix: validate machine rename input 2025-05-25 11:02:19 -04:00
Aarnav Tale 69d97ea585 fix: allow tags to wrap 2025-05-25 11:02:19 -04:00
Aarnav Tale 000e9326fb fix: hide version column if no agents are connected 2025-05-25 11:02:19 -04:00
Aarnav Tale 8299aac94f fix: show user under machine name 2025-05-25 11:02:19 -04:00
Aarnav Tale 08ee0eff42 feat: redo machine tagging system 2025-05-25 11:02:19 -04:00
Aarnav Tale d1f395acfd feat: rework the machine actions
this also fixes the registration regression introduced in 0.5.8
2025-05-25 11:02:19 -04:00
Aarnav Tale 6bc20bd0e6 docs: update changelog 2025-05-25 11:02:19 -04:00
Aarnav Tale 130b603d58 fix: update pnpm nix hash 2025-05-25 11:02:19 -04:00
Aarnav Tale ae0aadd83f chore: fix nix hash and docs version 2025-05-25 11:02:19 -04:00
Aarnav Tale 6be55ec686 fix: redirect /admin to /admin/ automatically 2025-05-25 11:02:19 -04:00
Aarnav Tale fb3bc35b40 fix: handle the login form state and report errors correctly on api key login 2025-05-25 11:02:19 -04:00
Aarnav Tale dedec8f317 docs: agent config was not under its correct section 2025-05-25 11:02:19 -04:00
Aarnav Tale 7eda09bae8 chore: go mod tidy 2025-05-25 11:02:19 -04:00
Aarnav Tale ed4041073c chore: use config settings in vite dev server 2025-05-25 11:02:19 -04:00
Aarnav Tale c5e6a6c407 feat: switch away from websocket to stdout messaging for agent 2025-05-25 11:02:19 -04:00
Aarnav Tale 608fcacdb1 feat: use a debug logger for agent 2025-05-25 11:02:19 -04:00
Aarnav Tale 3dca401837 feat: rework the entire pre-auth keys page 2025-05-25 11:02:19 -04:00
Aarnav Tale 785d5a663f chore: reorganize settings code layout 2025-05-25 11:02:19 -04:00
Aarnav Tale ece304638f chore: update notice instead of using a custom one on acls 2025-05-25 11:02:19 -04:00
Aarnav Tale 1a2b1ad3f2 fix: route errors should no longer use json 2025-05-25 11:02:19 -04:00
Aarnav Tale 169a7b14d0 feat: support oidc restriction management in the settings 2025-05-25 11:02:19 -04:00
Aarnav Tale 3a5ab68432 chore: defer the path based config loading till 0.6.1. (#223) 2025-05-23 10:46:14 -04:00
Erik Parawell 62863ad649 Cleanup 2025-05-19 16:38:35 -04:00
Erik Parawell 91fcf5233e Rework the documentation to reflect the whitelist approach 2025-05-19 16:38:35 -04:00
Erik Parawell 8ea8c7195f Implement path-based secret loading 2025-05-19 16:38:35 -04:00
github-actions[bot] c3a50ea4f4 chore: update flake.lock (#206)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-14 00:10:00 -04:00
github-actions[bot] 6716e5f0b0 chore: update flake.lock (#202)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 15:25:02 -04:00
github-actions[bot] 346b44ec69 chore: update flake.lock (#196)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-03 23:46:31 -04:00
George Ntoutsos faa61b0f1d feat: add filtering by container label for Docker integration (#194) 2025-04-24 19:03:33 -04:00
github-actions[bot] 6b63fe209f chore: update flake.lock (#190)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-20 15:38:03 -04:00
github-actions[bot] c8507cff7c chore: update flake.lock (#180)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-15 11:00:11 -04:00
Federico Cerutti b86f4461c0 feat: restart with headscale, update server.js path in systemd example (#184) 2025-04-10 09:08:43 -04:00
Aarnav Tale 77b510c927 docs: oops 2025-04-05 13:14:00 -04:00
Aarnav Tale 5adcb8c582 ci: skip ci build on certain paths 2025-04-05 12:07:31 -04:00
Aarnav Tale 524c5eb639 docs: docker version tags had an extra char 2025-04-05 11:58:32 -04:00
Aarnav Tale 2894c664d3 docs: fix tag typo 2025-04-05 11:56:59 -04:00
Aarnav Tale 5c2d08decd chore: update dep tooling 2025-04-04 16:09:10 -04:00
Aarnav Tale f2e8c6ae4c chore: update dep tooling 2025-04-04 16:06:22 -04:00
Aarnav Tale c3e727842a docs: mention versioning policy 2025-04-04 15:44:16 -04:00
Aarnav Tale 96345ab0a6 chore: switch from nightly to pr based incubration 2025-04-04 10:19:37 -04:00
Aarnav Tale fe2d7cb57a chore: v0.5.10 2025-04-03 23:44:19 -04:00
106 changed files with 4013 additions and 1933 deletions
+6 -1
View File
@@ -1,6 +1,12 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
@@ -30,7 +36,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Get pnpm store directory
@@ -1,11 +1,11 @@
name: Nightly
name: Pre-release (next)
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: publish-nightly-${{ github.ref }}
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
@@ -15,12 +15,22 @@ permissions:
jobs:
publish:
name: docker-publish
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=next
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -34,14 +44,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
+11 -13
View File
@@ -5,7 +5,7 @@ on:
- "*"
concurrency:
group: publish-${{ github.ref }}
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
@@ -14,13 +14,21 @@ permissions:
packages: write # Write access to the container registry
jobs:
publish:
name: docker-publish
docker:
name: Docker Release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -34,16 +42,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
-1
View File
@@ -1,2 +1 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*
+2
View File
@@ -0,0 +1,2 @@
pnpm 10.4.0
node 22
+28
View File
@@ -1,3 +1,31 @@
### 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 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.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
+17
View File
@@ -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" ]
+8 -1
View File
@@ -44,11 +44,18 @@ There are 2 ways to deploy Headplane:
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
## Contributing
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
### Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"
+19 -24
View File
@@ -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)
}
-83
View File
@@ -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()
}
-11
View File
@@ -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
}
-63
View File
@@ -1,63 +0,0 @@
package hpagent
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}
@@ -1,10 +1,6 @@
package config
import (
"os"
_ "github.com/joho/godotenv/autoload"
)
import "os"
// Config represents the configuration for the agent.
type Config struct {
@@ -12,8 +8,7 @@ type Config struct {
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
WorkDir string
}
const (
@@ -21,8 +16,7 @@ const (
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
)
// Load reads the agent configuration from environment variables.
@@ -32,8 +26,7 @@ func Load() (*Config, error) {
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
WorkDir: os.Getenv(WorkDirEnv),
}
if os.Getenv(DebugEnv) == "true" {
@@ -48,9 +41,5 @@ func Load() (*Config, error) {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}
@@ -16,16 +16,12 @@ func validateRequired(config *Config) error {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
if config.WorkDir == "" {
return fmt.Errorf("%s is required", WorkDirEnv)
}
return nil
@@ -38,8 +34,7 @@ func validateTSReady(config *Config) error {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
testURL = fmt.Sprintf("%s/health", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
@@ -51,23 +46,3 @@ func validateTSReady(config *Config) error {
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package hpagent
import (
"bufio"
"encoding/json"
"os"
"sync"
"github.com/tale/headplane/agent/internal/tsnet"
"github.com/tale/headplane/agent/internal/util"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string
}
type SendMessage struct {
Type string
Data any
}
// Starts listening for messages from stdin
func FollowMaster(agent *tsnet.TSAgent) {
log := util.GetLogger()
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Bytes()
var msg RecvMessage
err := json.Unmarshal(line, &msg)
if err != nil {
log.Error("Unable to unmarshal message: %s", err)
log.Debug("Full Error: %v", err)
continue
}
log.Debug("Recieved message from master: %v", line)
if len(msg.NodeIDs) == 0 {
log.Debug("Message recieved had no node IDs")
log.Debug("Full message: %s", line)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := agent.GetStatusForPeer(nodeID)
if err != nil {
log.Error("Unable to get status for node %s: %s", nodeID, err)
return
}
if result == nil {
log.Debug("No status for node %s", nodeID)
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
log.Debug("Sending status back to master: %v", results)
log.Msg(&SendMessage{
Type: "status",
Data: results,
})
}
if err := scanner.Err(); err != nil {
log.Fatal("Error reading from stdin: %s", err)
}
}
@@ -2,9 +2,11 @@ package tsnet
import (
"context"
"encoding/hex"
"fmt"
"log"
"strings"
"github.com/tale/headplane/agent/internal/util"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
@@ -13,26 +15,41 @@ import (
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
log := util.GetLogger()
if !strings.HasPrefix(id, "nodekey:") {
log.Debug("Node ID with missing prefix: %s", id)
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
log.Debug("Querying status of peer: %s", id)
status, err := s.Lc.Status(context.Background())
if err != nil {
log.Debug("Failed to get status: %s", err)
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
// We need to convert from 64 char hex to 32 byte raw.
bytes, err := hex.DecodeString(id[8:])
if err != nil {
log.Debug("Failed to decode hex: %s", err)
return nil, fmt.Errorf("failed to decode hex: %w", err)
}
raw := mem.B(bytes)
if raw.Len() != 32 {
log.Debug("Invalid node ID length: %d", raw.Len())
return nil, fmt.Errorf("invalid node ID length: %d", raw.Len())
}
nodeKey := key.NodePublicFromRaw32(raw)
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
log.Debug("Peer not found in status: %s", id)
return nil, nil
}
}
@@ -40,8 +57,10 @@ func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
log.Debug("Failed to get whois: %s", err)
return nil, fmt.Errorf("failed to get whois: %w", err)
}
log.Debug("Got whois for peer %s: %v", id, whois)
return &whois.Node.Hostinfo, nil
}
+77
View File
@@ -0,0 +1,77 @@
package tsnet
import (
"context"
"os"
"path/filepath"
"github.com/tale/headplane/agent/internal/config"
"github.com/tale/headplane/agent/internal/util"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(cfg *config.Config) *TSAgent {
log := util.GetLogger()
dir, err := filepath.Abs(cfg.WorkDir)
if err != nil {
log.Fatal("Failed to get absolute path: %s", err)
}
if err := os.MkdirAll(dir, 0700); err != nil {
log.Fatal("Cannot create agent work directory: %s", err)
}
server := &tsnet.Server{
Dir: dir,
Hostname: cfg.Hostname,
ControlURL: cfg.TSControlURL,
AuthKey: cfg.TSAuthKey,
Logf: func(string, ...any) {}, // Disabled by default
UserLogf: log.Info,
}
if cfg.Debug {
server.Logf = log.Debug
}
return &TSAgent{server, nil, ""}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) Connect() {
log := util.GetLogger()
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatal("Failed to connect to Tailnet: %s", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatal("Failed to initialize local Tailscale client: %s", err)
}
id, err := status.Self.PublicKey.MarshalText()
if err != nil {
log.Fatal("Failed to marshal public key: %s", err)
}
log.Info("Connected to Tailnet (PublicKey: %s)", status.Self.PublicKey)
s.ID = string(id)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}
+117
View File
@@ -0,0 +1,117 @@
package util
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
type LogLevel string
const (
LevelInfo LogLevel = "info"
LevelDebug LogLevel = "debug"
LevelError LogLevel = "error"
LevelFatal LogLevel = "fatal"
LevelMsg LogLevel = "msg"
)
type LogMessage struct {
Level LogLevel
Time string
Message any
}
type Logger struct {
debugEnabled bool
encoder *json.Encoder
pool *sync.Pool
}
var logger = NewLogger()
func GetLogger() *Logger {
return logger
}
func NewLogger() *Logger {
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
return &Logger{
encoder: enc,
pool: &sync.Pool{
New: func() any {
return &LogMessage{}
},
},
}
}
func (l *Logger) SetDebug(enabled bool) {
if enabled {
l.debugEnabled = true
l.Info("Enabling Debug logging for headplane-agent")
l.Info("Be careful, this will spam a lot of information")
}
}
func (l *Logger) log(level LogLevel, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
timestamp := time.Now().Format(time.RFC3339)
// Manually construct compact JSON line for performance
line := `{"Level":"` + string(level) +
`","Time":"` + timestamp +
`","Message":"` + escapeString(msg) + `"}` + "\n"
if level == LevelError || level == LevelFatal {
os.Stderr.WriteString(line)
}
// Always write to stdout but also write to stderr for errors
os.Stdout.WriteString(line)
if level == LevelFatal {
os.Exit(1)
}
}
func (l *Logger) Debug(format string, v ...any) {
if l.debugEnabled {
l.log(LevelDebug, format, v...)
}
}
func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
func (l *Logger) Error(format string, v ...any) { l.log(LevelError, format, v...) }
func (l *Logger) Fatal(format string, v ...any) { l.log(LevelFatal, format, v...) }
func (l *Logger) Msg(obj any) {
entry := l.pool.Get().(*LogMessage)
defer l.pool.Put(entry)
entry.Level = LevelMsg
entry.Time = time.Now().Format(time.RFC3339)
entry.Message = obj
// Because the encoder is tied to STDOUT we get a message
_ = l.encoder.Encode(entry)
// Reset the entry for reuse
entry.Level = ""
entry.Time = ""
entry.Message = nil
}
func escapeString(s string) string {
replacer := strings.NewReplacer(
`"`, `\"`,
`\`, `\\`,
"\n", `\n`,
"\t", `\t`,
)
return replacer.Replace(s)
}
-61
View File
@@ -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()
}
+39 -28
View File
@@ -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>
+2 -2
View File
@@ -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,
)}
>
+4 -7
View File
@@ -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
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
);
}
+34 -5
View File
@@ -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 />;
}
}
+3 -1
View File
@@ -78,7 +78,9 @@ function Select(props: SelectProps) {
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
props.isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
+32
View File
@@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExitNodeTagProps {
isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this
from the "Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+20
View File
@@ -0,0 +1,20 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn(
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
)}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide
host information in the web UI.
</Tooltip.Body>
</Tooltip>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface SubnetTagProps {
isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the
"Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+9 -23
View File
@@ -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>
+49 -40
View File
@@ -36,48 +36,56 @@ export async function loader({
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
if (!onboarded) {
@@ -108,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
View File
@@ -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'),
]),
]),
-45
View File
@@ -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>
);
}
+11 -7
View File
@@ -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' });
}}
-186
View File
@@ -1,186 +0,0 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { ActionFunctionArgs, data, redirect } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Key } from '~/types';
import log from '~/utils/log';
export async function loginAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const apiKey = formData.has('api_key')
? String(formData.get('api_key'))
: undefined;
if (apiKey === undefined) {
log.warn('auth', 'Request made without API key');
log.warn(
'auth',
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
);
throw data('Missing `api_key`', { status: 400 });
}
if (apiKey.length === 0) {
log.warn('auth', 'Request made with empty API key');
log.warn(
'auth',
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
);
throw data('Received an empty `api_key`', { status: 400 });
}
try {
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
if (!lookup) {
return {
success: false,
message: 'API key was not found in the Headscale database',
};
}
if (lookup.expiration === null || lookup.expiration === undefined) {
log.error('auth', 'Got an API key without an expiration');
throw data('API key is malformed', { status: 500 });
}
const expiry = new Date(lookup.expiration);
if (expiry.getTime() < Date.now()) {
return {
success: false,
message: 'API key has expired',
};
}
// Set the session
const session = await context.sessions.getOrCreate(request);
const expiresDays = Math.round(
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiry.getTime() - Date.now(),
}),
},
});
} catch (error) {
if (error instanceof ResponseError) {
// TODO: What in gods name is wrong with the headscale API?
if (
error.status === 401 ||
error.status === 403 ||
(error.status === 500 && error.response.trim() === 'Unauthorized')
) {
return {
success: false,
message: 'API key is invalid (it may be incorrect or expired)',
};
}
}
log.error('auth', 'Error while validating API key: %s', error);
log.debug('auth', 'Error details: %o', error);
return {
success: false,
message: 'Error while validating API key (see logs for details)',
};
}
}
+15
View File
@@ -0,0 +1,15 @@
import Card from '~/components/Card';
export default function Logout() {
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect } from 'react';
import {
ActionFunctionArgs,
Form,
LoaderFunctionArgs,
Link as RemixLink,
data,
redirect,
useActionData,
useLoaderData,
useSearchParams,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import { useLiveData } from '~/utils/live-data';
import { loginAction } from './action';
import Logout from './logout';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const qp = new URL(request.url).searchParams;
const state = qp.get('s') ?? undefined;
// OIDC config cannot be undefined if an OIDC client is set
// Also check if we are in a logout state and skip redirect if we are
const ssoOnly = context.config.oidc?.disable_api_key_login;
if (state !== 'logout' && ssoOnly) {
// This shouldn't be possible, but still a safe sanity check
if (!context.oidc) {
throw data(
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
{
status: 400,
},
);
}
return redirect('/oidc/start');
}
return {
oidc: context.oidc,
state,
};
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return loginAction(request);
}
export default function Page() {
const { state, oidc } = useLoaderData<typeof loader>();
const formData = useActionData<typeof action>();
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return <Logout />;
}
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
isRequired
labelHidden
label="API Key"
name="api_key"
placeholder="API Key"
type="password"
className="mt-8 mb-2"
/>
{formData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{formData.message}
</Card.Text>
) : undefined}
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
{oidc ? (
<RemixLink to="/oidc/start">
<Button variant="light" className="w-full mt-2">
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
);
}
+12 -12
View File
@@ -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" />
+24 -4
View File
@@ -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}
+17 -17
View File
@@ -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);
}
+1 -1
View File
@@ -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 {
+93 -81
View File
@@ -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;
}
});
}
+18 -29
View File
@@ -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>
+2 -2
View File
@@ -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>
);
+2 -2
View File
@@ -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>
);
+11 -3
View File
@@ -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>
+6 -6
View File
@@ -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>
+33 -2
View File
@@ -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 ? (
+26 -46
View File
@@ -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&apos;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, {
+2 -2
View File
@@ -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 ? (
+222 -203
View File
@@ -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
View File
@@ -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 machines 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 machines 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 machines IPv4 address within your tailnet (your private Tailscale network)."
name="Tailscale IPv4"
value={getIpv4Address(node.ipAddresses)}
/>
<Attribute
isCopyable
tooltip="This machines 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 machines 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 '—';
}
+19 -26
View File
@@ -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" />;
}
-216
View File
@@ -1,216 +0,0 @@
import { useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import type { PreAuthKey, User } from '~/types';
import { send } from '~/utils/res';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const users = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
const preAuthKeys = await Promise.all(
users.users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
);
return {
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
// Expiring a pre-auth key
if (request.method === 'DELETE') {
const key = data.get('key');
const user = data.get('user');
if (!key || !user) {
return send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('api_key')!,
{
user: user,
key: key,
},
);
return { message: 'Pre-auth key expired' };
}
// Creating a new pre-auth key
if (request.method === 'POST') {
const user = data.get('user');
const expiry = data.get('expiry');
const reusable = data.get('reusable');
const ephemeral = data.get('ephemeral');
if (!user || !expiry || !reusable || !ephemeral) {
return send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('api_key')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
},
);
return { message: 'Pre-auth key created', key };
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('__headplane_all');
const [status, setStatus] = useState('active');
const filteredKeys = keys.filter((key) => {
if (user !== '__headplane_all' && key.user !== user) {
return false;
}
if (status !== 'all') {
const now = new Date();
const expiry = new Date(key.expiration);
if (status === 'active') {
return !(expiry < now) && (!key.used || key.reusable);
}
if (status === 'expired') {
return key.used || expiry < now;
}
if (status === 'reusable') {
return key.reusable;
}
if (status === 'ephemeral') {
return key.ephemeral;
}
}
return true;
});
// TODO: Fix the selects
return (
<div className="flex flex-col w-2/3">
<p className="mb-8 text-md">
<RemixLink to="/settings" className="font-medium">
Settings
</RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys
</p>
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add
devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
<AddPreAuthKey users={users} />
<div className="flex items-center gap-4 mt-4">
<Select
label="Filter by User"
placeholder="Select a user"
className="w-full"
defaultSelectedKey="__headplane_all"
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...users.map((user) => (
<Select.Item key={user.name}>{user.name}</Select.Item>
)),
]}
</Select>
<Select
label="Filter by status"
placeholder="Select a status"
className="w-full"
defaultSelectedKey="active"
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">No pre-auth keys</p>
</TableList.Item>
) : (
filteredKeys.map((key) => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))
)}
</TableList>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { PreAuthKey } from '~/types';
export async function authKeysAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.generate_authkeys,
);
if (!check) {
throw data('You do not have permission to manage pre-auth keys', {
status: 403,
});
}
const formData = await request.formData();
const apiKey = session.get('api_key')!;
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
status: 400,
});
}
switch (action) {
case 'add_preauthkey':
return await addPreAuthKey(formData, apiKey, context);
case 'expire_preauthkey':
return await expirePreAuthKey(formData, apiKey, context);
default:
return data('Invalid action', {
status: 400,
});
}
}
async function addPreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const user = formData.get('user_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');
}
@@ -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');
@@ -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">
@@ -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
+255
View File
@@ -0,0 +1,255 @@
import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { PreAuthKey, User } from '~/types';
import log from '~/utils/log';
import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row';
import AddAuthKey from './dialogs/add-auth-key';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
const qp = new URLSearchParams();
qp.set('user', user.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>
);
}
+42 -5
View File
@@ -1,12 +1,22 @@
import { ArrowRightIcon } from '@primer/octicons-react';
import { Link as RemixLink } from 'react-router';
import Button from '~/components/Button';
import {
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link';
import cn from '~/utils/cn';
import { LoadContext } from '~/server';
import AgentSection from './components/agent';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
return {
config: context.hs.writable(),
oidc: context.oidc,
};
}
export default function Page() {
const { config, oidc } = useLoaderData<typeof loader>();
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className="flex flex-col w-2/3">
@@ -37,7 +47,34 @@ export default function Page() {
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{/**<AgentSection />**/}
{config && oidc ? (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can
be used to limit access to your Tailnet to only certain users or
groups and Headplane will also respect these settings when
authenticating.{' '}
<Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center">
Manage Restrictions
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function restrictionAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
);
if (!check) {
throw data('You do not have permission to modify IAM settings.', {
status: 403,
});
}
if (!context.hs.writable()) {
throw data('The Headscale configuration file is not editable.', {
status: 403,
});
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('No action provided.', {
status: 400,
});
}
switch (action) {
case 'add_domain': {
return addDomain(formData, context);
}
case 'remove_domain': {
return removeDomain(formData, context);
}
case 'add_group': {
return addGroup(formData, context);
}
case 'remove_group': {
return removeGroup(formData, context);
}
case 'add_user': {
return addUser(formData, context);
}
case 'remove_user': {
return removeUser(formData, context);
}
default: {
throw data('Invalid action provided.', {
status: 400,
});
}
}
}
async function addDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain added successfully.');
}
async function removeDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain removed successfully.');
}
async function addUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User added successfully.');
}
async function removeUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User removed successfully.');
}
async function addGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group added successfully.');
}
async function removeGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group removed successfully.');
}
@@ -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>
+1 -1
View File
@@ -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(
+31 -2
View File
@@ -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>
+1 -1
View File
@@ -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.');
}
+124 -11
View File
@@ -6,22 +6,69 @@ import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
interface DockerContainer {
Id: string;
Names: string[];
}
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';
}
async getContainerName(label: string, value: string): Promise<string> {
if (!this.client) {
throw new Error('Docker client is not initialized');
}
const filters = encodeURIComponent(
JSON.stringify({
label: [`${label}=${value}`],
}),
);
const { body } = await this.client.request({
method: 'GET',
path: `/containers/json?filters=${filters}`,
});
const containers: DockerContainer[] =
(await body.json()) as DockerContainer[];
if (containers.length > 1) {
throw new Error(
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
);
}
if (containers.length === 0) {
throw new Error(
`No Docker containers found matching label: ${label}=${value}`,
);
}
log.info(
'config',
'Found Docker container matching label: %s=%s',
label,
value,
);
return containers[0].Id;
}
async isAvailable() {
if (this.context.container_name.length === 0) {
log.error('config', 'Docker container name is empty');
// 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',
'Missing a Docker `container_name` or `container_label`',
);
return false;
}
log.info('config', 'Using container: %s', this.context.container_name);
// Verify that Docker socket is reachable
let url: URL | undefined;
try {
url = new URL(this.context.socket);
@@ -73,7 +120,77 @@ export default class DockerIntegration extends Integration<T> {
});
}
return this.client !== undefined;
if (this.client === undefined) {
log.error('config', 'Failed to create Docker client');
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;
}
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 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) {
@@ -88,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) {
@@ -130,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;
}
}
+1 -1
View File
@@ -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) {
+4 -2
View File
@@ -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);
}
+4 -2
View File
@@ -38,9 +38,11 @@ export default class ProcIntegration extends Integration<T> {
try {
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid;
if (!data.includes('headscale') && !data.includes('serve')) {
throw new Error('Found PID without Headscale serve command');
}
return pid;
} catch (error) {
log.error('config', 'Failed to read %s: %s', path, error);
}
+1 -1
View File
@@ -209,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;
}
+50 -15
View File
@@ -1,22 +1,26 @@
import { type } from 'arktype';
const stringToBool = type('string | boolean').pipe((v) => Boolean(v));
const stringToBool = type('string | boolean').pipe((v) => {
if (typeof v === 'string') {
if (v === '1' || v === 'true' || v === 'yes') {
return true;
}
if (v === '0' || v === 'false' || v === 'no') {
return false;
}
throw new Error(`Invalid string value for boolean: ${v}`);
}
return Boolean(v);
});
const serverConfig = type({
host: 'string.ip',
port: type('string | number.integer').pipe((v) => Number(v)),
cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool,
agent: type({
authkey: 'string = ""',
ttl: 'number.integer = 180000', // Default to 3 minutes
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
})
.onDeepUndeclaredKey('reject')
.default(() => ({
authkey: '',
ttl: 180000,
cache_path: '/var/lib/headplane/agent_cache.json',
})),
});
const oidcConfig = type({
@@ -39,14 +43,43 @@ const headscaleConfig = type({
public_url: 'string.url?',
config_path: 'string?',
config_strict: stringToBool,
dns_records_path: 'string?',
}).onDeepUndeclaredKey('reject');
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"',
});
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',
@@ -61,12 +94,14 @@ const integrationConfig = type({
'docker?': dockerConfig,
'kubernetes?': kubernetesConfig,
'proc?': procConfig,
}).onDeepUndeclaredKey('reject');
'agent?': agentConfig,
});
const partialIntegrationConfig = type({
'docker?': dockerConfig.partial(),
'docker?': partialDockerConfig,
'kubernetes?': kubernetesConfig.partial(),
'proc?': procConfig.partial(),
'agent?': partialAgentConfig,
}).partial();
export const headplaneConfig = type({
+148 -11
View File
@@ -1,8 +1,132 @@
import { readFile } from 'node:fs/promises';
import { data } from 'react-router';
import { Agent, Dispatcher, request } from 'undici';
import { errors } from 'undici';
import log from '~/utils/log';
import ResponseError from './api-error';
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
const keys = Object.keys(error as Record<string, unknown>);
return (
typeof error === 'object' &&
error !== null &&
keys.includes('code') &&
keys.includes('errno')
);
}
function friendlyError(givenError: unknown) {
let error: unknown = givenError;
if (error instanceof AggregateError) {
error = error.errors[0];
}
switch (true) {
case error instanceof errors.BodyTimeoutError:
case error instanceof errors.ConnectTimeoutError:
case error instanceof errors.HeadersTimeoutError:
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
case error instanceof errors.SocketError:
case error instanceof errors.SecureProxyConnectionError:
case error instanceof errors.ClientClosedError:
case error instanceof errors.ClientDestroyedError:
case error instanceof errors.RequestAbortedError:
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
case error instanceof errors.InvalidArgumentError:
case error instanceof errors.InvalidReturnValueError:
case error instanceof errors.NotSupportedError:
return data('Unable to make a request (this is most likely a bug)', {
statusText: 'Internal Server Error',
status: 500,
});
case error instanceof errors.HeadersOverflowError:
case error instanceof errors.RequestContentLengthMismatchError:
case error instanceof errors.ResponseContentLengthMismatchError:
case error instanceof errors.ResponseExceededMaxSizeError:
return data('The Headscale API returned a malformed response', {
statusText: 'Bad Gateway',
status: 502,
});
case isNodeNetworkError(error):
if (error.code === 'ECONNREFUSED') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENOTFOUND') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EAI_AGAIN') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ETIMEDOUT') {
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
}
if (error.code === 'ECONNRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EPIPE') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETUNREACH') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
default:
return data((error as Error).message ?? 'An unknown error occurred', {
statusText: 'Internal Server Error',
status: 500,
});
}
}
export async function createApiClient(base: string, certPath?: string) {
if (!certPath) {
return new ApiClient(new Agent(), base);
@@ -37,21 +161,34 @@ export class ApiClient {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
return await request(new URL(url, this.base), {
dispatcher: this.agent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
try {
const res = await request(new URL(url, this.base), {
dispatcher: this.agent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error: unknown) {
throw friendlyError(error);
}
}
async healthcheck() {
try {
const res = await this.defaultFetch('/health');
const res = await request(new URL('/health', this.base), {
dispatcher: this.agent,
headers: {
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
});
return res.statusCode === 200;
} catch (error) {
log.debug('api', 'Healthcheck failed %o', error);
+127
View File
@@ -0,0 +1,127 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import log from '~/utils/log';
export interface DNSRecord {
type: 'A' | 'AAAA' | (string & {});
name: string;
value: string;
}
// This class is solely for DNS records that are out of tree in the main
// Headscale config file. If you are using dns.extra_records_path, it will
// be managed here and not in the main config file.
//
// All DNS insertions and deletions are handled by the main config manager,
// but are passed through to here if the extra file is being used.
export class HeadscaleDNSConfig {
private records: DNSRecord[];
private access: 'rw' | 'ro' | 'no';
private path?: string;
private writeLock = false;
constructor(
access: 'rw' | 'ro' | 'no',
records?: DNSRecord[],
path?: string,
) {
this.access = access;
this.records = records ?? [];
this.path = path;
}
readable() {
return this.access !== 'no';
}
writable() {
return this.access === 'rw';
}
get r() {
return this.records;
}
async patch(records: DNSRecord[]) {
if (!this.path || !this.readable() || !this.writable()) {
return;
}
this.records = records;
log.debug(
'config',
'Patching DNS records (%d -> %d)',
this.records.length,
records.length,
);
return this.write();
}
private async write() {
if (!this.path || !this.writable()) {
return;
}
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
log.debug('config', 'Writing updated DNS configuration to %s', this.path);
const data = JSON.stringify(this.records, null, 4);
await writeFile(this.path, data);
this.writeLock = false;
}
}
export async function loadHeadscaleDNS(path?: string) {
if (!path) {
return;
}
log.debug('config', 'Loading Headscale DNS configuration file: %s', path);
const { w, r } = await validateConfigPath(path);
if (!r) {
return new HeadscaleDNSConfig('no');
}
const records = await loadConfigFile(path);
if (!records) {
return new HeadscaleDNSConfig('no');
}
return new HeadscaleDNSConfig(w ? 'rw' : 'ro', records, path);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info('config', 'Found a valid Headscale DNS file at %s', path);
} catch (error) {
log.error('config', 'Unable to read a Headscale DNS file at %s', path);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn('config', 'Headscale DNS file at %s is not writable', path);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale DNS file at %s', path);
try {
const data = await readFile(path, 'utf8');
const records = JSON.parse(data) as DNSRecord[];
return records;
} catch (e) {
log.error('config', 'Error reading Headscale DNS file at %s', path);
log.error('config', '%s', e);
return false;
}
}
+141 -2
View File
@@ -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) {
+2 -1
View File
@@ -114,7 +114,8 @@ export const headscaleConfig = type({
type: 'string | "A"',
})
.array()
.default(() => []),
.optional(),
extra_records_path: 'string?',
},
unix_socket: 'string?',
+4 -21
View File
@@ -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);
},
+276 -118
View File
@@ -1,159 +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,
path: string,
ttl: number,
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
headscaleUrl: string,
) {
if (authkey.length === 0) {
if (!config?.enabled) {
return;
}
if (config.pre_authkey.trim().length === 0) {
log.error('agent', 'Agent `pre_authkey` is not set');
log.warn('agent', 'The agent will not run until resolved');
return;
}
try {
const handle = await open(path, 'w');
log.info('agent', 'Using agent cache file at %s', path);
await access(config.work_dir, constants.R_OK | constants.W_OK);
log.debug('config', 'Using agent work dir at %s', config.work_dir);
} catch (error) {
// 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
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
+5
View File
@@ -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;
+6 -1
View File
@@ -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[];
}
+3 -1
View File
@@ -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;
+54
View File
@@ -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(),
};
});
}
+1 -1
View File
@@ -3,7 +3,7 @@
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
+4 -1
View File
@@ -8,8 +8,10 @@ networks:
driver: "bridge"
services:
headscale:
image: "headscale/headscale:0.25.1"
image: "headscale/headscale:0.26.0-debug"
container_name: "headscale"
labels:
me.tale.headplane.target: headscale
restart: "unless-stopped"
command: "serve"
networks:
@@ -19,5 +21,6 @@ services:
- "./test:/etc/headscale"
ports:
- "8080:8080"
- "9090:9090"
environment:
TZ: "America/New_York"
+51 -3
View File
@@ -42,17 +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
# The name (or ID) of the container running Headscale
container_name: "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.
+5 -4
View File
@@ -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 v0.5.5 # Or whatever tag you want to use
git checkout v0.6.0 # Or whatever tag you want to use
pnpm install
pnpm build
```
@@ -68,14 +68,15 @@ Headplane service:
```ini
[Unit]
Description=Headplane
After=network.target
# Decomment the following line if running on bare metal with integrated mode (/proc integration)
# PartOf=headscale.service
[Service]
Type=simple
User=headplane
Group=headplane
WorkingDirectory=/path/to/headplane
ExecStart=/usr/bin/node /path/to/headplane/build/headplane/server.js
ExecStart=/usr/bin/node /path/to/headplane/build/server/index.js
Restart=always
[Install]
-33
View File
@@ -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.
+20 -10
View File
@@ -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.9
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:
@@ -70,11 +77,14 @@ you build the container yourself or run Headplane in Bare-Metal mode.
> setting up your `config.yaml` file to the appropriate values.
## Docker Integration
The Docker integration is the easiest to setup, as it only requires the Docker socket
to be mounted into the container along with some configuration. As long as Headplane
has access to the Docker socket and the name of the Headscale container, it will
automatically propagate config and DNS changes to Headscale without any additional
configuration.
The Docker integration is the easiest to set up, as it only requires mounting the
Docker socket into the container along with some basic configuration. Headplane
uses Docker labels to discover the Headscale container. As long as Headplane has
access to the Docker socket and can identify the Headscale container—either by
label or name—it will automatically propagate configuration and DNS changes to
Headscale without any additional setup. Alternatively, instead of using a label
to dynamically determine the container name, it is possible to directly specify
the container name.
## Native Linux (/proc) Integration
The `proc` integration is used when you are running Headscale and Headplane on
@@ -151,7 +161,7 @@ spec:
serviceAccountName: default
containers:
- name: headplane
image: ghcr.io/tale/headplane:0.5.9
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
@@ -168,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
View File
@@ -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.9
image: ghcr.io/tale/headplane:0.6.0
container_name: headplane
restart: unless-stopped
ports:
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1741646908,
"narHash": "sha256-55a1x5k+oFY2QCFjj7Mn5nPa8Do0shVl0m280mOAW/Q=",
"lastModified": 1746576598,
"narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ab0c5b18dab5e4b5d06ed679f8fd7cdc9970c4be",
"rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
"type": "github"
},
"original": {
+6 -4
View File
@@ -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
)
+69 -2
View File
@@ -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=

Some files were not shown because too many files have changed in this diff Show More