Compare commits

...

91 Commits

Author SHA1 Message Date
Aarnav Tale 0f92f09796 chore: v0.4.0 2025-01-18 07:52:04 +00:00
kevinf100 9bb20024fa fix: Reusable Pre-Auth Keys no longer show expired when used (#88) 2025-01-18 07:48:50 +00:00
Aarnav Tale 698122068d fix: center user icon in profile 2025-01-18 07:48:05 +00:00
Aarnav Tale e6a66d5804 fix: don't propagate errors on user page to root 2025-01-18 07:41:08 +00:00
Aarnav Tale d524f927a4 fix: don't throw when session manager isn't available 2025-01-18 07:39:12 +00:00
Aarnav Tale 539c76dfb3 chore: switch to shared healtcheck func 2025-01-18 07:38:55 +00:00
Aarnav Tale 51fa7c14d0 fix: handle prod config for server correctly 2025-01-18 07:37:51 +00:00
Aarnav Tale b92c4ce2e8 chore: remove broken vite plugin import 2025-01-18 07:37:26 +00:00
Aarnav Tale 325e9ba43d chore: push disabled local-agent code 2025-01-18 07:37:03 +00:00
Aarnav Tale de9a938da2 chore: use healthcheck method in healthz 2025-01-17 11:46:36 +00:00
Aarnav Tale 1b45b0917f fix: handle headscale unavailability gracefully 2025-01-17 11:45:36 +00:00
Aarnav Tale e01fd8d50c fix: handle undefined websocket in context 2025-01-17 08:34:19 +00:00
Aarnav Tale c9603ba38a chore: switch to vite for server dev 2025-01-17 08:33:22 +00:00
Aarnav Tale 377641265b fix: require auth for agent ws 2025-01-15 15:14:37 +05:30
Aarnav Tale 5bc313e5fe feat: containerize agent 2025-01-15 10:17:30 +05:30
Aarnav Tale af919b2d34 fix: support the agent querying itself 2025-01-10 14:49:13 +05:30
Aarnav Tale f464c42802 fix: format hostinfo in overview correctly 2025-01-10 14:41:05 +05:30
Aarnav Tale e667e112cb chore: add @types/ws 2025-01-10 14:24:23 +05:30
Aarnav Tale 39546849ae fix: mix cached and uncached data on the ws-agent 2025-01-10 14:24:15 +05:30
Aarnav Tale eb922c9318 fix: implement write-lock for agent cache 2025-01-10 14:06:22 +05:30
Aarnav Tale 5569ba4660 feat: rework oidc to be more resilient
This includes setting a custom redirect URI, handling errors, and using a better library.
As an API decision I've also disabled per session API keys as it clutters up too much.
2025-01-10 13:55:24 +05:30
Aarnav Tale dfd03e77bb fix: use headscale health route for healthz 2025-01-10 10:55:35 +05:30
Aarnav Tale e33504016b feat: integrate hostinfo into ui 2025-01-08 14:34:53 +05:30
Aarnav Tale 7d4da73141 fix: relax config requirement for paths by using nullish 2025-01-06 08:49:26 +05:30
Aarnav Tale 1316439786 fix: read aggregate and error cause bubbles 2025-01-06 08:47:24 +05:30
Aarnav Tale 888c1a5fb6 fix: resolve css issues with button icons 2025-01-06 08:39:15 +05:30
Aarnav Tale 328302cd81 fix: show toasts above the footer 2025-01-06 08:35:25 +05:30
Aarnav Tale b060700dfe fix: blur footer URL for screenshots 2025-01-06 08:31:49 +05:30
Aarnav Tale 69783f0d05 chore: re-stage entry files 2025-01-06 08:19:41 +05:30
Aarnav Tale 55c832c536 feat: do partial client method validation 2025-01-06 08:19:41 +05:30
Aarnav Tale fc5028994a chore: remove old import for fs-routes 2025-01-06 08:19:41 +05:30
Aarnav Tale 6745ee8529 fix: resolve type errors and lint components/routes 2025-01-06 08:19:41 +05:30
Aarnav Tale 414b95c293 fix: separate vite builds for server and framework 2025-01-06 08:19:41 +05:30
Aarnav Tale 7217659720 fix: prevent cookie manager from racing env init
this fixes a bug where using LOAD_ENV_FILE did not work with COOKIE_SECRET
2025-01-06 08:19:40 +05:30
Aarnav Tale a4bb3cce5f feat: fix build scripts and server 2025-01-06 08:19:40 +05:30
Aarnav Tale f3c9d8b54c style: run biome lint 2025-01-06 08:19:40 +05:30
Aarnav Tale aa9872a45b chore: switch to react-router v7 2025-01-06 08:19:40 +05:30
Artur Motyka 39504e2487 chore: update outdated docs links (#74) 2025-01-03 10:31:12 +05:30
Aarnav Tale 719d7a5e76 feat: create initial agent for localapi 2024-12-30 13:48:49 +05:30
Aarnav Tale 6156d78907 feat: add ws server for agent 2024-12-30 13:48:18 +05:30
Aarnav Tale 1c2c374ada feat: switch to server in dev & prod 2024-12-23 10:27:05 -05:00
Aarnav Tale 6cf343d623 feat: refactor all dashboard routes 2024-12-08 13:52:02 -05:00
Aarnav Tale 50e43bc0c3 feat: refactor/reorganize auth routes 2024-12-08 13:27:51 -05:00
Aarnav Tale 9d64a5beac chore: switch to react-router routes.ts config 2024-12-08 13:11:26 -05:00
Aarnav Tale 240d8d6197 chore: v0.3.9 2024-12-06 19:30:12 -05:00
Aarnav Tale b80bb0cc09 fix: await testOidc 2024-12-06 19:29:44 -05:00
Aarnav Tale 69cc6985b4 chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale f9b38939ba chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale 4cfa1e5209 chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale e713dae91b fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale c9bcc1d7c6 chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale 401731fd09 fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale f623e7bc66 feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale 1af292a5b0 fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale 21778a43f1 feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale 33762e53b5 chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale 8867cca494 chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
Aarnav Tale 41b1d3c847 chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale 712fc28683 feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale 320dab1d4f fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale da0ee1382b feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale a7d127c7bf feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale 9d9cbd8e0e chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale feb8b8bba5 chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale c304effcdf fix: externalize process.env 2024-11-08 12:03:08 -05:00
Aarnav Tale 982d811d53 chore: v0.3.4 2024-11-07 13:09:35 -05:00
Aarnav Tale d369e36fa4 fix: potentially catch undefined bearer errors 2024-11-07 13:08:43 -05:00
Aarnav Tale a2e659f36c feat(TALE-36): redesign machines page to account for exit nodes 2024-11-07 13:04:53 -05:00
Aarnav Tale 1490406784 feat(TALE-36): show exit node status on machine page 2024-11-06 17:01:36 -05:00
Aarnav Tale e1c87412d4 feat(TALE-36): support exit node enabling/disabling 2024-11-06 16:48:14 -05:00
Aarnav Tale 6e55f442fd fix: make the last acl tag removable on machines 2024-11-06 16:10:39 -05:00
Aarnav Tale 09e1b1f261 fix: address a bug that prevented remove split dns records 2024-11-06 15:57:43 -05:00
Aarnav Tale 3e83f8617b fix: use correct vite config in dev 2024-11-06 15:57:27 -05:00
Aarnav Tale 1d6472765a docs: better explain the native integration 2024-11-06 15:18:21 -05:00
Aarnav Tale 2bc85085f5 fix: handle non-prefixed routes on the production server 2024-11-06 14:29:04 -05:00
Aarnav Tale 5c949e2da5 feat: use all native node deps for the server 2024-11-04 22:05:46 -05:00
Aarnav Tale 12754bd0aa fix: resolve type errors across the codebase 2024-11-04 18:13:10 -05:00
Aarnav Tale 10bb4eba97 fix: skip loading env file if context is loaded 2024-11-04 18:00:50 -05:00
Aarnav Tale 13a734923d feat: ditch remix-serve for custom-baked server 2024-11-04 18:00:35 -05:00
Aarnav Tale d5ee8ae0f3 fix: clicking a machine name in users page redirects 2024-11-04 14:59:13 -05:00
Aarnav Tale 331a9c8dcf chore: v0.3.3 2024-10-28 15:10:20 -04:00
Aarnav Tale 808b01c5d7 chore: update react 19 compiler to next beta 2024-10-27 13:13:34 -04:00
Aarnav Tale a8abd37e3a fix: handle errors and fix api logic with acl updating 2024-10-27 13:10:32 -04:00
Aarnav Tale d1fa76971b feat: switch back to codemirror with jsonc support 2024-10-22 00:41:44 -04:00
Aarnav Tale a9e8394dec fix: do not pass the text value as a form field
This fixes an issue where on ephemeral key creation, it would fail due to localized dates
2024-10-20 18:34:43 -04:00
Aarnav Tale 9dafd8e8b0 feat: load from dotenv if specified 2024-10-16 23:07:41 -04:00
Václav Šmejkal 0c1f6969da feat: hide api key on login page (#39) 2024-10-16 13:23:02 -04:00
Aarnav Tale 4214e14ba8 chore: reorganize machines page to prep for routing data 2024-10-13 00:32:21 -04:00
163 changed files with 11036 additions and 12639 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
node_modules node_modules
/.react-router
/.cache /.cache
/build /build
.env .env
+52
View File
@@ -1,3 +1,55 @@
### 0.4.0 (January 6, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
- The footer now blurs your Headscale URL as a privacy measure
- Updated to the next stable beta of the React Compiler
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
- Redo the OIDC integration for better error handling and configuration
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI
### 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024) ### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22)) - Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27)) - Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
+2 -1
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm RUN npm install -g pnpm
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
COPY patches ./patches COPY patches ./patches
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -19,4 +20,4 @@ RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ] CMD [ "node", "./build/headplane/server.js" ]
+15
View File
@@ -0,0 +1,15 @@
FROM golang:1.23 AS builder
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 scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]
+40
View File
@@ -0,0 +1,40 @@
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"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}
+55
View File
@@ -0,0 +1,55 @@
package config
import (
_ "github.com/joho/godotenv/autoload"
"os"
)
// Config represents the configuration for the agent.
type Config struct {
Debug bool
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
}
const (
DebugEnv = "HP_AGENT_DEBUG"
HostnameEnv = "HP_AGENT_HOSTNAME"
TSControlURLEnv = "HP_AGENT_TS_SERVER"
TSAuthKeyEnv = "HP_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HP_AGENT_HP_SERVER"
HPAuthKeyEnv = "HP_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
func Load() (*Config, error) {
c := &Config{
Debug: false,
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
c.Debug = true
}
if err := validateRequired(c); err != nil {
return nil, err
}
if err := validateTSReady(c); err != nil {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}
+73
View File
@@ -0,0 +1,73 @@
package config
import (
"fmt"
"net/http"
"strings"
)
// Checks to make sure all required environment variables are set
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
}
if config.TSControlURL == "" {
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)
}
return nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
}
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
}
+83
View File
@@ -0,0 +1,83 @@
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
@@ -0,0 +1,11 @@
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
}
+56
View File
@@ -0,0 +1,56 @@
package hpagent
import (
"fmt"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
"log"
"net/http"
"net/url"
)
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-TS-Node-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)
}
return u.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}
+61
View File
@@ -0,0 +1,61 @@
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()
}
+31 -32
View File
@@ -1,43 +1,42 @@
import { CopyIcon } from '@primer/octicons-react' import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
import { toast } from './Toaster'
interface Props { interface Props {
name: string name: string;
value: string value: string;
isCopyable?: boolean isCopyable?: boolean;
link?: string;
} }
export default function Attribute({ name, value, isCopyable }: Props) { export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false const canCopy = isCopyable ?? false;
return ( return (
<dl className="flex gap-1 text-sm w-full"> <dl className="flex gap-1 text-sm w-full">
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1"> <dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
{name} {link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
</dt> </dt>
{canCopy {canCopy ? (
? ( <button
<button type="button"
type="button" className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md" onClick={async () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises await navigator.clipboard.writeText(value);
onClick={async () => { toast(`Copied ${name}`);
await navigator.clipboard.writeText(value) }}
toast(`Copied ${name}`) >
}} <dd className="min-w-0 truncate px-2 py-1">{value}</dd>
> <CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
<dd className="min-w-0 truncate px-2 py-1"> </button>
{value} ) : (
</dd> <dd className="min-w-0 truncate px-2 py-1">{value}</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" /> )}
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
)}
</dl> </dl>
) );
} }
+22 -21
View File
@@ -1,37 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import { Button as AriaButton } from 'react-aria-components' import { Button as AriaButton } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type Props = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
};
type ButtonProperties = Parameters<typeof AriaButton>[0] & { export default function Button(props: Props) {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
readonly variant?: 'heavy' | 'light'
}
export default function Button(properties: ButtonProperties) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
properties.variant === 'heavy' props.variant === 'heavy'
? 'bg-main-700 dark:bg-main-800' ? 'bg-main-700 dark:bg-main-800'
: 'bg-main-200 dark:bg-main-700/30', : 'bg-main-200 dark:bg-main-700/30',
properties.variant === 'heavy' props.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700' ? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30', : 'hover:bg-main-300 dark:hover:bg-main-600/30',
properties.variant === 'heavy' props.variant === 'heavy'
? 'text-white' ? 'text-white'
: 'text-ui-700 dark:text-ui-300', : 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className, props.className,
)} )}
// If control is passed, set the state value // If control is passed, set the state value
onPress={properties.control onPress={
? () => { props.control
properties.control?.[1](true) ? () => {
} props.control?.[1](true);
: properties.onPress} }
: props.onPress
}
/> />
) );
} }
+20 -30
View File
@@ -1,53 +1,43 @@
import { type HTMLProps } from 'react' import type { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components' import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' function Title(props: Parameters<typeof AriaHeading>[0]) {
function Title(properties: Parameters<typeof AriaHeading>[0]) {
return ( return (
<AriaHeading <AriaHeading
{...properties} {...props}
slot='title' slot="title"
className={cn( className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/> />
) );
} }
function Text(properties: React.HTMLProps<HTMLParagraphElement>) { function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return ( return (
<p <p {...props} className={cn('text-base leading-6 my-0', props.className)} />
{...properties} );
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
} }
type Properties = HTMLProps<HTMLDivElement> & { type Props = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat'; variant?: 'raised' | 'flat';
} };
function Card(properties: Properties) { function Card(props: Props) {
return ( return (
<div <div
{...properties} {...props}
className={cn( className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4', 'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat' props.variant === 'flat'
? 'bg-transparent shadow-none' ? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm', : 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700', 'border border-ui-200 dark:border-ui-700',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(Card, { Title, Text }) export default Object.assign(Card, { Title, Text });
+49 -10
View File
@@ -1,12 +1,51 @@
import clsx from 'clsx' import { useState, HTMLProps } from 'react';
import { type HTMLProps } from 'react' import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { toast } from '~/components/Toaster';
type Properties = HTMLProps<HTMLSpanElement> interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
export default function Code(properties: Properties) { }
return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}> export default function Code(props: Props) {
{properties.children} const [isCopied, setIsCopied] = useState(false);
</code>
) return (
<>
<code
className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className,
)}
>
{props.children}
</code>
{props.isCopyable ? (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center',
)}
onClick={() => {
if (!props.children) {
throw new Error('Made copyable without children');
}
navigator.clipboard.writeText(props.children.join(''));
toast('Copied to clipboard');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
{isCopied ? (
<CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</button>
) : undefined}
</>
);
} }
+54 -65
View File
@@ -1,106 +1,99 @@
/* eslint-disable unicorn/no-keyword-prefix */ import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { import {
Button as AriaButton, Button as AriaButton,
Dialog as AriaDialog, Dialog as AriaDialog,
DialogTrigger, DialogTrigger,
Heading as AriaHeading, Heading as AriaHeading,
Modal, Modal,
ModalOverlay ModalOverlay,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type ButtonProps = Parameters<typeof AriaButton>[0] & {
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
} };
function Button(properties: ButtonProperties) { function Button(props: ButtonProps) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
aria-label='Dialog' aria-label="Dialog"
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white', 'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700', 'hover:bg-main-800 dark:hover:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className props.className,
)} )}
// If control is passed, set the state value // If control is passed, set the state value
onPress={properties.control ? () => { onPress={
properties.control?.[1](true) props.control
} : undefined} ? () => {
props.control?.[1](true);
}
: undefined
}
/> />
) );
} }
type ActionProperties = Parameters<typeof AriaButton>[0] & { type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm'; readonly variant: 'cancel' | 'confirm';
} };
function Action(properties: ActionProperties) { function Action(props: ActionProps) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
type={properties.variant === 'confirm' ? 'submit' : 'button'} type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn( className={cn(
'px-4 py-2 rounded-lg', 'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel' props.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300' ? 'text-ui-700 dark:text-ui-300'
: 'text-ui-300 dark:text-ui-300', : 'text-ui-300 dark:text-ui-300',
properties.variant === 'confirm' props.variant === 'confirm'
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800' ? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700', : 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
properties.className props.className,
)} )}
/> />
) );
} }
function Title(properties: Parameters<typeof AriaHeading>[0]) { function Title(props: Parameters<typeof AriaHeading>[0]) {
return ( return (
<AriaHeading <AriaHeading
{...properties} {...props}
slot='title' slot="title"
className={cn( className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/> />
) );
} }
function Text(properties: React.HTMLProps<HTMLParagraphElement>) { function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return ( return (
<p <p {...props} className={cn('text-base leading-6 my-0', props.className)} />
{...properties} );
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
} }
type PanelProperties = { interface PanelProps {
readonly children: (close: () => void) => ReactNode; children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string; className?: string;
} }
function Panel({ children, control, className }: PanelProperties) { function Panel({ children, control, className }: PanelProps) {
return ( return (
<ModalOverlay <ModalOverlay
aria-hidden='true' aria-hidden="true"
className={cn( className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30', 'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70', 'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out', 'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in', 'exiting:fade-out exiting:duration-100 exiting:ease-in',
className className,
)} )}
isOpen={control ? control[0] : undefined} isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined} onOpenChange={control ? control[1] : undefined}
@@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700', 'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200', 'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100' 'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
)} )}
> >
<AriaDialog role='alertdialog' className='outline-none relative'> <AriaDialog role="alertdialog" className="outline-none relative">
{({ close }) => children(close)} {({ close }) => children(close)}
</AriaDialog> </AriaDialog>
</Modal> </Modal>
</ModalOverlay> </ModalOverlay>
) );
} }
type DialogProperties = { interface DialogProps {
readonly children: ReactNode; children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; control?: [boolean, Dispatch<SetStateAction<boolean>>];
} }
function Dialog({ children, control }: DialogProperties) { function Dialog({ children, control }: DialogProps) {
if (control) { if (control) {
return children return children;
} }
return ( return <DialogTrigger>{children}</DialogTrigger>;
<DialogTrigger>
{children}
</DialogTrigger>
)
} }
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action }) export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });
+41 -26
View File
@@ -1,19 +1,40 @@
import { AlertIcon } from '@primer/octicons-react' import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from '@remix-run/react' import { isRouteErrorResponse, useRouteError } from 'react-router';
import { cn } from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import { cn } from '~/utils/cn' interface Props {
type?: 'full' | 'embedded';
import Card from './Card'
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
} }
export function ErrorPopup({ type = 'full' }: Properties) { function getMessage(error: Error | unknown) {
const error = useRouteError() if (!(error instanceof Error)) {
const routing = isRouteErrorResponse(error) return "An unknown error occurred";
const message = (error instanceof Error ? error.message : 'An unexpected error occurred') }
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
}
return rootError.message;
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
return ( return (
<div <div
@@ -21,26 +42,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
'flex items-center justify-center', 'flex items-center justify-center',
type === 'embedded' type === 'embedded'
? 'pointer-events-none mt-24' ? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50' : 'fixed inset-0 h-screen w-screen z-50',
)} )}
> >
<Card> <Card>
<div className='flex items-center justify-between'> <div className="flex items-center justify-between">
<Card.Title className='text-3xl mb-0'> <Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'} {routing ? error.status : 'Error'}
</Card.Title> </Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/> <AlertIcon className="w-12 h-12 text-red-500" />
</div> </div>
<Card.Text className='mt-4 text-lg'> <Card.Text className="mt-4 text-lg">
{routing ? ( {routing ? error.statusText : <Code>{message}</Code>}
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Card.Text> </Card.Text>
</Card> </Card>
</div> </div>
) );
} }
+46
View File
@@ -0,0 +1,46 @@
import { cn } from '~/utils/cn';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
declare global {
const __VERSION__: string;
}
interface FooterProps {
url: string;
debug: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-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"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong className="blur-xs hover:blur-none">
{url}
</strong>
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}
+101 -76
View File
@@ -1,42 +1,49 @@
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react' import {
import { Form } from '@remix-run/react' GearIcon,
GlobeIcon,
LockIcon,
PaperAirplaneIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { Form } from 'react-router';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { HeadplaneContext } from '~/utils/config/headplane' import type { HeadplaneContext } from '~/utils/config/headplane';
import { type SessionData } from '~/utils/sessions' import type { SessionData } from '~/utils/sessions.server';
import Menu from './Menu' import Menu from './Menu';
import TabLink from './TabLink' import TabLink from './TabLink';
interface Properties { interface Props {
readonly data?: { config: HeadplaneContext['config'];
config: HeadplaneContext['config'] user?: SessionData['user'];
user?: SessionData['user']
}
} }
interface LinkProperties { interface LinkProps {
readonly href: string href: string;
readonly text: string text: string;
readonly isMenu?: boolean isMenu?: boolean;
} }
function Link({ href, text, isMenu }: LinkProperties) { function Link({ href, text, isMenu }: LinkProps) {
return ( return (
<a <a
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={cn( className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block', !isMenu &&
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
)} )}
> >
{text} {text}
</a> </a>
) );
} }
export default function Header({ data }: Properties) { export default function Header(data: Props) {
return ( return (
<header className="bg-main-700 dark:bg-main-800 text-ui-50"> <header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className="container flex items-center justify-between py-4"> <div className="container flex items-center justify-between py-4">
@@ -48,69 +55,87 @@ export default function Header({ data }: Properties) {
<Link href="https://tailscale.com/download" text="Download" /> <Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" /> <Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" /> <Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user {data.user ? (
? ( <Menu>
<Menu> <Menu.Button
<Menu.Button className={cn( className={cn(
'rounded-full h-9 w-9', 'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700', 'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700', 'hover:bg-main-600 dark:hover:bg-main-700',
'flex items-center justify-center',
)} )}
> >
<PersonIcon className="h-5 w-5 mt-0.5" /> <PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
<Menu.Item className="text-right"> <Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p> <p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p> <p>{data.user.email}</p>
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://tailscale.com/download" href="https://tailscale.com/download"
text="Download" text="Download"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://github.com/tale/headplane" href="https://github.com/tale/headplane"
text="GitHub" text="GitHub"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://github.com/juanfont/headscale" href="https://github.com/juanfont/headscale"
text="Headscale" text="Headscale"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400"> <Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout"> <Form method="POST" action="/logout">
<button type="submit" className="w-full text-right"> <button type="submit" className="w-full text-right">
Logout Logout
</button> </button>
</Form> </Form>
</Menu.Item> </Menu.Item>
</Menu.Items> </Menu.Items>
</Menu> </Menu>
) ) : undefined}
: undefined}
</div> </div>
</div> </div>
<nav className="container flex items-center gap-x-4 overflow-x-auto"> <nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} /> <TabLink
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} /> to="/machines"
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} /> name="Machines"
{data?.config.read icon={<ServerIcon className="w-4 h-4" />}
? ( />
<> <TabLink
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} /> to="/users"
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} /> name="Users"
</> icon={<PeopleIcon className="w-4 h-4" />}
) />
: undefined} <TabLink
to="/acls"
name="Access Control"
icon={<LockIcon className="w-4 h-4" />}
/>
{data.config.read ? (
<>
<TabLink
to="/dns"
name="DNS"
icon={<GlobeIcon className="w-4 h-4" />}
/>
<TabLink
to="/settings"
name="Settings"
icon={<GearIcon className="w-4 h-4" />}
/>
</>
) : undefined}
</nav> </nav>
</header> </header>
) );
} }
+7 -8
View File
@@ -1,12 +1,11 @@
import { LinkExternalIcon } from '@primer/octicons-react' import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
to: string to: string;
name: string name: string;
children: string children: string;
className?: string className?: string;
} }
export default function Link({ to, name: alt, children, className }: Props) { export default function Link({ to, name: alt, children, className }: Props) {
@@ -26,5 +25,5 @@ export default function Link({ to, name: alt, children, className }: Props) {
{children} {children}
<LinkExternalIcon className="h-3 w-3" /> <LinkExternalIcon className="h-3 w-3" />
</a> </a>
) );
} }
+43 -50
View File
@@ -1,98 +1,91 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { import {
Button as AriaButton, Button as AriaButton,
Menu as AriaMenu, Menu as AriaMenu,
MenuItem, MenuItem,
MenuTrigger, MenuTrigger,
Popover Popover,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' function Button(props: Parameters<typeof AriaButton>[0]) {
function Button(properties: Parameters<typeof AriaButton>[0]) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn('outline-none', props.className)}
'outline-none', aria-label="Menu"
properties.className
)}
aria-label='Menu'
/> />
) );
} }
function Items(properties: Parameters<typeof AriaMenu>[0]) { function Items(props: Parameters<typeof AriaMenu>[0]) {
return ( return (
<Popover className={cn( <Popover
'mt-2 rounded-md', className={cn(
'bg-ui-50 dark:bg-ui-800', 'mt-2 rounded-md',
'overflow-hidden z-50', 'bg-ui-50 dark:bg-ui-800',
'border border-ui-200 dark:border-ui-600', 'overflow-hidden z-50',
'entering:animate-in exiting:animate-out', 'border border-ui-200 dark:border-ui-600',
'entering:fade-in entering:zoom-in-95', 'entering:animate-in exiting:animate-out',
'exiting:fade-out exiting:zoom-out-95', 'entering:fade-in entering:zoom-in-95',
'fill-mode-forwards origin-left-right' 'exiting:fade-out exiting:zoom-out-95',
)} 'fill-mode-forwards origin-left-right',
)}
> >
<AriaMenu <AriaMenu
{...properties} {...props}
className={cn( className={cn(
'outline-none', 'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600', 'divide-y divide-ui-200 dark:divide-ui-600',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</AriaMenu> </AriaMenu>
</Popover> </Popover>
) );
} }
type ButtonProperties = Parameters<typeof AriaButton>[0] & { type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
} };
function ItemButton(properties: ButtonProperties) { function ItemButton(props: ButtonProps) {
return ( return (
<MenuItem className='outline-none'> <MenuItem className="outline-none">
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn(
'px-4 py-2 w-full outline-none text-left', 'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className props.className,
)} )}
aria-label='Menu Dialog' aria-label="Menu Dialog"
// If control is passed, set the state value // If control is passed, set the state value
onPress={event => { onPress={(event) => {
properties.onPress?.(event) props.onPress?.(event);
properties.control?.[1](true) props.control?.[1](true);
}} }}
/> />
</MenuItem> </MenuItem>
) );
} }
function Item(properties: Parameters<typeof MenuItem>[0]) { function Item(props: Parameters<typeof MenuItem>[0]) {
return ( return (
<MenuItem <MenuItem
{...properties} {...props}
className={cn( className={cn(
'px-4 py-2 w-full outline-none', 'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className props.className,
)} )}
/> />
) );
} }
function Menu({ children }: { readonly children: ReactNode }) { function Menu({ children }: { children: ReactNode }) {
return ( return <MenuTrigger>{children}</MenuTrigger>;
<MenuTrigger>
{children}
</MenuTrigger>
)
} }
export default Object.assign(Menu, { Button, Item, ItemButton, Items }) export default Object.assign(Menu, { Button, Item, ItemButton, Items });
+12 -12
View File
@@ -1,23 +1,23 @@
import { InfoIcon } from '@primer/octicons-react' import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
className?: string className?: string;
children: ReactNode children: ReactNode;
} }
export default function Notice({ children, className }: Props) { export default function Notice({ children, className }: Props) {
return ( return (
<div className={cn( <div
'p-4 rounded-md w-full flex items-center gap-3', className={cn(
'bg-ui-200 dark:bg-ui-800', 'p-4 rounded-md w-full flex items-center gap-3',
className, 'bg-ui-200 dark:bg-ui-800',
)} className,
)}
> >
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" /> <InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children} {children}
</div> </div>
) );
} }
+19 -21
View File
@@ -1,18 +1,17 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react' import { PlusIcon, DashIcon } from '@primer/octicons-react';
import { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import { import {
Button, Button,
Group, Group,
Input, Input,
NumberField as AriaNumberField NumberField as AriaNumberField,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & { type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string; label: string;
state?: [number, Dispatch<SetStateAction<number>>]; state?: [number, Dispatch<SetStateAction<number>>];
} };
export default function NumberField(props: NumberFieldProps) { export default function NumberField(props: NumberFieldProps) {
return ( return (
@@ -21,21 +20,20 @@ export default function NumberField(props: NumberFieldProps) {
aria-label={props.label} aria-label={props.label}
className="w-full" className="w-full"
value={props.state?.[0]} value={props.state?.[0]}
onChange={value => { onChange={(value) => {
props.state?.[1](value) props.state?.[1](value);
}} }}
> >
<Group className={cn( <Group
'flex px-2.5 py-1.5 w-full rounded-lg my-1', className={cn(
'border border-ui-200 dark:border-ui-600', 'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'dark:bg-ui-800 dark:text-ui-300 gap-2', 'border border-ui-200 dark:border-ui-600',
'focus-within:ring-2 focus-within:ring-blue-600', 'dark:bg-ui-800 dark:text-ui-300 gap-2',
props.className 'focus-within:ring-2 focus-within:ring-blue-600',
)}> props.className,
<Input )}
className="w-full bg-transparent focus:outline-none" >
name={props.name} <Input className="w-full bg-transparent focus:outline-none" />
/>
<Button slot="decrement"> <Button slot="decrement">
<DashIcon className="w-4 h-4" /> <DashIcon className="w-4 h-4" />
</Button> </Button>
@@ -44,5 +42,5 @@ export default function NumberField(props: NumberFieldProps) {
</Button> </Button>
</Group> </Group>
</AriaNumberField> </AriaNumberField>
) );
} }
+20 -22
View File
@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '@primer/octicons-react' import { ChevronDownIcon } from '@primer/octicons-react';
import { Dispatch, ReactNode, SetStateAction } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { import {
Button, Button,
ListBox, ListBox,
@@ -7,15 +7,14 @@ import {
Popover, Popover,
Select as AriaSelect, Select as AriaSelect,
SelectValue, SelectValue,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type SelectProps = Parameters<typeof AriaSelect>[0] & { type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>] readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode readonly children: ReactNode;
} };
function Select(props: SelectProps) { function Select(props: SelectProps) {
return ( return (
@@ -24,7 +23,7 @@ function Select(props: SelectProps) {
aria-label={props.label} aria-label={props.label}
selectedKey={props.state?.[0]} selectedKey={props.state?.[0]}
onSelectionChange={(key) => { onSelectionChange={(key) => {
props.state?.[1](key.toString()) props.state?.[1](key.toString());
}} }}
className={cn( className={cn(
'block w-full rounded-lg my-1', 'block w-full rounded-lg my-1',
@@ -34,10 +33,11 @@ function Select(props: SelectProps) {
props.className, props.className,
)} )}
> >
<Button className={cn( <Button
'w-full flex items-center justify-between', className={cn(
'px-2.5 py-1.5 rounded-lg', 'w-full flex items-center justify-between',
)} 'px-2.5 py-1.5 rounded-lg',
)}
> >
<SelectValue /> <SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" /> <ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
className={cn( className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]', 'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm', 'bg-ui-100 dark:bg-ui-800 shadow-sm',
'overflow-hidden z-50', 'z-50 overflow-y-auto',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95', 'entering:fade-in entering:zoom-in-95',
@@ -54,15 +54,13 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right', 'fill-mode-forwards origin-left-right',
)} )}
> >
<ListBox> <ListBox orientation="vertical">{props.children}</ListBox>
{props.children}
</ListBox>
</Popover> </Popover>
</AriaSelect> </AriaSelect>
) );
} }
type ItemProps = Parameters<typeof ListBoxItem>[0] type ItemProps = Parameters<typeof ListBoxItem>[0];
function Item(props: ItemProps) { function Item(props: ItemProps) {
return ( return (
@@ -76,7 +74,7 @@ function Item(props: ItemProps) {
> >
{props.children} {props.children}
</ListBoxItem> </ListBoxItem>
) );
} }
export default Object.assign(Select, { Item }) export default Object.assign(Select, { Item });
+8 -9
View File
@@ -1,23 +1,22 @@
import clsx from 'clsx' import clsx from 'clsx';
type Properties = { interface Props {
// eslint-disable-next-line unicorn/no-keyword-prefix
className?: string; className?: string;
} }
export default function Spinner(properties: Properties) { export default function Spinner({ className }: Props) {
return ( return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}> <div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
<div <div
className={clsx( className={clsx(
'animate-spin rounded-full w-full h-full', 'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent', 'border-2 border-current border-t-transparent',
properties.className className,
)} )}
role='status' role="status"
> >
<span className='sr-only'>Loading...</span> <span className="sr-only">Loading...</span>
</div> </div>
</div> </div>
) );
} }
+10 -10
View File
@@ -1,24 +1,24 @@
import clsx from 'clsx' import clsx from 'clsx';
import { type HTMLProps } from 'react' import type { HTMLProps } from 'react';
type Properties = HTMLProps<SVGElement> & { type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean; readonly isOnline: boolean;
} };
// eslint-disable-next-line unicorn/no-keyword-prefix // eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) { export default function StatusCircle({ isOnline, className }: Props) {
return ( return (
<svg <svg
className={clsx( className={clsx(
className, className,
isOnline isOnline
? 'text-green-700 dark:text-green-400' ? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500' : 'text-gray-300 dark:text-gray-500',
)} )}
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='currentColor' fill="currentColor"
> >
<circle cx='12' cy='12' r='8'/> <circle cx="12" cy="12" r="8" />
</svg> </svg>
) );
} }
+17 -17
View File
@@ -1,34 +1,34 @@
import { Switch as AriaSwitch } from 'react-aria-components' import { Switch as AriaSwitch } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
readonly label: string; readonly label: string;
} };
export default function Switch(properties: SwitchProperties) { export default function Switch(props: SwitchProps) {
return ( return (
<AriaSwitch <AriaSwitch
{...properties} {...props}
aria-label={properties.label} aria-label={props.label}
className='group flex gap-2 items-center' className="group flex gap-2 items-center"
> >
<div <div
className={cn( className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0', 'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2', 'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700', 'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className props.className,
)} )}
> >
<span className={cn( <span
'h-[18px] w-[18px] transform rounded-full', className={cn(
'bg-white transition duration-100 ease-in-out', 'h-[18px] w-[18px] transform rounded-full',
'translate-x-0 group-selected:translate-x-[100%]' 'bg-white transition duration-100 ease-in-out',
)} 'translate-x-0 group-selected:translate-x-[100%]',
)}
/> />
</div> </div>
</AriaSwitch> </AriaSwitch>
) );
} }
+17 -16
View File
@@ -1,32 +1,33 @@
import { NavLink } from '@remix-run/react' import { NavLink } from 'react-router';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' interface Props {
name: string;
type Properties = { to: string;
readonly name: string; icon: ReactNode;
readonly to: string;
readonly icon: ReactNode;
} }
export default function TabLink({ name, to, icon }: Properties) { export default function TabLink({ name, to, icon }: Props) {
return ( return (
<NavLink <NavLink
to={to} to={to}
prefetch='intent' prefetch="intent"
className={({ isActive }) => cn( className={({ isActive }) =>
'border-b-2 py-1.5', cn(
isActive ? 'border-white' : 'border-transparent' 'border-b-2 py-1.5',
)} isActive ? 'border-white' : 'border-transparent',
)
}
> >
<div <div
className={cn( className={cn(
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap', 'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md' 'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
)} )}
> >
{icon} {name} {icon} {name}
</div> </div>
</NavLink> </NavLink>
) );
} }
+13 -14
View File
@@ -1,37 +1,36 @@
import clsx from 'clsx' import clsx from 'clsx';
import { type HTMLProps } from 'react' import type { HTMLProps } from 'react';
function TableList(properties: HTMLProps<HTMLDivElement>) { function TableList(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={clsx(
'border border-gray-300 rounded-lg overflow-clip', 'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300', 'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800', // 'dark:bg-zinc-800',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
function Item(properties: HTMLProps<HTMLDivElement>) { function Item(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={clsx(
'flex items-center justify-between px-3 py-2', 'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0', 'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800', 'dark:border-zinc-800',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(TableList, { Item }) export default Object.assign(TableList, { Item });
+14 -22
View File
@@ -1,38 +1,30 @@
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import { import { Input, TextField as AriaTextField } from 'react-aria-components';
Input, import { cn } from '~/utils/cn';
TextField as AriaTextField
} from 'react-aria-components'
import { cn } from '~/utils/cn' type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
readonly label: string; readonly label: string;
readonly placeholder: string; readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>]; readonly state?: [string, Dispatch<SetStateAction<string>>];
} };
export default function TextField(properties: TextFieldProperties) { export default function TextField(props: TextFieldProps) {
return ( return (
<AriaTextField <AriaTextField {...props} aria-label={props.label} className="w-full">
{...properties}
aria-label={properties.label}
className='w-full'
>
<Input <Input
placeholder={properties.placeholder} placeholder={props.placeholder}
value={properties.state?.[0]} value={props.state?.[0]}
name={properties.name} name={props.name}
className={cn( className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1', 'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300', 'dark:bg-ui-800 dark:text-ui-300',
properties.className props.className,
)} )}
onChange={event => { onChange={(event) => {
properties.state?.[1](event.target.value) props.state?.[1](event.target.value);
}} }}
/> />
</AriaTextField> </AriaTextField>
) );
} }
+55 -43
View File
@@ -1,20 +1,32 @@
import { XIcon } from '@primer/octicons-react' import { XIcon } from '@primer/octicons-react';
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast' import {
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast' AriaToastProps,
import { type ReactNode, useRef } from 'react' useToast,
import { Button } from 'react-aria-components' useToastRegion,
import { createPortal } from 'react-dom' } from '@react-aria/toast';
import { ClientOnly } from 'remix-utils/client-only' import {
ToastQueue,
ToastState,
useToastQueue,
} from '@react-stately/toast';
import { ReactNode, useRef } from 'react';
import { Button } from 'react-aria-components';
import { createPortal } from 'react-dom';
import { ClientOnly } from 'remix-utils/client-only';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type ToastProps = AriaToastProps<ReactNode> & {
type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>; readonly state: ToastState<ReactNode>;
} };
function Toast({ state, ...properties }: ToastProperties) { function Toast({ state, ...properties }: ToastProps) {
const reference = useRef(null) const reference = useRef(null);
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
const { toastProps, titleProps, closeButtonProps } = useToast(
properties,
state,
reference,
);
return ( return (
<div <div
@@ -24,7 +36,7 @@ function Toast({ state, ...properties }: ToastProperties) {
'bg-main-700 dark:bg-main-800 rounded-lg', 'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50', 'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700', 'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80' 'flex items-center justify-between p-3 pl-4 w-80',
)} )}
> >
<div {...titleProps}>{properties.toast.content}</div> <div {...titleProps}>{properties.toast.content}</div>
@@ -32,48 +44,48 @@ function Toast({ state, ...properties }: ToastProperties) {
{...closeButtonProps} {...closeButtonProps}
className={cn( className={cn(
'outline-none rounded-full p-1', 'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700' 'hover:bg-main-600 dark:hover:bg-main-700',
)} )}
> >
<XIcon className='w-4 h-4'/> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
) );
} }
const toasts = new ToastQueue<ReactNode>({ const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5 maxVisibleToasts: 5,
}) });
export function toast(text: string) { export function toast(text: string) {
return toasts.add(text, { timeout: 5000 }) return toasts.add(text, { timeout: 5000 });
} }
export function Toaster() { export function Toaster() {
const reference = useRef(null) const reference = useRef(null);
const state = useToastQueue(toasts) const state = useToastQueue(toasts);
const { regionProps } = useToastRegion({}, state, reference)
const { regionProps } = useToastRegion({}, state, reference);
return ( return (
<ClientOnly> <ClientOnly>
{() => createPortal( {
state.visibleToasts.length >= 0 ? ( () =>
<div createPortal(
className={cn( state.visibleToasts.length >= 0 ? (
'fixed bottom-4 right-4', <div
'flex flex-col gap-4' className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
)} {...regionProps}
{...regionProps} ref={reference}
ref={reference} >
> {state.visibleToasts.map((toast) => (
{state.visibleToasts.map(toast => ( <Toast key={toast.key} toast={toast} state={state} />
<Toast key={toast.key} toast={toast} state={state}/> ))}
))} </div>
</div> ) : undefined,
) : undefined, document.body,
document.body )
)} }
</ClientOnly> </ClientOnly>
) );
} }
+16 -22
View File
@@ -1,43 +1,37 @@
import { ReactNode } from 'react' import type { ReactNode } from 'react';
import { import {
Button as AriaButton, Button as AriaButton,
Tooltip as AriaTooltip, Tooltip as AriaTooltip,
TooltipTrigger, TooltipTrigger,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
children: ReactNode children: ReactNode;
className?: string className?: string;
} }
function Tooltip({ children }: Props) { function Tooltip({ children }: Props) {
return ( return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
} }
function Button(props: Parameters<typeof AriaButton>[0]) { function Button(props: Parameters<typeof AriaButton>[0]) {
return ( return <AriaButton {...props} />;
<AriaButton {...props} />
)
} }
function Body({ children, className }: Props) { function Body({ children, className }: Props) {
return ( return (
<AriaTooltip className={cn( <AriaTooltip
'text-sm max-w-xs p-2 rounded-lg mb-2', className={cn(
'bg-white dark:bg-ui-900 drop-shadow-sm', 'text-sm max-w-xs p-2 rounded-lg mb-2',
'border border-gray-200 dark:border-zinc-700', 'bg-white dark:bg-ui-900 drop-shadow-sm',
className, 'border border-gray-200 dark:border-zinc-700',
)} className,
)}
> >
{children} {children}
</AriaTooltip> </AriaTooltip>
) );
} }
export default Object.assign(Tooltip, { Button, Body }) export default Object.assign(Tooltip, { Button, Body });
+12
View File
@@ -0,0 +1,12 @@
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});
+60 -51
View File
@@ -1,62 +1,71 @@
import { PassThrough } from 'node:stream' import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from '@remix-run/node' import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from '@remix-run/node' import { createReadableStreamFromReadable } from "@react-router/node";
import { RemixServer } from '@remix-run/react' import { ServerRouter } from "react-router";
import { isbot } from 'isbot' import { isbot } from "isbot";
import { renderToPipeableStream } from 'react-dom/server' import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { loadContext } from "~/utils/config/headplane";
import { loadContext } from './utils/config/headplane' loadContext();
await loadContext() export const streamTimeout = 5_000;
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
remixContext: EntryContext, routerContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars loadContext: AppLoadContext
_loadContext: AppLoadContext,
) { ) {
const ua = request.headers.get('user-agent') return new Promise((resolve, reject) => {
const isBot = ua ? isbot(ua) : false let shellRendered = false;
const userAgent = request.headers.get("user-agent");
return new Promise((resolve, reject) => { // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
let shellRendered = false // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const { pipe, abort } = renderToPipeableStream( const readyOption: keyof RenderToPipeableStreamOptions =
<RemixServer (userAgent && isbot(userAgent)) || routerContext.isSpaMode
context={remixContext} ? "onAllReady"
url={request.url} : "onShellReady";
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve( const { pipe, abort } = renderToPipeableStream(
new Response(stream, { <ServerRouter context={routerContext} url={request.url} />,
headers: responseHeaders, {
status: responseStatusCode, [readyOption]() {
}), shellRendered = true;
) const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
pipe(body) responseHeaders.set("Content-Type", "text/html");
},
onShellError(error: unknown) {
reject(error as Error)
},
onError(error: unknown) {
responseStatusCode = 500
if (shellRendered) {
console.error(error)
}
},
},
)
setTimeout(abort, 5000) resolve(
}) new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
} }
+60 -67
View File
@@ -1,17 +1,17 @@
import { access, constants } from 'node:fs/promises' import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises' import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici' import { Client } from 'undici';
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
client: Client | undefined client: Client | undefined;
container: string | undefined container: string | undefined;
maxAttempts: number maxAttempts: number;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@@ -24,133 +24,126 @@ export default createIntegration<Context>({
isAvailable: async (context) => { isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first // Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket // to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability') log.debug('INTG', 'Checking Docker integration availability');
context.container = process.env.HEADSCALE_CONTAINER context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
?.trim()
.toLowerCase()
if (!context.container || context.container.length === 0) { if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable') log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
return false return false;
} }
log.info('INTG', 'Using container: %s', context.container) log.info('INTG', 'Using container: %s', context.container);
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock' const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
let url: URL | undefined let url: URL | undefined;
try { try {
url = new URL(path) url = new URL(path);
} catch { } catch {
log.error('INTG', 'Invalid Docker socket path: %s', path) log.error('INTG', 'Invalid Docker socket path: %s', path);
return false return false;
} }
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') { if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s', log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
url.protocol, return false;
)
return false
} }
// The API is available as an HTTP endpoint and this // The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici // will simplify the fetching logic in undici
if (url.protocol === 'tcp:') { if (url.protocol === 'tcp:') {
// Apparently setting url.protocol doesn't work anymore? // Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:') const fetchU = url.href.replace(url.protocol, 'http:');
try { try {
log.info('INTG', 'Checking API: %s', fetchU) log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href) await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error) log.debug('INTG', 'Failed to connect to Docker API', error);
log.error('INTG', 'Failed to connect to Docker API') log.error('INTG', 'Failed to connect to Docker API');
return false return false;
} }
context.client = new Client(fetchU) context.client = new Client(fetchU);
} }
// Check if the socket is accessible // Check if the socket is accessible
if (url.protocol === 'unix:') { if (url.protocol === 'unix:') {
try { try {
log.info('INTG', 'Checking socket: %s', log.info('INTG', 'Checking socket: %s', url.pathname);
url.pathname, await access(url.pathname, constants.R_OK);
)
await access(url.pathname, constants.R_OK)
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error) log.debug('INTG', 'Failed to access Docker socket: %s', error);
log.error('INTG', 'Failed to access Docker socket: %s', log.error('INTG', 'Failed to access Docker socket: %s', path);
path, return false;
)
return false
} }
context.client = new Client('http://localhost', { context.client = new Client('http://localhost', {
socketPath: url.pathname, socketPath: url.pathname,
}) });
} }
return context.client !== undefined return context.client !== undefined;
}, },
onConfigChange: async (context) => { onConfigChange: async (context) => {
if (!context.client || !context.container) { if (!context.client || !context.container) {
return return;
} }
log.info('INTG', 'Restarting Headscale via Docker') log.info('INTG', 'Restarting Headscale via Docker');
let attempts = 0 let attempts = 0;
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
log.debug( log.debug(
'INTG', 'Restarting container: %s (attempt %d)', 'INTG',
'Restarting container: %s (attempt %d)',
context.container, context.container,
attempts, attempts,
) );
const response = await context.client.request({ const response = await context.client.request({
method: 'POST', method: 'POST',
path: `/v1.30/containers/${context.container}/restart`, path: `/v1.30/containers/${context.container}/restart`,
}) });
if (response.statusCode !== 204) { if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) { if (attempts < context.maxAttempts) {
attempts++ attempts++;
await setTimeout(1000) await setTimeout(1000);
continue continue;
} }
const stringCode = response.statusCode.toString() const stringCode = response.statusCode.toString();
const body = await response.body.text() const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`) throw new Error(`API request failed: ${stringCode} ${body}`);
} }
break break;
} }
attempts = 0 attempts = 0;
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
try { try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts) log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await pull('v1', '') await pull('v1', '');
return return;
} catch (error) { } catch (error) {
if (error instanceof HeadscaleError && error.status === 401) { if (error instanceof HeadscaleError && error.status === 401) {
break break;
} }
if (error instanceof HeadscaleError && error.status === 404) { if (error instanceof HeadscaleError && error.status === 404) {
break break;
} }
if (attempts < context.maxAttempts) { if (attempts < context.maxAttempts) {
attempts++ attempts++;
await setTimeout(1000) await setTimeout(1000);
continue continue;
} }
throw new Error(`Missed restart deadline for ${context.container}`) throw new Error(`Missed restart deadline for ${context.container}`);
} }
} }
}, },
}) });
+27 -34
View File
@@ -1,75 +1,68 @@
import log from '~/utils/log' import log from '~/utils/log';
import dockerIntegration from './docker' import dockerIntegration from './docker';
import { IntegrationFactory } from './integration' import type { IntegrationFactory } from './integration';
import kubernetesIntegration from './kubernetes' import kubernetesIntegration from './kubernetes';
import procIntegration from './proc' import procIntegration from './proc';
export * from './integration' export * from './integration';
export async function loadIntegration() { export async function loadIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
?.trim()
.toLowerCase()
// Old HEADSCALE_CONTAINER variable upgrade path // Old HEADSCALE_CONTAINER variable upgrade path
// This ensures that when people upgrade from older versions of Headplane // This ensures that when people upgrade from older versions of Headplane
// they don't explicitly need to define the new HEADSCALE_INTEGRATION // they don't explicitly need to define the new HEADSCALE_INTEGRATION
// variable that is needed to configure docker // variable that is needed to configure docker
if (!integration && process.env.HEADSCALE_CONTAINER) { if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker' integration = 'docker';
} }
if (!integration) { if (!integration) {
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION') log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
return return;
} }
let integrationFactory: IntegrationFactory | undefined let integrationFactory: IntegrationFactory | undefined;
switch (integration.toLowerCase().trim()) { switch (integration.toLowerCase().trim()) {
case 'docker': { case 'docker': {
integrationFactory = dockerIntegration integrationFactory = dockerIntegration;
break break;
} }
case 'proc': case 'proc':
case 'native': case 'native':
case 'linux': { case 'linux': {
integrationFactory = procIntegration integrationFactory = procIntegration;
break break;
} }
case 'kubernetes': case 'kubernetes':
case 'k8s': { case 'k8s': {
integrationFactory = kubernetesIntegration integrationFactory = kubernetesIntegration;
break break;
} }
default: { default: {
log.error('INTG', 'Unknown integration: %s', integration) log.error('INTG', 'Unknown integration: %s', integration);
throw new Error(`Unknown integration: ${integration}`) throw new Error(`Unknown integration: ${integration}`);
} }
} }
log.info('INTG', 'Loading integration: %s', integration) log.info('INTG', 'Loading integration: %s', integration);
try { try {
const res = await integrationFactory.isAvailable( const res = await integrationFactory.isAvailable(
integrationFactory.context, integrationFactory.context,
) );
if (!res) { if (!res) {
log.error('INTG', 'Integration %s is not available', log.error('INTG', 'Integration %s is not available', integration);
integration, return;
)
return
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to load integration %s: %s', log.error('INTG', 'Failed to load integration %s: %s', integration, error);
integration, return;
error,
)
return
} }
log.info('INTG', 'Loaded integration: %s', integration) log.info('INTG', 'Loaded integration: %s', integration);
return integrationFactory return integrationFactory;
} }
+6 -8
View File
@@ -1,13 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IntegrationFactory<T = any> { export interface IntegrationFactory<T = any> {
name: string name: string;
context: T context: T;
isAvailable: (context: T) => Promise<boolean> | boolean isAvailable: (context: T) => Promise<boolean> | boolean;
onConfigChange?: (context: T) => Promise<void> | void onConfigChange?: (context: T) => Promise<void> | void;
} }
export function createIntegration<T>( export function createIntegration<T>(options: IntegrationFactory<T>) {
options: IntegrationFactory<T>, return options;
) {
return options
} }
+99 -92
View File
@@ -1,16 +1,16 @@
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os' import { platform } from 'node:os';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { kill } from 'node:process' import { kill } from 'node:process';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node' import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
pid: number | undefined pid: number | undefined;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@@ -20,185 +20,192 @@ export default createIntegration<Context>({
}, },
isAvailable: async (context) => { isAvailable: async (context) => {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('INTG', 'Kubernetes is only available on Linux') log.error('INTG', 'Kubernetes is only available on Linux');
return false return false;
} }
const svcRoot = Config.SERVICEACCOUNT_ROOT const svcRoot = Config.SERVICEACCOUNT_ROOT;
try { try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot) log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot) const files = await readdir(svcRoot);
if (files.length === 0) { if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found') log.error('INTG', 'Kubernetes service account not found');
return false return false;
} }
const mappedFiles = new Set(files.map(file => join(svcRoot, file))) const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [ const expectedFiles = [
Config.SERVICEACCOUNT_CA_PATH, Config.SERVICEACCOUNT_CA_PATH,
Config.SERVICEACCOUNT_TOKEN_PATH, Config.SERVICEACCOUNT_TOKEN_PATH,
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
] ];
log.debug('INTG', 'Looking for %s', expectedFiles.join(', ')) log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every(file => mappedFiles.has(file))) { if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account') log.error('INTG', 'Malformed Kubernetes service account');
return false return false;
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to access %s: %s', svcRoot, error) log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
return false return false;
} }
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot) log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile( const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8', 'utf8',
) );
// Some very ugly nesting but it's necessary // Some very ugly nesting but it's necessary
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') { if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
log.warn('INTG', 'Skipping strict Pod status check') log.warn('INTG', 'Skipping strict Pod status check');
} else { } else {
const pod = process.env.POD_NAME const pod = process.env.POD_NAME;
if (!pod) { if (!pod) {
log.error('INTG', 'Missing POD_NAME variable') log.error('INTG', 'Missing POD_NAME variable');
return false return false;
} }
if (pod.trim().length === 0) { if (pod.trim().length === 0) {
log.error('INTG', 'Pod name is empty') log.error('INTG', 'Pod name is empty');
return false return false;
} }
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s', log.debug(
'INTG',
'Checking Kubernetes pod %s in namespace %s',
pod, pod,
namespace, namespace,
) );
try { try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig') log.debug('INTG', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig() const kc = new KubeConfig();
kc.loadFromCluster() kc.loadFromCluster();
const cluster = kc.getCurrentCluster() const cluster = kc.getCurrentCluster();
if (!cluster) { if (!cluster) {
log.error('INTG', 'Malformed kubeconfig') log.error('INTG', 'Malformed kubeconfig');
return false return false;
} }
log.info('INTG', 'Service account connected to %s (%s)', log.info(
'INTG',
'Service account connected to %s (%s)',
cluster.name, cluster.name,
cluster.server, cluster.server,
) );
const kCoreV1Api = kc.makeApiClient(CoreV1Api) const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info('INTG', 'Checking pod %s in namespace %s (%s)', log.info(
'INTG',
'Checking pod %s in namespace %s (%s)',
pod, pod,
namespace, namespace,
kCoreV1Api.basePath, kCoreV1Api.basePath,
) );
log.debug('INTG', 'Reading pod info for %s', pod) log.debug('INTG', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod( const { response, body } = await kCoreV1Api.readNamespacedPod(
pod, pod,
namespace, namespace,
) );
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
log.error('INTG', 'Failed to read pod info: http %d',
response.statusCode,
)
return false
}
log.debug('INTG', 'Got pod info: %o', body.spec)
const shared = body.spec?.shareProcessNamespace
if (shared === undefined) {
log.error( log.error(
'INTG', 'INTG',
'Pod does not have spec.shareProcessNamespace set', 'Failed to read pod info: http %d',
) response.statusCode,
return false );
return false;
}
log.debug('INTG', 'Got pod info: %o', body.spec);
const shared = body.spec?.shareProcessNamespace;
if (shared === undefined) {
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
return false;
} }
if (!shared) { if (!shared) {
log.error( log.error(
'INTG', 'INTG',
'Pod has set but disabled spec.shareProcessNamespace', 'Pod has set but disabled spec.shareProcessNamespace',
) );
return false return false;
} }
log.info('INTG', 'Pod %s enabled shared processes', pod) log.info('INTG', 'Pod %s enabled shared processes', pod);
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to read pod info: %s', error) log.error('INTG', 'Failed to read pod info: %s', error);
return false return false;
} }
} }
log.debug('INTG', 'Looking for namespaced process in /proc') log.debug('INTG', 'Looking for namespaced process in /proc');
const dir = resolve('/proc') const dir = resolve('/proc');
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => { const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10) const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) { if (Number.isNaN(pid)) {
return return;
} }
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline');
try { try {
log.debug('INTG', 'Reading %s', path) log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid;
} }
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error) log.debug('INTG', 'Failed to read %s: %s', path, error);
} }
}) });
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises);
const pids = [] const pids = [];
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === 'fulfilled' && result.value) {
pids.push(result.value) pids.push(result.value);
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids) log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
) );
return false return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process') log.error('INTG', 'Could not find Headscale process');
return false return false;
} }
context.pid = pids[0] context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid) log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true return true;
} catch { } catch {
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc');
return false return false;
} }
}, },
onConfigChange: (context) => { onConfigChange: (context) => {
if (!context.pid) { if (!context.pid) {
return return;
} }
log.info('INTG', 'Sending SIGTERM to Headscale') log.info('INTG', 'Sending SIGTERM to Headscale');
kill(context.pid, 'SIGTERM') kill(context.pid, 'SIGTERM');
}, },
}) });
+38 -36
View File
@@ -1,14 +1,14 @@
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os' import { platform } from 'node:os';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { kill } from 'node:process' import { kill } from 'node:process';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
pid: number | undefined pid: number | undefined;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@@ -18,62 +18,64 @@ export default createIntegration<Context>({
}, },
isAvailable: async (context) => { isAvailable: async (context) => {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux') log.error('INTG', '/proc is only available on Linux');
return false return false;
} }
log.debug('INTG', 'Checking /proc for Headscale process') log.debug('INTG', 'Checking /proc for Headscale process');
const dir = resolve('/proc') const dir = resolve('/proc');
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => { const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10) const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) { if (Number.isNaN(pid)) {
return return;
} }
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline');
try { try {
log.debug('INTG', 'Reading %s', path) log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid;
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error) log.error('INTG', 'Failed to read %s: %s', path, error);
} }
}) });
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises);
const pids = [] const pids = [];
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === 'fulfilled' && result.value) {
pids.push(result.value) pids.push(result.value);
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids) log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
) );
return false return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process') log.error('INTG', 'Could not find Headscale process');
return false return false;
} }
context.pid = pids[0] context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid) log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true return true;
} catch { } catch {
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc');
return false return false;
} }
} },
}) });
+77
View File
@@ -0,0 +1,77 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { useEffect } from 'react'
import { ErrorPopup } from '~/components/Error';
import Header from '~/components/Header';
import { toast } from '~/components/Toaster';
import Footer from '~/components/Footer';
import Link from '~/components/Link';
import { useLiveData } from '~/utils/useLiveData'
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { HeadscaleError, pull, healthcheck } from '~/utils/headscale';
import { destroySession, getSession } from '~/utils/sessions.server';
import { XCircleFillIcon } from '@primer/octicons-react';
import log from '~/utils/log';
export async function loader({ request }: LoaderFunctionArgs) {
let healthy = false;
try {
healthy = await healthcheck();
} catch (error) {
log.debug('APIC', 'Healthcheck failed %o', error);
}
// We shouldn't session invalidate if Headscale is down
if (healthy) {
// We can assert because shell ensures this is set
const session = await getSession(request.headers.get('Cookie'));
const apiKey = session.get('hsApiKey')!;
try {
await pull('v1/apikey', apiKey);
} catch (error) {
if (error instanceof HeadscaleError) {
log.debug('APIC', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
}
}
return {
healthy,
}
}
export default function Layout() {
useLiveData({ interval: 3000 });
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>
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Header from '~/components/Header';
import Footer from '~/components/Footer';
import { getSession } from '~/utils/sessions.server';
import { loadContext } from '~/utils/config/headplane';
import { useLoaderData, LoaderFunctionArgs, Outlet, redirect } from 'react-router';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return redirect('/login');
}
const context = await loadContext();
return {
config: context.config,
url: context.headscalePublicUrl ?? context.headscaleUrl,
debug: context.debug,
user: session.get('user'),
};
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
<Outlet />
<Footer {...data} />
</>
)
}
+33 -17
View File
@@ -1,24 +1,25 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node' import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
import { import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
Links, import { loadContext } from '~/utils/config/headplane';
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error' import { ProgressBar } from 'react-aria-components';
import { Toaster } from '~/components/Toaster' import { ErrorPopup } from '~/components/Error';
import stylesheet from '~/tailwind.css?url' // TODO: Make this a default export
import { Toaster } from '~/components/Toaster';
import stylesheet from '~/tailwind.css?url';
import { cn } from '~/utils/cn';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }, {
] name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }, { rel: 'stylesheet', href: stylesheet },
] ];
export function Layout({ children }: { readonly children: React.ReactNode }) { export function Layout({ children }: { readonly children: React.ReactNode }) {
return ( return (
@@ -36,13 +37,28 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Scripts /> <Scripts />
</body> </body>
</html> </html>
) );
} }
export function ErrorBoundary() { export function ErrorBoundary() {
return <ErrorPopup /> return <ErrorPopup />;
} }
export default function App() { export default function App() {
return <Outlet /> const nav = useNavigation();
return (
<>
<ProgressBar aria-label="Loading...">
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100',
)}
/>
</ProgressBar>
<Outlet />
</>
)
} }
+34
View File
@@ -0,0 +1,34 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/editor.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];
-99
View File
@@ -1,99 +0,0 @@
import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import Fallback from '~/routes/_data.acls._index/fallback'
import { cn } from '~/utils/cn'
interface Props {
variant: 'edit' | 'diff'
language: 'json' | 'yaml'
state: [string, (value: string) => void]
policy?: string
isDisabled?: boolean
}
function monacoCallback(monaco: Monaco) {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: true,
schemas: [],
enableSchemaRequest: true,
trailingCommas: 'ignore',
})
monaco.languages.register({ id: 'json' })
monaco.languages.register({ id: 'yaml' })
}
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
const [light, setLight] = useState(false)
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
}, [])
return (
<>
<div className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'edit'
? (
<Editor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
value={state[0]}
onChange={(updated) => {
if (!updated) {
return
}
if (updated !== state[0]) {
state[1](updated)
}
}}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
readOnly: isDisabled,
}}
/>
)
: (
<DiffEditor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
original={policy}
modified={state[0]}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 13,
readOnly: isDisabled,
}}
/>
)}
</ClientOnly>
</div>
</div>
</>
)
}
-317
View File
@@ -1,317 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { setTimeout } from 'node:timers/promises'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import Monaco from './editor'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
)
try {
// We have read access, now do we have write access?
// Attempt to set the policy to what we just got
await put('v1/policy', session.get('hsApiKey')!, {
policy,
})
return {
hasAclWrite: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
}
if (error.status === 500) {
return {
hasAclWrite: false,
currentAcl: policy,
aclType: 'json',
} as const
}
}
} catch {}
return {
hasAclWrite: true,
currentAcl: '',
aclType: 'json',
} as const
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401,
})
}
const { acl } = await request.json() as { acl: string, api: boolean }
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: acl,
})
await setTimeout(250)
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
return json({ success: true })
}
export function ErrorBoundary() {
return (
<div>
<Notice className="mb-4">
An ACL policy is not available or an error occurred while trying to fetch it.
</Notice>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<div>
<div className="max-w-prose">
<p className="mb-4 text-md">
If you are running Headscale 0.23-beta1 or later, the
ACL configuration is most likely set to
{' '}
<Code>file</Code>
{' '}
mode but the ACL file is not available. In order to
resolve this you will either need to correctly set
{' '}
<Code>policy.path</Code>
{' '}
in your Headscale configuration or set the
{' '}
<Code>policy.mode</Code>
{' '}
to
{' '}
<Code>database</Code>
.
</p>
</div>
</div>
</div>
)
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl)
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
} else {
toast('Failed to update tailnet ACL policy')
}
setToasted(true)
setAcl(data.currentAcl)
}, [fetcher.data, toasted, data.currentAcl])
return (
<div>
{data.hasAclWrite
? undefined
: (
<div className="mb-4">
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
but you cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to
database in your Headscale configuration.
</Notice>
</div>
)}
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<Tabs>
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id="edit"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id="diff"
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id="preview"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id="edit">
<Monaco
isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType}
state={[acl, setAcl]}
/>
</TabPanel>
<TabPanel id="diff">
<Monaco
variant="diff"
language={data.aclType}
state={[acl, setAcl]}
policy={data.currentAcl}
/>
</TabPanel>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>
</div>
</TabPanel>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
}, {
method: 'PATCH',
encType: 'application/json',
})
}}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes
</Button>
</div>
)
}
-121
View File
@@ -1,121 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { useMemo, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
interface Props {
records: { name: string, type: 'A', value: string }[]
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit()
const [name, setName] = useState('')
const [ip, setIp] = useState('')
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false
const lookup = records.find(record => record.name === name)
if (!lookup) return false
return lookup.value === ip
}, [records, name, ip])
return (
<Dialog>
<Dialog.Button>
Add DNS record
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add DNS record
</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!name || !ip) return
setName('')
setIp('')
submit({
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
}, {
method: 'PATCH',
encType: 'application/json',
})
close()
}}
>
<TextField
label="Domain"
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn(
'mt-2',
isDuplicate && 'outline outline-red-500',
)}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(
isDuplicate && 'outline outline-red-500',
)}
/>
{isDuplicate
? (
<p className="text-sm opacity-50">
A record with the domain name
{' '}
<Code>{name}</Code>
{' '}
and IP address
{' '}
<Code>{ip}</Code>
{' '}
already exists.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,162 +0,0 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import TextField from '~/components/TextField'
import Tooltip from '~/components/Tooltip'
import { cn } from '~/utils/cn'
interface Props {
nameservers: Record<string, string[]>
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit()
const [split, setSplit] = useState(false)
const [ns, setNs] = useState('')
const [domain, setDomain] = useState('')
return (
<Dialog>
<Dialog.Button>
Add nameserver
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add nameserver
</Dialog.Title>
<Dialog.Text className="font-semibold">
Nameserver
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!ns) return
if (split) {
const splitNs: Record<string, string[]> = {}
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue
splitNs[key] = value
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns)
} else {
splitNs[domain] = [ns]
}
submit({
'dns.nameservers.split': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const globalNs = nameservers.global
globalNs.push(ns)
submit({
'dns.nameservers.global': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
}
setNs('')
setDomain('')
setSplit(false)
close()
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS
(Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => { setSplit(!split) }}
/>
</div>
{split
? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries
matching this suffix should use the nameserver.
</Dialog.Text>
</>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-62
View File
@@ -1,62 +0,0 @@
import { useFetcher } from '@remix-run/react'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
type Properties = {
readonly isEnabled: boolean;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain.
The search domain will also be disabled.
</Dialog.Text>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-111
View File
@@ -1,111 +0,0 @@
import { useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Button } from 'react-aria-components'
import Link from '~/components/Link'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import AddNameserver from './dialogs/nameserver'
interface Props {
nameservers: Record<string, string[]>
isDisabled: boolean
}
export default function Nameservers({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map(key => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
name={key}
/>
))}
{isDisabled
? undefined
: (
<AddNameserver nameservers={nameservers} />
)}
</div>
</div>
)
}
interface ListProps {
isGlobal: boolean
isDisabled: boolean
nameservers: string[]
name: string
}
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const submit = useSubmit()
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit({
'dns.nameservers.global': nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns.nameservers.split."${name}"`
const list = nameservers.filter((_, i) => i !== index)
submit({
[key]: list.length ? list : null,
}, {
method: 'PATCH',
encType: 'application/json',
})
}
}}
>
Remove
</Button>
</TableList.Item>
))}
</TableList>
</div>
)
}
-101
View File
@@ -1,101 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable unicorn/no-keyword-prefix */
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import { Input } from 'react-aria-components'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<Code>
[device].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<Input
readOnly
className={cn(
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none'
)}
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Rename Tailnet
</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
</Dialog.Text>
<TextField
label='Tailnet name'
placeholder='ts.net'
state={[newName, setNewName]}
className='my-2'
/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
onPress={() => {
fetcher.submit({
'dns.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
)
}
-205
View File
@@ -1,205 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './_data.machines._index/action'
import MenuOptions from './_data.machines._index/menu'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
const context = await loadContext()
let magic: string | undefined
if (context.config.read) {
const config = await loadConfig()
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
}
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
return {
machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id),
users: users.users,
magic,
}
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
}
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
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 = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
return (
<div>
<p className="mb-8 text-md">
<Link
to="/machines"
className="font-medium"
>
All Machines
</Link>
<span className="mx-2">
/
</span>
{machine.givenName}
</p>
<div className="flex justify-between items-center">
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">
{machine.givenName}
</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</div>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
<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
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name="Expiry"
value={expired
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
/>
{magic
? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
)
: undefined}
</Card>
<h2 className="text-xl font-medium mb-4 mt-8">
Machine Routes
</h2>
<Card variant="flat" className="w-full max-w-full">
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: routes.map((route, i) => (
<div
key={route.id}
className={cn(
'flex items-center justify-between',
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
'border-ui-100 dark:border-ui-800',
)}
>
<div>
<p className="font-mono mb-1">
{route.prefix}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{' '}
(Created:
{' '}
{new Date(route.createdAt).toLocaleString()}
)
</p>
</div>
<div className="text-right">
<p className="mb-1">
{route.enabled ? 'Enabled' : 'Disabled'}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{route.isPrimary ? 'Primary' : 'Secondary'}
</p>
</div>
</div>
))}
</Card>
</div>
)
}
-146
View File
@@ -1,146 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, json } from '@remix-run/node'
import { del, post } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
case 'move': {
if (!data.has('to')) {
return json({ message: 'No destination provided' }, {
status: 400,
})
}
const to = String(data.get('to'))
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved node ${id} to ${to}` })
} catch {
return json({ message: `Failed to move node ${id} to ${to}` }, {
status: 500,
})
}
}
case 'tags': {
const tags = data.get('tags')?.toString()
.split(',') ?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch {
return json({ message: 'Failed to update tags' }, {
status: 500,
})
}
}
case 'register': {
const key = data.get('mkey')?.toString()
const user = data.get('user')?.toString()
if (!key) {
return json({ message: 'No machine key provided' }, {
status: 400,
})
}
if (!user) {
return json({ 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 post(url, session.get('hsApiKey')!, {
user, key,
})
return json({
success: true,
message: 'Machine registered'
})
} catch {
return json({
success: false,
message: 'Failed to register machine'
}, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -1,87 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Select from '~/components/Select'
import { type Machine, User } from '~/types'
interface MoveProps {
readonly machine: Machine
readonly users: User[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name)
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Change the owner of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
{magic
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{magic}
</Code>
.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Change owner
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,136 +0,0 @@
import { Form, useFetcher, Link } from '@remix-run/react'
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Select from '~/components/Select'
import Menu from '~/components/Menu'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
export interface NewProps {
server: string
users: User[]
}
export default function New(data: NewProps) {
const fetcher = useFetcher()
const mkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState('')
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Registered new machine')
} else {
toast('Failed to register machine due to an invalid key')
}
setToasted(true)
}, [fetcher.data, toasted, mkey])
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{close => (
<>
<Dialog.Title>
Register Machine Key
</Dialog.Title>
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code>
tailscale up --login-server=
</Code>
<Code>
{data.server}
</Code>
{' '}
on your device.
</Dialog.Text>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='mkey:ff.....'
name="mkey"
state={[mkey, setMkey]}
className='my-2 font-mono'
/>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
)}
>
Add Device
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className='w-4 h-4 mr-2'/>
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton>
<Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
}
@@ -1,100 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
interface RenameProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName)
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit machine name for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className="my-2"
/>
{magic
? (
name.length > 0 && name !== machine.givenName
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
.
</p>
)
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Rename
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,97 +0,0 @@
import { useFetcher } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
interface RoutesProps {
readonly machine: Machine
readonly routes: Route[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST',
})
}}
/>
</div>
))}
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,155 +0,0 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
import { Link } from '@remix-run/react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import MenuOptions from './menu'
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly users: User[]
readonly magic?: string
}
export default function MachineRow({ machine, routes, magic, users }: 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 = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic
return (
<tr
key={machine.id}
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className="pl-0.5 py-2">
<Link
to={`/machines/${machine.id}`}
className="group/link h-full"
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<div className="flex gap-1 mt-1">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
{ip}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic
? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${prefix}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}
.
{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className="py-2">
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
</tr>
)
}
-111
View File
@@ -1,111 +0,0 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { useState } from 'react'
import MenuComponent from '~/components/Menu'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Move from './dialogs/move'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
import Tags from './dialogs/tags'
interface MenuProps {
machine: Machine
routes: Route[]
users: User[]
magic?: string
}
export default function Menu({ machine, routes, magic, users }: MenuProps) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const moveState = useState(false)
const tagsState = 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()
return (
<>
<Rename
machine={machine}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
state={removeState}
/>
{expired
? undefined
: (
<Expire
machine={machine}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<Tags
machine={machine}
state={tagsState}
/>
<Move
machine={machine}
state={moveState}
users={users}
magic={magic}
/>
<MenuComponent>
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={tagsState}>
Edit ACL tags
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired
? undefined
: (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
>
Remove
</MenuComponent.ItemButton>
</MenuComponent.Items>
</MenuComponent>
</>
)
}
-118
View File
@@ -1,118 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './action'
import MachineRow from './machine'
import NewMachine from './dialogs/new'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
const context = await loadContext()
let magic: string | undefined
if (context.config.read) {
const config = await loadConfig()
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
server: context.headscaleUrl,
}
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
return (
<>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<NewMachine server={data.server} users={data.users} />
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
<tr className="text-left uppercase text-xs font-bold px-0.5">
<th className="pb-2">Name</th>
<th className="pb-2">
<div className="flex items-center gap-x-1">
Addresses
{data.magic
? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700',
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
)
: undefined}
</div>
</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700',
)}
>
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
users={data.users}
magic={data.magic}
/>
))}
</tbody>
</table>
</>
)
}
@@ -1,48 +0,0 @@
import Link from '~/components/Link'
import Button from '~/components/Button'
import { Link as RemixLink } from '@remix-run/react'
import { ArrowRightIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
export default function Page() {
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
</p>
</div>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
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>
</div>
<RemixLink
to="/settings/auth-keys"
name="Auth Keys"
>
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</div>
)
}
@@ -1,69 +0,0 @@
import { useFetcher } from '@remix-run/react'
import type { PreAuthKey } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
interface Props {
authKey: PreAuthKey
}
export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button className="my-4">
Expire Key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Expire auth key?
</Dialog.Title>
<fetcher.Form method="DELETE" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately
prevent it from being used to authenticate new devices.
{' '}
This action cannot be undone.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -1,222 +0,0 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
import { Link as RemixLink } from '@remix-run/react'
import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import AddPreAuthKey from './dialogs/new'
import AuthKeyRow from './key'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
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 json({ message: 'Missing parameters' }, {
status: 400,
})
}
await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
{
user: user,
key: key,
}
)
return json({ 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 json({ 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 post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
}
)
return json({ message: 'Pre-auth key created', key })
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
const session = await getSession(request.headers.get('Cookie'))
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
const preAuthKeys = await Promise.all(users.users.map(user => {
const qp = new URLSearchParams()
qp.set('user', user.name)
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!
)
}))
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users,
server: context.headscaleUrl,
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>()
const [user, setUser] = useState('All')
const [status, setStatus] = useState('Active')
const filteredKeys = keys.filter(key => {
if (user !== '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
}
if (status === 'Used/Expired') {
return key.used || expiry < now
}
if (status === 'Reusable') {
return key.reusable
}
if (status === 'Ephemeral') {
return key.ephemeral
}
}
return true
})
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-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 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 justify-between gap-4 mt-4">
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by user
</p>
<Select
label="Filter by User"
placeholder="Select a user"
state={[user, setUser]}
>
<Select.Item id="All">All</Select.Item>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
</div>
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by status
</p>
<Select
label="Filter by status"
placeholder="Select a status"
state={[status, setStatus]}
>
<Select.Item id="All">All</Select.Item>
<Select.Item id="Active">Active</Select.Item>
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
<Select.Item id="Reusable">Reusable</Select.Item>
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
</Select>
</div>
</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>
)
}
-75
View File
@@ -1,75 +0,0 @@
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session),
},
})
}
// Otherwise propagate to boundary
throw error
}
const context = await loadContext()
return {
config: context.config,
user: session.get('user'),
}
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
const nav = useNavigation()
return (
<>
<ProgressBar
aria-label="Loading..."
>
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100',
)}
/>
</ProgressBar>
<Header data={data} />
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
)
}
export function ErrorBoundary() {
return (
<>
<Header />
<ErrorPopup type="embedded" />
</>
)
}
-81
View File
@@ -1,81 +0,0 @@
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
interface Props {
magic?: string
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('')
const submit = useSubmit()
return (
<Dialog>
<Dialog.Button>
Add a new user
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add a new user
</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user.
{' '}
{magic
? (
<>
Since Magic DNS is enabled, machines will be
accessible via
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</>
)
: undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="create" />
<TextField
label="Username"
placeholder="my-new-user"
name="username"
state={[username, setUsername]}
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Create
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
-73
View File
@@ -1,73 +0,0 @@
import { XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
interface Props {
username: string
}
export default function Remove({ username }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
return (
<>
<Button
variant="light"
control={dialogState}
className="rounded-full p-0 w-8 h-8"
>
<XIcon className="w-4 h-4" />
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
<>
<Dialog.Title>
Delete
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8">
Are you sure you want to delete
{' '}
{username}
?
{' '}
A deleted user cannot be recovered.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Delete
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
</>
)
}
-351
View File
@@ -1,351 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Auth from './auth'
import Oidc from './oidc'
import Remove from './remove'
import Rename from './rename'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
const users = apiUsers.users.map(user => ({
...user,
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
const context = await loadContext()
let magic: string | undefined
if (context.config.read) {
const config = await loadConfig()
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
}
return {
oidc: context.oidc,
magic,
users,
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method')) {
return json({ message: 'No method provided' }, {
status: 400,
})
}
const method = String(data.get('_method'))
switch (method) {
case 'create': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const username = String(data.get('username'))
await post('v1/user', session.get('hsApiKey')!, {
name: username,
})
return json({ message: `User ${username} created` })
}
case 'delete': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!)
return json({ message: `User ${username} deleted` })
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
return json({ message: 'No old or new name provided' }, {
status: 400,
})
}
const old = String(data.get('old'))
const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return json({ message: `User ${old} renamed to ${newName}` })
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
return json({ message: 'No ID or destination provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const to = String(data.get('to'))
const name = String(data.get('name'))
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved ${name} to ${to}` })
} catch {
return json({ message: `Failed to move ${name} to ${to}` }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [users, setUsers] = useState(data.users)
const actionData = useActionData<typeof action>()
useLiveData({ interval: 3000 })
useEffect(() => {
if (!actionData) {
return
}
toast(actionData.message)
if (actionData.message.startsWith('Failed')) {
setUsers(data.users)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData])
useEffect(() => {
setUsers(data.users)
}, [data.users])
return (
<>
<h1 className="text-2xl font-medium mb-1.5">
Users
</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
</p>
{data.oidc
? (
<Oidc
oidc={data.oidc}
magic={data.magic}
/>
)
: (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
<Users users={users} />
}
>
{() => (
<InteractiveUsers
users={users}
setUsers={setUsers}
magic={data.magic}
/>
)}
</ClientOnly>
</>
)
}
type UserMachine = User & { machines: Machine[] }
interface UserProps {
users: UserMachine[]
setUsers?: (users: UserMachine[]) => void
magic?: string
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
))}
</div>
)
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit()
return (
<DndContext onDragEnd={(event) => {
const { over, active } = event
if (!over) {
return
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>()
const reference = active.data as DataRef<Machine>
if (!reference.current) {
return
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return
}
for (const user of users) {
newUsers.push({
...user,
machines: over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter(m => m.id !== active.id),
})
}
setUsers?.(newUsers)
const data = new FormData()
data.append('_method', 'move')
data.append('id', active.id.toString())
data.append('to', over.id.toString())
data.append('name', reference.current.givenName)
submit(data, {
method: 'POST',
})
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
))}
</div>
</DndContext>
)
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
})
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center w-full gap-2 py-1',
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
)}
style={{
transform: transform
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
: undefined,
}}
{...listeners}
{...attributes}
>
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
<Attribute
name={machine.givenName}
value={machine.ipAddresses[0]}
/>
</div>
)
}
interface CardProps {
user: UserMachine
magic?: string
}
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
return (
<div ref={setNodeRef}>
<Card
variant="flat"
className={cn(
'max-w-full w-full overflow-visible h-full',
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">
{user.name}
</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{user.machines.length === 0
? (
<Remove username={user.name} />
)
: undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map(machine => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}
+136
View File
@@ -0,0 +1,136 @@
import React, { useEffect } from 'react';
import Merge from 'react-codemirror-merge';
import CodeMirror from '@uiw/react-codemirror';
import * as shopify from '@shopify/lang-jsonc';
import { ClientOnly } from 'remix-utils/client-only';
import { ErrorBoundary } from 'react-error-boundary';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import { useState } from 'react';
import { cn } from '~/utils/cn';
import Fallback from './fallback';
interface EditorProps {
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<p
className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}
>
Failed to load the editor.
</p>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
</div>
);
}
interface DifferProps {
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
{props.left === props.right ? (
<p
className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}
>
No changes
</p>
) : (
<ErrorBoundary
fallback={
<p
className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}
>
Failed to load the editor.
</p>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge
orientation="a-b"
theme={light ? githubLight : githubDark}
>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
)}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
message: string;
}
export function ErrorView({ message }: Props) {
return (
<Card variant="flat" className="max-w-full mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">Error</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy due to the following error:
<br />
<Code>{message}</Code>
</Card.Text>
</Card>
);
}
@@ -1,11 +1,11 @@
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface FallbackProps { interface Props {
readonly acl: string readonly acl: string;
} }
export default function Fallback({ acl }: FallbackProps) { export default function Fallback({ acl }: Props) {
return ( return (
<div className="inline-block relative w-full h-editor"> <div className="inline-block relative w-full h-editor">
<Spinner className="w-4 h-4 absolute p-2" /> <Spinner className="w-4 h-4 absolute p-2" />
@@ -15,10 +15,10 @@ export default function Fallback({ acl }: FallbackProps) {
'w-full h-editor font-mono resize-none', 'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300', 'text-sm text-gray-600 dark:text-gray-300',
'bg-ui-100 dark:bg-ui-800', 'bg-ui-100 dark:bg-ui-800',
'pl-16 pr-8 pt-0.5 leading-snug', 'pl-10 pt-1 leading-snug',
)} )}
value={acl} value={acl}
/> />
</div> </div>
) );
} }
@@ -0,0 +1,39 @@
import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react';
import Code from '~/components/Code';
import Card from '~/components/Card';
interface Props {
mode: 'file' | 'database';
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration. This is most likely due
to a misconfiguration in your Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode is set to{' '}
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
<Code>policy.path</Code> is set to a valid path in your Headscale
configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of Headplane,
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
<Code>database</Code> in your Headscale configuration.
</p>
)}
</Card>
);
}
+352
View File
@@ -0,0 +1,352 @@
import {
BeakerIcon,
EyeIcon,
IssueDraftIcon,
PencilIcon,
} from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useRevalidator, useFetcher } from 'react-router';
//import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher';
import { useEffect, useState, useMemo } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { setTimeout } from 'node:timers/promises';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { HeadscaleError, pull, put } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import log from '~/utils/log';
import { Editor, Differ } from './components/cm.client';
import { Unavailable } from './components/unavailable';
import { ErrorView } from './components/error';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
// File: The ACL policy is readonly to the API and manually edited
// Database: The ACL policy is read/write to the API
//
// To determine if we first have an ACL policy available we need to check
// if fetching the v1/policy route gives us a 500 status code or a 200.
//
// 500 can mean many different things here unfortunately:
// - In file based that means the file is not accessible
// - In database mode this can mean that we have never set an ACL policy
// - In database mode this can mean that the ACL policy is not available
// - A general server error may have occurred
//
// Unfortunately the server errors are not very descriptive so we have to
// do some silly guesswork here. If we are running in an integration mode
// and have the Headscale configuration available to us, our assumptions
// can be more accurate, otherwise we just HAVE to assume that the ACL
// policy has never been set.
//
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
const context = await loadContext();
let modeGuess = 'database'; // Assume database mode
if (context.config.read) {
const config = await loadConfig();
modeGuess = config.policy?.mode ?? 'database';
}
// Attempt to load the policy, for both the frontend and for checking
// if we are able to write to the policy for write access
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
);
let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: policy,
});
write = true;
} catch (error) {
write = false;
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
}
}
return {
read: true,
write,
mode: modeGuess,
policy,
};
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
return {
read: false,
write: false,
mode: modeGuess,
policy: null,
};
}
// Assume that we have write access otherwise?
// This is sort of a brittle assumption to make but we don't want
// to create a default policy if we don't have to.
return {
read: true,
write: true,
mode: modeGuess,
policy: null,
};
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ success: false, error: null }, 401);
}
try {
const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
{
policy: acl,
},
);
return { success: true, policy, error: null };
} catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof HeadscaleError ? error.status : 500,
},
);
}
return { success: true, error: null };
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) {
toast('Updated tailnet ACL policy');
} else {
toast('Failed to update tailnet ACL policy');
}
setToasted(true);
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
}, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true;
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true;
}
if (revalidator.state === 'loading') {
return true;
}
// If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) {
return false;
}
return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return (
<div>
{data.read && !data.write ? (
<div className="mb-4">
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{
// @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false ? (
// @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} />
) : undefined
}
{data.read ? (
<>
<Tabs>
<TabList
className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id="edit"
className={({ isSelected }) =>
cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id="diff"
className={({ isSelected }) =>
cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id="preview"
className={({ isSelected }) =>
cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id="edit">
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</TabPanel>
<TabPanel id="diff">
<Differ left={data?.policy ?? ''} right={acl} />
</TabPanel>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress. It is
a bit complicated to implement right now but hopefully it will
be available soon.
</p>
</div>
</TabPanel>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={disabled}
onPress={() => {
setToasted(false);
fetcher.submit(
{
acl,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '');
}}
>
Discard Changes
</Button>
</>
) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import { useMemo } from 'react';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import TextField from '~/components/TextField';
import type { Key } from '~/types';
import { loadContext } from '~/utils/config/headplane';
import { pull } from '~/utils/headscale';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { commitSession, getSession } from '~/utils/sessions.server';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
const context = await loadContext();
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return redirect('/oidc/start');
}
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
};
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await getSession(request.headers.get('Cookie'));
if (oidcStart) {
const context = await loadContext();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
throw new 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('hsApiKey', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const showOr = useMemo(() => data.oidc && data.apiKey, [data]);
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
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}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
/>
<Button className="w-full mt-2.5" variant="heavy" type="submit">
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
) : undefined}
{data.oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button className="w-full" variant="heavy" type="submit">
Login with SSO
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions.server';
export async function loader() {
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
+77
View File
@@ -0,0 +1,77 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { loadContext } from '~/utils/config/headplane';
import { getSession, commitSession } from '~/utils/sessions.server';
import { finishAuthFlow, getRedirectUri, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
export async function loader({ request }: LoaderFunctionArgs) {
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/machines');
}
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines')
}
// This is a hold-over from the old code
// TODO: Rewrite checkOIDC in the context loader
const { oidc } = await loadContext();
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const oidcConfig = {
issuer: oidc.issuer,
clientId: oidc.client,
clientSecret: oidc.secret,
redirectUri: oidc.redirectUri,
tokenEndpointAuthMethod: oidc.method,
}
const codeVerifier = session.get('oidc_code_verif');
const state = session.get('oidc_state');
const nonce = session.get('oidc_nonce');
if (!codeVerifier || !state || !nonce) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
const flowOptions = {
redirect_uri: request.url,
codeVerifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
}
try {
const user = await finishAuthFlow(oidcConfig, flowOptions);
session.set('user', user);
session.unset('oidc_code_verif');
session.unset('oidc_state');
session.unset('oidc_nonce');
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
session.set('hsApiKey', oidc.rootKey);
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error) {
return new Response(
JSON.stringify(formatError(error)),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { type LoaderFunctionArgs, data, redirect } from 'react-router';
import { commitSession, getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { loadContext } from '~/utils/config/headplane';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines')
}
// This is a hold-over from the old code
// TODO: Rewrite checkOIDC in the context loader
const { oidc } = await loadContext();
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const oidcConfig = {
issuer: oidc.issuer,
clientId: oidc.client,
clientSecret: oidc.secret,
redirectUri: oidc.redirectUri,
tokenEndpointAuthMethod: oidc.method,
}
const redirectUri = oidcConfig.redirectUri ?? getRedirectUri(request);
const data = await beginAuthFlow(oidcConfig, redirectUri);
session.set('oidc_code_verif', data.codeVerifier);
session.set('oidc_state', data.state);
session.set('oidc_nonce', data.nonce);
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
@@ -1,34 +1,29 @@
import { useSubmit } from '@remix-run/react' import { useSubmit } from 'react-router';
import { Button } from 'react-aria-components' import { Button } from 'react-aria-components';
import Code from '~/components/Code' import Code from '~/components/Code';
import Link from '~/components/Link' import Link from '~/components/Link';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import AddDNS from './dialogs/dns' import AddDNS from '../dialogs/dns';
interface Props { interface Props {
records: { name: string, type: 'A', value: string }[] records: { name: string; type: 'A'; value: string }[];
isDisabled: boolean isDisabled: boolean;
} }
export default function DNS({ records, isDisabled }: Props) { export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1> <h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet. Headscale supports adding custom DNS records to your Tailnet. As of now,
As of now, only only <Code>A</Code> records are supported.{' '}
{' '}
<Code>A</Code>
{' '}
records are supported.
{' '}
<Link <Link
to="https://headscale.net/dns-records/" to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation" name="Headscale DNS Records documentation"
> >
Learn More Learn More
@@ -36,15 +31,12 @@ export default function DNS({ records, isDisabled }: Props) {
</p> </p>
<div className="mt-4"> <div className="mt-4">
<TableList className="mb-8"> <TableList className="mb-8">
{records.length === 0 {records.length === 0 ? (
? ( <TableList.Item>
<TableList.Item> <p className="opacity-50 text-sm mx-auto">No DNS records found</p>
<p className="opacity-50 text-sm mx-auto"> </TableList.Item>
No DNS records found ) : (
</p> records.map((record, index) => (
</TableList.Item>
)
: records.map((record, index) => (
<TableList.Item key={index}> <TableList.Item key={index}>
<div className="flex gap-24"> <div className="flex gap-24">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -62,27 +54,28 @@ export default function DNS({ records, isDisabled }: Props) {
)} )}
isDisabled={isDisabled} isDisabled={isDisabled}
onPress={() => { onPress={() => {
submit({ submit(
'dns.extra_records': records {
.filter((_, i) => i !== index), 'dns.extra_records': records.filter(
}, { (_, i) => i !== index,
method: 'PATCH', ),
encType: 'application/json', },
}) {
method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
Remove Remove
</Button> </Button>
</TableList.Item> </TableList.Item>
))} ))
)}
</TableList> </TableList>
{isDisabled {isDisabled ? undefined : <AddDNS records={records} />}
? undefined
: (
<AddDNS records={records} />
)}
</div> </div>
</div> </div>
) );
} }
@@ -1,88 +1,88 @@
/* eslint-disable unicorn/no-keyword-prefix */ /* eslint-disable unicorn/no-keyword-prefix */
import { import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import { import {
restrictToParentElement, restrictToParentElement,
restrictToVerticalAxis restrictToVerticalAxis,
} from '@dnd-kit/modifiers' } from '@dnd-kit/modifiers';
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
useSortable, useSortable,
verticalListSortingStrategy verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react' import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { type FetcherWithComponents, useFetcher } from '@remix-run/react' import { type FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components' import { Button, Input } from 'react-aria-components';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
type Properties = { type Properties = {
readonly baseDomain?: string; readonly baseDomain?: string;
readonly searchDomains: string[]; readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean; readonly disabled?: boolean;
} };
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) { export default function Domains({
baseDomain,
searchDomains,
disabled,
}: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types // eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null) const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains) const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('') const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher() const fetcher = useFetcher();
useEffect(() => { useEffect(() => {
setLocalDomains(searchDomains) setLocalDomains(searchDomains);
}, [searchDomains]) }, [searchDomains]);
return ( return (
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1> <h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'> <p className="text-gray-700 dark:text-gray-300 mb-2">
Set custom DNS search domains for your Tailnet. Set custom DNS search domains for your Tailnet. When using Magic DNS,
When using Magic DNS, your tailnet domain is used as the first search domain. your tailnet domain is used as the first search domain.
</p> </p>
<DndContext <DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]} modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners} collisionDetection={closestCorners}
onDragStart={event => { onDragStart={(event) => {
setActiveId(event.active.id) setActiveId(event.active.id);
}} }}
onDragEnd={event => { onDragEnd={(event) => {
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
setActiveId(null) setActiveId(null);
const { active, over } = event const { active, over } = event;
if (!over) { if (!over) {
return return;
} }
const activeItem = localDomains[active.id as number - 1] const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[over.id as number - 1] const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) { if (!activeItem || !overItem) {
return return;
} }
const oldIndex = localDomains.indexOf(activeItem) const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem) const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) { if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex)) setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
} }
}} }}
> >
<TableList> <TableList>
{baseDomain ? ( {baseDomain ? (
<TableList.Item key='magic-dns-sd'> <TableList.Item key="magic-dns-sd">
<p className='font-mono text-sm'>{baseDomain}</p> <p className="font-mono text-sm">{baseDomain}</p>
<LockIcon className='h-4 w-4'/> <LockIcon className="h-4 w-4" />
</TableList.Item> </TableList.Item>
) : undefined} ) : undefined}
<SortableContext <SortableContext
@@ -101,25 +101,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
/> />
))} ))}
<DragOverlay adjustScale> <DragOverlay adjustScale>
{activeId ? <Domain {activeId ? (
isDrag <Domain
domain={localDomains[activeId as number - 1]} isDrag
localDomains={localDomains} domain={localDomains[(activeId as number) - 1]}
id={activeId as number - 1} localDomains={localDomains}
disabled={disabled} id={(activeId as number) - 1}
fetcher={fetcher} disabled={disabled}
/> : undefined} fetcher={fetcher}
/>
) : undefined}
</DragOverlay> </DragOverlay>
</SortableContext> </SortableContext>
{disabled ? undefined : ( {disabled ? undefined : (
<TableList.Item key='add-sd'> <TableList.Item key="add-sd">
<Input <Input
type='text' type="text"
className='font-mono text-sm bg-transparent w-full mr-2' className="font-mono text-sm bg-transparent w-full mr-2"
placeholder='Search Domain' placeholder="Search Domain"
value={newDomain} value={newDomain}
onChange={event => { onChange={(event) => {
setNewDomain(event.target.value) setNewDomain(event.target.value);
}} }}
/> />
{fetcher.state === 'idle' ? ( {fetcher.state === 'idle' ? (
@@ -128,32 +130,35 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
'text-sm font-semibold', 'text-sm font-semibold',
'text-blue-600 dark:text-blue-400', 'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300', 'hover:text-blue-700 dark:hover:text-blue-300',
newDomain.length === 0 && 'opacity-50 cursor-not-allowed' newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={newDomain.length === 0} isDisabled={newDomain.length === 0}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
// eslint-disable-next-line @typescript-eslint/naming-convention {
'dns.search_domains': [...localDomains, newDomain] // eslint-disable-next-line @typescript-eslint/naming-convention
}, { 'dns.search_domains': [...localDomains, newDomain],
method: 'PATCH', },
encType: 'application/json' {
}) method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('') setNewDomain('');
}} }}
> >
Add Add
</Button> </Button>
) : ( ) : (
<Spinner className='w-3 h-3 mr-0'/> <Spinner className="w-3 h-3 mr-0" />
)} )}
</TableList.Item> </TableList.Item>
)} )}
</TableList> </TableList>
</DndContext> </DndContext>
</div> </div>
) );
} }
type DomainProperties = { type DomainProperties = {
@@ -164,17 +169,24 @@ type DomainProperties = {
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean; readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>; readonly fetcher: FetcherWithComponents<unknown>;
} };
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) { function Domain({
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging isDragging,
} = useSortable({ id }) } = useSortable({ id });
// TODO: Figure out why TableList.Item breaks dndkit // TODO: Figure out why TableList.Item breaks dndkit
return ( return (
@@ -184,17 +196,19 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'flex items-center justify-between px-3 py-2', 'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800', 'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '', isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : '' isDrag
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
: '',
)} )}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition transition,
}} }}
> >
<p className='font-mono text-sm flex items-center gap-4'> <p className="font-mono text-sm flex items-center gap-4">
{disabled ? undefined : ( {disabled ? undefined : (
<ThreeBarsIcon <ThreeBarsIcon
className='h-4 w-4 text-gray-400 focus:outline-none' className="h-4 w-4 text-gray-400 focus:outline-none"
{...attributes} {...attributes}
{...listeners} {...listeners}
/> />
@@ -207,21 +221,26 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'text-sm', 'text-sm',
'text-red-600 dark:text-red-400', 'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300', 'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed' disabled && 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1) {
}, { 'dns.search_domains': localDomains.filter(
method: 'PATCH', (_, index) => index !== id - 1,
encType: 'application/json' ),
}) },
{
method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
Remove Remove
</Button> </Button>
)} )}
</div> </div>
) );
} }
+59
View File
@@ -0,0 +1,59 @@
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
type Properties = {
readonly isEnabled: boolean;
readonly disabled?: boolean;
};
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher();
return (
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={() => {
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useSubmit } from 'react-router';
import { useState } from 'react';
import { Button } from 'react-aria-components';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
import AddNameserver from '../dialogs/nameserver';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function Nameservers({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled ? undefined : <AddNameserver nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const submit = useSubmit();
const list = isGlobal ? nameservers['global'] : nameservers[name];
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit(
{
'dns.nameservers.global': list.filter(
(_, i) => i !== index,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
submit(
{
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
},
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
}}
>
Remove
</Button>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useFetcher } from 'react-router';
import { useState } from 'react';
import { Input } from 'react-aria-components';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
type Properties = {
readonly name: string;
readonly disabled?: boolean;
};
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name);
const fetcher = useFetcher();
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Tailnet Name</h1>
<p className="text-gray-700 dark:text-gray-300">
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
readOnly
className={cn(
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none',
)}
type="text"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of
unexpected behavior and may break existing devices in your
tailnet.
</Dialog.Text>
<TextField
label="Tailnet name"
placeholder="ts.net"
state={[newName, setNewName]}
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={() => {
fetcher.submit(
{
'dns.base_domain': newName,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Form, useSubmit } from 'react-router';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
interface Props {
records: { name: string; type: 'A'; value: string }[];
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit();
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!name || !ip) return;
setName('');
setIp('');
submit(
{
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
<TextField
label="Domain"
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(isDuplicate && 'outline outline-red-500')}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP
address <Code>{ip}</Code> already exists.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import TextField from '~/components/TextField';
import Tooltip from '~/components/Tooltip';
import { cn } from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit();
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add nameserver</Dialog.Title>
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!ns) return;
if (split) {
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue;
splitNs[key] = value;
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns);
} else {
splitNs[domain] = [ns];
}
submit(
{
'dns.nameservers.split': splitNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
const globalNs = nameservers.global;
globalNs.push(ns);
submit(
{
'dns.nameservers.global': globalNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
setNs('');
setDomain('');
setSplit(false);
close();
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or
later for most platforms) will use this nameserver.
Older clients will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => {
setSplit(!split);
}}
/>
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this
suffix should use the nameserver.
</Dialog.Text>
</>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -1,27 +1,27 @@
import { type ActionFunctionArgs } from '@remix-run/node' import type { ActionFunctionArgs } from 'react-router';
import { json, useLoaderData } from '@remix-run/react' import { data, useLoaderData } from 'react-router';
import Code from '~/components/Code' import Code from '~/components/Code';
import Notice from '~/components/Notice' import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig, patchConfig } from '~/utils/config/headscale' import { loadConfig, patchConfig } from '~/utils/config/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import DNS from './dns' import DNS from './components/dns';
import Domains from './domains' import Domains from './components/domains';
import MagicModal from './magic' import MagicModal from './components/magic';
import Nameservers from './nameservers' import Nameservers from './components/nameservers';
import RenameModal from './rename' import RenameModal from './components/rename';
// We do not want to expose every config value // We do not want to expose every config value
export async function loader() { export async function loader() {
const context = await loadContext() const context = await loadContext();
if (!context.config.read) { if (!context.config.read) {
throw new Error('No configuration is available') throw new Error('No configuration is available');
} }
const config = await loadConfig() const config = await loadConfig();
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
@@ -32,69 +32,58 @@ export async function loader() {
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records, extraRecords: config.dns.extra_records,
} };
return { return {
...dns, ...dns,
...context, ...context,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ success: false }, { return data({ success: false }, { status: 401 });
status: 401,
})
} }
const context = await loadContext() const context = await loadContext();
if (!context.config.write) { if (!context.config.write) {
return json({ success: false }, { return data({ success: false }, { status: 403 });
status: 403,
})
} }
const data = await request.json() as Record<string, unknown> const patch = (await request.json()) as Record<string, unknown>;
await patchConfig(data) await patchConfig(patch);
if (context.integration?.onConfigChange) { if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context) await context.integration.onConfigChange(context.integration.context);
} }
return json({ success: true }) return data({ success: true });
} }
export default function Page() { export default function Page() {
useLiveData({ interval: 5000 }) useLiveData({ interval: 5000 });
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {} const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) { for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key] allNs[key] = data.splitDns[key];
} }
allNs.global = data.nameservers allNs.global = data.nameservers;
return ( return (
<div className="flex flex-col gap-16 max-w-screen-lg"> <div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write {data.config.write ? undefined : (
? undefined <Notice>
: ( The Headscale configuration is read-only. You cannot make changes to
<Notice> the configuration
The Headscale configuration is read-only. You cannot make changes to the configuration </Notice>
</Notice> )}
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} /> <RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers <Nameservers nameservers={allNs} isDisabled={!data.config.write} />
nameservers={allNs}
isDisabled={!data.config.write}
/>
<DNS <DNS records={data.extraRecords} isDisabled={!data.config.write} />
records={data.extraRecords}
isDisabled={!data.config.write}
/>
<Domains <Domains
baseDomain={data.magicDns ? data.baseDomain : undefined} baseDomain={data.magicDns ? data.baseDomain : undefined}
@@ -105,18 +94,16 @@ export default function Page() {
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1> <h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4"> <p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device Automatically register domain names for each device on the tailnet.
on the tailnet. Devices will be accessible at Devices will be accessible at{' '}
{' '}
<Code> <Code>
[device]. [device].
{data.baseDomain} {data.baseDomain}
</Code> </Code>{' '}
{' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} /> <MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div> </div>
</div> </div>
) );
} }
-162
View File
@@ -1,162 +0,0 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session),
},
})
}
const context = await loadContext()
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return startOidc(context.oidc, request)
}
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
}
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
// We know it exists here because this action only happens on OIDC
return startOidc(context.oidc, request)
}
const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie'))
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
if (!key) {
throw new 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('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays.toString()} days`,
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn,
}),
},
})
} catch {
return json({
error: 'Invalid API key',
})
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title>
Welcome to Headplane
</Card.Title>
{data.apiKey
? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
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}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
/>
<Button
className="w-full mt-2.5"
variant="heavy"
type="submit"
>
Login
</Button>
</Form>
)
: undefined}
{showOr
? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
)
: undefined}
{data.oidc
? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full"
variant="heavy"
type="submit"
>
Login with SSO
</Button>
</Form>
)
: undefined}
</Card>
</div>
)
}
-15
View File
@@ -1,15 +0,0 @@
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const returnTo = new URL(request.url).pathname
return redirect(`/login?returnTo=${returnTo}`, {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
+208
View File
@@ -0,0 +1,208 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import log from '~/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
);
}
const id = String(data.get('id'));
const method = String(data.get('_method'));
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!);
return { message: 'Machine removed' };
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
return { message: 'Route updated' };
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
}),
);
return { message: 'Exit node updated' };
}
case 'move': {
if (!data.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(data.get('to'));
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved node ${id} to ${to}` };
} catch {
return send(
{ message: `Failed to move node ${id} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
data
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
});
return { message: 'Tags updated' };
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = data.get('mkey')?.toString();
const user = data.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 post(url, session.get('hsApiKey')!, {
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,
},
);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { Link } from 'react-router';
import { useMemo } from 'react';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import type { Machine, Route, User, HostInfo } from '~/types';
import { cn } from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
stats?: HostInfo;
}
export default function MachineRow({ machine, routes, magic, users, stats }: 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 = [...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');
}
return (
<tr
key={machine.id}
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className="pl-0.5 py-2">
<Link to={`/machines/${machine.id}`} className="group/link h-full">
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map((ip) => (
<Menu.ItemButton
key={ip}
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip);
toast('Copied IP address to clipboard');
}}
>
{ip}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${prefix}`;
await navigator.clipboard.writeText(ip);
toast('Copied hostname to clipboard');
}}
>
{machine.givenName}.{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
) : undefined}
</Menu.Items>
</Menu>
</div>
</td>
{/**
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">
{hinfo.getTSVersion(stats)}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300">
Unknown
</p>
)}
</td>
**/}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
</tr>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { KebabHorizontalIcon } from '@primer/octicons-react';
import { type ReactNode, useState } from 'react';
import MenuComponent from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
buttonChild?: ReactNode;
}
export default function Menu({
machine,
routes,
magic,
users,
buttonChild,
}: MenuProps) {
const renameState = useState(false);
const expireState = useState(false);
const removeState = useState(false);
const routesState = useState(false);
const moveState = useState(false);
const tagsState = 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();
return (
<>
<Rename machine={machine} state={renameState} magic={magic} />
<Delete machine={machine} state={removeState} />
{expired ? undefined : <Expire machine={machine} state={expireState} />}
<Routes machine={machine} routes={routes} state={routesState} />
<Tags machine={machine} state={tagsState} />
<Move machine={machine} state={moveState} users={users} magic={magic} />
<MenuComponent>
{buttonChild ?? (
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
)}
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={tagsState}>
Edit ACL tags
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired ? undefined : (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
>
Remove
</MenuComponent.ItemButton>
</MenuComponent.Items>
</MenuComponent>
</>
);
}
@@ -1,46 +1,39 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import { type Machine } from '~/types' import type { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface DeleteProps { interface DeleteProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Delete({ machine, state }: DeleteProps) { export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Remove {machine.givenName}</Dialog.Title>
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This machine will be permanently removed from This machine will be permanently removed from your network. To
your network. To re-add it, you will need to re-add it, you will need to reauthenticate to your tailnet from
reauthenticate to your tailnet from the device. the device.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="delete" /> <input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@@ -61,5 +54,5 @@ export default function Delete({ machine, state }: DeleteProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }
@@ -1,46 +1,38 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import { type Machine } from '~/types' import type { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface ExpireProps { interface ExpireProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Expire({ machine, state }: ExpireProps) { export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Expire {machine.givenName}</Dialog.Title>
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This will disconnect the machine from your Tailnet. This will disconnect the machine from your Tailnet. In order to
In order to reconnect, you will need to re-authenticate reconnect, you will need to re-authenticate from the device.
from the device.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="expire" /> <input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@@ -61,5 +53,5 @@ export default function Expire({ machine, state }: ExpireProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }
+72
View File
@@ -0,0 +1,72 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
readonly machine: Machine;
readonly users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
>
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
{magic ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">
{machine.givenName}.{magic}
</Code>
.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Change owner
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Form, useFetcher, Link } from 'react-router';
import { Dispatch, SetStateAction, useState, useEffect } from 'react';
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Select from '~/components/Select';
import Menu from '~/components/Menu';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { Machine, type User } from '~/types';
export interface NewProps {
server: string;
users: User[];
}
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>();
const mkeyState = useState(false);
const [mkey, setMkey] = useState('');
const [user, setUser] = useState('');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
if (fetcher.data.success) {
toast('Registered new machine');
} else {
toast('Failed to register machine due to an invalid key');
}
setToasted(true);
}, [fetcher.data, toasted, mkey]);
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{(close) => (
<>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
</Dialog.Text>
<fetcher.Form
method="POST"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label="Machine Key"
placeholder="mkey:ff....."
name="mkey"
state={[mkey, setMkey]}
className="my-2 font-mono"
/>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
isDisabled={
!mkey || !mkey.trim().startsWith('mkey:') || !user
}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
)}
>
Add Device
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className="w-4 h-4 mr-2" />
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton>
<Link to="/settings/auth-keys">
<KeyIcon className="w-4 h-4 mr-2" />
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import type { Machine } from '~/types';
interface RenameProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients, and
used when generating MagicDNS names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className="my-2"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname{' '}
<Code className="text-sm">{machine.givenName}</Code> will no
longer point to this machine.
</p>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">{machine.givenName}</Code>.
</p>
)
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useFetcher } from 'react-router';
import { Dispatch, SetStateAction, useMemo } from 'react';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import type { Machine, Route } from '~/types';
import { cn } from '~/utils/cn';
interface RoutesProps {
readonly machine: Machine;
readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: 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]);
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit route settings of {machine.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 IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</Dialog.Text>
<div
className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{subnet.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No routes are advertised on this machine.</p>
</div>
) : undefined}
{subnet.map((route) => (
<div
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>{route.prefix}</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
))}
</div>
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
>
Learn More
</Link>
</Dialog.Text>
<div
className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{exit.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>This machine is not an exit node.</p>
</div>
) : (
<div
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>Use as exit node</p>
<Switch
defaultSelected={exitEnabled}
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('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -1,51 +1,44 @@
import { PlusIcon, XIcon } from '@primer/octicons-react' import { PlusIcon, XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react' import { type Dispatch, type SetStateAction, useState } from 'react';
import { Button, Input } from 'react-aria-components' import { Button, Input } from 'react-aria-components';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Link from '~/components/Link' import Link from '~/components/Link';
import { type Machine } from '~/types' import type { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface TagsProps { interface TagsProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Tags({ machine, state }: TagsProps) { export default function Tags({ machine, state }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags) const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('') const [tag, setTag] = useState('');
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
Edit ACL tags for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. ACL tags can be used to reference machines in your ACL policies.
See the See the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1068/acl-tags" to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation" name="Tailscale documentation"
> >
Tailscale documentation Tailscale documentation
</Link> </Link>{' '}
{' '}
for more information. for more information.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="tags" /> <input type="hidden" name="_method" value="tags" />
@@ -58,21 +51,18 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
> >
<div className="divide-y divide-ui-200 dark:divide-ui-600"> <div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0 {tags.length === 0 ? (
? ( <div
<div className={cn(
className={cn( 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800', 'items-center justify-center rounded-t-lg',
'items-center justify-center rounded-t-lg', 'text-ui-600 dark:text-ui-300',
'text-ui-600 dark:text-ui-300', )}
)} >
> <p>No tags are set on this machine.</p>
<p> </div>
No tags are set on this machine. ) : (
</p> tags.map((item) => (
</div>
)
: tags.map(item => (
<div <div
key={item} key={item}
id={item} id={item}
@@ -86,13 +76,14 @@ export default function Tags({ machine, state }: TagsProps) {
<Button <Button
className="rounded-full p-0 w-6 h-6" className="rounded-full p-0 w-6 h-6"
onPress={() => { onPress={() => {
setTags(tags.filter(tag => tag !== item)) setTags(tags.filter((tag) => tag !== item));
}} }}
> >
<XIcon className="w-4 h-4" /> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
))} ))
)}
</div> </div>
<div <div
className={cn( className={cn(
@@ -101,8 +92,9 @@ export default function Tags({ machine, state }: TagsProps) {
'rounded-b-lg justify-between items-center', 'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300', 'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600', 'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 && !tag.startsWith('tag:') tag.length > 0 &&
&& 'outline outline-red-500', !tag.startsWith('tag:') &&
'outline outline-red-500',
)} )}
> >
<Input <Input
@@ -115,23 +107,23 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
value={tag} value={tag}
onChange={(e) => { onChange={(e) => {
setTag(e.currentTarget.value) setTag(e.currentTarget.value);
}} }}
/> />
<Button <Button
className={cn( className={cn(
'rounded-lg p-0 h-6 w-6', 'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') !tag.startsWith('tag:') &&
&& 'opacity-50 cursor-not-allowed', 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={ isDisabled={
tag.length === 0 tag.length === 0 ||
|| !tag.startsWith('tag:') !tag.startsWith('tag:') ||
|| tags.includes(tag) tags.includes(tag)
} }
onPress={() => { onPress={() => {
setTags([...tags, tag]) setTags([...tags, tag]);
setTag('') setTag('');
}} }}
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
@@ -139,16 +131,10 @@ export default function Tags({ machine, state }: TagsProps) {
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Save Save
</Dialog.Action> </Dialog.Action>
</div> </div>
@@ -157,5 +143,5 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }
+366
View File
@@ -0,0 +1,366 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import {
InfoIcon,
GearIcon,
CheckCircleIcon,
SkipIcon,
PersonIcon,
} from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Menu from '~/components/Menu';
import Tooltip from '~/components/Tooltip';
import StatusCircle from '~/components/StatusCircle';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import Link from '~/components/Link';
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!params.id) {
throw new Error('No machine ID provided');
}
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
}
}
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]);
return {
machine: machine.node,
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request);
}
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>();
const routesState = useState(false);
useLiveData({ interval: 1000 });
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 = [...machine.forcedTags, ...machine.validTags];
if (expired) {
tags.unshift('Expired');
}
// 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');
}
return (
<div>
<p className="mb-8 text-md">
<RemixLink to="/machines" className="font-medium">
All Machines
</RemixLink>
<span className="mx-2">/</span>
{machine.givenName}
</p>
<div
className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
buttonChild={
<Menu.Button
className={cn(
'flex items-center justify-center gap-x-2',
'bg-main-200 dark:bg-main-700/30',
'hover:bg-main-300 dark:hover:bg-main-600/30',
'text-ui-700 dark:text-ui-300 mb-2',
'w-fit text-sm rounded-lg px-3 py-2',
)}
>
<GearIcon className="w-5" />
Machine Settings
</Menu.Button>
}
/>
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<div
className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}
>
<PersonIcon className="w-4 h-4" />
</div>
{machine.user.name}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
</div>
</div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes machine={machine} routes={routes} state={routesState} />
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</p>
<Button variant="light" control={routesState}>
Review
</Button>
</div>
<Card
variant="flat"
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
) : (
<ul className="leading-normal">
{subnetApproved.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
This machine is advertising these routes, but they must be
approved before traffic will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
) : (
<ul className="leading-normal">
{subnet.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"></span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</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
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
) : undefined}
</Card>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { InfoIcon } from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import Code from '~/components/Code';
import Link from '~/components/Link';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import type { Machine, Route, User } from '~/types';
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
import { ErrorPopup } from '~/components/Error'
import { menuAction } from './action';
import MachineRow from './components/machine';
import NewMachine from './dialogs/new';
export async function loader({ request, context: lC }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]);
initAgentSocket(lC);
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
}
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`;
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
stats,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request);
}
export default function Page() {
useLiveData({ interval: 3000 });
const data = useLoaderData<typeof loader>();
return (
<>
<div className="flex justify-between items-center mb-8">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<p className="text-gray-700 dark:text-gray-300">
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
>
Learn more
</Link>
</p>
</div>
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
<tr className="text-left uppercase text-xs font-bold px-0.5">
<th className="pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
Addresses
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700',
)}
>
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
) : undefined}
</div>
</th>
{/**<th className="pb-2">Version</th>**/}
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700',
)}
>
{data.nodes.map((machine) => (
<MachineRow
key={machine.id}
machine={machine}
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users}
magic={data.magic}
stats={data.stats?.[machine.nodeKey]}
/>
))}
</tbody>
</table>
</>
);
}
export function ErrorBoundary() {
return (
<ErrorPopup type="embedded" />
)
}
-13
View File
@@ -1,13 +0,0 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { loadContext } from '~/utils/config/headplane'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(context.oidc, request)
}

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