Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.

Releases: CGG888/headplaneCN

v0.22.27

Choose a tag to compare

@github-actions github-actions released this 08 Oct 17:06

Changes

  • The embedded DERP card now says which two fields a hand-written map has to fill. server_url is where clients reach this deployment's relay, and the card already printed the host and port it yields — but a local DERP map has no server_url: it spells the same endpoint as a node's hostname and derpport. The card now prints that pair explicitly, so a map written by hand puts relay.example.com in hostname and 8443 in derpport instead of gluing the port onto the name.

Fixes

  • A node whose hostname carries a port is now reported instead of accepted. Tailscale's map format keeps the port in derpport, and a hostname: relay.example.com:8443 with no derpport was a document the panel called valid: each client then dialed 443 and failed the TLS handshake, while the same node's STUN port stayed reachable, so the relay looked alive and was picked as the preferred one. The map validator — the editor and the save action share it — now reports a port inside hostname as its own problem, naming the host and the port and saying which field each belongs in. A bare IPv6 literal is still accepted.

  • The official-region mirror no longer copies the relay this deployment serves itself. A mirror source can describe the embedded relay — this deployment's own local map served back over HTTP, or a pasted copy of it — and mirroring it wrote a second copy of the same relay into the 900s; that copy usually lost the port, so clients preferred it and could not connect. The embedded region is now left out of both the numbering and the written file, the region table marks that row with the reason, prints no number for it and disables its checkbox, and the recommended preset skips it, so the selection matches what a run actually writes. A port glued onto a node's hostname anywhere in the source is split into hostname and derpport when the mirror renders it, instead of being carried forward.

v0.22.26

Choose a tag to compare

@github-actions github-actions released this 08 Oct 15:03

Changes

  • The DERP tab now says when each remote map was fetched, and can fetch it again on demand. The remote maps that get merged into the region table come from derp.urls, and the table itself cannot say whether it is showing a map that answered a minute ago or one this process cached hours earlier — a source whose last fetch failed looked exactly like a source that is simply not in use. A new card lists every configured URL with the time its answer arrived and how long ago that was, when the cached entry is fetched again, and the reason when the last attempt failed. Refresh now clears the cached answers and dials every URL again, which picks up a map that changed without waiting for derp.update_frequency or restarting Headscale.

Fixes

  • A DERP change Headscale did not pick up now says which step failed. When a DERP tab saved something and the configured integration could not reload or restart Headscale, the notice on the page only said that Headscale had not come back healthy and pointed at the HeadplaneCN logs. It now names the step that failed — no running process found, not allowed to signal it, the process did not stop in time, it stopped but did not start again — using the same wording as Settings → System, so the next thing to do is usually obvious from the notice itself.

  • A DERP map that was being downloaded while something was written is no longer reused for six hours afterwards. Every DERP write clears this process's DERP caches, but clearing them forgot the stored answers without forgetting the downloads still in flight: a download that started just before the write could finish after it and store its pre-write contents under a six-hour lifetime, and a lookup made in the meantime joined that older download instead of dialing again. A write now discards downloads that started before it, so the next lookup fetches the map that is actually on disk.

  • Two texts no longer describe more than the code does. The native-integration hint now says that a reload re-reads the access policy and that any other configuration change needs a restart, and the DERP settings reference no longer calls derp.auto_update_enabled the only switch that puts derp.urls and derp.paths edits into effect — with it off, a reload or restart does that too.

  • The dashboard no longer calls a declared relay address a contradiction. derp.server.ipv6 is the address the operator wants clients to reach, and it is routinely not one this machine holds: the relay can sit behind a router or a NAT66 prefix. The amber notice that compared it with the machine's own address (or with the address the echo probe saw) and offered the other value to copy is gone, and so is the copy button that came with it. What stays is the warning that compares the declared address with what the relay hostname actually resolves to, because that is the address a client would end up following.

v0.22.25

Choose a tag to compare

@github-actions github-actions released this 08 Oct 14:25

Changes

  • The DERP address sync can now take IPv6 from the relay hostname instead of the host. The new IPv6 address source select in Settings → Headscale → DERP → auto-sync chooses between the host machine's own global unicast address (the default, and exactly what every installation did before the select existed — that mode still does no DNS lookup at all) and the first usable global unicast AAAA answer of server_url. Pick the record when the relay is not where clients connect — a router or reverse proxy terminating TLS in front of this machine makes the host's own address unreachable even though it is a real global unicast address — and the candidate list then says which rule decided: an address that lost to the record reads usable, but the relay hostname's AAAA record was preferred (dns-wins), one that lost to the external echo still reads echo-wins, and the echo outranks both. A preferred record with no usable answer leaves the host probe as the fallback, so the family keeps working and the configured value is only left alone when neither source has anything usable. Stored settings written before the option existed normalize to the host source.

Fixes

  • Browser SSH says why it could not join instead of spinning on "Joining Tailnet…" forever. The in-browser Tailnet node only ever reported success, so an unreachable control URL, a key that needs machine approval or a session that died all looked like the same endless spinner, with no error and no retry button until the page was reloaded. The console now reports each backend state as it happens ("Signing in to Headscale…"), ends the session after 60 seconds with a message pointing at the Headscale address the browser itself must be able to reach (public_url, not the panel's own address), and fails with its own message when Headscale wants the machine approved, when the pre-auth key is refused, or when the node stops — each with the retry button.

  • The access-control editor no longer offers rules Headscale will refuse. Both the ACL policy dialog and the SSH rule dialog now run the same fifteen checks Headscale 0.29.4's own policy parser runs before saving: a user, tag, group, host or alias used as an SSH source or destination, an autogroup used as an SSH source or destination, autogroup:self used as the source of a policy rule, a checkPeriod on an accept rule or a period that is not a positive duration, a tag or group the policy never defines, a tag source that resolves to a user, to the members of an autogroup or to the autogroup itself, and a user destination that is not the same user the rule belongs to. The offending values are listed under the rule in the panel's own language, and Save stays disabled until they are fixed — the editor can no longer write a policy that Headscale is certain to reject.

  • A policy Headscale rejects is now reported instead of a 500. The save action recognised only two of the three prefixes Headscale's parser uses, so a rejection from the policy validator (parsing policy:) was never matched and surfaced as "Unexpected Server Error" with no explanation. All three prefixes are recognised now, the response is a 400 that carries Headscale's own message, and the editor labels the two kinds apart — Invalid policy: for a validator rejection, Syntax error: for broken HuJSON.

v0.22.24

Choose a tag to compare

@github-actions github-actions released this 08 Oct 11:55

Changes

  • Caddy ships with the panel in both NAS layouts. The native-mode (/install/fnos) and dual-image (/install/dual-image) compose files now define the caddy service and carry the CADDY_PORT line in .env, so the Lucky route no longer needs a second stack under /vol1/1000/APP/caddy: the Caddyfile lives in caddy/ next to the panel, /install/reverse-proxy-lucky documents the merged layout first and scripts/dual-image-install.sh generates the service, the .env keys and the Caddyfile. /install/migration and the README compose example carry the same three-container shape.
  • One .env line routes all three images through a proxy. IMAGE_PROXY — v6.gh-proxy.org/docker/ by default, v4.gh-proxy.org/docker/ on a host without IPv6, empty for a direct pull — is prepended to the Headscale, HeadplaneCN and Caddy image references, so a blocked registry is fixed by editing one line instead of three. /install/dual-image, /install/fnos, /install/reverse-proxy-lucky, /install/migration, the README example and scripts/dual-image-install.sh (--image-proxy) all read it, and the old per-image CADDY_IMAGE key is gone.
  • The install script speaks Chinese and English. scripts/dual-image-install.sh prints every prompt, plan line, warning, error, help section and self-test description as 中文 / English — one-liners joined by a slash, multi-line passages with the Chinese paragraph first and the English one indented beneath it — so the only script that generates a dual-image stack can be read in either language. Flag names, .env keys, paths, the --- would write: … --- dry-run anchors the self-test greps for, and the self test passed: N checks, no failure line are unchanged.

Fixes

  • The version card no longer reads "not reported" on a rate-limited or blocked address. The panel asked the GitHub REST API for the newest Headscale and Headplane releases, and that endpoint allows only 60 unauthenticated requests per hour per address: on a shared connection it answers HTTP 403 API rate limit exceeded, so both Latest version rows rendered — (not reported) while the panel itself was healthy. The lookup now asks https://github.com/<owner>/<repo>/releases/latest and reads the tag out of the 302's Location header — not subject to that limit and no token needed — and when github.com cannot be reached at all it retries the same lookup through a mirror (https://ghproxy.net/, https://ghfast.top/, https://v6.gh-proxy.org/, https://gh-proxy.com/), accepting either the redirect, the API payload or a proxied release page, then tries the route that worked first. Set HEADPLANE_RELEASE_MIRROR to your own prefix (comma-separated, off disables the fallback), or start the panel with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY to send the lookup through a real proxy.
  • Browser SSH opens behind a TLS-terminating proxy instead of answering Unexpected Server Error. Before rendering the terminal the console probes its WASM bundle with a HEAD request to the origin React Router derives from the incoming request. Behind a proxy that terminates TLS the socket is cleartext, so that origin was http://<public-host>:<public-port> — the probe asked a TLS port for a file over HTTP, Node failed it with ECONNRESET, and because a rejected fetch is not a Response the router turned it into the generic Unexpected Server Error instead of the localized "bundle missing" card. It now probes the listener Headplane actually bound (server.host/server.port, https when server.tls_* is set, loopback for a wildcard bind) first and keeps the request origin as the fallback the Vite dev server needs, and a fetch that fails counts as a missing asset so the page shows its own message rather than a 500.

v0.22.23

Choose a tag to compare

@github-actions github-actions released this 08 Oct 09:33

Changes

  • The install guides are split by the decision you are actually making. /install now opens with the three choices that decide everything else — deployment shape, domain scheme and reverse proxy route — and answers each one with a table before linking out. The fnOS pages become install-only (fnOS · native mode, fnOS · dual-image mode); migration in both directions moved to a new /install/migration, the domain question (one domain with a path split, one domain on two ports, two domains, DNS records, where the certificate lives, ICP filing in mainland China) to /install/domains, and the two proxy routes to /install/reverse-proxy-lucky (home NAS: Lucky terminates TLS, Caddy inside the NAS splits /admin) and /install/reverse-proxy-caddy (cloud server: Caddy does both, with automatic certificates). Every guide states which values you must change, which you may change and which to leave alone, and all example domains, addresses, keys and certificate paths have been replaced with placeholders. Both languages were kept in step.
  • The NAS guides point at the full configuration template. Both fnOS pages now link config.example.yaml — the commented, full-field file that pnpm run dev:app itself loads — so the minimal snippet in the guide does not read like the whole story. Any key the snippet omits keeps its default.
  • DERP lives on its own page. The embedded relay, the local map file listed in derp.paths, the online editor, the official region mirror and the udp/3478 STUN requirement are no longer spread across the two NAS guides: they are collected in /configuration/derp, with the deployment-specific paths and mounts for both shapes, and the NAS pages link to it instead of repeating it.
  • server.base_url no longer claims to take /admin. The panel always lives under /admin, so the field holds the panel's own address — scheme, domain and port, with no path. The install guides and /install/domains said the opposite and blamed a missing /admin for failed saves; a /admin in there actually only makes alert links and the login self-test read …/admin/admin/….

Fixes

  • Saving Headscale's configuration works on a single-file bind mount. The panel wrote Headscale's config.yaml by putting the new content into a temp file and renaming it over the target, which the kernel refuses when that file is a bind mount (EBUSY) — so Authentication restrictions, trusted proxies, extra DNS records and embedded DERP URLs all failed with Unexpected Server Error on the documented Docker deployment. The write now falls back to copying the bytes through the mount point when a rename is impossible, and keeps the atomic rename everywhere else; mounting the whole directory (/vol1/1000/APP/headplaneCN/headscale:/etc/headscale) still works and is no longer required.

v0.22.22

Choose a tag to compare

@github-actions github-actions released this 08 Oct 06:18

Changes

  • The region filter's target file follows your deployment instead of a hardcoded path. The DERP region mirror used to default to /vol1/@appdata/headscale/derp-maps/official-mirror.yaml, the path an fnOS-native Headscale uses — so the same panel running in the two-container shape reported the map as unreadable, because that directory is not mounted there. The default is now derived from Headscale's live configuration on every read: the directory of the first absolute derp.paths entry, otherwise the data directory inferred from noise.private_key_path, sqlite.path, derp.server.private_key_path and unix_socket, in that order, with the old path kept only as a last resort. A path you picked by hand still wins, and because the value is never written back nothing is frozen: move the stack and the default moves with it. The mount hint on the page now follows derp.paths as well.

  • The deployment shape is written on every page. The footer now names the integration this instance is running — dual image, native process, Kubernetes, or none — just before the version number, with a tooltip explaining what that means for reloading. The dual-image shape needed a name because the same panel can now be deployed three ways.

  • The node key is hidden until you ask for it. On the machine detail page the Node key row is now masked like an address, with the same eye button to reveal that one value and a copy click that still copies the real key. Masking is the browser-local preference the console already uses for addresses and hostnames, so the header's visibility menu and every eye button govern both, and the accessible name says Show Node key rather than claiming an address is hidden.

  • The NAS guides describe the shape that actually runs. The dual-image page was rewritten around the verified two-container deployment: host networking for both containers, the Headscale data directory mounted at the same absolute path so not one absolute path in its configuration changes, .env-parameterized compose, both complete configuration files, and migration in both directions (fnOS native ⇄ dual image). It no longer asks for security_opt: ["apparmor=unconfined"] — that belongs to the native-process integration this shape replaces — and says plainly that pid: host is optional and only matters to the Agent. scripts/dual-image-install.sh generates that stack (.env, Docker integration, same-absolute-path mounts, the Headscale configuration left untouched) and its --self-test asserts the new shape; the fnOS page keeps the native shape, links to the dual-image page and gets a comparison table; the README's compose example is now the two-container one. Example values in every guide and script are redacted.

  • The diagnostics card on a machine no longer repeats the page. It used to reprint what the cards above it already show — the operating system, the hostname, the end-points, the seven network facts, the preferred and home relay region, the per-region latency table and the served-region list — so a reader had to compare two views of one fact. It now keeps only what has no other home: the two version strings the agent reported verbatim, the IPv4 ICMP self-test the connectivity card has no row for, and one line counting the regions this deployment serves, the client reported and this server measured. The empty, unavailable and no-agent states are unchanged.

  • Nothing in the 900s is pinned any more, and the region filter starts empty. Hong Kong was always numbered 901 and Singapore 902, their checkboxes could not be cleared, and a fresh install came with both already ticked. The 900-series numbering now begins at 901 and follows measured latency alone — fastest first, ties to the lower official id, unmeasured regions last — so whichever region your machines actually reach first takes 901, and a number once assigned is still kept until you press Renumber. The official region filter now opens with no region selected, and the settings page, the fnOS guide and the mirrored documentation no longer describe a pinned pair.

  • Every relay region now carries its country's flag. A DERP region was named in nine places — a machine's home and preferred relay, its latency table, the DERP node column of the machines list, the region fact and the region list on the overview, the status table's region and latency columns on the DERP settings page, the official region filter and the region-name editor — and nowhere said which country it stood in. Each of those labels now begins with a small flag. The country is derived rather than typed in: the region's code (hkg, fra) or name (香港, 东京, São Paulo) is resolved by app/utils/region-country.ts, which maps the official map's three-letter codes to ISO 3166-1 alpha-2 and covers the Chinese city names the panel accepts. A region no table can place draws no flag instead of a guess. The artwork is flag-icons 7.5.0 (MIT, Copyright (c) 2013 Panayiotis Lipiridis), fifty-eight flags vendored into app/assets/flags/.

  • The NixOS module can describe the container shapes too. services.headplane.settings.integration only declared agent and proc, so a declarative install could not express the Docker integration the two-container deployment uses — and since the submodule is strict, naming it failed at evaluation instead of being ignored. integration.docker (enabled, container_name, container_label, socket) and integration.kubernetes (enabled, pod_name, validate_manifest) now exist with the same types and defaults as the configuration schema, and docs/NixOS-options.md lists them. A Docker or Kubernetes block that is not enabled is left out of the generated config.yaml instead of being written as enabled: false (the Kubernetes schema requires pod_name, so an empty block would be rejected), the service joins the docker group when that group exists and the integration is enabled, and enabling Kubernetes without pod_name stops at evaluation with a readable message.

Fixes

  • Signing in now lands on the overview. The console sent every sign-in, and the bare / path, to the machine list — a page that needs read_machines, so an account holding only ui_access was redirected into a page it could not read. Sign-in, the root path, the logout redirect and the "already signed in" bounce now all land on Overview (/overview), which is the one UI page ui_access alone opens.
  • The relay card no longer marks both regions as in use. When the assigned home region was also the one the client was using — the healthy case, and the common one — the home row and the preferred row both carried the in use badge, because both were compared against the preferred region. Only the row that is actually in use carries the badge now, and the two rows say Home region (assigned) and Preferred region (in use) so the distinction is written on screen instead of implied.
  • Operation timestamps show the clock you actually read. The audit log pinned every entry to UTC, so a login at 12:14 local time was listed as 04:14 — the page said "time" and meant "UTC", with nothing on screen saying so. The list and the operation-detail dialog now format the recorded instant in the viewer's own time zone and name that zone (GMT+8), and the first paint, where the server and the browser disagree because they sit in different zones, is no longer treated as a hydration error. CSV and JSON exports are unchanged: at stays an absolute ISO 8601 UTC timestamp.
  • A refused reload now says which step failed. Reloading or restarting Headscale no longer reports a generic failure: the page names the step that stopped — no headscale serve process was found, the integration is not configured, permission to send the signal was denied, the signal was sent but /health never confirmed it, or the signal could not be sent at all. Before this release a refused reload could be reported as a success. The install guides now also say that a panel running in a container next to a native Headscale needs security_opt: ["apparmor=unconfined"] in addition to pid: host, because Docker's default AppArmor profile refuses to signal an unconfined process and the reload fails with kill EACCES.

v0.22.21

Choose a tag to compare

@github-actions github-actions released this 08 Oct 01:37

Changes

  • Expired pre-auth keys can be cleaned up in one click. The pre-auth key page counts expired keys beside the status filter, and when any exist it offers Delete all expired. The cleanup deletes exactly the keys that are already expired — spent single-use keys included — and never touches a key that can still authenticate. Because a deleted key cannot be recovered the button asks for confirmation first, then reports how many were removed and names the ones that failed; it appears only on Headscale versions whose API can delete keys (0.28 and later), and everywhere else expiring remains the way to revoke.
  • DERP edits are applied instead of being left to a restart you have to remember. Saving anything under DERP — a map URL, a map file, the embedded server, a mirror or sync run, even a snapshot restore — now drops the cached DERP data so the UI shows what was written, then triggers the reload the deployment actually supports: Docker restarts the container, the native integration restarts the process once integration.proc.allow_restart is on, and a deployment that cannot restart is told a manual reload is still needed instead of getting a silent no-op. An edit confined to the contents of a map file listed in derp.paths needs none of that while derp.auto_update_enabled is on, because Headscale re-reads map files itself within derp.update_frequency; when that switch is off the DERP page offers to turn it on with one click. The page also reports what happened after each save — refreshed on the timer, reloaded, or still waiting for a manual reload.
  • A native Headscale can be restarted from Settings → System. It is off by default: integration.proc.allow_restart has to be set to true (or the matching NixOS option), because HeadplaneCN only sends SIGTERM and then waits for a new headscale serve to appear — without a supervisor such as systemd or s6 nothing brings the process back. The button asks for confirmation first, warns that every node drops offline briefly, and every stage of the attempt — no process, stale pid, stop timeout, not restarted, unhealthy — is named instead of a bare failure.

Fixes

  • The process card no longer implies that SIGHUP reloads everything. A SIGHUP to headscale serve only re-reads the access policy in place; DNS, OIDC and trusted_proxies changes need the process to read its configuration again, which means a restart. The process card, the System status page and the install guides now say that, and point at the restart option above.

v0.22.20

Choose a tag to compare

@github-actions github-actions released this 07 Oct 19:57

Fixes

  • Several places that trusted upstream data now check it instead. A Headscale response that is missing its node or API-key array, a remote DERP map cache that could grow without bound, a DERP map file swapped between its size check and its read, and a configured DNS records path that is actually a directory are now reported and refused instead of silently treated as empty or read anyway. A misconfigured dns.extra_records_path reports a normal error instead of terminating the process from inside a library function.
  • Long-running services stop leaking and stop writing after shutdown. Alert cooldown state is pruned instead of growing forever, the audit log cannot be asked for an unbounded number of rows, stored latency readings expire after a week, a DERP "check" no longer records the run it only inspected, and a disposed mirror or sync service abandons the run it was in the middle of instead of persisting it.
  • The console login's OIDC path is stricter. The info endpoint's secret is compared in constant time, a UserInfo response whose subject disagrees with the ID token is ignored instead of trusted, the self-test judges client_secret_jwt by the value the runtime actually uses, and the login page no longer treats a missing error state or an unparseable session expiration as valid.
  • Smaller corrections. An API key whose expiration cannot be parsed is treated as expired; the policy editor's comment and trailing-comma stripping no longer corrupts content inside strings; the audit log's "Load more" appends the next page instead of replacing the rows on screen; a machine rename submits the name it validated; bulk machine actions say which machines failed and why; the key-expiry toggle distinguishes "disabled" from "restored" and the uptime history counts the last sample in its window; the notification settings no longer send the stored webhook secret to the browser; and the install script writes its migration backup mode 600, no longer probes the base directory before the plan is confirmed, and can bind the admin UI to 127.0.0.1 with --admin-bind.

v0.22.19

Choose a tag to compare

@github-actions github-actions released this 06 Oct 15:11

Changes

  • The console's own OIDC login can now be configured in the web UI. Settings → Console login gained a form above its self-test covering the issuer, client id and secret, scopes, PKCE, default role, end-session logout and the allow-lists/pinning that matter for signing in. Every field shows where its current value comes from — an environment variable, what was saved here, the configuration file, or the schema default — and a field pinned by an environment variable is shown read-only with the variable named, because the environment always wins: environment variable, then what was saved here, then the configuration file. Edits are stored in the application's own data directory rather than written into the configuration file, which is frequently mounted read-only; the file is only ever read.
  • The secret stays secret and the form cannot lock you out. The client secret is never rendered back — the form shows that one is set, lets you replace it or clear it deliberately, and leaves it untouched when you submit the field empty — and it never reaches the audit log, an error message, a URL or a log line. Values are validated against the schema before saving (an enabled configuration needs an absolute HTTPS issuer, a client id, scopes and a secret from some layer), and a change that could leave nobody able to sign in asks for confirmation first, spelling out what will happen and what would still work; it refuses only when there would genuinely be no way in. Every change is audited by field name, including refused ones.
  • You can see whether a restart is still pending. The page compares the running configuration against what is stored and shows a banner naming the fields that differ, since the configuration is read at start-up, and the self-test now evaluates the merged configuration so a saved edit is checked before you restart.

v0.22.18

Choose a tag to compare

@github-actions github-actions released this 06 Oct 14:42

Changes

  • Signing out can now sign you out of the identity provider as well. With oidc.logout_idp: true (the older oidc.use_end_session still works), logging out of the console clears the local session as before and then sends the browser to the provider's end-session endpoint, so the next "sign in with OIDC" really asks for credentials instead of silently signing you back in from the provider's still-live session. oidc.end_session_endpoint overrides the endpoint discovered from the issuer and oidc.post_logout_redirect_uri sets where the provider returns you. Everything about the attempt is fail-soft: a missing endpoint, a non-HTTPS or malformed URL, a slow or erroring provider, or no stored ID token all end on the login page with the local session already cleared. The default is off, so nothing changes for an existing deployment.
  • A self-test for the console's own OIDC login (Settings → Console login). It fetches the discovery document and reports, item by item and with the value it saw: whether the issuer is reachable and its HTTP status, whether the document's iss matches the configured issuer character for character, whether every configured scope is advertised — with a pointed warning that providers such as Logto require profile and email to be granted to the application before they may be requested, which is what usually causes invalid_scope — whether the runtime can actually verify the advertised ID-token signing algorithm (it performs a real signing probe rather than trusting the list, so an ES384-only provider is answered definitively), whether an end-session endpoint exists for the logout behaviour above, which token-endpoint authentication methods the provider offers, and a reminder that the configuration is read at start-up so a change needs a restart. It runs server-side and never exposes the client secret.