mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5171120df8 | |||
| 71bdca6b8b | |||
| 13a711f505 | |||
| 62863ad649 | |||
| 91fcf5233e | |||
| 8ea8c7195f | |||
| c3a50ea4f4 | |||
| 6716e5f0b0 | |||
| 346b44ec69 | |||
| faa61b0f1d | |||
| 6b63fe209f | |||
| c8507cff7c | |||
| b86f4461c0 | |||
| 77b510c927 | |||
| 5adcb8c582 | |||
| 524c5eb639 | |||
| 2894c664d3 | |||
| 5c2d08decd | |||
| f2e8c6ae4c | |||
| c3e727842a | |||
| 96345ab0a6 | |||
| fe2d7cb57a |
@@ -1,6 +1,12 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- ".zed/**"
|
||||
- "assets/**"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "README.md"
|
||||
branches:
|
||||
- "main"
|
||||
- "next"
|
||||
@@ -30,7 +36,6 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Nightly
|
||||
name: Pre-release (next)
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *"
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: publish-nightly-${{ github.ref }}
|
||||
group: pre-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -15,12 +15,22 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: docker-publish
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=next
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -34,14 +44,6 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ github.ref }}
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -14,13 +14,21 @@ permissions:
|
||||
packages: write # Write access to the container registry
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: docker-publish
|
||||
docker:
|
||||
name: Docker Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -34,16 +42,6 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pnpm 10.4.0
|
||||
node 22
|
||||
@@ -1,3 +1,6 @@
|
||||
### 0.5.10 (April 4, 2025)
|
||||
- Fix an issue where other prefernences to skip onboarding affected every user.
|
||||
|
||||
### 0.5.9 (April 3, 2025)
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||
|
||||
@@ -44,11 +44,18 @@ There are 2 ways to deploy Headplane:
|
||||
Simple mode does not include the automatic management of DNS and Headplane
|
||||
settings, requiring manual editing and reloading when making changes.
|
||||
|
||||
## Contributing
|
||||
### Versioning
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||
PR is opened and actively in testing.
|
||||
|
||||
### Contributing
|
||||
Headplane is an open-source project and contributions are welcome! If you have
|
||||
any suggestions, bug reports, or feature requests, please open an issue. Also
|
||||
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
|
||||
+48
-40
@@ -36,48 +36,56 @@ export async function loader({
|
||||
if (context.oidc && !request.url.endsWith('/onboarding')) {
|
||||
let onboarded = false;
|
||||
|
||||
try {
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
onboarded = false;
|
||||
}
|
||||
|
||||
const user = users.find((u) => {
|
||||
if (u.provider !== 'oidc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
return true;
|
||||
}
|
||||
|
||||
return subject === sessionUser.subject;
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const sessionUser = session.get('user');
|
||||
if (sessionUser) {
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
onboarded = true;
|
||||
} else {
|
||||
try {
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
onboarded = false;
|
||||
}
|
||||
|
||||
const user = users.find((u) => {
|
||||
if (u.provider !== 'oidc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
return true;
|
||||
}
|
||||
|
||||
return subject === sessionUser.subject;
|
||||
});
|
||||
|
||||
if (user) {
|
||||
onboarded = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we cannot lookup users, just assume our user is onboarded
|
||||
log.debug('api', 'Failed to lookup users %o', e);
|
||||
onboarded = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If we cannot lookup users, just assume our user is onboarded
|
||||
log.debug('api', 'Failed to lookup users %o', e);
|
||||
onboarded = true;
|
||||
}
|
||||
|
||||
if (!onboarded) {
|
||||
|
||||
@@ -6,6 +6,11 @@ import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
interface DockerContainer {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
}
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
|
||||
export default class DockerIntegration extends Integration<T> {
|
||||
private maxAttempts = 10;
|
||||
@@ -15,13 +20,63 @@ export default class DockerIntegration extends Integration<T> {
|
||||
return 'Docker';
|
||||
}
|
||||
|
||||
async getContainerName(label: string, value: string): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Docker client is not initialized');
|
||||
}
|
||||
|
||||
const filters = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
label: [`${label}=${value}`],
|
||||
}),
|
||||
);
|
||||
const { body } = await this.client.request({
|
||||
method: 'GET',
|
||||
path: `/containers/json?filters=${filters}`,
|
||||
});
|
||||
const containers: DockerContainer[] =
|
||||
(await body.json()) as DockerContainer[];
|
||||
if (containers.length > 1) {
|
||||
throw new Error(
|
||||
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
|
||||
);
|
||||
}
|
||||
if (containers.length === 0) {
|
||||
throw new Error(
|
||||
`No Docker containers found matching label: ${label}=${value}`,
|
||||
);
|
||||
}
|
||||
log.info(
|
||||
'config',
|
||||
'Found Docker container matching label: %s=%s',
|
||||
label,
|
||||
value,
|
||||
);
|
||||
return containers[0].Id;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
if (this.context.container_name.length === 0) {
|
||||
log.error('config', 'Docker container name is empty');
|
||||
// Perform a basic check to see if any of the required properties are set
|
||||
if (
|
||||
this.context.container_name.length === 0 &&
|
||||
!this.context.container_label
|
||||
) {
|
||||
log.error('config', 'Docker container name and label are both empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info('config', 'Using container: %s', this.context.container_name);
|
||||
if (
|
||||
this.context.container_name.length > 0 &&
|
||||
!this.context.container_label
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Docker container name and label are mutually exclusive',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify that Docker socket is reachable
|
||||
let url: URL | undefined;
|
||||
try {
|
||||
url = new URL(this.context.socket);
|
||||
@@ -72,6 +127,38 @@ export default class DockerIntegration extends Integration<T> {
|
||||
socketPath: url.pathname,
|
||||
});
|
||||
}
|
||||
if (this.client === undefined) {
|
||||
log.error('config', 'Failed to create Docker client');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.context.container_name.length === 0) {
|
||||
try {
|
||||
if (this.context.container_label === undefined) {
|
||||
log.error('config', 'Docker container label is not defined');
|
||||
return false;
|
||||
}
|
||||
const containerName = await this.getContainerName(
|
||||
this.context.container_label.name,
|
||||
this.context.container_label.value,
|
||||
);
|
||||
if (containerName.length === 0) {
|
||||
log.error(
|
||||
'config',
|
||||
'No Docker containers found matching label: %s=%s',
|
||||
this.context.container_label.name,
|
||||
this.context.container_label.value,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.context.container_name = containerName;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to get Docker container name: %s', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log.info('config', 'Using container: %s', this.context.container_name);
|
||||
|
||||
return this.client !== undefined;
|
||||
}
|
||||
|
||||
@@ -41,10 +41,16 @@ const headscaleConfig = type({
|
||||
config_strict: stringToBool,
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const containerLabel = type({
|
||||
name: 'string',
|
||||
value: 'string',
|
||||
}).optional();
|
||||
|
||||
const dockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string',
|
||||
socket: 'string = "unix:///var/run/docker.sock"',
|
||||
container_label: containerLabel,
|
||||
});
|
||||
|
||||
const kubernetesConfig = type({
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
headscale:
|
||||
image: "headscale/headscale:0.25.1"
|
||||
container_name: "headscale"
|
||||
labels:
|
||||
- com.headplane.selector=headscale
|
||||
restart: "unless-stopped"
|
||||
command: "serve"
|
||||
networks:
|
||||
|
||||
+8
-2
@@ -47,8 +47,14 @@ headscale:
|
||||
integration:
|
||||
docker:
|
||||
enabled: false
|
||||
# The name (or ID) of the container running Headscale
|
||||
container_name: "headscale"
|
||||
# Preferred method: use container_label to dynamically discover the Headscale container.
|
||||
container_label:
|
||||
name: "com.headplane.selector"
|
||||
value: "headscale"
|
||||
# Optional fallback: directly specify the container name (or ID)
|
||||
# of the container running Headscale
|
||||
# container_name: "headscale"
|
||||
|
||||
# The path to the Docker socket (do not change this if you are unsure)
|
||||
# Docker socket paths must start with unix:// or tcp:// and at the moment
|
||||
# https connections are not supported.
|
||||
|
||||
+4
-3
@@ -30,7 +30,7 @@ Clone the Headplane repository, install dependencies, and build the project:
|
||||
```sh
|
||||
git clone https://github.com/tale/headplane
|
||||
cd headplane
|
||||
git checkout v0.5.5 # Or whatever tag you want to use
|
||||
git checkout 0.5.10 # Or whatever tag you want to use
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
@@ -68,14 +68,15 @@ Headplane service:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Headplane
|
||||
After=network.target
|
||||
# Decomment the following line if running on bare metal with integrated mode (/proc integration)
|
||||
# PartOf=headscale.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=headplane
|
||||
Group=headplane
|
||||
WorkingDirectory=/path/to/headplane
|
||||
ExecStart=/usr/bin/node /path/to/headplane/build/headplane/server.js
|
||||
ExecStart=/usr/bin/node /path/to/headplane/build/server/index.js
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
|
||||
+10
-7
@@ -34,7 +34,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.9
|
||||
image: ghcr.io/tale/headplane:0.5.10
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -70,11 +70,14 @@ you build the container yourself or run Headplane in Bare-Metal mode.
|
||||
> setting up your `config.yaml` file to the appropriate values.
|
||||
|
||||
## Docker Integration
|
||||
The Docker integration is the easiest to setup, as it only requires the Docker socket
|
||||
to be mounted into the container along with some configuration. As long as Headplane
|
||||
has access to the Docker socket and the name of the Headscale container, it will
|
||||
automatically propagate config and DNS changes to Headscale without any additional
|
||||
configuration.
|
||||
The Docker integration is the easiest to set up, as it only requires mounting the
|
||||
Docker socket into the container along with some basic configuration. Headplane
|
||||
uses Docker labels to discover the Headscale container. As long as Headplane has
|
||||
access to the Docker socket and can identify the Headscale container—either by
|
||||
label or name—it will automatically propagate configuration and DNS changes to
|
||||
Headscale without any additional setup. Alternatively, instead of using a label
|
||||
to dynamically determine the container name, it is possible to directly specify
|
||||
the container name.
|
||||
|
||||
## Native Linux (/proc) Integration
|
||||
The `proc` integration is used when you are running Headscale and Headplane on
|
||||
@@ -151,7 +154,7 @@ spec:
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:0.5.9
|
||||
image: ghcr.io/tale/headplane:0.5.10
|
||||
env:
|
||||
# Set these if the pod name for Headscale is not static
|
||||
# We will use the downward API to get the pod name instead
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.9
|
||||
image: ghcr.io/tale/headplane:0.5.10
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
Generated
+3
-3
@@ -40,11 +40,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1741646908,
|
||||
"narHash": "sha256-55a1x5k+oFY2QCFjj7Mn5nPa8Do0shVl0m280mOAW/Q=",
|
||||
"lastModified": 1746576598,
|
||||
"narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ab0c5b18dab5e4b5d06ed679f8fd7cdc9970c4be",
|
||||
"rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+6
-3
@@ -2,9 +2,10 @@
|
||||
"name": "headplane",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "0.5.9",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"build": "react-router build",
|
||||
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
|
||||
"start": "node build/server/index.js",
|
||||
@@ -73,14 +74,16 @@
|
||||
"vite-plugin-babel": "^1.3.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.0",
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"pnpm": ">=10 <11"
|
||||
"pnpm": ">=10.4 <11"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch",
|
||||
"react-router-hono-server": "patches/react-router-hono-server.patch"
|
||||
}
|
||||
},
|
||||
"onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "lefthook"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user