mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a222c47ee | |||
| cb9d0d5317 | |||
| bb0d24b708 | |||
| ebe487daf0 | |||
| 4fd8062476 | |||
| 9967ef9e4c | |||
| 43cbc177d6 | |||
| 0eef9aea9b | |||
| 9b53711673 | |||
| 341a0e8f81 | |||
| cee28fba2f | |||
| 65f56842de | |||
| 4803b2aa4a | |||
| 1dfb2fb777 | |||
| 07c5c6d496 | |||
| 4df15fd4b2 | |||
| 5b138c191a | |||
| 957157de5f | |||
| 9f0564943b | |||
| 2fcd19e2b4 | |||
| eb4669498a | |||
| bda9dedbfd | |||
| a9d5e10be9 | |||
| bd8a7a56d4 | |||
| d7b1e19985 | |||
| 9bc8483a8f | |||
| 4351e1fcb3 | |||
| 9183f805a6 | |||
| cd4e8f8399 | |||
| 6c5656334d | |||
| ff3bdc1898 | |||
| 356abab90a | |||
| 82f6294499 | |||
| 8fc657f86a | |||
| bcd87453bd | |||
| 7adacae940 | |||
| 797ed56c07 | |||
| ae2bd35cbf | |||
| d2c4f5eb2b | |||
| 8cb91cd45b | |||
| a4a037ed68 | |||
| 17712cbb1c | |||
| 616b63bb71 | |||
| 361bc493c0 | |||
| bc3fc5ec5d | |||
| c98dc5ed1d | |||
| 9192013bfe | |||
| fdbfe584c5 | |||
| 97e2557710 | |||
| 82a3eb626e | |||
| cdcb38f11e | |||
| f20b2c869f | |||
| 1355e1535d | |||
| ebd219b622 | |||
| 1ce0dc375e | |||
| 1eaf3abe21 | |||
| 8a9235836a | |||
| 344901d38c | |||
| ea004df2bb | |||
| c59632a0b7 | |||
| 89e38e1356 | |||
| d257b22f91 | |||
| 441e31690b | |||
| e0fde7d0f4 | |||
| 943e5d2a15 | |||
| 7f952bcb3b | |||
| c3112507b5 | |||
| 8b3d0e9bb6 | |||
| 7a34a01dd0 | |||
| 51a85e1bfe | |||
| 5ab9686460 | |||
| 5cfd9e411b | |||
| 65a14aefcf | |||
| f0c1f617c7 | |||
| 44577aff81 | |||
| 27b2831f00 | |||
| ad469129bc | |||
| 7691f74d43 | |||
| f7547a4c62 | |||
| ce4617d0e1 | |||
| 6d99aca058 | |||
| d809eea564 | |||
| eacde3deb8 | |||
| fc7bedc8b1 | |||
| ecc547601f | |||
| eed12d348e | |||
| 5a254b746b | |||
| 2cc4339499 | |||
| 84c820efb1 | |||
| 779af42601 | |||
| f990670e86 | |||
| 0344f17887 | |||
| f28bfd5f86 | |||
| 8f1b577b5e | |||
| 6ea9fe169d | |||
| 9a81b76398 | |||
| 144a8b87cd | |||
| 2bf088b4a9 | |||
| 87b8d64bcc | |||
| 8819af270d | |||
| 1150d1616e | |||
| dd287c0444 | |||
| cf55621b57 | |||
| 73f0a0d273 | |||
| 71f130ede5 | |||
| c0af2aa9c8 | |||
| b18147fa82 | |||
| bf1d75a27a | |||
| 1e86b0e95b | |||
| b34a3f0ca1 | |||
| 799d7b5728 | |||
| 4961e83a9c | |||
| ab7587e7a9 | |||
| 6d15c411f1 | |||
| 7f376c3f70 | |||
| 6a0e097412 | |||
| 721439868f | |||
| f4af5b920d | |||
| 0f9bf73b82 | |||
| 7a6ad8d2d5 | |||
| f9751f5ab2 | |||
| 46f1576b8f | |||
| ccde3513dc | |||
| a0a80852eb | |||
| cb32637938 | |||
| 55eacb59e9 | |||
| 7dfcbef774 | |||
| f6d8ad25e1 | |||
| e36193a8c0 | |||
| 0c87f3e6ec | |||
| 090f5c1ba9 | |||
| 9b9d5432c6 | |||
| 2a26a74b65 | |||
| a298686d6b | |||
| 2f316176c8 | |||
| 59525b7b63 | |||
| b7009e387b | |||
| e173376319 | |||
| 59d7f15694 |
@@ -0,0 +1,3 @@
|
||||
* @tale
|
||||
|
||||
/nix @tale @StealthBadger747
|
||||
@@ -28,20 +28,15 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Setup Mise
|
||||
uses: jdx/mise-action@v2
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
- name: Set caching paths
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
echo "GO_CACHE=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "GO_MODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
@@ -51,11 +46,16 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Setup Go cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.GO_CACHE }}
|
||||
${{ env.GO_MODCACHE }}
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: CI pipeline
|
||||
run: mise run ci
|
||||
|
||||
nix:
|
||||
name: nix
|
||||
|
||||
@@ -12,6 +12,8 @@ permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -19,6 +21,14 @@ jobs:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag: 'next'
|
||||
- target: debug-shell
|
||||
tag: 'next-shell'
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -29,7 +39,7 @@ jobs:
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=next
|
||||
type=raw,value=${{ matrix.tag }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -44,12 +54,28 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -12,11 +12,21 @@ permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker Release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag_suffix: ''
|
||||
- target: debug-shell
|
||||
tag_suffix: '-shell'
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -28,6 +38,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
suffix=${{ matrix.tag_suffix }},onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -42,12 +55,25 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -4,3 +4,9 @@ node_modules
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
app/hp_ssh.wasm
|
||||
app/wasm_exec.js
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
|
||||
/.direnv
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
pnpm 10.4.0
|
||||
node 22
|
||||
+9
-1
@@ -6,12 +6,20 @@
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
"source.organizeImports.biome": true,
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"languages": {
|
||||
"YAML": {
|
||||
"tab_size": 2,
|
||||
"hard_tabs": false
|
||||
},
|
||||
"Go": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "gopls"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
### 0.6.1 (October 12, 2025)
|
||||
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
||||
- The database is created automatically if it does not exist.
|
||||
- It currently stores SSH connection details and HostInfo for the agent.
|
||||
- User information is automatically migrated from the previous database.
|
||||
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
||||
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
||||
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
||||
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
||||
- Add a build attestation to validate SLSA provenance for the Docker image.
|
||||
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
||||
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
||||
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
||||
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||
- Environment variables are interpolatable into these paths
|
||||
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
||||
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
||||
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
|
||||
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
||||
- OIDC now allows passing many custom parameters:
|
||||
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||
|
||||
### 0.6.0 (May 25, 2025)
|
||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
https://localhost {
|
||||
reverse_proxy headscale:8080
|
||||
tls /certs/localhost.pem /certs/localhost-key.pem
|
||||
|
||||
header {
|
||||
Access-Control-Allow-Origin *
|
||||
Access-Control-Allow-Headers *
|
||||
Access-Control-Allow-Methods GET, POST, OPTIONS
|
||||
}
|
||||
}
|
||||
+49
-21
@@ -1,35 +1,63 @@
|
||||
FROM golang:1.24 AS agent-build
|
||||
WORKDIR /app
|
||||
FROM --platform=$BUILDPLATFORM jdxcode/mise:latest AS mise-context
|
||||
COPY mise.toml ./
|
||||
RUN --mount=type=secret,id=gh_token,env=MISE_GITHUB_TOKEN mise install
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mise-context AS go-build
|
||||
WORKDIR /build/
|
||||
|
||||
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
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG IMAGE_TAG
|
||||
RUN mkdir -p /build/app/ && \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||
mise run wasm ::: agent ::: fake-shell
|
||||
|
||||
RUN npm install -g pnpm@10
|
||||
RUN apk add --no-cache git
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN chmod +x /build/build/hp_agent
|
||||
RUN chmod +x /build/build/sh
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mise-context AS js-build
|
||||
WORKDIR /build
|
||||
COPY patches ./patches
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN mise trust
|
||||
COPY --from=go-build /build/app/hp_ssh.wasm /build/app/hp_ssh.wasm
|
||||
COPY --from=go-build /build/app/wasm_exec.js /build/app/wasm_exec.js
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN mkdir -p /var/lib/headplane
|
||||
RUN mkdir -p /usr/libexec/headplane
|
||||
ARG IMAGE_TAG
|
||||
RUN IMAGE_TAG=$IMAGE_TAG pnpm run build
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||
COPY --from=js-build /build/build/ /app/build/
|
||||
COPY --from=js-build /build/drizzle /app/drizzle/
|
||||
COPY --from=js-build /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=js-build /build/node_modules/ /app/node_modules/
|
||||
COPY --from=go-build /build/build/hp_agent /usr/libexec/headplane/agent
|
||||
|
||||
# Fake shell to inform the user that they should use the debug image
|
||||
COPY --from=go-build /build/build/sh /bin/sh
|
||||
COPY --from=go-build /build/build/sh /bin/bash
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent
|
||||
RUN chmod +x /usr/libexec/headplane/agent
|
||||
CMD [ "node", "./build/server/index.js" ]
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
FROM node:22-alpine AS debug-shell
|
||||
RUN apk add --no-cache bash curl git
|
||||
|
||||
COPY --from=js-build /build/build/ /app/build/
|
||||
COPY --from=js-build /build/drizzle /app/drizzle/
|
||||
COPY --from=js-build /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=js-build /build/node_modules/ /app/node_modules/
|
||||
COPY --from=go-build /build/build/hp_agent /usr/libexec/headplane/agent
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/preview-dark.png"
|
||||
srcset="./docs/assets/preview-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/preview-light.png"
|
||||
srcset="./docs/assets/preview-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Preview"
|
||||
src="./assets/preview-dark.png"
|
||||
src="./docs/assets/preview-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
@@ -44,12 +44,12 @@ There are 2 ways to deploy Headplane:
|
||||
Simple mode does not include the automatic management of DNS and Headplane
|
||||
settings, requiring manual editing and reloading when making changes.
|
||||
|
||||
### Versioning
|
||||
## Versioning
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||
PR is opened and actively in testing.
|
||||
|
||||
### Contributing
|
||||
## Contributing
|
||||
Headplane is an open-source project and contributions are welcome! If you have
|
||||
any suggestions, bug reports, or feature requests, please open an issue. Also
|
||||
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||
@@ -59,30 +59,30 @@ refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/acls-dark.png"
|
||||
srcset="./docs/assets/acls-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/acls-light.png"
|
||||
srcset="./docs/assets/acls-light.png"
|
||||
>
|
||||
<img
|
||||
alt="ACLs"
|
||||
src="./assets/acls-dark.png"
|
||||
src="./docs/assets/acls-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/machine-dark.png"
|
||||
srcset="./docs/assets/machine-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/machine-light.png"
|
||||
srcset="./docs/assets/machine-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Machine Management"
|
||||
src="./assets/machine-dark.png"
|
||||
src="./docs/assets/machine-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/hpagent"
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
type Register struct {
|
||||
Type string
|
||||
ID string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := util.GetLogger()
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load config: %s", err)
|
||||
}
|
||||
|
||||
log.SetDebug(cfg.Debug)
|
||||
agent := tsnet.NewAgent(cfg)
|
||||
|
||||
agent.Connect()
|
||||
defer agent.Shutdown()
|
||||
|
||||
log.Msg(&Register{
|
||||
Type: "register",
|
||||
ID: agent.ID,
|
||||
})
|
||||
|
||||
hpagent.FollowMaster(agent)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Represents messages from the Headplane master
|
||||
type RecvMessage struct {
|
||||
NodeIDs []string
|
||||
}
|
||||
|
||||
type SendMessage struct {
|
||||
Type string
|
||||
Data any
|
||||
}
|
||||
|
||||
// Starts listening for messages from stdin
|
||||
func FollowMaster(agent *tsnet.TSAgent) {
|
||||
log := util.GetLogger()
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
var msg RecvMessage
|
||||
err := json.Unmarshal(line, &msg)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal message: %s", err)
|
||||
log.Debug("Full Error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Recieved message from master: %v", line)
|
||||
|
||||
if len(msg.NodeIDs) == 0 {
|
||||
log.Debug("Message recieved had no node IDs")
|
||||
log.Debug("Full message: %s", line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate the results since we invoke via gofunc
|
||||
results := make(map[string]*tailcfg.HostinfoView)
|
||||
mu := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
for _, nodeID := range msg.NodeIDs {
|
||||
wg.Add(1)
|
||||
go func(nodeID string) {
|
||||
defer wg.Done()
|
||||
result, err := agent.GetStatusForPeer(nodeID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get status for node %s: %s", nodeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
log.Debug("No status for node %s", nodeID)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
results[nodeID] = result
|
||||
mu.Unlock()
|
||||
}(nodeID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Send the results back to the Headplane master
|
||||
log.Debug("Sending status back to master: %v", results)
|
||||
log.Msg(&SendMessage{
|
||||
Type: "status",
|
||||
Data: results,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatal("Error reading from stdin: %s", err)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"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) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if !strings.HasPrefix(id, "nodekey:") {
|
||||
log.Debug("Node ID with missing prefix: %s", id)
|
||||
return nil, fmt.Errorf("invalid node ID: %s", id)
|
||||
}
|
||||
|
||||
log.Debug("Querying status of peer: %s", id)
|
||||
status, err := s.Lc.Status(context.Background())
|
||||
if err != nil {
|
||||
log.Debug("Failed to get status: %s", err)
|
||||
return nil, fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
|
||||
// We need to convert from 64 char hex to 32 byte raw.
|
||||
bytes, err := hex.DecodeString(id[8:])
|
||||
if err != nil {
|
||||
log.Debug("Failed to decode hex: %s", err)
|
||||
return nil, fmt.Errorf("failed to decode hex: %w", err)
|
||||
}
|
||||
|
||||
raw := mem.B(bytes)
|
||||
if raw.Len() != 32 {
|
||||
log.Debug("Invalid node ID length: %d", raw.Len())
|
||||
return nil, fmt.Errorf("invalid node ID length: %d", raw.Len())
|
||||
}
|
||||
|
||||
nodeKey := key.NodePublicFromRaw32(raw)
|
||||
peer := status.Peer[nodeKey]
|
||||
if peer == nil {
|
||||
// Check if we are on Self.
|
||||
if status.Self.PublicKey == nodeKey {
|
||||
peer = status.Self
|
||||
} else {
|
||||
log.Debug("Peer not found in status: %s", id)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
ip := peer.TailscaleIPs[0].String()
|
||||
whois, err := s.Lc.WhoIs(context.Background(), ip)
|
||||
if err != nil {
|
||||
log.Debug("Failed to get whois: %s", err)
|
||||
return nil, fmt.Errorf("failed to get whois: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Got whois for peer %s: %v", id, whois)
|
||||
return &whois.Node.Hostinfo, nil
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function Attribute({
|
||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
||||
...(isCopyable
|
||||
? [
|
||||
'cursor-pointer hover:shadow-sm',
|
||||
'cursor-pointer hover:shadow-xs',
|
||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
||||
]
|
||||
@@ -70,8 +70,8 @@ export default function Attribute({
|
||||
</div>
|
||||
{isCopyable ? (
|
||||
<div>
|
||||
<Check className="size-4 hidden data-[copied]:block" />
|
||||
<Copy className="size-4 block data-[copied]:hidden" />
|
||||
<Check className="size-4 hidden data-copied:block" />
|
||||
<Copy className="size-4 block data-copied:hidden" />
|
||||
</div>
|
||||
) : undefined}
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Button({ variant = 'light', ...props }: ButtonProps) {
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-xl px-3 py-2',
|
||||
'focus:outline-none focus:ring',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
...(variant === 'heavy'
|
||||
? [
|
||||
|
||||
@@ -15,7 +15,7 @@ function Card({ variant = 'raised', ...props }: Props) {
|
||||
'w-full max-w-md rounded-3xl p-5',
|
||||
variant === 'flat'
|
||||
? 'bg-transparent shadow-none'
|
||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
|
||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
<code
|
||||
className={cn(
|
||||
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
|
||||
'rounded-lg focus-within:outline-none focus-within:ring-2',
|
||||
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
|
||||
isCopyable && 'relative pr-7',
|
||||
className,
|
||||
)}
|
||||
@@ -21,7 +21,6 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
{children}
|
||||
{isCopyable && (
|
||||
<button
|
||||
type="button"
|
||||
className="bottom-0 right-0 absolute"
|
||||
onClick={async (event) => {
|
||||
const text = Array.isArray(children) ? children.join('') : children;
|
||||
@@ -40,9 +39,10 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
||||
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
|
||||
<Check className="h-4.5 w-4.5 p-1 hidden data-copied:block" />
|
||||
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
|
||||
</button>
|
||||
)}
|
||||
</code>
|
||||
|
||||
@@ -117,7 +117,7 @@ function Panel(props: DialogPanelProps) {
|
||||
method={method ?? 'POST'}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'outline-none rounded-3xl w-full max-w-lg',
|
||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
|
||||
interface Props {
|
||||
type?: 'full' | 'embedded';
|
||||
@@ -69,14 +70,16 @@ export function ErrorPopup({ type = 'full' }: Props) {
|
||||
)}
|
||||
>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-3xl mb-0">
|
||||
{routing ? error.status : title}
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-12 h-12 text-red-500" />
|
||||
<div className="flex items-center gap-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
<div className="flex justify-between items-center gap-2 w-full">
|
||||
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
|
||||
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
|
||||
<Card.Text
|
||||
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||
>
|
||||
{routing ? error.data : message}
|
||||
</Card.Text>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||
tabIndex={0} // Allows keyboard focus
|
||||
className={cn(
|
||||
'blur-sm hover:blur-none focus:blur-none transition',
|
||||
'focus:outline-none focus:ring-2 rounded-sm',
|
||||
'focus:outline-hidden focus:ring-2 rounded-xs',
|
||||
)}
|
||||
>
|
||||
{url}
|
||||
|
||||
+22
-22
@@ -2,13 +2,13 @@ import {
|
||||
CircleUser,
|
||||
Globe2,
|
||||
Lock,
|
||||
PlaneTakeoff,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink, useSubmit } from 'react-router';
|
||||
import Logo from '~/components/Logo';
|
||||
import Menu from '~/components/Menu';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import cn from '~/utils/cn';
|
||||
@@ -42,18 +42,18 @@ function TabLink({ name, to, icon }: TabLinkProps) {
|
||||
return (
|
||||
<div className="relative py-2">
|
||||
<NavLink
|
||||
to={to}
|
||||
prefetch="intent"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
||||
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
||||
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
||||
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
||||
'focus:outline-none focus:ring',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
isActive ? 'after:visible' : 'after:invisible',
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={to}
|
||||
>
|
||||
{icon} {name}
|
||||
</NavLink>
|
||||
@@ -64,13 +64,13 @@ function TabLink({ name, to, icon }: TabLinkProps) {
|
||||
function Link({ href, text }: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'hidden sm:block hover:underline text-sm',
|
||||
'focus:outline-none focus:ring rounded-md',
|
||||
'focus:outline-hidden focus:ring-3 rounded-md',
|
||||
)}
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
@@ -91,7 +91,7 @@ export default function Header(data: Props) {
|
||||
>
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PlaneTakeoff />
|
||||
<Logo />
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
@@ -101,20 +101,21 @@ export default function Header(data: Props) {
|
||||
{data.user ? (
|
||||
<Menu>
|
||||
<Menu.IconButton
|
||||
label="User"
|
||||
className={cn(data.user.picture ? 'p-0' : '')}
|
||||
label="User"
|
||||
>
|
||||
{data.user.picture ? (
|
||||
<img
|
||||
src={data.user.picture}
|
||||
alt={data.user.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
src={data.user.picture}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser />
|
||||
)}
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
disabledKeys={['profile']}
|
||||
onAction={(key) => {
|
||||
if (key === 'logout') {
|
||||
submit(
|
||||
@@ -126,7 +127,6 @@ export default function Header(data: Props) {
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabledKeys={['profile']}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="profile" textValue="Profile">
|
||||
@@ -148,39 +148,39 @@ export default function Header(data: Props) {
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
{data.access.machines ? (
|
||||
<TabLink
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
name="Machines"
|
||||
to="/machines"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.users ? (
|
||||
<TabLink
|
||||
to="/users"
|
||||
name="Users"
|
||||
icon={<Users className="w-5" />}
|
||||
name="Users"
|
||||
to="/users"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.policy ? (
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
name="Access Control"
|
||||
to="/acls"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
{data.access.dns ? (
|
||||
<TabLink
|
||||
to="/dns"
|
||||
name="DNS"
|
||||
icon={<Globe2 className="w-5" />}
|
||||
name="DNS"
|
||||
to="/dns"
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.settings ? (
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
name="Settings"
|
||||
to="/settings"
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function IconButton({
|
||||
aria-label={props.label}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center p-1',
|
||||
'focus:outline-none focus:ring',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
...(variant === 'heavy'
|
||||
? [
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function Input(props: InputProps) {
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl px-3 py-2',
|
||||
'focus:outline-none focus:ring',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Link({
|
||||
'inline-flex items-center gap-x-0.5',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
'focus:outline-none focus:ring rounded-md',
|
||||
'focus:outline-hidden focus:ring-3 rounded-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import cn from '~/utils/cn';
|
||||
import LogoSvg from '../../public/logo-light.svg';
|
||||
|
||||
export interface LogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Logo({ className }: LogoProps) {
|
||||
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function Panel(props: MenuPanelProps) {
|
||||
<ul
|
||||
{...menuProps}
|
||||
ref={ref}
|
||||
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
||||
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection
|
||||
@@ -149,7 +149,7 @@ function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'py-2 px-3 mx-1 rounded-lg',
|
||||
'focus:outline-none select-none',
|
||||
'focus:outline-hidden select-none',
|
||||
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
||||
isDisabled
|
||||
? 'text-headplane-400 dark:text-headplane-600'
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function NumberInput(props: InputProps) {
|
||||
{...groupProps}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-xl pr-1',
|
||||
'focus-within:outline-none focus-within:ring',
|
||||
'focus-within:outline-hidden focus-within:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
@@ -60,7 +60,7 @@ export default function NumberInput(props: InputProps) {
|
||||
required={props.isRequired}
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
|
||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
|
||||
/>
|
||||
<input type="hidden" name={name} value={state.numberValue} />
|
||||
<IconButton
|
||||
|
||||
@@ -52,7 +52,7 @@ function Option({ item, state }: OptionsOptionProps) {
|
||||
className={cn(
|
||||
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
|
||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||
'focus:outline-none focus:ring z-10',
|
||||
'focus:outline-hidden focus:ring-3 z-10',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Popover(props: PopoverProps) {
|
||||
{...popoverProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-10 shadow-sm rounded-xl',
|
||||
'z-10 shadow-xs rounded-xl',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-200 dark:border-headplane-800',
|
||||
className,
|
||||
|
||||
@@ -60,7 +60,7 @@ function Select(props: SelectProps) {
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-xl focus:outline-none focus-within:ring',
|
||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
@@ -69,7 +69,7 @@ function Select(props: SelectProps) {
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||
data-1p-ignore
|
||||
/>
|
||||
<button
|
||||
@@ -127,7 +127,7 @@ function ListBox(props: ListBoxProps) {
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
ref={listBoxRef}
|
||||
className="w-full max-h-72 overflow-auto outline-none pt-1"
|
||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option key={item.key} item={item} state={state} />
|
||||
@@ -158,7 +158,7 @@ function Option({ item, state }: OptionProps) {
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||
'focus:outline-none select-none',
|
||||
'focus:outline-hidden select-none',
|
||||
isFocused || isSelected
|
||||
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||
|
||||
@@ -11,6 +11,7 @@ import cn from '~/utils/cn';
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
@@ -45,14 +46,16 @@ export default function Switch(props: SwitchProps) {
|
||||
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
|
||||
isFocusVisible && 'ring-2',
|
||||
props.isDisabled && 'opacity-50',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-50 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-[100%]',
|
||||
state.isSelected && 'translate-x-[100%]',
|
||||
'translate-x-0 group-selected:translate-x-full',
|
||||
state.isSelected && 'translate-x-full',
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ function Tab({ item, state }: TabsTabProps) {
|
||||
className={cn(
|
||||
'pl-2 pr-3 py-2.5',
|
||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||
'focus:outline-none focus:ring z-10',
|
||||
'focus:outline-hidden focus:ring-3 z-10',
|
||||
'border-r border-headplane-100 dark:border-headplane-800',
|
||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ function Tooltip(props: TooltipProps) {
|
||||
{...triggerProps}
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'focus:outline-none focus:ring rounded-xl',
|
||||
'focus:outline-hidden focus:ring-3 rounded-xl',
|
||||
)}
|
||||
>
|
||||
{component}
|
||||
@@ -65,7 +65,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) {
|
||||
{...mergeProps(props, tooltipProps)}
|
||||
className={cn(
|
||||
'absolute z-50 p-3 top-full mt-1',
|
||||
'outline-none rounded-3xl text-sm w-48',
|
||||
'outline-hidden rounded-3xl text-sm w-48',
|
||||
'bg-white dark:bg-headplane-950',
|
||||
'text-black dark:text-white',
|
||||
'shadow-lg dark:shadow-md rounded-xl',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
export function TailscaleSSHTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn(
|
||||
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine advertises Tailscale SSH, which allows you to authenticate
|
||||
SSH credentials using your Tailscale account and via the Headplane web
|
||||
UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
+10
-19
@@ -1,37 +1,30 @@
|
||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
||||
import { ServerCrash } from 'lucide-react';
|
||||
import {
|
||||
type LoaderFunctionArgs,
|
||||
isRouteErrorResponse,
|
||||
redirect,
|
||||
useRouteError,
|
||||
} from 'react-router';
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
import Card from '~/components/Card';
|
||||
import { ErrorPopup, getErrorMessage } from '~/components/Error';
|
||||
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
...rest
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
const session = await context.sessions.auth(request);
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
|
||||
// We shouldn't session invalidate if Headscale is down
|
||||
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
|
||||
if (healthy) {
|
||||
try {
|
||||
await context.client.get('v1/apikey', session.get('api_key')!);
|
||||
await context.client.get('v1/apikey', session.api_key);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
log.debug('api', 'API Key validation failed %o', error);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -45,11 +38,9 @@ export async function loader({
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+19
-70
@@ -1,3 +1,4 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { CircleCheckIcon } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
@@ -10,9 +11,8 @@ import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
@@ -23,72 +23,18 @@ export async function loader({
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!session.has('api_key')) {
|
||||
// There is a session, but it's not valid
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
context.oidc &&
|
||||
session.user.subject !== 'unknown-non-oauth' &&
|
||||
!request.url.endsWith('/onboarding')
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
// Onboarding is only a feature of the OIDC flow
|
||||
if (context.oidc && !request.url.endsWith('/onboarding')) {
|
||||
let onboarded = false;
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (sessionUser) {
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
onboarded = true;
|
||||
} else {
|
||||
try {
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
onboarded = false;
|
||||
}
|
||||
|
||||
const user = users.find((u) => {
|
||||
if (u.provider !== 'oidc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
return true;
|
||||
}
|
||||
|
||||
return subject === sessionUser.subject;
|
||||
});
|
||||
|
||||
if (user) {
|
||||
onboarded = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we cannot lookup users, just assume our user is onboarded
|
||||
log.debug('api', 'Failed to lookup users %o', e);
|
||||
onboarded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!onboarded) {
|
||||
if (!user?.onboarded) {
|
||||
return redirect('/onboarding');
|
||||
}
|
||||
}
|
||||
@@ -99,7 +45,7 @@ export async function loader({
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.get('user'),
|
||||
user: session.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
@@ -119,8 +65,11 @@ export async function loader({
|
||||
healthy: await context.client.healthcheck(),
|
||||
};
|
||||
} catch {
|
||||
// No session, so we can just return
|
||||
return redirect('/login');
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default [
|
||||
route('/logout', 'routes/auth/logout.ts'),
|
||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||
route('/ssh', 'routes/ssh/console.tsx'),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function aclAction({
|
||||
const { policy, updatedAt } = await context.client.put<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('v1/policy', session.get('api_key')!, {
|
||||
}>('v1/policy', session.api_key, {
|
||||
policy: policyData,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function aclLoader({
|
||||
const { policy, updatedAt } = await context.client.get<{
|
||||
policy: string;
|
||||
updatedAt: string | null;
|
||||
}>('v1/policy', session.get('api_key')!);
|
||||
}>('v1/policy', session.api_key);
|
||||
|
||||
// Successfully loaded the policy, mark it as readable
|
||||
// If `updatedAt` is null, it means the policy is in file mode.
|
||||
|
||||
@@ -66,8 +66,8 @@ export default function Page() {
|
||||
The ACL policy mode is most likely set to <Code>file</Code> in your
|
||||
Headscale configuration. This means that the ACL file cannot be edited
|
||||
through the web interface. In order to resolve this, you'll need to
|
||||
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
|
||||
configuration.
|
||||
set <Code>policy.mode</Code> to <Code>database</Code> in your
|
||||
Headscale configuration.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
||||
|
||||
@@ -60,25 +60,23 @@ export async function loginAction({
|
||||
};
|
||||
}
|
||||
|
||||
// Set the session
|
||||
const session = await context.sessions.getOrCreate(request);
|
||||
const expiresDays = Math.round(
|
||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
||||
);
|
||||
|
||||
session.set('state', 'auth');
|
||||
session.set('api_key', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
});
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session, {
|
||||
maxAge: expiry.getTime() - Date.now(),
|
||||
}),
|
||||
'Set-Cookie': await context.sessions.createSession(
|
||||
{
|
||||
api_key: apiKey,
|
||||
user: {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
},
|
||||
},
|
||||
expiry.getTime() - Date.now(),
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
data,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
@@ -24,10 +24,8 @@ export async function loader({
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
@@ -104,26 +102,26 @@ export default function Page() {
|
||||
terminal.
|
||||
</Card.Text>
|
||||
<Input
|
||||
className="mt-8 mb-2"
|
||||
isRequired
|
||||
labelHidden
|
||||
label="API Key"
|
||||
labelHidden
|
||||
name="api_key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
className="mt-8 mb-2"
|
||||
/>
|
||||
{formData?.success === false ? (
|
||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||
{formData.message}
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
{oidc ? (
|
||||
<RemixLink to="/oidc/start">
|
||||
<Button variant="light" className="w-full mt-2">
|
||||
<Button className="w-full mt-2" variant="light">
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
|
||||
@@ -9,9 +9,10 @@ export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!session.has('api_key')) {
|
||||
return redirect('/login');
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
@@ -22,7 +23,7 @@ export async function action({
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { count, eq } from 'drizzle-orm';
|
||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
||||
import { finishAuthFlow, formatError } from '~/utils/oidc';
|
||||
import { HeadplaneConfig } from '~/server/config/schema';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Roles } from '~/server/web/roles';
|
||||
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
|
||||
import { send } from '~/utils/res';
|
||||
|
||||
interface OidcFlowSession {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
@@ -18,13 +30,21 @@ export async function loader({
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if (session.get('state') !== 'flow') {
|
||||
return redirect('/login'); // Haven't started an OIDC flow
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300, // 5 minutes
|
||||
});
|
||||
|
||||
const data: OidcFlowSession | null = await cookie.parse(
|
||||
request.headers.get('Cookie'),
|
||||
);
|
||||
|
||||
if (data === null) {
|
||||
console.warn('OIDC flow session not found');
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const payload = session.get('oidc')!;
|
||||
const { code_verifier, state, nonce, redirect_uri } = payload;
|
||||
const { code_verifier, state, nonce, redirect_uri } = data;
|
||||
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||
}
|
||||
@@ -42,20 +62,39 @@ export async function loader({
|
||||
};
|
||||
|
||||
try {
|
||||
const user = await finishAuthFlow(context.oidc, flowOptions);
|
||||
session.unset('oidc');
|
||||
const userSession = session as Session<AuthSession>;
|
||||
let user = await finishAuthFlow(context.oidc, flowOptions);
|
||||
user = {
|
||||
...user,
|
||||
picture: setOidcPictureForSource(
|
||||
user,
|
||||
context.config.oidc?.profile_picture_source ?? 'oidc',
|
||||
),
|
||||
};
|
||||
|
||||
const [{ count: userCount }] = await context.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.caps, Roles.owner));
|
||||
|
||||
await context.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: user.subject,
|
||||
caps: userCount === 0 ? Roles.owner : Roles.member,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
// 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.
|
||||
userSession.set('user', user);
|
||||
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
|
||||
userSession.set('state', 'auth');
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(userSession),
|
||||
'Set-Cookie': await context.sessions.createSession({
|
||||
// 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.
|
||||
api_key: context.config.oidc?.headscale_api_key!,
|
||||
user,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -67,3 +106,26 @@ export async function loader({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type PictureSource = NonNullable<
|
||||
HeadplaneConfig['oidc']
|
||||
>['profile_picture_source'];
|
||||
|
||||
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
|
||||
// Already set by default in the callback, so we can just return it
|
||||
if (source === 'oidc') {
|
||||
return user.picture;
|
||||
}
|
||||
|
||||
if (source === 'gravatar') {
|
||||
if (!user.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emailHash = user.email.trim().toLowerCase();
|
||||
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if ((session as Session<AuthSession>).has('api_key')) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!context.oidc) {
|
||||
if (!context.oidc || !context.config.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300, // 5 minutes
|
||||
});
|
||||
|
||||
const redirectUri =
|
||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(
|
||||
context.oidc,
|
||||
redirectUri,
|
||||
// We can't get here without the OIDC config being defined
|
||||
context.config.oidc!.token_endpoint_auth_method,
|
||||
context.config.oidc.scope,
|
||||
context.config.oidc.extra_params,
|
||||
);
|
||||
|
||||
session.set('state', 'flow');
|
||||
session.set('oidc', {
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
return redirect(data.url, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session),
|
||||
'Set-Cookie': await cookie.serialize({
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isSortableDragging ? 'opacity-50' : '',
|
||||
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
|
||||
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
||||
)}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -179,7 +179,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
<GripVertical
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-0.5 focus:ring outline-none rounded-md"
|
||||
className="p-0.5 focus:ring-3 outline-hidden rounded-md"
|
||||
/>
|
||||
)}
|
||||
{domain}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Form } from 'react-router';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import Switch from '~/components/Switch';
|
||||
import TableList from '~/components/TableList';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import cn from '~/utils/cn';
|
||||
import AddNS from '../dialogs/add-ns';
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>;
|
||||
overrideLocalDns: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function ManageNS({ nameservers, isDisabled }: Props) {
|
||||
export default function ManageNS({
|
||||
nameservers,
|
||||
isDisabled,
|
||||
overrideLocalDns,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
@@ -31,6 +39,7 @@ export default function ManageNS({ nameservers, isDisabled }: Props) {
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers}
|
||||
overrideLocalDns={overrideLocalDns}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
@@ -45,6 +54,7 @@ interface ListProps {
|
||||
isGlobal: boolean;
|
||||
isDisabled: boolean;
|
||||
nameservers: Record<string, string[]>;
|
||||
overrideLocalDns: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -52,6 +62,7 @@ function NameserverList({
|
||||
isGlobal,
|
||||
isDisabled,
|
||||
nameservers,
|
||||
overrideLocalDns,
|
||||
name,
|
||||
}: ListProps) {
|
||||
const list = isGlobal ? nameservers.global : nameservers[name];
|
||||
@@ -59,12 +70,54 @@ function NameserverList({
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
{isGlobal ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
Global Nameservers
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tooltip>
|
||||
<Info className="size-4" />
|
||||
<Tooltip.Body>
|
||||
When enabled, use the DNS servers listed below to resolve
|
||||
names outside the tailnet. When disabled (default), devices
|
||||
will prefer their local DNS configuration.
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||
name="Tailscale Global Nameservers Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
<p>Override DNS servers</p>
|
||||
<Switch
|
||||
label="Override local DNS settings"
|
||||
className="h-[15px] w-[23px] p-[2px]"
|
||||
switchClassName="h-[9px] w-[9px]"
|
||||
name="override_dns"
|
||||
defaultSelected={overrideLocalDns}
|
||||
onChange={(v) => {
|
||||
submit(
|
||||
{
|
||||
action_id: 'override_dns',
|
||||
override_dns: v ? 'true' : 'false',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="text-md font-medium opacity-80">{name}</h2>
|
||||
)}
|
||||
</div>
|
||||
<TableList>
|
||||
{list.length > 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
||||
import { Split } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Chip from '~/components/Chip';
|
||||
import Dialog from '~/components/Dialog';
|
||||
@@ -35,15 +35,15 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
<Dialog.Button>Add nameserver</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
|
||||
<input type="hidden" name="action_id" value="add_ns" />
|
||||
<input name="action_id" type="hidden" value="add_ns" />
|
||||
<Input
|
||||
description="Use this IPv4 or IPv6 address to resolve names."
|
||||
isInvalid={isInvalid}
|
||||
isRequired
|
||||
label="Nameserver"
|
||||
description="Use this IPv4 or IPv6 address to resolve names."
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
onChange={setNs}
|
||||
isInvalid={isInvalid}
|
||||
placeholder="1.2.3.4"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div className="block">
|
||||
@@ -53,9 +53,9 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Split DNS"
|
||||
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
|
||||
className={cn('inline-flex items-center')}
|
||||
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
|
||||
text="Split DNS"
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS (Tailscale v1.8 or later
|
||||
@@ -76,9 +76,9 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
<Input
|
||||
isRequired={split === true}
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="split_name"
|
||||
onChange={setDomain}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries matching this suffix
|
||||
@@ -86,7 +86,7 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
</Dialog.Text>
|
||||
</>
|
||||
) : (
|
||||
<input type="hidden" name="split_name" value="global" />
|
||||
<input name="split_name" type="hidden" value="global" />
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
|
||||
@@ -42,6 +42,8 @@ export async function dnsAction({
|
||||
return removeRecord(formData, context);
|
||||
case 'add_record':
|
||||
return addRecord(formData, context);
|
||||
case 'override_dns':
|
||||
return overrideDns(formData, context);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
@@ -104,7 +106,7 @@ async function removeNs(formData: FormData, context: LoadContext) {
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -230,3 +232,20 @@ async function addRecord(formData: FormData, context: LoadContext) {
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function overrideDns(formData: FormData, context: LoadContext) {
|
||||
const override = formData.get('override_dns')?.toString();
|
||||
if (!override) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const overrideValue = override === 'true';
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.override_local_dns',
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function loader({
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
overrideDns: config.dns.override_local_dns,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
|
||||
@@ -70,7 +71,7 @@ export default function Page() {
|
||||
const isDisabled = data.access === false || data.writable === false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to
|
||||
@@ -84,7 +85,11 @@ export default function Page() {
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
|
||||
<ManageNS
|
||||
nameservers={allNs}
|
||||
isDisabled={isDisabled}
|
||||
overrideLocalDns={data.overrideDns}
|
||||
/>
|
||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||
<ManageDomains
|
||||
searchDomains={data.searchDomains}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
|
||||
import { ChevronDown, Copy } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import Chip from '~/components/Chip';
|
||||
import Menu from '~/components/Menu';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
||||
import { SubnetTag } from '~/components/tags/Subnet';
|
||||
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
@@ -46,24 +46,26 @@ export default function MachineRow({
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={node.id}
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
key={node.id}
|
||||
>
|
||||
<td className="pl-0.5 py-2 focus-within:ring">
|
||||
<td className="pl-0.5 py-2 focus-within:ring-3">
|
||||
<Link
|
||||
className={cn('group/link h-full focus:outline-hidden')}
|
||||
to={`/machines/${node.id}`}
|
||||
className={cn('group/link h-full focus:outline-none')}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'group-hover/link:dark:text-blue-400',
|
||||
'dark:group-hover/link:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">{node.user.name}</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||
</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.map((tag) => (
|
||||
@@ -77,7 +79,7 @@ export default function MachineRow({
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={async (key) => {
|
||||
@@ -95,7 +97,7 @@ export default function MachineRow({
|
||||
)}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
<Copy className="w-3 h-3" />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
@@ -129,8 +131,8 @@ export default function MachineRow({
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={node.online && !node.expired}
|
||||
className="w-4 h-4"
|
||||
isOnline={node.online && !node.expired}
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{node.online && !node.expired
|
||||
@@ -141,10 +143,10 @@ export default function MachineRow({
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
isDisabled={isDisabled}
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
magic={magic}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -175,6 +177,10 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
uiTags.push('subnet-approved');
|
||||
}
|
||||
|
||||
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||
uiTags.push('tailscale-ssh');
|
||||
}
|
||||
|
||||
if (isAgent === true) {
|
||||
uiTags.push('headplane-agent');
|
||||
}
|
||||
@@ -187,27 +193,30 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||
switch (tag) {
|
||||
case 'exit-approved':
|
||||
case 'exit-waiting':
|
||||
return <ExitNodeTag key={tag} isEnabled={tag === 'exit-approved'} />;
|
||||
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
||||
|
||||
case 'subnet-approved':
|
||||
case 'subnet-waiting':
|
||||
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />;
|
||||
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
||||
|
||||
case 'expired':
|
||||
case 'no-expiry':
|
||||
return (
|
||||
<ExpiryTag
|
||||
expiry={node.expiry ?? undefined}
|
||||
key={tag}
|
||||
variant={tag}
|
||||
expiry={node.expiry ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tailscale-ssh':
|
||||
return <TailscaleSSHTag key={tag} />;
|
||||
|
||||
case 'headplane-agent':
|
||||
return <HeadplaneAgentTag />;
|
||||
return <HeadplaneAgentTag key={tag} />;
|
||||
|
||||
default:
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Cog, Ellipsis } from 'lucide-react';
|
||||
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Button from '~/components/Button';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
@@ -10,7 +11,6 @@ import Move from '../dialogs/move';
|
||||
import Rename from '../dialogs/rename';
|
||||
import Routes from '../dialogs/routes';
|
||||
import Tags from '../dialogs/tags';
|
||||
|
||||
interface MenuProps {
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
@@ -29,8 +29,11 @@ export default function MachineMenu({
|
||||
isDisabled,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
const supportsTailscaleSSH =
|
||||
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end px-4 gap-1.5">
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={node}
|
||||
@@ -88,6 +91,49 @@ export default function MachineMenu({
|
||||
/>
|
||||
)}
|
||||
|
||||
{supportsTailscaleSSH ? (
|
||||
isFullButton ? (
|
||||
<Button
|
||||
className="flex items-center gap-x-2"
|
||||
variant="heavy"
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SquareTerminal className="h-5" />
|
||||
<p>SSH</p>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
'py-0.5 w-fit bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
'dark:group-hover:border-headplane-700',
|
||||
'opacity-0 pointer-events-none group-hover:opacity-100',
|
||||
'group-hover:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
SSH
|
||||
</Button>
|
||||
)
|
||||
) : undefined}
|
||||
<Menu isDisabled={isDisabled}>
|
||||
{isFullButton ? (
|
||||
<Menu.Button className="flex items-center gap-x-2">
|
||||
@@ -126,6 +172,6 @@ export default function MachineMenu({
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
|
||||
This will disconnect the machine from your Tailnet. In order to
|
||||
reconnect, you will need to re-authenticate from the device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="expire" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input name="action_id" type="hidden" value="expire" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -59,10 +59,10 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
)}
|
||||
<TableList.Item
|
||||
className={cn(
|
||||
'rounded-b-xl focus-within:ring',
|
||||
'rounded-b-xl focus-within:ring-3',
|
||||
tag.length > 0 &&
|
||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
||||
'ring ring-red-500 ring-opacity-50',
|
||||
'ring-3 ring-red-500 ring-opacity-50',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function machineAction({
|
||||
);
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.get('api_key')!;
|
||||
const apiKey = session.api_key;
|
||||
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
@@ -55,7 +55,7 @@ export async function machineAction({
|
||||
}
|
||||
|
||||
if (
|
||||
node.user.providerId?.split('/').pop() !== session.get('user')!.subject &&
|
||||
node.user.providerId?.split('/').pop() !== session.user.subject &&
|
||||
!check
|
||||
) {
|
||||
throw data('You do not have permission to act on this machine', {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import { mapTag } from 'yaml/util';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
@@ -40,9 +39,9 @@ export async function loader({
|
||||
const [machine, { users }] = await Promise.all([
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.get('api_key')!,
|
||||
session.api_key,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
|
||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
||||
@@ -78,7 +77,7 @@ export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink to="/machines" className="font-medium">
|
||||
<RemixLink className="font-medium" to="/machines">
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
@@ -92,9 +91,9 @@ export default function Page() {
|
||||
>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle isOnline={node.online} className="w-4 h-4" />
|
||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions isFullButton node={node} users={users} magic={magic} />
|
||||
<MenuOptions isFullButton magic={magic} node={node} users={users} />
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||
@@ -109,7 +108,10 @@ export default function Page() {
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<UserCircle />
|
||||
{node.user.name}
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
@@ -124,14 +126,14 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
|
||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -139,11 +141,11 @@ export default function Page() {
|
||||
<Button onPress={() => setShowRouting(true)}>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',
|
||||
)}
|
||||
variant="flat"
|
||||
>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
@@ -167,11 +169,11 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => setShowRouting(true)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -199,11 +201,11 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => setShowRouting(true)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -234,11 +236,11 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => setShowRouting(true)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -250,15 +252,23 @@ export default function Page() {
|
||||
issues.
|
||||
</p>
|
||||
<Card
|
||||
variant="flat"
|
||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute name="Creator" value={node.user.name} />
|
||||
<Attribute
|
||||
name="Creator"
|
||||
value={
|
||||
node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id
|
||||
}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
name="OS hostname"
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
@@ -268,14 +278,14 @@ export default function Page() {
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
name="ID"
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
@@ -312,27 +322,27 @@ export default function Page() {
|
||||
</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||
name="Short domain"
|
||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||
value={node.givenName}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||
name="Full domain"
|
||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
@@ -342,13 +352,13 @@ export default function Page() {
|
||||
Client Connectivity
|
||||
</p>
|
||||
<Attribute
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||
name="Varies"
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||
name="Hairpinning"
|
||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InfoIcon } from '@primer/octicons-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
@@ -18,7 +18,7 @@ export async function loader({
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
const user = session.user;
|
||||
if (!user) {
|
||||
throw new Error('Missing user session. Please log in again.');
|
||||
}
|
||||
@@ -41,11 +41,8 @@ export async function loader({
|
||||
);
|
||||
|
||||
const [{ nodes }, { users }] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
|
||||
let magic: string | undefined;
|
||||
@@ -90,18 +87,18 @@ export default function Page() {
|
||||
<p>
|
||||
Manage the devices connected to your Tailnet.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!data.writable}
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
isDisabled={!data.writable}
|
||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
@@ -113,7 +110,7 @@ export default function Page() {
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
{data.magic ? (
|
||||
<Tooltip>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based on
|
||||
their name and also at{' '}
|
||||
@@ -141,16 +138,16 @@ export default function Page() {
|
||||
>
|
||||
{data.populatedNodes.map((machine) => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
node={machine}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
||||
isDisabled={
|
||||
data.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||
}
|
||||
key={machine.id}
|
||||
magic={data.magic}
|
||||
node={machine}
|
||||
users={data.users}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function authKeysAction({
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.get('api_key')!;
|
||||
const apiKey = session.api_key;
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
|
||||
@@ -17,8 +17,12 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
||||
<Attribute name="User" value={user.name} isCopyable />
|
||||
<Attribute isCopyable name="Key" value={authKey.key} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="User"
|
||||
value={user.name || user.displayName || user.email || user.id}
|
||||
/>
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
@@ -30,13 +34,12 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server={url} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
||||
<div className="flex gap-4 items-center" suppressHydrationWarning>
|
||||
{(authKey.used && !authKey.reusable) ||
|
||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
className="my-4"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
@@ -45,6 +48,7 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
|
||||
toast('Copied command to clipboard');
|
||||
}}
|
||||
variant="light"
|
||||
>
|
||||
Copy Tailscale Command
|
||||
</Button>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
}}
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<NumberInput
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FileKey2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
@@ -23,7 +22,7 @@ export async function loader({
|
||||
const session = await context.sessions.auth(request);
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const preAuthKeys = await Promise.all(
|
||||
@@ -36,7 +35,7 @@ export async function loader({
|
||||
try {
|
||||
const { preAuthKeys } = await context.client.get<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!);
|
||||
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
@@ -139,13 +138,15 @@ export default function Page() {
|
||||
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:w-2/3">
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
@@ -176,8 +177,8 @@ export default function Page() {
|
||||
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"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
@@ -185,31 +186,31 @@ export default function Page() {
|
||||
<AddAuthKey users={users} />
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
label="User"
|
||||
placeholder="Select a user"
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
label="User"
|
||||
onSelectionChange={(value) =>
|
||||
setSelectedUser(value?.toString() ?? '')
|
||||
}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Select a status"
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
label="Status"
|
||||
onSelectionChange={(value) =>
|
||||
setStatus((value?.toString() ?? 'active') as Status)
|
||||
}
|
||||
placeholder="Select a status"
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export default function AgentSection() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
|
||||
<p>
|
||||
Headplane provides a local agent that can be installed on a server to
|
||||
provide additional features including viewing device information and
|
||||
SSH access via the web interface (soon). To learn more about the agent
|
||||
visit the{' '}
|
||||
<Link
|
||||
to="https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"
|
||||
name="Headplane Agent Documentation"
|
||||
>
|
||||
Headplane documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/local-agent">
|
||||
<div className={cn('text-lg font-medium flex items-center')}>
|
||||
Manage Agent
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import Card from '~/components/Card';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import type { HostInfo } from '~/types';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
export type Props = {
|
||||
reachable: boolean;
|
||||
hostInfo: HostInfo;
|
||||
};
|
||||
|
||||
export default function AgentManagement({ reachable, hostInfo }: Props) {
|
||||
console.log('hostInfo:', hostInfo);
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Local Agent Configuration</h1>
|
||||
<p className="mb-8">
|
||||
A local agent has already been configured for this Headplane instance.
|
||||
You can manage the agent settings here.
|
||||
</p>
|
||||
<Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusCircle isOnline={reachable} className="w-4 h-4 px-1" />
|
||||
<div>
|
||||
<p className="text-lg font-bold">
|
||||
{hostInfo.Hostname ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(hostInfo)}
|
||||
<span className="ml-2 text-sm text-headplane-600 dark:text-headplane-300">
|
||||
{hinfo.getOSInfo(hostInfo)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{JSON.stringify(hostInfo)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import { queryAgent } from '~/utils/ws-agent';
|
||||
import AgentManagement from './components/agent/manage';
|
||||
|
||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
||||
const { ws, wsAuthKey } = context;
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const onboarding = session.get('agent_onboarding') ?? false;
|
||||
|
||||
const nodeKey =
|
||||
'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b';
|
||||
const stats = await queryAgent([nodeKey]);
|
||||
|
||||
return {
|
||||
configured: wsAuthKey !== undefined,
|
||||
onboarding,
|
||||
stats: stats?.[nodeKey],
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
// Whether we show the onboarding or management UI
|
||||
const management = useMemo(() => {
|
||||
return data.configured && data.onboarding === false;
|
||||
}, [data.configured, data.onboarding]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
{management ? (
|
||||
<AgentManagement reachable={true} hostInfo={data.stats} />
|
||||
) : (
|
||||
<div>
|
||||
<h1>Local Agent Coming Soon</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
@@ -18,7 +18,7 @@ export default function Page() {
|
||||
const { config, oidc } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
||||
<p>
|
||||
@@ -34,8 +34,8 @@ export default function Page() {
|
||||
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"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
@@ -44,7 +44,7 @@ export default function Page() {
|
||||
<RemixLink to="/settings/auth-keys">
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Auth Keys
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
{config && oidc ? (
|
||||
@@ -60,8 +60,8 @@ export default function Page() {
|
||||
groups and Headplane will also respect these settings when
|
||||
authenticating.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -70,7 +70,7 @@ export default function Page() {
|
||||
<RemixLink to="/settings/restrictions">
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Restrictions
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Page() {
|
||||
const isDisabled = writable ? !access : true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-screen-lg">
|
||||
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
data,
|
||||
LoaderFunctionArgs,
|
||||
ShouldRevalidateFunction,
|
||||
useLoaderData,
|
||||
useSubmit,
|
||||
} from 'react-router';
|
||||
import wasm from '~/hp_ssh.wasm?url';
|
||||
import { LoadContext } from '~/server';
|
||||
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
|
||||
import { Machine, PreAuthKey, User } from '~/types';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import '~/wasm_exec';
|
||||
import UserPrompt from './user-prompt';
|
||||
import XTerm from './xterm.client';
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.user.subject === 'unknown-non-oauth') {
|
||||
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
}
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||
// authenticated with Headscale first.
|
||||
const lookup = users.find((u) => {
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
return subject === session.user.subject;
|
||||
});
|
||||
|
||||
if (!lookup) {
|
||||
throw data(
|
||||
`User with subject ${session.user.subject} not found within Headscale`,
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.api_key,
|
||||
{
|
||||
user: lookup.id,
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: Enable config to enforce generate_authkeys capability
|
||||
// For now, any user is capable of WebSSH connections
|
||||
// const check = await context.sessions.check(
|
||||
// request,
|
||||
// Capabilities.generate_authkeys,
|
||||
// );
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const username = qp.get('username') || undefined;
|
||||
const hostname = qp.get('hostname') || undefined;
|
||||
if (!hostname) {
|
||||
throw data('Missing required parameter: hostname', 400);
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
ipnDetails: undefined,
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// We're making a request to <url>/key?v=116 to check the CORS headers
|
||||
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
// const res = await fetch(`${u}/key?v=116`, {
|
||||
// method: 'GET',
|
||||
// });
|
||||
|
||||
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
||||
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
||||
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
||||
// console.log(corsOrigin, corsMethods, corsHeaders);
|
||||
|
||||
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
||||
// throw data(
|
||||
// 'Headscale server does not have the required CORS headers for WebSSH',
|
||||
// 500,
|
||||
// );
|
||||
// }
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
// node.name is the hostname, given_name is the set name
|
||||
const lookupNode = nodes.find((n) => n.name === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
|
||||
// Last thing is keeping track of the ephemeral node in the database
|
||||
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||
const [_ephemeralNode] = await context.db
|
||||
.insert(ephemeralNodes)
|
||||
.values({
|
||||
auth_key: preAuthKey.key,
|
||||
} satisfies EphemeralNodeInsert)
|
||||
.returning();
|
||||
|
||||
return {
|
||||
ipnDetails: {
|
||||
PreAuthKey: preAuthKey.key,
|
||||
Hostname: generateHostname(username),
|
||||
ControlURL: u,
|
||||
},
|
||||
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const adjective = faker.word.adjective({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const noun = faker.word.noun({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const _session = await context.sessions.auth(request);
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const nodeKey = form.get('node_key');
|
||||
const authKey = form.get('auth_key');
|
||||
|
||||
if (nodeKey === null || typeof nodeKey !== 'string') {
|
||||
throw data('Missing node_key', 400);
|
||||
}
|
||||
|
||||
if (authKey === null || typeof authKey !== 'string') {
|
||||
throw data('Missing auth_key', 400);
|
||||
}
|
||||
|
||||
await context.db
|
||||
.update(ephemeralNodes)
|
||||
.set({
|
||||
node_key: nodeKey,
|
||||
})
|
||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
||||
(value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(ipnDetails, {
|
||||
NotifyState: (state) => {
|
||||
console.log('State changed:', state);
|
||||
if (state === 'Running') {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
// Only set NodeKey if it is not already set and then
|
||||
// also dispatch that to the backend to track the
|
||||
// ephemeral node.
|
||||
//
|
||||
// We open an SSE connection to the backend
|
||||
// so that when the connection is closed,
|
||||
// the backend can delete the ephemeral node.
|
||||
if (nodeKey === null) {
|
||||
setNodeKey(netmap.NodeKey);
|
||||
submit(
|
||||
{
|
||||
node_key: netmap.NodeKey,
|
||||
auth_key: ipnDetails.PreAuthKey,
|
||||
},
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log('Browse to URL:', url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error('Panic recover:', message);
|
||||
},
|
||||
});
|
||||
|
||||
handle.Start();
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!sshDetails.username) {
|
||||
return <UserPrompt hostname={sshDetails.hostname} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-headplane-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto h-screen flex items-center justify-center">
|
||||
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-screen">
|
||||
<XTerm
|
||||
hostname={sshDetails.hostname}
|
||||
ipn={ipn}
|
||||
username={sshDetails.username}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
declare function TsWasmNet(
|
||||
options: TsWasmNetOptions,
|
||||
callbacks: TsWasmNetCallbacks,
|
||||
): TsWasmNet;
|
||||
|
||||
interface TsWasmNetOptions {
|
||||
ControlURL: string;
|
||||
PreAuthKey: string;
|
||||
Hostname: string;
|
||||
}
|
||||
|
||||
interface TsWasmNetCallbacks {
|
||||
NotifyState: (state: IPNState) => void;
|
||||
NotifyNetMap: (netmap: TsWasmNetMap) => void;
|
||||
NotifyBrowseToURL: (url: string) => void;
|
||||
NotifyPanicRecover: (err: string) => void;
|
||||
}
|
||||
|
||||
interface TsWasmNetMap {
|
||||
NodeKey: string;
|
||||
}
|
||||
|
||||
interface TsWasmNet {
|
||||
Start: () => void;
|
||||
OpenSSH: (
|
||||
hostname: string,
|
||||
username: string,
|
||||
options: XtermConfig,
|
||||
) => SSHSession;
|
||||
}
|
||||
|
||||
type IPNState =
|
||||
| 'NoState'
|
||||
| 'InUseOtherUser'
|
||||
| 'NeedsLogin'
|
||||
| 'NeedsMachineAuth'
|
||||
| 'Stopped'
|
||||
| 'Starting'
|
||||
| 'Running';
|
||||
|
||||
interface XtermConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
timeout?: number;
|
||||
|
||||
onStdout: (data: Uint8Array) => void;
|
||||
onStderr: (data: Uint8Array) => void;
|
||||
onStdin: (func: (input: Uint8Array) => void) => void;
|
||||
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
interface SSHSession {
|
||||
Close(): boolean;
|
||||
Resize(rows: number, cols: number): boolean;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface UserPromptProps {
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Card>
|
||||
<Card.Title>Enter Username</Card.Title>
|
||||
<Card.Text className="mb-4">
|
||||
Enter the username you want to use to connect to{' '}
|
||||
<Code>{hostname}</Code>
|
||||
{'. '}
|
||||
WebSSH follows the Headscale ACLs, so only permitted usernames will be
|
||||
able to connect.
|
||||
</Card.Text>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
onChange={setUsername}
|
||||
/>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="w-full"
|
||||
onPress={() => {
|
||||
// We can't use the navigate hook here as we need to do a
|
||||
// full page reload to ensure the SSH connection is established
|
||||
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare class Go {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import * as xterm from '@xterm/xterm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
interface XTermProps {
|
||||
ipn: TsWasmNet;
|
||||
username: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
// Go's WASM -> JS crosses realms so we might have to normalize the data under
|
||||
// certain conditions. This also enforces bytes instead of strings being sent.
|
||||
function normU8(data: unknown) {
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const any = data as {
|
||||
buffer?: ArrayBufferLike;
|
||||
byteOffset?: number;
|
||||
byteLength?: number;
|
||||
};
|
||||
|
||||
if (
|
||||
any.buffer instanceof ArrayBuffer &&
|
||||
typeof any.byteLength === 'number'
|
||||
) {
|
||||
return new Uint8Array(
|
||||
any.buffer.slice(
|
||||
any.byteOffset ?? 0,
|
||||
(any.byteOffset ?? 0) + any.byteLength,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
|
||||
}
|
||||
|
||||
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const genRef = useRef(0);
|
||||
const termRef = useRef<xterm.Terminal>(null);
|
||||
const roRef = useRef<ResizeObserver>(null);
|
||||
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
||||
const sshRef = useRef<SSHSession>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGen = ++genRef.current;
|
||||
const term = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.loadAddon(new ClipboardAddon());
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
||||
}),
|
||||
);
|
||||
|
||||
term.unicode.activeVersion = '11';
|
||||
termRef.current = term;
|
||||
term.open(divRef.current!);
|
||||
fit.fit();
|
||||
term.focus();
|
||||
|
||||
const session = ipn.OpenSSH(hostname, username, {
|
||||
rows: term.rows,
|
||||
cols: term.cols,
|
||||
onStdout: (data) => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring stdout');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normU8(data);
|
||||
term.write(text);
|
||||
},
|
||||
onStderr: (data) => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring stderr');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normU8(data);
|
||||
term.write(text);
|
||||
const str = new TextDecoder().decode(text);
|
||||
setError(str);
|
||||
},
|
||||
onStdin: (func) => {
|
||||
inputRef.current = func;
|
||||
},
|
||||
onConnect: () => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring onConnect');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
onDisconnect: () => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring onDisconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
roRef.current?.disconnect();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
inputRef.current = null;
|
||||
sshRef.current = null;
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
sshRef.current = session;
|
||||
const enc = new TextEncoder();
|
||||
term.onData((data) => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring onData');
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = enc.encode(data);
|
||||
inputRef.current?.(bytes);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring resize');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
fit.fit();
|
||||
sshRef.current?.Resize(term.cols, term.rows);
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
});
|
||||
|
||||
roRef.current = ro;
|
||||
ro.observe(divRef.current!);
|
||||
|
||||
return () => {
|
||||
++genRef.current;
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
|
||||
sshRef.current?.Close();
|
||||
sshRef.current = null;
|
||||
|
||||
term.dispose();
|
||||
if (termRef.current === term) {
|
||||
termRef.current = null;
|
||||
}
|
||||
|
||||
inputRef.current = null;
|
||||
};
|
||||
}, [ipn, username, hostname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<div className="absolute w-screen z-50 mx-auto h-screen flex items-center justify-center">
|
||||
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
||||
</div>
|
||||
) : undefined}
|
||||
<div
|
||||
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
|
||||
ref={divRef}
|
||||
/>
|
||||
{termRef.current && isResizing ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||
)}
|
||||
>
|
||||
{termRef.current.cols}x{termRef.current.rows}
|
||||
</div>
|
||||
) : undefined}
|
||||
{error !== null ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
|
||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||
)}
|
||||
>
|
||||
Failed to connect to SSH session
|
||||
{error}
|
||||
</div>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,14 +26,14 @@ export default function UserRow({ user, role }: UserRowProps) {
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
src={user.profilePicUrl}
|
||||
alt={user.name}
|
||||
alt={user.name || user.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="w-10 h-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className={cn('font-semibold leading-snug')}>{user.name}</p>
|
||||
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
|
||||
<p className="text-sm opacity-50">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,7 @@ interface DeleteProps {
|
||||
}
|
||||
|
||||
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
||||
const name =
|
||||
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
|
||||
const name = user.name || user.displayName;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ReassignUser({
|
||||
<Dialog.Panel
|
||||
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
||||
>
|
||||
<Dialog.Title>Change role for {user.name}?</Dialog.Title>
|
||||
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Most roles are carried straight from Tailscale. However, keep in mind
|
||||
that I have not fully implemented permissions yet and some things may
|
||||
|
||||
@@ -13,9 +13,9 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Rename {user.name}?</Dialog.Title>
|
||||
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Enter a new username for {user.name}. Changing a username will not
|
||||
Enter a new username for {user.name || user.displayName}. Changing a username will not
|
||||
update any ACL policies that may refer to this user by their old
|
||||
username.
|
||||
</Dialog.Text>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { users } from '~/server/db/schema';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
try {
|
||||
const { user } = await context.sessions.auth(request);
|
||||
await context.db
|
||||
.update(users)
|
||||
.set({
|
||||
onboarded: true,
|
||||
})
|
||||
.where(eq(users.sub, user.subject));
|
||||
|
||||
return redirect('/machines');
|
||||
} catch {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
context.sessions.overrideOnboarding(user.subject, true);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@ import { GrApple } from 'react-icons/gr';
|
||||
import { ImFinder } from 'react-icons/im';
|
||||
import { MdAndroid } from 'react-icons/md';
|
||||
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
NavLink,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
@@ -27,10 +22,6 @@ export async function loader({
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
||||
// We need to convert this to a known value to return it to the client so we can
|
||||
@@ -60,11 +51,11 @@ export async function loader({
|
||||
break;
|
||||
}
|
||||
|
||||
let firstMachine: Machine | undefined = undefined;
|
||||
let firstMachine: Machine | undefined;
|
||||
try {
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const node = nodes.find((n) => {
|
||||
@@ -79,12 +70,7 @@ export async function loader({
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subject !== sessionUser.subject) {
|
||||
if (subject !== session.user.subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,7 +84,7 @@ export async function loader({
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
user: session.user,
|
||||
osValue,
|
||||
firstMachine,
|
||||
};
|
||||
@@ -126,7 +112,7 @@ export default function Page() {
|
||||
return (
|
||||
<div className="fixed w-full h-screen flex items-center px-4">
|
||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
||||
<Card variant="flat" className="max-w-lg">
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
@@ -138,9 +124,9 @@ export default function Page() {
|
||||
</Card.Text>
|
||||
|
||||
<Options
|
||||
className="my-4"
|
||||
defaultSelectedKey={osValue}
|
||||
label="Download Selector"
|
||||
className="my-4"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
@@ -183,12 +169,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
aria-label="Download for Windows"
|
||||
target="_blank"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
@@ -206,12 +192,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
aria-label="Download for macOS"
|
||||
target="_blank"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
@@ -238,12 +224,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
aria-label="Download for iOS"
|
||||
target="_blank"
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
@@ -261,12 +247,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
aria-label="Download for Android"
|
||||
target="_blank"
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
@@ -287,8 +273,8 @@ export default function Page() {
|
||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle
|
||||
isOnline={firstMachine.online}
|
||||
className="size-6 mt-3"
|
||||
isOnline={firstMachine.online}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold leading-snug">
|
||||
@@ -300,7 +286,7 @@ export default function Page() {
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p key={ip} className="text-xs font-mono opacity-50">
|
||||
<p className="text-xs font-mono opacity-50" key={ip}>
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
@@ -308,8 +294,8 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/">
|
||||
<Button variant="heavy" className="w-full">
|
||||
<NavLink to="/onboarding/skip">
|
||||
<Button className="w-full" variant="heavy">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
@@ -335,7 +321,7 @@ export default function Page() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
|
||||
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
|
||||
<Button className="flex items-center gap-1">
|
||||
I already know what I'm doing
|
||||
<ArrowRight className="p-1" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData, useSubmit } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine, User } from '~/types';
|
||||
@@ -32,11 +32,8 @@ export async function loader({
|
||||
);
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
|
||||
const users = apiUsers.users.map((user) => ({
|
||||
@@ -44,30 +41,32 @@ export async function loader({
|
||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||
}));
|
||||
|
||||
const roles = users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => {
|
||||
if (user.provider !== 'oidc') {
|
||||
return 'no-oidc';
|
||||
}
|
||||
|
||||
if (user.provider === 'oidc' && user.providerId) {
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId.split('/').pop();
|
||||
if (!subject) {
|
||||
return 'invalid-oidc';
|
||||
const roles = await Promise.all(
|
||||
users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(async (user) => {
|
||||
if (user.provider !== 'oidc') {
|
||||
return 'no-oidc';
|
||||
}
|
||||
|
||||
const role = context.sessions.roleForSubject(subject);
|
||||
return role ?? 'no-role';
|
||||
}
|
||||
if (user.provider === 'oidc' && user.providerId) {
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId.split('/').pop();
|
||||
if (!subject) {
|
||||
return 'invalid-oidc';
|
||||
}
|
||||
|
||||
// No role means the user is not registered in Headplane, but they
|
||||
// are in Headscale. We also need to handle what happens if someone
|
||||
// logs into the UI and they don't have a Headscale setup.
|
||||
return 'no-role';
|
||||
});
|
||||
const role = await context.sessions.roleForSubject(subject);
|
||||
return role ?? 'no-role';
|
||||
}
|
||||
|
||||
// No role means the user is not registered in Headplane, but they
|
||||
// are in Headscale. We also need to handle what happens if someone
|
||||
// logs into the UI and they don't have a Headscale setup.
|
||||
return 'no-role';
|
||||
}),
|
||||
);
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
@@ -105,10 +104,9 @@ export default function Page() {
|
||||
<>
|
||||
<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.
|
||||
Manage the users in your network and their permissions.
|
||||
</p>
|
||||
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
|
||||
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
@@ -129,8 +127,8 @@ export default function Page() {
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
role={data.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ActionFunctionArgs, Session, data } from 'react-router';
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities, Roles } from '~/server/web/roles';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import { User } from '~/types';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
|
||||
@@ -15,7 +14,7 @@ export async function userAction({
|
||||
throw data403('You do not have permission to update users');
|
||||
}
|
||||
|
||||
const apiKey = session.get('api_key')!;
|
||||
const apiKey = session.api_key;
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
|
||||
@@ -7,6 +7,10 @@ many side-effects (in this case, importing a module may run code).
|
||||
```
|
||||
server
|
||||
├── index.ts: Loads everything and starts the web server.
|
||||
├── agent/
|
||||
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
|
||||
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
|
||||
│ ├── env.ts: Checks the environment variables for custom overrides.
|
||||
├── config/
|
||||
│ ├── integration/
|
||||
│ │ ├── abstract.ts: Defines the abstract class for integrations.
|
||||
|
||||
@@ -3,13 +3,18 @@ import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node
|
||||
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
||||
const svcRoot = '/var/run/secrets/kubernetes.io/serviceaccount';
|
||||
const svcCaPath = `${svcRoot}/ca.crt`;
|
||||
const svcTokenPath = `${svcRoot}/token`;
|
||||
const svcNamespacePath = `${svcRoot}/namespace`;
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
|
||||
export default class KubernetesIntegration extends Integration<T> {
|
||||
private pid: number | undefined;
|
||||
@@ -25,7 +30,6 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
return false;
|
||||
}
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT;
|
||||
try {
|
||||
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
|
||||
const files = await readdir(svcRoot);
|
||||
@@ -35,11 +39,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
||||
const expectedFiles = [
|
||||
Config.SERVICEACCOUNT_CA_PATH,
|
||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
];
|
||||
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
||||
|
||||
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
|
||||
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||
@@ -52,10 +52,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
);
|
||||
const namespace = await readFile(svcNamespacePath, 'utf8');
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (this.context.validate_manifest === false) {
|
||||
@@ -99,36 +96,32 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||
|
||||
log.info(
|
||||
'config',
|
||||
'Checking pod %s in namespace %s (%s)',
|
||||
pod,
|
||||
namespace,
|
||||
kCoreV1Api.basePath,
|
||||
);
|
||||
|
||||
log.info('config', 'Checking pod %s in namespace %s', pod, namespace);
|
||||
log.debug('config', 'Reading pod info for %s', pod);
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
const body = await kCoreV1Api.readNamespacedPod({
|
||||
name: pod,
|
||||
namespace,
|
||||
);
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
if (!body.spec) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to read pod info: http %d',
|
||||
response.statusCode,
|
||||
'Missing spec in pod info for %s/%s',
|
||||
pod,
|
||||
namespace,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('config', 'Got pod info: %o', body.spec);
|
||||
const shared = body.spec?.shareProcessNamespace;
|
||||
const shared = body.spec.shareProcessNamespace;
|
||||
if (shared === undefined) {
|
||||
log.error(
|
||||
'config',
|
||||
'Pod does not have spec.shareProcessNamespace set',
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -137,6 +130,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
'config',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -158,12 +152,14 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline');
|
||||
const path = join('/proc', dir, 'comm');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (!data.includes('headscale') && !data.includes('serve')) {
|
||||
throw new Error('Found PID without Headscale serve command');
|
||||
if (data.trim() !== 'headscale') {
|
||||
throw new Error(
|
||||
`Found PID with unexpected command: ${data.trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return pid;
|
||||
|
||||
@@ -34,17 +34,19 @@ export default class ProcIntegration extends Integration<T> {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline');
|
||||
const path = join('/proc', dir, 'comm');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (!data.includes('headscale') && !data.includes('serve')) {
|
||||
throw new Error('Found PID without Headscale serve command');
|
||||
if (data.trim() !== 'headscale') {
|
||||
throw new Error(
|
||||
`Found PID with unexpected command: ${data.trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', path, error);
|
||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -59,14 +61,37 @@ export default class ProcIntegration extends Integration<T> {
|
||||
|
||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
||||
if (pids.length > 1) {
|
||||
log.error(
|
||||
log.warn(
|
||||
'config',
|
||||
'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
|
||||
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
||||
for (const pid of pids) {
|
||||
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', pidStatusPath);
|
||||
const pidData = await readFile(pidStatusPath, 'utf8');
|
||||
const ppidResult = pidData.match(ppidRegex);
|
||||
|
||||
if (ppidResult !== null) {
|
||||
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
||||
if (potentialPPid === 1) {
|
||||
this.pid = pid;
|
||||
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('config', 'Could not find Headscale process');
|
||||
|
||||
+119
-41
@@ -1,5 +1,5 @@
|
||||
import { constants, access, readFile } from 'node:fs/promises';
|
||||
import { env, exit } from 'node:process';
|
||||
import { access, constants, readFile } from 'node:fs/promises';
|
||||
import { env } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import { configDotenv } from 'dotenv';
|
||||
import { parseDocument } from 'yaml';
|
||||
@@ -11,6 +11,28 @@ import {
|
||||
partialHeadplaneConfig,
|
||||
} from './schema';
|
||||
|
||||
// Custom error for config issues
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate environment variables in a string
|
||||
* Replaces ${VAR_NAME} patterns with the actual environment variable values
|
||||
*/
|
||||
export function interpolateEnvVars(str: string): string {
|
||||
return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
||||
const value = env[varName];
|
||||
if (value === undefined) {
|
||||
throw new ConfigError(`Environment variable "${varName}" not found`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
// loadConfig is a has a lifetime of the entire application and is
|
||||
// used to load the configuration for Headplane. It is called once.
|
||||
//
|
||||
@@ -18,54 +40,108 @@ import {
|
||||
// But this may not be necessary as a use-case anyways
|
||||
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
||||
log.debug('config', 'Loading configuration file: %s', path);
|
||||
const valid = await validateConfigPath(path);
|
||||
if (!valid) {
|
||||
exit(1);
|
||||
}
|
||||
await validateConfigPath(path);
|
||||
|
||||
const data = await loadConfigFile(path);
|
||||
if (!data) {
|
||||
exit(1);
|
||||
throw new ConfigError('Failed to load configuration file');
|
||||
}
|
||||
|
||||
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
||||
if (!config) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!loadEnv) {
|
||||
log.debug('config', 'Environment variable overrides are disabled');
|
||||
log.debug('config', 'This also disables the loading of a .env file');
|
||||
return config;
|
||||
const moddedConfig = await loadSecretsFromFiles(config);
|
||||
log.debug('config', 'Loaded file-based secrets');
|
||||
return moddedConfig;
|
||||
}
|
||||
|
||||
log.info('config', 'Loading a .env file (if available)');
|
||||
configDotenv({ override: true });
|
||||
config = coalesceEnv(config);
|
||||
if (!config) {
|
||||
exit(1);
|
||||
configDotenv({ override: true, quiet: true });
|
||||
const merged = coalesceEnv(config);
|
||||
if (merged) config = merged;
|
||||
if (config.headscale && typeof config.headscale.config_path === 'string') {
|
||||
config.headscale.config_path = interpolateEnvVars(
|
||||
config.headscale.config_path,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
const moddedConfig = await loadSecretsFromFiles(config);
|
||||
log.debug('config', 'Loaded file-based secrets');
|
||||
|
||||
return moddedConfig;
|
||||
}
|
||||
|
||||
export async function hp_loadConfig() {
|
||||
// // OIDC Related Checks
|
||||
// if (config.oidc) {
|
||||
// if (!config.oidc.client_secret && !config.oidc.client_secret_path) {
|
||||
// log.error('CFGX', 'OIDC configuration is missing a secret, disabling');
|
||||
// log.error(
|
||||
// 'CFGX',
|
||||
// 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`',
|
||||
// );
|
||||
// }
|
||||
// if (config.oidc?.strict_validation) {
|
||||
// const result = await testOidc(config.oidc);
|
||||
// if (!result) {
|
||||
// log.error('CFGX', 'OIDC configuration failed validation, disabling');
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
/**
|
||||
* Recursively walks the config object; for any key in the whitelist of secret path keys,
|
||||
* reads that file and assigns its contents to the corresponding key
|
||||
* without the suffix, then removes the "_path" property.
|
||||
*/
|
||||
const SECRET_PATH_KEYS = [
|
||||
'pre_authkey_path',
|
||||
'client_secret_path',
|
||||
'headscale_api_key_path',
|
||||
'cookie_secret_path',
|
||||
] as const;
|
||||
|
||||
// For fast set hashing lookups, but we still need the array for typings
|
||||
const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
|
||||
|
||||
type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
|
||||
type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
|
||||
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
|
||||
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
|
||||
|
||||
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
|
||||
[K in MappedKeys<T>]-?: string;
|
||||
};
|
||||
|
||||
type NestedNonNullablized<T> = T extends readonly (infer U)[]
|
||||
? readonly NestedNonNullablized<U>[]
|
||||
: T extends (infer U)[]
|
||||
? NestedNonNullablized<U>[]
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof NonNullablized<T>]: NestedNonNullablized<
|
||||
NonNullablized<T>[K]
|
||||
>;
|
||||
}
|
||||
: T;
|
||||
|
||||
async function loadSecretsFromFiles<T extends object>(
|
||||
obj: T,
|
||||
): Promise<NestedNonNullablized<T>> {
|
||||
// Work with a Record so we can mutate/delete properties
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
for (const key of Object.keys(record)) {
|
||||
const val = record[key];
|
||||
|
||||
if (val && typeof val === 'object') {
|
||||
// recurse into nested objects
|
||||
record[key] = await loadSecretsFromFiles(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
|
||||
try {
|
||||
const path = interpolateEnvVars(val);
|
||||
const content = await readFile(path, 'utf8');
|
||||
const secretKey = key.slice(0, -5); // drop '_path'
|
||||
record[secretKey] = content.trim();
|
||||
delete record[key];
|
||||
log.debug('config', 'Loaded secret from %s → %s', val, secretKey);
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) throw err;
|
||||
log.error('config', 'Failed to read secret file %s: %s', val, err);
|
||||
throw new ConfigError(`Failed to read secret file ${val}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cast back to the original T so callers keep their precise type
|
||||
return record as NestedNonNullablized<T>;
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
@@ -76,7 +152,9 @@ async function validateConfigPath(path: string) {
|
||||
} catch (error) {
|
||||
log.error('config', 'Unable to read a configuration file at %s', path);
|
||||
log.error('config', '%s', error);
|
||||
return false;
|
||||
throw new ConfigError(
|
||||
`Unable to read configuration file at ${path}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +169,7 @@ async function loadConfigFile(path: string): Promise<unknown> {
|
||||
log.error('config', ` - ${error.toString()}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
throw new ConfigError(`Cannot parse configuration file at ${path}`);
|
||||
}
|
||||
|
||||
if (configYaml.warnings.length > 0) {
|
||||
@@ -109,7 +187,7 @@ async function loadConfigFile(path: string): Promise<unknown> {
|
||||
} catch (e) {
|
||||
log.error('config', 'Error reading configuration file at %s', path);
|
||||
log.error('config', '%s', e);
|
||||
return false;
|
||||
throw new ConfigError(`Error reading configuration file at ${path}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,14 +195,14 @@ export function validateConfig(config: unknown) {
|
||||
log.debug('config', 'Validating Headplane configuration');
|
||||
const result = headplaneConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
log.error('config', 'Error validating Headplane configuration:');
|
||||
const errorMessages = [];
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
const errorMsg = error.toString();
|
||||
log.error('config', ` - (${number}): ${errorMsg}`);
|
||||
errorMessages.push(errorMsg);
|
||||
}
|
||||
|
||||
return;
|
||||
throw new ConfigError(errorMessages.join('\n'));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+116
-17
@@ -19,8 +19,36 @@ const stringToBool = type('string | boolean').pipe((v) => {
|
||||
const serverConfig = type({
|
||||
host: 'string.ip',
|
||||
port: type('string | number.integer').pipe((v) => Number(v)),
|
||||
cookie_secret: '32 <= string <= 32',
|
||||
data_path: 'string = "/var/lib/headplane/"',
|
||||
cookie_secret: '(32 <= string <= 32)?',
|
||||
cookie_secret_path: 'string?',
|
||||
cookie_secure: stringToBool,
|
||||
})
|
||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||
const hasVal = obj.cookie_secret != null && `${obj.cookie_secret}` !== '';
|
||||
const hasPath =
|
||||
obj.cookie_secret_path != null && obj.cookie_secret_path !== '';
|
||||
if (hasVal && hasPath)
|
||||
return ctx.reject(
|
||||
`Only one of "cookie_secret" or "cookie_secret_path" may be set.`,
|
||||
);
|
||||
if (!hasVal && !hasPath)
|
||||
return ctx.reject(
|
||||
`Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret.`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialServerConfig = type({
|
||||
host: 'string.ip?',
|
||||
port: type('string | number.integer')
|
||||
.pipe((v) => Number(v))
|
||||
.optional(),
|
||||
data_path: 'string = "/var/lib/headplane/"',
|
||||
cookie_secret: '32 <= string <= 32?',
|
||||
cookie_secret_path: 'string?',
|
||||
cookie_secure: stringToBool.optional(),
|
||||
});
|
||||
|
||||
const oidcConfig = type({
|
||||
@@ -33,9 +61,53 @@ const oidcConfig = type({
|
||||
redirect_uri: 'string.url?',
|
||||
user_storage_file: 'string = "/var/lib/headplane/users.json"',
|
||||
disable_api_key_login: stringToBool,
|
||||
headscale_api_key: 'string',
|
||||
headscale_api_key: 'string?',
|
||||
headscale_api_key_path: 'string?',
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
strict_validation: stringToBool.default(true),
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
scope: 'string = "openid email profile"',
|
||||
extra_params: 'Record<string, string>?',
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
})
|
||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||
const hasVal =
|
||||
obj.headscale_api_key != null && `${obj.headscale_api_key}` !== '';
|
||||
const hasPath =
|
||||
obj.headscale_api_key_path != null && obj.headscale_api_key_path !== '';
|
||||
if (hasVal && hasPath)
|
||||
return ctx.reject(
|
||||
`Only one of "headscale_api_key" or "headscale_api_key_path" may be set.`,
|
||||
);
|
||||
if (!hasVal && !hasPath)
|
||||
return ctx.reject(
|
||||
`Either "headscale_api_key" or "headscale_api_key_path" must be provided.`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialOidcConfig = type({
|
||||
issuer: 'string.url?',
|
||||
client_id: 'string?',
|
||||
client_secret: 'string?',
|
||||
client_secret_path: 'string?',
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
redirect_uri: 'string.url?',
|
||||
user_storage_file: 'string?',
|
||||
disable_api_key_login: stringToBool.optional(),
|
||||
headscale_api_key: 'string?',
|
||||
headscale_api_key_path: 'string?',
|
||||
profile_picture_source: '("oidc" | "gravatar")?',
|
||||
strict_validation: stringToBool.default(true),
|
||||
scope: 'string?',
|
||||
extra_params: 'Record<string, string>?',
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
||||
@@ -46,26 +118,53 @@ const headscaleConfig = type({
|
||||
dns_records_path: 'string?',
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialHeadscaleConfig = type({
|
||||
url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
tls_cert_path: 'string?',
|
||||
public_url: 'string.url?',
|
||||
config_path: 'string?',
|
||||
config_strict: stringToBool.optional(),
|
||||
dns_records_path: 'string?',
|
||||
});
|
||||
|
||||
const agentConfig = type({
|
||||
enabled: stringToBool.default(false),
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string = ""',
|
||||
pre_authkey: 'string?',
|
||||
pre_authkey_path: 'string?',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
})
|
||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||
const hasVal = obj.pre_authkey != null && `${obj.pre_authkey}` !== '';
|
||||
const hasPath = obj.pre_authkey_path != null && obj.pre_authkey_path !== '';
|
||||
if (hasVal && hasPath)
|
||||
return ctx.reject(
|
||||
`Only one of "pre_authkey" or "pre_authkey_path" may be set.`,
|
||||
);
|
||||
if (!hasVal && !hasPath)
|
||||
return ctx.reject(
|
||||
`Either "pre_authkey" or "pre_authkey_path" must be provided.`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialAgentConfig = type({
|
||||
enabled: stringToBool.default(false),
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string?',
|
||||
pre_authkey_path: 'string?',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
});
|
||||
|
||||
const partialAgentConfig = type({
|
||||
enabled: stringToBool,
|
||||
host_name: 'string | undefined',
|
||||
pre_authkey: 'string | undefined',
|
||||
cache_ttl: 'number.integer | undefined',
|
||||
cache_path: 'string | undefined',
|
||||
executable_path: 'string | undefined',
|
||||
work_dir: 'string | undefined',
|
||||
}).partial();
|
||||
|
||||
const dockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string = ""',
|
||||
@@ -114,10 +213,10 @@ export const headplaneConfig = type({
|
||||
|
||||
export const partialHeadplaneConfig = type({
|
||||
debug: stringToBool,
|
||||
server: serverConfig.partial(),
|
||||
'oidc?': oidcConfig.partial(),
|
||||
server: partialServerConfig,
|
||||
'oidc?': partialOidcConfig,
|
||||
'integration?': partialIntegrationConfig,
|
||||
headscale: headscaleConfig.partial(),
|
||||
headscale: partialHeadscaleConfig,
|
||||
}).partial();
|
||||
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { migrate } from 'drizzle-orm/libsql/migrator';
|
||||
import { drizzle } from 'drizzle-orm/libsql/sqlite3';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function createDbClient(path: string) {
|
||||
const realPath = resolve(path);
|
||||
try {
|
||||
await mkdir(dirname(realPath), { recursive: true });
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'server',
|
||||
'Failed to create directory for database at %s: %s',
|
||||
realPath,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw new Error(`Could not create directory for database at ${realPath}`);
|
||||
}
|
||||
|
||||
// Turn the path into a URL with the file protocol
|
||||
const db = drizzle(`file://${realPath}`);
|
||||
migrate(db, {
|
||||
migrationsFolder: './drizzle',
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { eq, isNotNull } from 'drizzle-orm';
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { Machine } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { LoadContext } from '..';
|
||||
import { ephemeralNodes } from './schema';
|
||||
|
||||
export async function pruneEphemeralNodes({
|
||||
context,
|
||||
request,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
.from(ephemeralNodes)
|
||||
.where(isNotNull(ephemeralNodes.node_key));
|
||||
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug('api', 'No ephemeral nodes to prune');
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
|
||||
if (toPrune.length === 0) {
|
||||
log.debug('api', 'No SSH nodes to prune');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug('api', `Pruning node ${node.name}`);
|
||||
await context.client.delete(`v1/node/${node.id}`, session.api_key);
|
||||
|
||||
await context.db
|
||||
.delete(ephemeralNodes)
|
||||
.where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug('api', `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { HostInfo } from '~/types';
|
||||
|
||||
export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
|
||||
auth_key: text('auth_key').primaryKey(),
|
||||
node_key: text('node_key'),
|
||||
});
|
||||
|
||||
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
|
||||
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
|
||||
|
||||
export const hostInfo = sqliteTable('host_info', {
|
||||
host_id: text('host_id').primaryKey(),
|
||||
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
|
||||
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
|
||||
() => new Date(),
|
||||
),
|
||||
});
|
||||
|
||||
export type HostInfoRecord = typeof hostInfo.$inferSelect;
|
||||
export type HostInfoInsert = typeof hostInfo.$inferInsert;
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
sub: text('sub').notNull().unique(),
|
||||
caps: integer('caps').notNull().default(0),
|
||||
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type UserInsert = typeof users.$inferInsert;
|
||||
@@ -103,6 +103,7 @@ export const headscaleConfig = type({
|
||||
dns: {
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: 'string = "headscale.net"',
|
||||
override_local_dns: goBool.default(false),
|
||||
nameservers: type({
|
||||
global: type('string[]').default(() => []),
|
||||
split: type('Record<string, string[]>').default(() => ({})),
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
|
||||
import EventEmitter from 'node:events';
|
||||
import { access, constants, mkdir, open } from 'node:fs/promises';
|
||||
import { getegid, geteuid } from 'node:process';
|
||||
import { createInterface, Interface } from 'node:readline';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
|
||||
import { HostInfo } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from './config/schema';
|
||||
import { hostInfo } from './db/schema';
|
||||
|
||||
export async function createHeadplaneAgent(
|
||||
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||
headscaleUrl: string,
|
||||
db: LibSQLDatabase,
|
||||
) {
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.pre_authkey) {
|
||||
log.error('agent', 'Agent `pre_authkey` is not set');
|
||||
log.warn('agent', 'The agent will not run until resolved');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||
log.debug('config', 'Using agent work dir at %s', config.work_dir);
|
||||
} catch (error) {
|
||||
// Try to create the directory just in case
|
||||
try {
|
||||
await mkdir(config.work_dir, { recursive: true });
|
||||
log.debug('config', 'Created agent work dir at %s', config.work_dir);
|
||||
log.info(
|
||||
'config',
|
||||
'Created missing agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
|
||||
return;
|
||||
} catch (innerError) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to create agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.info(
|
||||
'config',
|
||||
'Agent work dir not accessible at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
log.debug('config', 'Create error details: %s', innerError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await open(config.cache_path, 'a+');
|
||||
log.info('agent', 'Using agent cache file at %s', config.cache_path);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info(
|
||||
'agent',
|
||||
'Agent cache file not accessible at %s',
|
||||
config.cache_path,
|
||||
);
|
||||
log.debug('agent', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = new HeadplaneAgent({
|
||||
...config,
|
||||
headscaleUrl,
|
||||
});
|
||||
|
||||
agent.on('spawn', () => {
|
||||
log.info('agent', 'Headplane agent started');
|
||||
});
|
||||
|
||||
agent.on('ready', () => {
|
||||
log.info('agent', 'Headplane agent is ready and serving queries');
|
||||
});
|
||||
|
||||
agent.on('error', (err) => {
|
||||
log.warn('agent', 'Headplane agent experienced an error: %s', err.message);
|
||||
log.debug('agent', 'Error details: %o', err);
|
||||
});
|
||||
|
||||
agent.on('exit', ({ code, signal }) => {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Headplane agent exited with code %s and signal %s',
|
||||
code,
|
||||
signal,
|
||||
);
|
||||
});
|
||||
|
||||
agent.on('restart', ({ delay, attempt }) => {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Headplane agent will restart in %f seconds (attempt %d)',
|
||||
delay / 1000,
|
||||
attempt,
|
||||
);
|
||||
});
|
||||
|
||||
agent.on('stderr', (data) => {
|
||||
log.error('agent', 'Headplane agent stderr:', data);
|
||||
});
|
||||
|
||||
agent.on('info', async ({ id, info }) => {
|
||||
log.debug('agent', 'Received HostInfo for %s', id);
|
||||
try {
|
||||
const parsedInfo = JSON.parse(info) as HostInfo;
|
||||
await db
|
||||
.insert(hostInfo)
|
||||
.values({
|
||||
host_id: id,
|
||||
payload: parsedInfo,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: hostInfo.host_id,
|
||||
set: {
|
||||
payload: parsedInfo,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'agent',
|
||||
'Failed to parse HostInfo for %s: %s',
|
||||
id,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
agent.start();
|
||||
|
||||
process.on('SIGTERM', () => agent.shutdown());
|
||||
process.on('SIGINT', () => agent.shutdown());
|
||||
|
||||
return {
|
||||
agentID: () => agent.agentID(),
|
||||
lookup: async (nodes: string[]) => {
|
||||
const results = await db
|
||||
.select()
|
||||
.from(hostInfo)
|
||||
.where(inArray(hostInfo.host_id, nodes));
|
||||
|
||||
return Object.fromEntries(
|
||||
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
|
||||
) as Record<string, HostInfo>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AgentOptions = NonNullable<
|
||||
NonNullable<HeadplaneConfig['integration']>['agent']
|
||||
> & {
|
||||
headscaleUrl: string;
|
||||
};
|
||||
|
||||
interface AgentEvents {
|
||||
ready: [];
|
||||
spawn: [];
|
||||
error: [Error];
|
||||
exit: [{ code?: number; signal?: NodeJS.Signals }];
|
||||
restart: [{ delay: number; attempt: number }];
|
||||
stderr: [string];
|
||||
info: [{ id: string; info: string }];
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom class that turns the lifecycle of the agent into an event emitter.
|
||||
* It has many different responsibilities ensuring that:
|
||||
* - The agent is spawned with the correct configuration
|
||||
* - The agent is ready and still running (ping/heartbeat)
|
||||
* - The agent is restarted on a backoff strategy
|
||||
*/
|
||||
class HeadplaneAgent extends EventEmitter<AgentEvents> {
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private readline?: Interface;
|
||||
|
||||
private options: AgentOptions;
|
||||
|
||||
private hbInterval?: NodeJS.Timeout;
|
||||
private hbDeadline?: NodeJS.Timeout;
|
||||
private restartTimer?: NodeJS.Timeout;
|
||||
private isWaitingForAck = false;
|
||||
private isShuttingDown = false;
|
||||
private backoffAttempt = 0;
|
||||
private agentId?: string;
|
||||
|
||||
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
|
||||
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
|
||||
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
|
||||
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
|
||||
|
||||
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
|
||||
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
|
||||
|
||||
constructor(options: AgentOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
agentID() {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.isShuttingDown = false;
|
||||
this.spawnInternalChild();
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.isShuttingDown = true;
|
||||
this.agentId = undefined;
|
||||
|
||||
clearTimeout(this.restartTimer);
|
||||
clearInterval(this.hbInterval);
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.isWaitingForAck = false;
|
||||
|
||||
this.send('SHUTDOWN');
|
||||
this.child?.kill('SIGTERM');
|
||||
this.readline?.close();
|
||||
}
|
||||
|
||||
private spawnInternalChild() {
|
||||
this.child = spawn(this.options.executable_path, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
uid: geteuid?.() ?? undefined,
|
||||
gid: getegid?.() ?? undefined,
|
||||
env: {
|
||||
HOME: process.env.HOME,
|
||||
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
|
||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
|
||||
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
|
||||
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
|
||||
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
|
||||
},
|
||||
});
|
||||
|
||||
this.emit('spawn');
|
||||
this.child.on('error', (err) => this.emit('error', err));
|
||||
this.child.stderr.on('data', (data) =>
|
||||
this.emit('stderr', data.toString()),
|
||||
);
|
||||
|
||||
this.child.on('exit', (code, signal) => {
|
||||
this.agentId = undefined;
|
||||
this.emit('exit', {
|
||||
code: code ?? undefined,
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
|
||||
this.readline?.close();
|
||||
clearInterval(this.hbInterval);
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.isWaitingForAck = false;
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
log.info('agent', 'Child process exited gracefully');
|
||||
return;
|
||||
}
|
||||
|
||||
this.backoffAttempt++;
|
||||
const delay = this.calculateBackoff();
|
||||
this.emit('restart', { delay, attempt: this.backoffAttempt });
|
||||
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
|
||||
});
|
||||
|
||||
this.readline = createInterface({ input: this.child.stdout });
|
||||
this.readline.on('line', (line) => this.readlineHandler(line));
|
||||
this.send('START');
|
||||
|
||||
// Start the heartbeat loop with our custom interval
|
||||
this.hbInterval = setInterval(() => {
|
||||
if (!this.child || this.child.killed) return;
|
||||
|
||||
// If we get here, we missed the last PONG response and can die
|
||||
if (this.isWaitingForAck) {
|
||||
this.agentId = undefined;
|
||||
this.emit('error', new Error('Agent heartbeat missed'));
|
||||
this.child.kill('SIGTERM');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForAck = true;
|
||||
this.send('PING');
|
||||
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.hbDeadline = setTimeout(() => {
|
||||
if (this.isWaitingForAck) {
|
||||
this.agentId = undefined;
|
||||
this.emit('error', new Error('Agent heartbeat timeout'));
|
||||
this.child?.kill('SIGTERM');
|
||||
}
|
||||
}, this.HEARTBEAT_TIMEOUT_MS);
|
||||
}, this.HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private send(s: string) {
|
||||
if (!this.child || this.child.killed) return;
|
||||
const ok = this.child.stdin.write(`${s}\n`);
|
||||
if (!ok) this.child.stdin.once('drain', () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a backoff time based on the current attempt.
|
||||
* Supports a randomized jitter to avoid thundering herd problems.
|
||||
*
|
||||
* @param min The minimum backoff time in milliseconds.
|
||||
* @param max The maximum backoff time in milliseconds.
|
||||
* @returns The calculated backoff time in milliseconds.
|
||||
*/
|
||||
private calculateBackoff() {
|
||||
const attempt = this.backoffAttempt;
|
||||
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
|
||||
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
|
||||
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
|
||||
}
|
||||
|
||||
const cap = Math.min(
|
||||
this.MAX_BACKOFF_MS,
|
||||
this.BASE_BACKOFF_MS * 2 ** attempt,
|
||||
);
|
||||
|
||||
return Math.floor(Math.random() * (cap + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and dispatches the appropriate response based on the message.
|
||||
* @param line The message to process (piped straight from readline)
|
||||
*/
|
||||
private readlineHandler(line: string) {
|
||||
// When we are ready we force a refresh so that the UI has the most
|
||||
// up-to-date information and will gracefully handle new info being sent
|
||||
if (line.startsWith('READY')) {
|
||||
this.backoffAttempt = 0;
|
||||
this.send('REFRESH');
|
||||
this.emit('ready');
|
||||
|
||||
const agentId = line.slice(5).trim();
|
||||
if (this.agentId && this.agentId !== agentId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Agent ID changed from %s to %s',
|
||||
this.agentId,
|
||||
agentId,
|
||||
);
|
||||
}
|
||||
|
||||
this.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('PONG')) {
|
||||
this.isWaitingForAck = false;
|
||||
clearTimeout(this.hbDeadline);
|
||||
|
||||
const agentId = line.slice(5).trim();
|
||||
if (this.agentId && this.agentId !== agentId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Agent ID changed from %s to %s',
|
||||
this.agentId,
|
||||
agentId,
|
||||
);
|
||||
}
|
||||
|
||||
this.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('HOSTINFO')) {
|
||||
const data = line.slice(9).trim();
|
||||
const [id, ...infoParts] = data.split(' ');
|
||||
const info = infoParts.join(' ');
|
||||
this.emit('info', { id, info });
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('ERROR')) {
|
||||
const error = line.slice(6).trim();
|
||||
this.emit('error', new Error(error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('LOG')) {
|
||||
const logSnippet = line.slice(4).trim();
|
||||
const [level, ...messageParts] = logSnippet.split(' ');
|
||||
const message = messageParts.join(' ');
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
log.info('agent', message);
|
||||
break;
|
||||
case 'WARN':
|
||||
log.warn('agent', message);
|
||||
break;
|
||||
case 'ERROR':
|
||||
log.error('agent', message);
|
||||
break;
|
||||
default:
|
||||
log.debug('agent', message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-15
@@ -1,14 +1,15 @@
|
||||
import { join } from 'node:path';
|
||||
import { env, versions } from 'node:process';
|
||||
import { createHonoServer } from 'react-router-hono-server/node';
|
||||
|
||||
import log from '~/utils/log';
|
||||
import { configureConfig, configureLogger, envVariables } from './config/env';
|
||||
import { loadIntegration } from './config/integration';
|
||||
import { loadConfig } from './config/loader';
|
||||
import { createDbClient } from './db/client.server';
|
||||
import { createApiClient } from './headscale/api-client';
|
||||
import { loadHeadscaleConfig } from './headscale/config-loader';
|
||||
import { loadAgentSocket } from './web/agent';
|
||||
import { createOidcClient } from './web/oidc';
|
||||
import { createHeadplaneAgent } from './hp-agent';
|
||||
import { configureOidcAuth } from './web/oidc';
|
||||
import { createSessionStorage } from './web/sessions';
|
||||
|
||||
declare global {
|
||||
@@ -28,6 +29,13 @@ const config = await loadConfig(
|
||||
}),
|
||||
);
|
||||
|
||||
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
|
||||
const agents = await createHeadplaneAgent(
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
db,
|
||||
);
|
||||
|
||||
// We also use this file to load anything needed by the react router code.
|
||||
// These are usually per-request things that we need access to, like the
|
||||
// helper that can issue and revoke cookies.
|
||||
@@ -41,27 +49,27 @@ const appLoadContext = {
|
||||
),
|
||||
|
||||
// TODO: Better cookie options in config
|
||||
sessions: await createSessionStorage(
|
||||
{
|
||||
name: '_hp_session',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
sessions: await createSessionStorage({
|
||||
secret: config.server.cookie_secret,
|
||||
db,
|
||||
oidcUsersFile: config.oidc?.user_storage_file,
|
||||
cookie: {
|
||||
name: '_hp_auth',
|
||||
secure: config.server.cookie_secure,
|
||||
secrets: [config.server.cookie_secret],
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
// domain: config.server.cookie_domain,
|
||||
},
|
||||
config.oidc?.user_storage_file,
|
||||
),
|
||||
}),
|
||||
|
||||
client: await createApiClient(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
),
|
||||
|
||||
agents: await loadAgentSocket(
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
),
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
|
||||
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
|
||||
db,
|
||||
};
|
||||
|
||||
declare module 'react-router' {
|
||||
@@ -72,6 +80,18 @@ export default createHonoServer({
|
||||
overrideGlobalObjects: true,
|
||||
port: config.server.port,
|
||||
hostname: config.server.host,
|
||||
beforeAll: async (app) => {
|
||||
app.use(__PREFIX__, async (c) => {
|
||||
return c.redirect(`${__PREFIX__}/`);
|
||||
});
|
||||
},
|
||||
serveStaticOptions: {
|
||||
clientAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
|
||||
},
|
||||
},
|
||||
|
||||
// Only log in development mode
|
||||
defaultLogger: import.meta.env.DEV,
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
constants,
|
||||
access,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { exit } from 'node:process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { type } from 'arktype';
|
||||
import { HostInfo } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config/schema';
|
||||
|
||||
interface LogResponse {
|
||||
Level: 'info' | 'debug' | 'error' | 'fatal';
|
||||
Message: string;
|
||||
}
|
||||
|
||||
interface RegisterMessage {
|
||||
Type: 'register';
|
||||
ID: string;
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
Type: 'status';
|
||||
Data: Record<string, HostInfo>;
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
Level: 'msg';
|
||||
Message: RegisterMessage | StatusMessage;
|
||||
}
|
||||
|
||||
type AgentResponse = LogResponse | MessageResponse;
|
||||
|
||||
export async function loadAgentSocket(
|
||||
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||
headscaleUrl: string,
|
||||
) {
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.pre_authkey.trim().length === 0) {
|
||||
log.error('agent', 'Agent `pre_authkey` is not set');
|
||||
log.warn('agent', 'The agent will not run until resolved');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||
log.debug('config', 'Using agent work dir at %s', config.work_dir);
|
||||
} catch (error) {
|
||||
// Try to create the directory just in case
|
||||
try {
|
||||
await mkdir(config.work_dir, { recursive: true });
|
||||
log.debug('config', 'Created agent work dir at %s', config.work_dir);
|
||||
log.info(
|
||||
'config',
|
||||
'Created missing agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
|
||||
return;
|
||||
} catch (innerError) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to create agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.info(
|
||||
'config',
|
||||
'Agent work dir not accessible at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
log.debug('config', 'Create error details: %s', innerError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await open(config.cache_path, 'a+');
|
||||
log.info('agent', 'Using agent cache file at %s', config.cache_path);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info(
|
||||
'agent',
|
||||
'Agent cache file not accessible at %s',
|
||||
config.cache_path,
|
||||
);
|
||||
log.debug('agent', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = new TimedCache<HostInfo>(config.cache_ttl, config.cache_path);
|
||||
return new AgentManager(cache, config, headscaleUrl);
|
||||
}
|
||||
|
||||
class AgentManager {
|
||||
private static readonly MAX_RESTARTS = 5;
|
||||
private restartCounter = 0;
|
||||
|
||||
private cache: TimedCache<HostInfo>;
|
||||
private headscaleUrl: string;
|
||||
private config: NonNullable<
|
||||
NonNullable<HeadplaneConfig['integration']>['agent']
|
||||
>;
|
||||
|
||||
private spawnProcess: ChildProcess | null;
|
||||
private agentId: string | null;
|
||||
|
||||
constructor(
|
||||
cache: TimedCache<HostInfo>,
|
||||
config: NonNullable<NonNullable<HeadplaneConfig['integration']>['agent']>,
|
||||
headscaleUrl: string,
|
||||
) {
|
||||
this.cache = cache;
|
||||
this.config = config;
|
||||
this.headscaleUrl = headscaleUrl;
|
||||
this.spawnProcess = null;
|
||||
this.agentId = null;
|
||||
this.startAgent();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
this.spawnProcess?.kill();
|
||||
exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
this.spawnProcess?.kill();
|
||||
exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by the UI to indicate why the agent is not running.
|
||||
* Exhaustion requires a manual restart of the agent.
|
||||
* (Which can be invoked via the UI)
|
||||
* @returns true if the agent is exhausted
|
||||
*/
|
||||
exhausted() {
|
||||
return this.restartCounter >= AgentManager.MAX_RESTARTS;
|
||||
}
|
||||
|
||||
/*
|
||||
* Called by the UI to manually force a restart of the agent.
|
||||
*/
|
||||
deExhaust() {
|
||||
this.restartCounter = 0;
|
||||
this.startAgent();
|
||||
}
|
||||
|
||||
/*
|
||||
* Stored agent ID for the current process. This is caught by the agent
|
||||
* when parsing the stdout on agent startup.
|
||||
*/
|
||||
agentID() {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
private startAgent() {
|
||||
if (this.spawnProcess) {
|
||||
log.debug('agent', 'Agent already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.exhausted()) {
|
||||
log.error('agent', 'Agent is exhausted, cannot start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot be detached since we want to follow our process lifecycle
|
||||
// We also need to be able to send data to the process by using stdin
|
||||
log.info(
|
||||
'agent',
|
||||
'Starting agent process (attempt %d)',
|
||||
this.restartCounter,
|
||||
);
|
||||
this.spawnProcess = spawn(this.config.executable_path, [], {
|
||||
detached: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
HOME: process.env.HOME,
|
||||
HEADPLANE_EMBEDDED: 'true',
|
||||
HEADPLANE_AGENT_WORK_DIR: this.config.work_dir,
|
||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
|
||||
HEADPLANE_AGENT_HOSTNAME: this.config.host_name,
|
||||
HEADPLANE_AGENT_TS_SERVER: this.headscaleUrl,
|
||||
HEADPLANE_AGENT_TS_AUTHKEY: this.config.pre_authkey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.spawnProcess?.pid) {
|
||||
log.error('agent', 'Failed to start agent process');
|
||||
this.restartCounter++;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.spawnProcess.stdin === null || this.spawnProcess.stdout === null) {
|
||||
log.error('agent', 'Failed to connect to agent process');
|
||||
this.restartCounter++;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const rlStdout = createInterface({
|
||||
input: this.spawnProcess.stdout,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
rlStdout.on('line', (line) => {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as AgentResponse;
|
||||
if (parsed.Level === 'msg') {
|
||||
switch (parsed.Message.Type) {
|
||||
case 'register':
|
||||
this.agentId = parsed.Message.ID;
|
||||
break;
|
||||
case 'status':
|
||||
for (const [key, value] of Object.entries(parsed.Message.Data)) {
|
||||
// Mark the agent as the one that is running
|
||||
// We store it in the cache so that it shows
|
||||
// itself later
|
||||
if (key === this.agentId) {
|
||||
value.HeadplaneAgent = true;
|
||||
}
|
||||
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (parsed.Level) {
|
||||
case 'info':
|
||||
case 'debug':
|
||||
case 'error':
|
||||
log[parsed.Level]('agent', parsed.Message);
|
||||
break;
|
||||
case 'fatal':
|
||||
log.error('agent', parsed.Message);
|
||||
break;
|
||||
default:
|
||||
log.debug('agent', 'Unknown agent response: %s', line);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug('agent', 'Failed to parse agent response: %s', error);
|
||||
log.debug('agent', 'Raw data: %s', line);
|
||||
}
|
||||
});
|
||||
|
||||
this.spawnProcess.on('error', (error) => {
|
||||
log.error('agent', 'Failed to start agent process: %s', error);
|
||||
this.restartCounter++;
|
||||
this.spawnProcess = null;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
});
|
||||
|
||||
this.spawnProcess.on('exit', (code) => {
|
||||
log.error('agent', 'Agent process exited with code %d', code ?? -1);
|
||||
this.restartCounter++;
|
||||
this.spawnProcess = null;
|
||||
global.setTimeout(() => this.startAgent(), 1000);
|
||||
});
|
||||
}
|
||||
|
||||
async lookup(nodeIds: string[]) {
|
||||
const entries = this.cache.toJSON();
|
||||
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
|
||||
if (missing.length > 0) {
|
||||
await this.requestData(missing);
|
||||
}
|
||||
|
||||
return Object.entries(entries).reduce<Record<string, HostInfo>>(
|
||||
(acc, [key, value]) => {
|
||||
if (nodeIds.includes(key)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
// Request data from the internal agent by sending a message to the process
|
||||
// via stdin. This is a blocking call, so it will wait for the agent to
|
||||
// respond before returning.
|
||||
private async requestData(nodeList: string[]) {
|
||||
if (this.exhausted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the process to be spawned, busy waiting is gross
|
||||
while (this.spawnProcess === null) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
// Send the request to the agent, without waiting for a response.
|
||||
// The live data invalidator will re-request the data if it is not
|
||||
// available in the cache anyways.
|
||||
const data = JSON.stringify({ NodeIDs: nodeList });
|
||||
this.spawnProcess.stdin?.write(`${data}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const diskSchema = type({
|
||||
key: 'string',
|
||||
value: 'unknown',
|
||||
expires: 'number?',
|
||||
}).array();
|
||||
|
||||
// A persistent HashMap with a TTL for each key
|
||||
class TimedCache<V> {
|
||||
private _cache = new Map<string, V>();
|
||||
private _timings = new Map<string, number>();
|
||||
|
||||
// Default TTL is 1 minute
|
||||
private defaultTTL: number;
|
||||
private filePath: string;
|
||||
private writeLock = false;
|
||||
|
||||
// Last flush ID is essentially a hash of the flush contents
|
||||
// Prevents unnecessary flushing if nothing has changed
|
||||
private lastFlushId = '';
|
||||
|
||||
constructor(defaultTTL: number, filePath: string) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.filePath = filePath;
|
||||
|
||||
// Load the cache from disk and then queue flushes every 10 seconds
|
||||
this.load().then(() => {
|
||||
setInterval(() => this.flush(), 10000);
|
||||
});
|
||||
}
|
||||
|
||||
set(key: string, value: V, ttl: number = this.defaultTTL) {
|
||||
this._cache.set(key, value);
|
||||
this._timings.set(key, Date.now() + ttl);
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
const value = this._cache.get(key);
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expires = this._timings.get(key);
|
||||
if (!expires || expires < Date.now()) {
|
||||
this._cache.delete(key);
|
||||
this._timings.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Map into a Record without any TTLs
|
||||
toJSON() {
|
||||
const result: Record<string, V> = {};
|
||||
for (const [key, value] of this._cache.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// WARNING: This function expects that this.filePath is NOT ENOENT
|
||||
private async load() {
|
||||
const data = await readFile(this.filePath, 'utf-8');
|
||||
const cache = () => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const diskData = cache();
|
||||
if (diskData === undefined) {
|
||||
log.error('agent', 'Failed to load cache at %s', this.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheData = diskSchema(diskData);
|
||||
if (cacheData instanceof type.errors) {
|
||||
log.debug('agent', 'Failed to load cache at %s', this.filePath);
|
||||
log.debug('agent', 'Error details: %s', cacheData.toString());
|
||||
|
||||
// Skip loading the cache (it should be overwritten soon)
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { key, value, expires } of diskData) {
|
||||
this._cache.set(key, value);
|
||||
this._timings.set(key, expires);
|
||||
}
|
||||
|
||||
log.info('agent', 'Loaded cache from %s', this.filePath);
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
const data = Array.from(this._cache.entries()).map(([key, value]) => {
|
||||
return { key, value, expires: this._timings.get(key) };
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the hash of the data
|
||||
const dumpData = JSON.stringify(data);
|
||||
const sha = createHash('sha256').update(dumpData).digest('hex');
|
||||
if (sha === this.lastFlushId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to lock the writeLock so that we don't try to write
|
||||
// to the file while we're already writing to it
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
await writeFile(this.filePath, dumpData, 'utf-8');
|
||||
log.debug('agent', 'Flushed cache to %s', this.filePath);
|
||||
this.lastFlushId = sha;
|
||||
this.writeLock = false;
|
||||
}
|
||||
}
|
||||
+105
-110
@@ -1,90 +1,37 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as client from 'openid-client';
|
||||
import * as oidc from 'openid-client';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config/schema';
|
||||
import { HeadplaneConfig } from '../config/schema';
|
||||
|
||||
async function loadClientSecret(path: string) {
|
||||
// We need to interpolate environment variables into the path
|
||||
// Path formatting can be like ${ENV_NAME}/path/to/secret
|
||||
const matches = path.match(/\${(.*?)}/g);
|
||||
let resolvedPath = path;
|
||||
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
const env = match.slice(2, -1);
|
||||
const value = process.env[env];
|
||||
if (!value) {
|
||||
log.error('config', 'Environment variable %s is not set', env);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Interpolating %s with %s', match, value);
|
||||
resolvedPath = resolvedPath.replace(match, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('config', 'Reading client secret from %s', resolvedPath);
|
||||
const secret = await readFile(resolvedPath, 'utf-8');
|
||||
if (secret.trim().length === 0) {
|
||||
log.error('config', 'Empty OIDC client secret');
|
||||
return;
|
||||
}
|
||||
|
||||
return secret.trim();
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read client secret from %s', path);
|
||||
log.error('config', 'Error: %s', error);
|
||||
log.debug('config', 'Error details: %o', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clientAuthMethod(
|
||||
method: string,
|
||||
): (secret: string) => client.ClientAuth {
|
||||
switch (method) {
|
||||
case 'client_secret_post':
|
||||
return client.ClientSecretPost;
|
||||
export async function configureOidcAuth(config: OidcConfig) {
|
||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||
let clientAuthMethod: oidc.ClientAuth;
|
||||
switch (config.token_endpoint_auth_method) {
|
||||
case 'client_secret_basic':
|
||||
return client.ClientSecretBasic;
|
||||
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
|
||||
break;
|
||||
case 'client_secret_post':
|
||||
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
|
||||
break;
|
||||
case 'client_secret_jwt':
|
||||
return client.ClientSecretJwt;
|
||||
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid client authentication method');
|
||||
}
|
||||
}
|
||||
|
||||
// Loads and configures an OIDC client to support OIDC authentication.
|
||||
// This runs under the assumption the OIDC configuration exists and is valid.
|
||||
// If it is invalid, Headplane automatically disables it.
|
||||
//
|
||||
// TODO: Support custom endpoints instead of relying on OIDC discovery.
|
||||
// This will enable us to support servers like GitHub that do not support
|
||||
// nor advertise a .well-known endpoint.
|
||||
export async function createOidcClient(
|
||||
config: NonNullable<HeadplaneConfig['oidc']>,
|
||||
) {
|
||||
// const secret = await loadClientSecret(oidc);
|
||||
const secret = config.client_secret_path
|
||||
? await loadClientSecret(config.client_secret_path)
|
||||
: config.client_secret;
|
||||
|
||||
if (!secret) {
|
||||
log.error('config', 'Missing an OIDC client secret');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||
let oidcClient: oidc.Configuration;
|
||||
try {
|
||||
const oidc = await client.discovery(
|
||||
const discovery = await oidc.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
secret,
|
||||
clientAuthMethod(config.token_endpoint_auth_method)(secret),
|
||||
config.client_secret!, // TODO: Fix this config schema
|
||||
clientAuthMethod,
|
||||
);
|
||||
|
||||
const metadata = oidc.serverMetadata();
|
||||
if (!metadata.authorization_endpoint) {
|
||||
const meta = discovery.serverMetadata();
|
||||
if (!meta.authorization_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `authorization_endpoint`',
|
||||
@@ -93,70 +40,118 @@ export async function createOidcClient(
|
||||
'config',
|
||||
'OIDC server does not support authorization code flow',
|
||||
);
|
||||
log.error('config', 'You may need to set this manually in the config');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.token_endpoint) {
|
||||
if (!meta.token_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||
log.error('config', 'OIDC server does not support token exchange');
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support authorization code flow',
|
||||
);
|
||||
log.error('config', 'You may need to set this manually in the config');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this field is missing, assume the server supports all response types
|
||||
// and that we can continue safely.
|
||||
if (metadata.response_types_supported) {
|
||||
if (!metadata.response_types_supported.includes('code')) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `response_types_supported` does not include `code`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support code flow');
|
||||
return;
|
||||
}
|
||||
if (!meta.userinfo_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `userinfo_endpoint`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support user info endpoint');
|
||||
log.error('config', 'You may need to set this manually in the config');
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.token_endpoint_auth_methods_supported) {
|
||||
if (meta.token_endpoint_auth_methods_supported) {
|
||||
if (
|
||||
!metadata.token_endpoint_auth_methods_supported.includes(
|
||||
!meta.token_endpoint_auth_methods_supported.includes(
|
||||
config.token_endpoint_auth_method,
|
||||
)
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
|
||||
'OIDC server does not support client authentication method %s',
|
||||
config.token_endpoint_auth_method,
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support %s',
|
||||
config.token_endpoint_auth_method,
|
||||
'Supported methods: %s',
|
||||
meta.token_endpoint_auth_methods_supported.join(', '),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.userinfo_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `userinfo_endpoint`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support userinfo endpoint');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'OIDC client created successfully');
|
||||
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
|
||||
log.debug('config', 'OIDC discovery successful');
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
metadata.authorization_endpoint,
|
||||
meta.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
|
||||
return oidc;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to discover OIDC issuer');
|
||||
log.error('config', 'Error: %s', error);
|
||||
log.debug('config', 'Error details: %o', error);
|
||||
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
|
||||
|
||||
// Manually construct the endpoints to coalesce with config if needed
|
||||
oidcClient = new oidc.Configuration(
|
||||
{
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint:
|
||||
config.authorization_endpoint || meta.authorization_endpoint,
|
||||
token_endpoint: config.token_endpoint || meta.token_endpoint,
|
||||
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret!,
|
||||
clientAuthMethod,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('config', 'OIDC discovery failed: %s', err);
|
||||
log.debug('config', 'Error details: %o', err);
|
||||
log.error(
|
||||
'config',
|
||||
'This may be an error, or the server may not support discovery',
|
||||
);
|
||||
|
||||
if (
|
||||
!config.authorization_endpoint ||
|
||||
!config.token_endpoint ||
|
||||
!config.userinfo_endpoint
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Endpoints are not fully configured, cannot continue',
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
oidcClient = new oidc.Configuration(
|
||||
{
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint: config.authorization_endpoint,
|
||||
token_endpoint: config.token_endpoint,
|
||||
userinfo_endpoint: config.userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret!,
|
||||
clientAuthMethod,
|
||||
);
|
||||
|
||||
log.debug('config', 'Using manually configured endpoints');
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
config.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
|
||||
}
|
||||
|
||||
log.info('config', 'Successfully configured OIDC authentication');
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
+226
-206
@@ -1,13 +1,13 @@
|
||||
import { open, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { open, readFile, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { exit } from 'node:process';
|
||||
import {
|
||||
CookieSerializeOptions,
|
||||
Session,
|
||||
SessionStorage,
|
||||
createCookieSessionStorage,
|
||||
} from 'react-router';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
|
||||
import { EncryptJWT, jwtDecrypt } from 'jose';
|
||||
import { createCookie } from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import log from '~/utils/log';
|
||||
import { users } from '../db/schema';
|
||||
import { Capabilities, Roles } from './roles';
|
||||
|
||||
export interface AuthSession {
|
||||
@@ -22,6 +22,17 @@ export interface AuthSession {
|
||||
};
|
||||
}
|
||||
|
||||
interface JWTSession {
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcFlowSession {
|
||||
state: 'flow';
|
||||
oidc: {
|
||||
@@ -32,264 +43,243 @@ export interface OidcFlowSession {
|
||||
};
|
||||
}
|
||||
|
||||
type JoinedSession = AuthSession | OidcFlowSession;
|
||||
interface Error {
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface CookieOptions {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
secrets: string[];
|
||||
domain?: string;
|
||||
interface AuthSessionOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
oidcUsersFile?: string;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
class Sessionizer {
|
||||
private storage: SessionStorage<JoinedSession, Error>;
|
||||
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
|
||||
private capsPath?: string;
|
||||
private options: AuthSessionOptions;
|
||||
|
||||
constructor(
|
||||
options: CookieOptions,
|
||||
caps: Record<string, { c: Capabilities; oo?: boolean }>,
|
||||
capsPath?: string,
|
||||
) {
|
||||
this.caps = caps;
|
||||
this.capsPath = capsPath;
|
||||
this.storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
...options,
|
||||
httpOnly: true,
|
||||
path: __PREFIX__, // Only match on the prefix
|
||||
sameSite: 'lax', // TODO: Strictify with Domain,
|
||||
},
|
||||
});
|
||||
constructor(options: AuthSessionOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// This throws on the assumption that auth is already checked correctly
|
||||
// on something that wraps the route calling auth. The top-level routes
|
||||
// that call this are wrapped with try/catch to handle the error.
|
||||
async auth(request: Request) {
|
||||
const cookie = request.headers.get('cookie');
|
||||
const session = await this.storage.getSession(cookie);
|
||||
const type = session.get('state');
|
||||
if (!type) {
|
||||
throw new Error('Session state not found');
|
||||
}
|
||||
|
||||
if (type !== 'auth') {
|
||||
throw new Error('Session is not authenticated');
|
||||
}
|
||||
|
||||
return session as Session<AuthSession, Error>;
|
||||
return decodeSession(request, this.options);
|
||||
}
|
||||
|
||||
roleForSubject(subject: string): keyof typeof Roles | undefined {
|
||||
const role = this.caps[subject]?.c;
|
||||
if (!role) {
|
||||
async createSession(
|
||||
payload: JWTSession,
|
||||
maxAge = this.options.cookie.maxAge,
|
||||
) {
|
||||
// TODO: What the hell is this garbage
|
||||
return createSession(payload, {
|
||||
...this.options,
|
||||
cookie: {
|
||||
...this.options.cookie,
|
||||
maxAge,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async destroySession() {
|
||||
return destroySession(this.options);
|
||||
}
|
||||
|
||||
async roleForSubject(
|
||||
subject: string,
|
||||
): Promise<keyof typeof Roles | undefined> {
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need this in string form based on Object.keys of the roles
|
||||
for (const [key, value] of Object.entries(Roles)) {
|
||||
if (value === role) {
|
||||
if (value === user.caps) {
|
||||
return key as keyof typeof Roles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onboardForSubject(subject: string) {
|
||||
return this.caps[subject]?.oo ?? false;
|
||||
}
|
||||
|
||||
// Given an OR of capabilities, check if the session has the required
|
||||
// capabilities. If not, return false. Can throw since it calls auth()
|
||||
async check(request: Request, capabilities: Capabilities) {
|
||||
const session = await this.auth(request);
|
||||
const { subject } = session.get('user') ?? {};
|
||||
if (!subject) {
|
||||
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (session.user.subject === 'unknown-non-oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (subject === 'unknown-non-oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the role does not exist, then this is a new subject that we have
|
||||
// not seen before. Since this is new, we set access to the lowest
|
||||
// level by default which is the member role.
|
||||
//
|
||||
// This also allows us to avoid configuring preventing sign ups with
|
||||
// OIDC, since the default sign up logic gives member which does not
|
||||
// have access to the UI whatsoever.
|
||||
const role = this.caps[subject];
|
||||
if (!role) {
|
||||
const memberRole = await this.registerSubject(subject);
|
||||
return (capabilities & memberRole.c) === capabilities;
|
||||
}
|
||||
|
||||
return (capabilities & role.c) === capabilities;
|
||||
}
|
||||
|
||||
async checkSubject(subject: string, capabilities: Capabilities) {
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (subject === 'unknown-non-oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the role does not exist, then this is a new subject that we have
|
||||
// not seen before. Since this is new, we set access to the lowest
|
||||
// level by default which is the member role.
|
||||
//
|
||||
// This also allows us to avoid configuring preventing sign ups with
|
||||
// OIDC, since the default sign up logic gives member which does not
|
||||
// have access to the UI whatsoever.
|
||||
const role = this.caps[subject];
|
||||
if (!role) {
|
||||
const memberRole = await this.registerSubject(subject);
|
||||
return (capabilities & memberRole.c) === capabilities;
|
||||
}
|
||||
|
||||
return (capabilities & role.c) === capabilities;
|
||||
}
|
||||
|
||||
// This code is very simple, if the user does not exist in the database
|
||||
// file then we register it with the lowest level of access. If the user
|
||||
// database is empty, the first user to sign in will be given the owner
|
||||
// role.
|
||||
private async registerSubject(subject: string) {
|
||||
if (this.caps[subject]) {
|
||||
return this.caps[subject];
|
||||
}
|
||||
|
||||
if (Object.keys(this.caps).length === 0) {
|
||||
log.debug('auth', 'First user registered as owner: %s', subject);
|
||||
this.caps[subject] = { c: Roles.owner };
|
||||
await this.flushUserDatabase();
|
||||
return this.caps[subject];
|
||||
}
|
||||
|
||||
log.debug('auth', 'New user registered as member: %s', subject);
|
||||
this.caps[subject] = { c: Roles.member };
|
||||
await this.flushUserDatabase();
|
||||
return this.caps[subject];
|
||||
}
|
||||
|
||||
private async flushUserDatabase() {
|
||||
if (!this.capsPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
|
||||
u,
|
||||
c,
|
||||
oo,
|
||||
}));
|
||||
try {
|
||||
const handle = await open(this.capsPath, 'w');
|
||||
await handle.write(JSON.stringify(data));
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.error('config', 'Error writing user database file: %s', error);
|
||||
}
|
||||
return (capabilities & user.caps) === capabilities;
|
||||
}
|
||||
|
||||
// Updates the capabilities and roles of a subject
|
||||
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||
// Check if we are owner
|
||||
if (this.roleForSubject(subject) === 'owner') {
|
||||
const subjectRole = await this.roleForSubject(subject);
|
||||
if (subjectRole === 'owner') {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.caps[subject] = {
|
||||
...this.caps[subject], // Preserve the existing capabilities if any
|
||||
c: Roles[role],
|
||||
};
|
||||
await this.options.db
|
||||
.update(users)
|
||||
.set({
|
||||
caps: Roles[role],
|
||||
})
|
||||
.where(eq(users.sub, subject));
|
||||
|
||||
await this.flushUserDatabase();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Overrides the onboarding status for a subject
|
||||
async overrideOnboarding(subject: string, onboarding: boolean) {
|
||||
this.caps[subject] = {
|
||||
...this.caps[subject], // Preserve the existing capabilities if any
|
||||
oo: onboarding,
|
||||
};
|
||||
await this.flushUserDatabase();
|
||||
}
|
||||
|
||||
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
|
||||
return this.storage.getSession(request.headers.get('cookie')) as Promise<
|
||||
Session<T, Error>
|
||||
>;
|
||||
}
|
||||
|
||||
destroy(session: Session) {
|
||||
return this.storage.destroySession(session);
|
||||
}
|
||||
|
||||
commit(session: Session, options?: CookieSerializeOptions) {
|
||||
return this.storage.commitSession(session, options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSessionStorage(
|
||||
options: CookieOptions,
|
||||
usersPath?: string,
|
||||
) {
|
||||
const map: Record<
|
||||
string,
|
||||
{
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}
|
||||
> = {};
|
||||
if (usersPath) {
|
||||
// We need to load our users from the file (default to empty map)
|
||||
// We then translate each user into a capability object using the helper
|
||||
// method defined in the roles.ts file
|
||||
const data = await loadUserFile(usersPath);
|
||||
log.debug('config', 'Loaded %d users from database', data.length);
|
||||
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
|
||||
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
|
||||
const jwt = await new EncryptJWT({
|
||||
...payload,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1d')
|
||||
.setIssuer('urn:tale:headplane')
|
||||
.setAudience('urn:tale:headplane')
|
||||
.setJti(ulid())
|
||||
.encrypt(secret);
|
||||
|
||||
for (const user of data) {
|
||||
map[user.u] = {
|
||||
c: user.c,
|
||||
oo: user.oo,
|
||||
};
|
||||
}
|
||||
}
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return new Sessionizer(options, map, usersPath);
|
||||
return cookie.serialize(jwt);
|
||||
}
|
||||
|
||||
async function loadUserFile(path: string) {
|
||||
async function decodeSession(request: Request, options: AuthSessionOptions) {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
if (cookieHeader === null) {
|
||||
throw new Error('No session cookie found');
|
||||
}
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
|
||||
if (cookieValue === null) {
|
||||
throw new Error('Session cookie is empty');
|
||||
}
|
||||
|
||||
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
|
||||
const { payload } = await jwtDecrypt(cookieValue, secret, {
|
||||
issuer: 'urn:tale:headplane',
|
||||
audience: 'urn:tale:headplane',
|
||||
});
|
||||
|
||||
// Safe since we encode the session directly into the JWT
|
||||
return payload as unknown as JWTSession;
|
||||
}
|
||||
|
||||
async function destroySession(options: AuthSessionOptions) {
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize('', {
|
||||
expires: new Date(0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSessionStorage(options: AuthSessionOptions) {
|
||||
if (options.oidcUsersFile) {
|
||||
await migrateUserDatabase(options.oidcUsersFile, options.db);
|
||||
}
|
||||
|
||||
return new Sessionizer(options);
|
||||
}
|
||||
|
||||
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
|
||||
log.info('config', 'Migrating old user database from %s', path);
|
||||
const realPath = resolve(path);
|
||||
|
||||
log.warn(
|
||||
'config',
|
||||
'oidc.user_storage_file is deprecated and will be removed in Headplane 0.7.0',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'You can ignore this warning if you do not use OIDC authentication.',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'Data will be automatically migrated to the new SQL database.',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'Refer to server.data_path to ensure this path is mounted correctly is using Docker.',
|
||||
);
|
||||
|
||||
try {
|
||||
const handle = await open(realPath, 'a+');
|
||||
log.info('config', 'Using user database file at %s', realPath);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info('config', 'User database file not accessible at %s', realPath);
|
||||
log.warn('config', 'Failed to migrate old user database at %s', realPath);
|
||||
log.warn(
|
||||
'config',
|
||||
'This is not an error, but existing users will not be migrated',
|
||||
);
|
||||
log.warn('config', 'Unable to open user database file: %s', error);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('config', 'Found old user database file at %s', realPath);
|
||||
log.info('config', 'Migrating user database to the new SQL database');
|
||||
|
||||
let migratableUsers: {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
try {
|
||||
const data = await readFile(realPath, 'utf8');
|
||||
if (data.trim().length === 0) {
|
||||
log.info('config', 'Old user database file is empty, nothing to migrate');
|
||||
log.info(
|
||||
'config',
|
||||
'You SHOULD remove oidc.user_storage_file from your config!',
|
||||
);
|
||||
await rm(realPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = JSON.parse(data.trim()) as {
|
||||
u?: string;
|
||||
c?: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
// Never trust user input
|
||||
return users.filter(
|
||||
migratableUsers = users.filter(
|
||||
(user) => user.u !== undefined && user.c !== undefined,
|
||||
) as {
|
||||
u: string;
|
||||
@@ -297,8 +287,38 @@ async function loadUserFile(path: string) {
|
||||
oo?: boolean;
|
||||
}[];
|
||||
} catch (error) {
|
||||
log.debug('config', 'Error reading user database file: %s', error);
|
||||
log.debug('config', 'Using empty user database');
|
||||
return [];
|
||||
log.warn('config', 'Error reading old user database file: %s', error);
|
||||
log.warn('config', 'Not migrating any users');
|
||||
return;
|
||||
}
|
||||
|
||||
if (migratableUsers.length === 0) {
|
||||
log.info('config', 'No users found in the old database to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
'config',
|
||||
'Migrating %d users from the old database',
|
||||
migratableUsers.length,
|
||||
);
|
||||
|
||||
const updated = await db
|
||||
.insert(users)
|
||||
.values(
|
||||
migratableUsers.map((user) => ({
|
||||
id: ulid(),
|
||||
sub: user.u,
|
||||
caps: user.c,
|
||||
onboarded: user.oo ?? false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: users.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.info('config', 'Migrated %d users successfully', updated.length);
|
||||
log.info('config', 'Removed old user database file %s', realPath);
|
||||
await rm(realPath, { force: true });
|
||||
}
|
||||
|
||||
+88
-3
@@ -1,6 +1,91 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "tailwindcss-react-aria-components";
|
||||
|
||||
@theme {
|
||||
--blur-xs: 2px;
|
||||
|
||||
--height-editor: calc(100vh - 20rem);
|
||||
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial,
|
||||
sans-serif;
|
||||
|
||||
--transition-duration-25: 25ms;
|
||||
--transition-duration-50: 50ms;
|
||||
|
||||
--color-main-50: #f8fafc;
|
||||
--color-main-100: #f1f5f9;
|
||||
--color-main-200: #e2e8f0;
|
||||
--color-main-300: #cbd5e1;
|
||||
--color-main-400: #94a3b8;
|
||||
--color-main-500: #64748b;
|
||||
--color-main-600: #475569;
|
||||
--color-main-700: #334155;
|
||||
--color-main-800: #1e293b;
|
||||
--color-main-900: #0f172a;
|
||||
--color-main-950: #020617;
|
||||
|
||||
--color-ui-50: #fafafa;
|
||||
--color-ui-100: #f5f5f5;
|
||||
--color-ui-200: #e5e5e5;
|
||||
--color-ui-300: #d4d4d4;
|
||||
--color-ui-400: #a3a3a3;
|
||||
--color-ui-500: #737373;
|
||||
--color-ui-600: #525252;
|
||||
--color-ui-700: #404040;
|
||||
--color-ui-800: #262626;
|
||||
--color-ui-900: #171717;
|
||||
--color-ui-950: #0a0a0a;
|
||||
|
||||
--color-headplane-50: #f2f2f2;
|
||||
--color-headplane-100: #e6e6e6;
|
||||
--color-headplane-200: #cccccc;
|
||||
--color-headplane-300: #b3b3b3;
|
||||
--color-headplane-400: #999999;
|
||||
--color-headplane-500: #808080;
|
||||
--color-headplane-600: #666666;
|
||||
--color-headplane-700: #4d4d4d;
|
||||
--color-headplane-800: #343434;
|
||||
--color-headplane-900: #1a1a1a;
|
||||
--color-headplane-950: #0d0d0d;
|
||||
|
||||
--animate-loading: loader 0.8s infinite ease-in-out;
|
||||
|
||||
@keyframes loader {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-sm)) {
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-lg)) {
|
||||
padding-inline: 4rem;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-xl)) {
|
||||
padding-inline: 5rem;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-2xl)) {
|
||||
padding-inline: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
html {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PreAuthKey } from './PreAuthKey';
|
||||
import type { User } from './User';
|
||||
|
||||
export interface Machine {
|
||||
@@ -12,7 +13,7 @@ export interface Machine {
|
||||
lastSeen: string;
|
||||
expiry: string | null;
|
||||
|
||||
preAuthKey?: unknown; // TODO
|
||||
preAuthKey?: PreAuthKey;
|
||||
|
||||
createdAt: string;
|
||||
registerMethod:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user