nixos/headplane: init module, 0.6.1 (#398667)

This commit is contained in:
Edward Tjörnhammar
2026-04-28 10:01:41 +00:00
committed by GitHub
7 changed files with 655 additions and 1 deletions
+2
View File
@@ -355,6 +355,8 @@ gnuradioMinimal.override {
}
```
- Added `headplane` and `headplane-agent` packages, and `services.headplane` service.
## Nixpkgs Library {#sec-nixpkgs-release-26.05-lib}
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
+12
View File
@@ -11291,6 +11291,12 @@
githubId = 55025025;
name = "Feather Lin";
};
igor-ramazanov = {
email = "personal@igorramazanov.tech";
github = "igor-ramazanov";
githubId = 12570166;
name = "Igor Ramazanov";
};
igsha = {
email = "igor.sharonov@gmail.com";
github = "igsha";
@@ -26038,6 +26044,12 @@
github = "StayBlue";
githubId = 23127866;
};
stealthbadger747 = {
email = "parawell.erik@gmail.com";
github = "StealthBadger747";
githubId = 26052996;
name = "Erik Parawell";
};
steamwalker = {
email = "steamwalker@xs4all.nl";
github = "steamwalker";
@@ -141,7 +141,9 @@
- [tabbyAPI](https://github.com/theroyallab/tabbyAPI), the official OpenAI compatible API server for Exllama. Available as [services.tabbyapi](#opt-services.tabbyapi.enable).
- [Tdarr](https://tdarr.io), Audio/Video Library Analytics & Transcode/Remux Automation. Available as [services.tdarr](#opt-services.tdarr.enable)
- [Tdarr](https://tdarr.io), Audio/Video Library Analytics & Transcode/Remux Automation. Available as [services.tdarr](#opt-services.tdarr.enable).
- [Headplane](https://headplane.net), a feature-complete Web UI for Headscale. Available as [services.headplane](#opt-services.headplane.enable).
- [whois](https://packages.qa.debian.org/w/whois.html), an intelligent WHOIS client. Available as `programs.whois`.
+1
View File
@@ -1226,6 +1226,7 @@
./services/networking/hans.nix
./services/networking/haproxy.nix
./services/networking/harmonia.nix
./services/networking/headplane.nix
./services/networking/headscale.nix
./services/networking/hickory-dns.nix
./services/networking/hostapd.nix
@@ -0,0 +1,479 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkOption
mkPackageOption
types
;
inherit (lib.attrsets) filterAttrsRecursive;
cfg = config.services.headplane;
settingsFormat = pkgs.formats.yaml { };
filterSettings = lib.converge (
filterAttrsRecursive (
_: v:
!lib.elem v [
{ }
null
]
)
);
agentSettings = cfg.settings.integration.agent;
settings = cfg.settings // {
integration = cfg.settings.integration // {
agent = if agentSettings == null || !agentSettings.enabled then null else agentSettings;
};
};
settingsFile = settingsFormat.generate "headplane-config.yaml" (filterSettings settings);
in
{
options.services.headplane = {
enable = mkEnableOption "Headplane";
package = mkPackageOption pkgs "headplane" { };
agent.package = mkPackageOption pkgs "headplane-agent" { };
debug = mkEnableOption "debug logging";
settings = mkOption {
description = ''
Headplane configuration options. Generates a YAML config file.
See: https://github.com/tale/headplane/blob/main/config.example.yaml
'';
type = types.submodule {
options = {
server = mkOption {
type = types.submodule {
options = {
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "The host address to bind to.";
example = "0.0.0.0";
};
port = mkOption {
type = types.port;
default = 3000;
description = "The port to listen on.";
};
cookie_secret_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing the cookie secret.
The secret must be exactly 32 characters long.
'';
example = lib.literalExpression "config.sops.secrets.headplane_cookie.path";
};
cookie_secure = mkOption {
type = types.bool;
default = true;
description = ''
Should the cookies only work over HTTPS?
Set to false if running via HTTP without a proxy.
Recommended to be true in production.
'';
};
data_path = mkOption {
type = types.path;
default = "/var/lib/headplane";
description = ''
The path to persist Headplane specific data.
All data going forward is stored in this directory, including the internal database and any cache related files.
Data formats prior to 0.6.1 will automatically be migrated.
'';
example = "/var/lib/headplane";
};
};
};
default = { };
description = "Server configuration for Headplane web application.";
};
headscale = mkOption {
type = types.submodule {
options = {
url = mkOption {
type = types.str;
default = "http://127.0.0.1:${toString config.services.headscale.port}";
defaultText = lib.literalExpression "http://127.0.0.1:\${toString config.services.headscale.port}";
description = ''
The URL to your Headscale instance.
All API requests are routed through this URL.
THIS IS NOT the gRPC endpoint, but the HTTP endpoint.
IMPORTANT: If you are using TLS this MUST be set to `https://`.
'';
example = "https://headscale.example.com";
};
tls_cert_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing the TLS certificate.
'';
example = lib.literalExpression "config.sops.secrets.tls_cert.path";
};
public_url = mkOption {
type = types.nullOr types.str;
default = config.services.headscale.settings.server_url;
defaultText = lib.literalExpression "config.services.headscale.settings.server_url";
description = "Public URL if different. This affects certain parts of the web UI.";
example = "https://headscale.example.com";
};
config_path = mkOption {
type = types.nullOr types.path;
default = config.services.headscale.configFile;
defaultText = lib.literalExpression "config.services.headscale.configFile";
description = ''
Path to the Headscale configuration file.
This is optional, but HIGHLY recommended for the best experience.
If this is read only, Headplane will show your configuration settings
in the Web UI, but they cannot be changed.
'';
example = "/etc/headscale/config.yaml";
};
config_strict = mkEnableOption ''
Headplane internally validates the Headscale configuration
to ensure that it changes the configuration in a safe way.
Disabled by default because it clashes with how the Headplane works in NixOS.
'';
dns_records_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
Ensure that the file is both readable and writable by the Headplane process.
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
'';
example = "/var/lib/headplane/extra_records.json";
};
};
};
default = { };
description = "Headscale specific settings for Headplane integration.";
};
integration = mkOption {
type = types.submodule {
options = {
agent = mkOption {
type = types.nullOr (
types.submodule {
options = {
enabled = mkOption {
type = types.bool;
default = false;
description = ''
The Headplane agent allows retrieving information about nodes.
This allows the UI to display version, OS, and connectivity data.
You will see the Headplane agent in your Tailnet as a node when it connects.
'';
};
executable_path = mkOption {
type = types.path;
readOnly = true;
default = "${cfg.agent.package}/bin/hp_agent";
defaultText = lib.literalExpression ''"''${config.services.headplane.agent.package}/bin/hp_agent"'';
description = ''
Path to the headplane agent binary.
'';
};
pre_authkey_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing the agent preauth key.
To connect to your Tailnet, you need to generate a pre-auth key.
This can be done via the web UI or through the `headscale` CLI.
'';
example = lib.literalExpression "config.sops.secrets.agent_pre_authkey.path";
};
host_name = mkOption {
type = types.str;
default = "headplane-agent";
description = "Optionally change the name of the agent in the Tailnet";
};
cache_ttl = mkOption {
type = types.ints.positive;
default = 180000;
description = ''
How long to cache agent information (in milliseconds).
If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale.
'';
};
cache_path = mkOption {
type = types.path;
default = "/var/lib/headplane/agent_cache.json";
description = "Where to store the agent cache.";
};
work_dir = mkOption {
type = types.path;
default = "/var/lib/headplane/agent";
description = ''
Do not change this unless you are running a custom deployment.
The work_dir represents where the agent will store its data to be able to automatically reauthenticate with your Tailnet.
It needs to be writable by the user running the Headplane process.
'';
};
};
}
);
default = null;
description = "Agent configuration for the Headplane agent.";
};
proc = mkOption {
type = types.submodule {
options = {
enabled = mkOption {
type = types.bool;
default = true;
description = ''
Enable "Native" integration that works when Headscale and
Headplane are running outside of a container. There is no additional
configuration, but you need to ensure that the Headplane process
can terminate the Headscale process.
'';
};
};
};
default = { };
description = "Native process integration settings.";
};
};
};
default = { };
description = "Integration configurations for Headplane to interact with Headscale.";
};
oidc = mkOption {
type = types.nullOr (
types.submodule {
options = {
issuer = mkOption {
type = types.str;
description = "URL to OpenID issuer.";
example = "https://provider.example.com/issuer-url";
};
client_id = mkOption {
type = types.str;
description = "The client ID for the OIDC client.";
example = "your-client-id";
};
client_secret_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing the OIDC client secret.
'';
example = lib.literalExpression "config.sops.secrets.oidc_client_secret.path";
};
disable_api_key_login = mkOption {
type = types.bool;
default = false;
description = "Whether to disable API key login.";
};
token_endpoint_auth_method = mkOption {
type = types.enum [
"client_secret_post"
"client_secret_basic"
"client_secret_jwt"
];
default = "client_secret_post";
description = "The token endpoint authentication method.";
};
headscale_api_key_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing the Headscale API key.
Required when `services.headplane.settings.oidc` is set.
'';
example = lib.literalExpression "config.sops.secrets.headscale_api_key.path";
};
redirect_uri = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
This should point to your publicly accessible URL
for your Headplane instance with /admin/oidc/callback.
'';
example = "https://headscale.example.com/admin/oidc/callback";
};
user_storage_file = mkOption {
type = types.path;
default = "/var/lib/headplane/users.json";
description = ''
Path to a file containing the users and their permissions for Headplane.
'';
example = "/var/lib/headplane/users.json";
};
profile_picture_source = mkOption {
type = types.enum [
"oidc"
"gravatar"
];
default = "oidc";
description = "Source for user profile pictures.";
};
strict_validation = mkOption {
type = types.bool;
default = true;
description = "Enable strict validation of OIDC configuration.";
};
scope = mkOption {
type = types.str;
default = "openid email profile";
description = "OIDC scope to request.";
};
extra_params = mkOption {
type = types.nullOr (types.attrsOf types.str);
default = null;
description = "Extra parameters to send to the OIDC provider.";
example = {
prompt = "consent";
};
};
authorization_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Custom authorization endpoint URL.";
example = "https://provider.example.com/authorize";
};
token_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Custom token endpoint URL.";
example = "https://provider.example.com/token";
};
userinfo_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Custom userinfo endpoint URL.";
example = "https://provider.example.com/userinfo";
};
};
}
);
default = null;
description = "OIDC Configuration for authentication.";
};
};
};
default = { };
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = config.services.headscale.enable;
message = ''
services.headplane requires services.headscale.enable = true.
The headplane module references the headscale systemd unit
(in `after`/`requires`) and reads its configFile, port, user,
and group. Enable headscale or disable headplane.
'';
}
{
assertion = cfg.settings.server.cookie_secret_path != null;
message = ''
services.headplane.settings.server.cookie_secret_path must be set.
Headplane refuses to start without either `cookie_secret` or
`cookie_secret_path` (validated at startup, see upstream
app/server/config/schema.ts). The NixOS module only exposes the
*_path form to keep secrets out of the world-readable /nix/store.
Provide a path to a file containing a 32-character secret, e.g.
via systemd `LoadCredential` or sops-nix.
'';
}
{
assertion =
cfg.settings.integration.agent == null
|| !cfg.settings.integration.agent.enabled
|| cfg.settings.integration.agent.pre_authkey_path != null;
message = ''
services.headplane.settings.integration.agent.pre_authkey_path must be set
when services.headplane.settings.integration.agent.enabled is true.
'';
}
{
assertion = cfg.settings.oidc == null || cfg.settings.oidc.headscale_api_key_path != null;
message = ''
services.headplane.settings.oidc.headscale_api_key_path must be set
when services.headplane.settings.oidc is non-null. Headplane's OIDC
flow requires a Headscale API key to mint sessions; upstream config
validation rejects an OIDC block without it.
'';
}
];
environment = {
systemPackages = [ cfg.package ];
etc."headplane/config.yaml".source = "${settingsFile}";
};
systemd.services.headplane = {
description = "Headscale Web UI";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [
"network-online.target"
config.systemd.services.headscale.name
];
requires = [ config.systemd.services.headscale.name ];
environment = {
HEADPLANE_DEBUG_LOG = toString cfg.debug;
};
serviceConfig = {
User = config.services.headscale.user;
Group = config.services.headscale.group;
StateDirectory = "headplane";
ExecStart = lib.getExe cfg.package;
Restart = "always";
RestartSec = 5;
# TODO: Harden `systemd` security according to the "The Principle of Least Power".
# See: `$ systemd-analyze security headplane`.
};
};
};
}
@@ -0,0 +1,39 @@
{
buildGoModule,
fetchFromGitHub,
lib,
}:
buildGoModule (finalAttrs: {
pname = "headplane-agent";
__structuredAttrs = true;
# Note, if you are upgrading this, you should upgrade headplane at the same time
version = "0.6.1";
src = fetchFromGitHub {
owner = "tale";
repo = "headplane";
tag = "v${finalAttrs.version}";
hash = "sha256-hsrnmEwKXJlPjV4aIfmS6GAE414ArVRGoPPpZGmV0x4=";
};
vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA=";
subPackages = [ "cmd/hp_agent" ];
ldflags = [
"-s"
"-w"
];
env.CGO_ENABLED = 0;
meta = {
description = "Optional sidecar process providing additional features for headplane";
homepage = "https://github.com/tale/headplane";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [
igor-ramazanov
stealthbadger747
];
mainProgram = "hp_agent";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
})
+119
View File
@@ -0,0 +1,119 @@
{
buildGoModule,
fetchFromGitHub,
fetchPnpmDeps,
git,
lib,
makeWrapper,
nodejs_22,
pnpm_10,
pnpmConfigHook,
stdenv,
}:
let
pname = "headplane";
# Note, if you are upgrading this, you should upgrade headplane-agent at the same time
version = "0.6.1";
pnpmDepsHash = "sha256-AYfEL3HSRg87I+Y0fkLthFSDWgHTg5u0DBpzn6KBn1Q=";
src = fetchFromGitHub {
owner = "tale";
repo = "headplane";
tag = "v${version}";
hash = "sha256-hsrnmEwKXJlPjV4aIfmS6GAE414ArVRGoPPpZGmV0x4=";
};
headplaneSshWasm = buildGoModule {
pname = "headplane-ssh-wasm";
inherit version src;
subPackages = [ "cmd/hp_ssh" ];
vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA=";
env.CGO_ENABLED = 0;
doCheck = false;
buildPhase = ''
export GOOS=js
export GOARCH=wasm
go build -o hp_ssh.wasm ./cmd/hp_ssh
'';
installPhase = ''
runHook preInstall
install -Dm444 hp_ssh.wasm "$out/hp_ssh.wasm"
# Go's WebAssembly shim (wasm_exec.js) has no stable `go env` key, and
# different Go packages may place it in different locations under GOROOT.
# 1. Ask `go env GOROOT` for the active GOROOT.
# 2. First try the path misc/wasm/wasm_exec.js.
# 3. If that fails, fall back to searching under GOROOT to handle
# distro / OS / packaging layout variations.
goRoot="$(go env GOROOT)"
wasm_exec="$goRoot/misc/wasm/wasm_exec.js"
if [ ! -e "$wasm_exec" ]; then
wasm_exec="$(find "$goRoot" -path '*wasm_exec.js' -print -quit || true)"
fi
if [[ -z "$wasm_exec" || ! -e "$wasm_exec" ]]; then
echo "ERROR: wasm_exec.js not found under GOROOT=$goRoot" >&2
exit 1
fi
install -Dm444 "$wasm_exec" "$out/wasm_exec.js"
runHook postInstall
'';
};
in
stdenv.mkDerivation (finalAttrs: {
inherit pname version src;
strictDeps = true;
__structuredAttrs = true;
nativeBuildInputs = [
git
makeWrapper
nodejs_22
pnpm_10
pnpmConfigHook
];
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
hash = pnpmDepsHash;
fetcherVersion = 3;
};
buildPhase = ''
runHook preBuild
cp ${headplaneSshWasm}/hp_ssh.wasm app/hp_ssh.wasm
cp ${headplaneSshWasm}/wasm_exec.js app/wasm_exec.js
pnpm --offline build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/{bin,share/headplane}
cp -r build $out/share/headplane/
cp -r node_modules $out/share/headplane/
cp -r drizzle $out/share/headplane/
substituteInPlace $out/share/headplane/build/server/index.js \
--replace "$PWD" "../.."
makeWrapper ${lib.getExe nodejs_22} $out/bin/headplane \
--chdir $out/share/headplane \
--add-flags $out/share/headplane/build/server/index.js
runHook postInstall
'';
meta = {
description = "Feature-complete Web UI for Headscale";
homepage = "https://github.com/tale/headplane";
changelog = "https://github.com/tale/headplane/releases/tag/${finalAttrs.version}";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [
igor-ramazanov
stealthbadger747
];
mainProgram = "headplane";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
})