CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to SFPanel are recorded here. Entries are derived from annotated git tags (`git tag -n50 <tag>`).
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/), with sections per release and the newest release at the top.
---
## [0.84.0] – 2026-11-09
### Added
- **A finger scrolls a tmux session's history a pixel at a time.** On a phone, a drag back into a session's history lays a copy of it over the terminal and moves that copy with the finger, a flick included, where 0.83.4 asked tmux for each line and waited for it to redraw, so the text moved a line at a time. Back at the bottom, a tap, any input, a resize or the new arrow button returns to the live screen. A program in the alternate screen, such as vim, still scrolls through tmux a line at a time, as it did in 0.83.4.
- **`GET /api/v1/ai/sessions/:id/scrollback`** returns that copy: the session's history down to the screen's last row, in colour, with the pane's size. `GET /api/v1/ai/tools` reports it as `touch_history`.
### Changed
- **Codex starts inline.** A new or restarted Codex session runs with `-c tui.alternate_screen=never`, so its conversation lands in the session's history, where a phone scrolls it smoothly, rather than in a screen of Codex's own. To keep that screen for a session, add `-c tui.alternate_screen=always` to its extra arguments. A session already running changes when it is restarted, and Codex 0.80 and older, which have no such setting, ignore it.
## [0.83.4] – 2026-11-09
### Fixed
- **A finger scrolls the terminal a line for each line it travels, and a flick carries on.** On a phone, ten lines of drag moved Codex two rows, and a shell's history nothing for three lines, then five at a time: each move became one wheel event, which the terminal sends as a single report, scaled down further for small moves, and each program scales a report its own way. Each line now reaches tmux as one line of history, or as one wheel report for a program that scrolls itself, such as Codex. A flick continues after the finger lifts and slows to a stop, and a tap that stops it does not raise the keyboard. A desktop mouse wheel is unchanged.
- **The page buttons move a page in a tmux session.** The Android app's page buttons moved Codex one line, and the web key bar's did nothing there at all; both now move a page, less one line.
### Changed
- **Dependencies.** gopsutil 4.26.10; Tauri updater plugin 2.14.1; @tailwindcss/vite 4.3.4; tailwind-merge 3.7.0; lucide-react 1.68.0, which redraws three of the icons the panel uses (the check circle, the key and the bolt). (#155–#159)
## [0.83.3] – 2026-11-06
### Changed
- **Dependencies.** monaco-editor 0.57, the code editor's own update, which the panel now loads through the entry points monaco-editor made the supported ones, with every editor feature as before; gRPC 1.85; SQLite (modernc) 1.61.1; sonner 2.0.8; Tauri CLI 2.12.4; setup-java 6.1 and rust-cache for the app builds. minimatch 11 and glob 14 under the build and lint tools close GHSA-g3hv-75wg-m249 and GHSA-3g3m-2fjr-6pw4. (#140–#143, #147–#149)
## [0.83.2] – 2026-11-06
### Fixed
- **The record of a voter a peers.json recovery left out can be removed, and the check on removing a voter counts the voters that make the quorum.** It counted the voters in the node list, which after a peers.json recovery still holds the voter left out: with one survivor that record could not be removed (`would drop the cluster below quorum (1/2 voters; quorum 2)`), and with two, removing a survivor passed as one of three and left a lone voter. It now counts the voters of the Raft configuration, so a record the configuration does not hold is removed, and one of its last two voters is not removed without `--force`. (#137)
- **`sudo sfpanel cluster remove <node-id> --force`** passes that check, as `cluster leave --force` does; the API's `?force=true` had no CLI counterpart. Any other flag is refused rather than taken for the node's ID, which the panel answered as removed while the node stayed. (#137)
## [0.83.1] – 2026-11-06
### Fixed
- **The bottom bar's icons keep their size on an iPhone.** The bar took the space the home indicator needs inside its row, which left each tab some 22px for its icon and its label, and the icons shrank to a few pixels. That space now sits under the row. (#152)
- **The cluster overview's node list fits a phone.** A node's name, metric bars and buttons sat on one line some 700px wide, and the card cut off the memory and disk bars and the edit and leave buttons, with no way to scroll to them; a tablet lost the end of the line too. Below 1024px the metrics now take a line of their own under the name. (#153)
## [0.83.0] – 2026-11-03
### Added
- **Saved logins for the agent install.** The Agent install (SSH) form can save the account it logs in with under a name — the user, the method, the password or the private key and its passphrase, and the sudo password — and pick it again next time; the login last used is chosen when the form opens. **Manage** changes a saved login (a secret left empty keeps the stored one) or deletes it. The cluster keeps them in the managers' replicated state, so they outlive a change of leader; agents never hold them. No answer returns a saved secret — the form only learns whether each one is stored — and a run records the name of the login it used, never its secrets.
- A saved login's password, or its sudo password, goes only to servers at a private address or in the leader's tailnet. A server elsewhere fails before anything is sent to it, with a sentence that says which case it is, and an account typed in the form is not limited.
- Saved logins are plaintext in the managers' Raft data, as the JWT key is. Anyone with a panel session — which has a root terminal on every manager — can read them, so save only accounts the panel's administrators may use. On a cluster whose Raft traffic is not TLS (one initialised before Raft TLS existed), they cross the managers' network in the clear, as the JWT key does: type the account there instead.
- For API clients: `GET` and `POST /api/v1/cluster/agent-installs/logins`, `PUT` and `DELETE …/logins/:id` (a body of at most 64 KiB), and `account.login` on the start.
### Fixed
- **The tailnet picker fits a phone.** Device names and addresses stay whole, the buttons stay in view under a long list, and the devices that cannot be picked are dimmed.
### Security
- **The file manager no longer serves the cluster's Raft data.** `/var/lib/sfpanel/cluster/` — the Raft log and snapshots, which hold the JWT key, the cluster CA key, the agents' keys and now the saved logins — is refused to reads, as the panel database already was.
## [0.82.0] – 2026-11-02
### Added
- **An agent install's servers can be picked from the tailnet.** **Pick from Tailscale** in the Agent install (SSH) form lists the Linux devices of the cluster leader's tailnet — the leader is the node that connects to them — online first, each with its Tailscale address, whether it runs Tailscale SSH, and the cluster node already at that address. A device already in the cluster, or offline, cannot be picked. The picks fill the server table, and when every one of them runs Tailscale SSH the login switches to it. A leader that is not on Tailscale says why — Tailscale not installed, not running, or without an IPv4 address — and to transfer the leadership to a manager on Tailscale. For API clients: `GET /api/v1/cluster/agent-installs/tailnet`, on any manager.
- **A server's host key is compared with the one Tailscale reports.** For a server at a Tailscale address, the host-key question now says whether Tailscale reports the same key for that device, or another key of the same type, in which case check the key on the server before you approve. It is said only: the approval stays yours, and nothing is sent to the server before it. The row records it as `host_key_tailscale` (`match` or `mismatch`).
## [0.81.1] – 2026-11-02
### Fixed
- **The macOS and Windows desktop apps build again.** Two modules of the Tasks screens had names that differed only in case from the components beside them (`TaskList.tsx` and `taskList.ts`, `TaskRunView.tsx` and `taskRunView.ts`). On a filesystem that ignores case an import of one could open the other, so 0.81.0 shipped no macOS or Windows build and no desktop update manifest. The panel itself is unchanged. A test now fails on two modules, or two folders, in one place whose names differ only in case.
## [0.81.0] – 2026-11-02
### Security
- **The file manager's write guard follows a link in a parent of a path that does not exist yet.** It checked a path as written, and as resolved when the path existed or was itself a link, so with `<dir>/link` pointing into `/etc/sfpanel` (the JWT key and the cluster CA key), `/root/.ssh` or `/etc/sudoers.d`, a new file or folder, a copy or a move to `<dir>/link/new` was judged by its literal path and written behind the link. The guard now also checks the path the write would reach, every link on the way followed.
### Added
- **Group tasks: the same steps on many nodes, from the new Cluster › Tasks tab.** A task is one YAML document of steps — `packages` (refresh the lists, then upgrade, install or remove), `service` (start, stop, restart, enable or disable), `command`, `file`, `stack` and `reboot` (always, or only when required) — with inputs asked for at each run, and `{{ node.name }}` and `{{ node.address }}` filled in per node. It runs on the nodes in the groups chosen (the `group` label), of the kinds chosen or chosen by ID, up to 200. The leader runs it and keeps its record in replicated state, so a run goes on once the browser that started it has closed, any manager shows it, and a new leader takes it over without starting any step twice. There is no CLI.
- **Built in:** Package update, Package install, Service restart, Run a command, Reboot (only when required), and Agent install (SSH), below. They cannot be changed; **Clone and edit** makes a task of your own from one.
- **Preview first.** **Run** asks every target node for its verdict on each step, from the guards its own pages use — a protected service or path, a forbidden compose file, a reboot on a Proxmox VE host — and opens the preview when anything needs a look: a step to confirm or that a node refuses, a node left out (offline, on an older release, out of the leader's reach, or held by another run), or a manager not ready. A step that needs confirming runs only as it was previewed: a start whose task, inputs or targets have changed since is refused (`409 TASK_PLAN_CHANGED`).
- **In order.** Agents first, in batches of the size the run sets; then every other manager, one at a time; then the leader, once it has handed its role to another manager voter, which takes the run over. A failed step stops its node unless it says `continue_on_error`, and with **Stop on failure** (the default) no later batch starts. **Stop** starts no new step and lets the ones under way finish; **Force stop** also cancels the commands under way and a reboot not yet begun. Package, service, file and stack steps always run to their end, since apt cut short can leave dpkg broken. A manager is rebooted only when the other managers online still make a quorum, or when it is the only one, else the run stops there; one run at a time reboots managers, and a Proxmox VE host skips its reboot steps.
- **On each node** a step is a job, one at a time, behind the guards of the page that does the same by hand. Commands run as root in a transient unit, and package upgrades, installs and removals in the Packages page's own transaction, so a panel restart does not stop them; a reboot step succeeds once the node is back in a new boot. A node keeps its 200 latest jobs, none older than 30 days, with their output under `/var/log/sfpanel/task-jobs`.
- **Progress and history.** A run shows each node's steps as they go, and any step's output from its node. The cluster keeps its 200 latest ended runs; **Rerun failed nodes only** starts each such node again at its first step that did not succeed.
- **The editor** shows a task as a form of step cards or as YAML, checked by the server with every problem at its line. In a command an input's value is shell-quoted unless the input is `raw`, and a reference inside quotes, `$( )`, backticks, a heredoc or a comment is refused, as the quoting would not hold there.
- **Import from Git** reads the `.yaml` and `.yml` files of a GitHub folder at the commit its preview showed, and **Pull again** brings in what changed. Imported tasks are read-only. A token, needed only for a private repository, goes to GitHub and is never stored.
- The leader's audit log has each run's start and end under the operator who started it, and each node's the steps it was sent. Input values are kept in the run's record, which every manager holds and the API returns: do not pass passwords or other secrets as inputs.
- **Agents installed over SSH by the leader.** **Agent install (SSH)** on Cluster › Tasks, which the Add agent dialog links to, takes 1 to 50 servers by IP or host name and installs 1 to 5 of them at a time (3 by default), each through five phases — connect, pre-flight check, install, register, connection check. It is a run like a task's, with the same progress, history and Stop. There is no CLI.
- **One account for every server:** a password, a private key with its passphrase, or Tailscale SSH (with a `check` rule, the row shows the approval address and waits up to ten minutes). A user other than root needs sudo — with the login password (the default), a password of its own, or none — and the sudo password reaches sudo on its standard input.
- **The host key first.** The first connection only reads the server's host key, the ED25519 one where there is one, and nothing is tried on the server until the operator approves its whole fingerprint. From then on that key alone is accepted: a server that presents another fails before anything is sent (`AGENT_INSTALL_HOST_KEY_CHANGED`, naming both keys).
- **The pre-flight check** runs before anything is installed: Ubuntu or Debian on amd64 or arm64 with systemd, root or a sudo that works, the tools, the panel and cluster ports free, a clock within 30 seconds of the leader's, the leader's cluster port reachable from the server, 256 MiB free, a valid name, the address given held by the server, and the gates an agent's registration meets. An agent, a manager, a manager's leftover files, a panel serving the operator's own certificate and a newer SFPanel are refused. A server that fails stops alone, its reason coded in the record and worded on screen. A server that runs SFPanel, or keeps an earlier one's data, waits for the operator to confirm its conversion — its local login goes away and its panel turns to HTTPS — and is never purged. With **Preview**, every server waits after its pre-flight check for one **Continue install**.
- **The install** pins the leader's version, whose `checksums.txt` signature the leader verifies, and runs the `install.sh` built into the leader's binary under `systemd-run`, so a dropped SSH connection does not stop it. Online, the server downloads the release from GitHub; offline, the leader downloads and uploads it; automatic chooses per server. The join token is one-time, bound to the server's IP, valid for 30 minutes, and passed in a file. A build that is not a release installs only from a directory that `SFPANEL_AGENT_ARCHIVES` names on the leader, unsigned — this is for test rigs.
- **After the join** the leader waits up to three minutes for the agent's heartbeat and for a relay to it and its panel to answer, then sets its group label. A registered server that fails these gets **Recheck** and **Revert**: Revert removes the node and, while the credentials last, SFPanel from a server that had none before. A server that fails before registering is cleaned up over SSH. One that ran SFPanel before is never purged, and its panel is started again if the install had stopped it. Where the panel cannot clean a server — the credentials gone, a clean-up that failed, a leader change — the screen gives the command to run on it.
- **The credentials** — password, key, passphrase, sudo password — live only in the leader's memory and are zeroed when they go: as soon as the install succeeds or is stopped, else ten minutes after it ends with a registered server failed. They are never written to disk, Raft, the run record, a log, an audit row or an API answer, nor put on a command line.
- **One install at a time,** and none while a task run, a cluster update, a CA rotation, a conversion or a promotion is under way. While one runs, a second install, a task run and a manual leader transfer are refused (`409 AGENT_INSTALL_ACTIVE`), as are a cluster update, a CA rotation, a conversion and a promotion (`409 TASK_RUN_ACTIVE`), and the leader keeps its role. **Stop** leaves the servers not yet started as they are, stops and cleans up those not yet registered, and leaves those registered in the cluster until they are reverted. A new leader does not take an install over, as it lived in the old leader's memory: it ends it (`AGENT_INSTALL_LEADER_CHANGED`) and the screen says where each server stopped; a server cut off mid-install keeps the install's temporary directory under `/tmp`, which the cleanup command removes.
- **It needs** SSH from the leader to each server, each server reaching the leader's cluster port (3629), the managers reaching the agent's 3629 and 3628, the leader reaching GitHub, and every manager on the leader's version (`409 AGENT_INSTALL_MIXED_VERSIONS`). A server at a Tailscale address joins at the leader's Tailscale address; a leader that has none fails it and names the managers that have one, to transfer the leadership to.
- **Managers on more than one network.** The cluster now records every address a manager is reached at — the one it advertises, then its Tailscale IPv4 addresses (in 100.64.0.0/10, or on an interface named `tailscale*`) — and the manager's certificate names all of them, reissued with the same key when one is added. An agent learns each manager's addresses from the leader and dials the one that worked last time first, then those on its own network, then the rest; its panel takes requests from every address of its managers without `cluster.agent_allowed_sources`. To join a server that is only on the tailnet, give the join a manager's Tailscale address: `sudo sfpanel agent join 100.64.0.10:3629 <token> --ca <fingerprint>`.
- **Each manager knows which agents it reaches, and the leader goes to the one that reaches the most.** Every manager connects to each agent's cluster port once a minute, and every ten seconds while one is out of reach. A request for an agent that the manager it goes through could not reach is refused at once (`502 CLUSTER_NODE_UNREACHABLE`) instead of timing out, and the page says to open the agent from a manager on its network: no manager relays through another. Agents heartbeat to the leader, so a leader that reaches fewer agents than another manager voter checks again, then hands its role to the one that reaches the most — not within 30 seconds of its election, nor within ten minutes of its last such hand-over, nor while a cluster update, a CA rotation or an agent install runs. Its other hand-overs — on shutdown, on leave, at the end of a cluster update — choose the same way.
- **`--token-file PATH`** for `sfpanel agent join` and `install.sh --agent` reads the join token from a file, so it is on no command line. With `--agent`, `install.sh` also takes `--version`, `--checksums` (a `checksums.txt` whose signature the caller has verified) and `--archive` (a release archive to install without downloading one), as the SSH install uses them.
### Changed
- **A manual leader transfer to a manager that reaches fewer agents does not last.** That manager hands the role on to the one that reaches the most agents, usually within a minute, and meanwhile the agents it cannot reach show offline.
- **Cluster operations wait for one another.** A cluster update, a CA rotation, a conversion and a promotion are refused while a group task run or an agent install is under way (`409 TASK_RUN_ACTIVE`, naming the run), and a run is refused while one of them is (`409 UPDATE_IN_PROGRESS`, `CLUSTER_CA_ROTATING` or `TASK_CLUSTER_CHANGING`).
- **`sudo sfpanel cluster reissue-cert`** names the advertise address, this host's Tailscale addresses and every name of the certificate it replaces, where it named the advertise address alone.
- **For API clients.** New routes, on managers: `/api/v1/cluster/tasks` (with `validate`, `format`, `import/preview` and `import`) and `/api/v1/cluster/task-runs` (`plan`, start, list, read, `stop`, `rerun`, a step's `output`, and `stream`, an event stream any manager answers); `/api/v1/cluster/agent-installs`, whose runs are read, followed and stopped through the task-run routes; and, on every node, `/api/v1/task-jobs`, which takes the task engine's signed calls only — a manager refuses to relay it for `?node=` (`403 TASK_JOB_CLUSTER_ONLY`). They answer with `TASK_*` and `AGENT_INSTALL_*` codes. `GET /cluster/nodes` and `/cluster/overview` add `addresses`. A `?node=` request for an agent this manager does not reach, a WebSocket included, answers `502 CLUSTER_NODE_UNREACHABLE` (`details.name`) in the usual error envelope. `POST /cluster/update`, `/cluster/rotate/ca`, `/cluster/nodes/<id>/convert` and `…/promote` can answer `409 TASK_RUN_ACTIVE` (`details.run_id`), and `POST /cluster/leader-transfer` `409 AGENT_INSTALL_ACTIVE`. `POST /docker/compose` with a name that is taken answers `409 STACK_ALREADY_EXISTS` (was `500 COMPOSE_ERROR`); the stack import from GitHub adds `429 GIT_RATE_LIMITED` (`reset_at`, `limit`, `retry_after`, as GitHub gave them) and `400 FILE_TOO_LARGE`, and its `GIT_CLONE_FAILED` carries `details.cause`.
### Fixed
These were in 0.80 as well.
- **A manager moves its heartbeat to a new leader within a second.** A follower moved at its next send, up to thirty seconds later, and until then the new leader listed it offline. It now looks for a new leader every second. A manager that has just joined starts its heartbeat the same way, as soon as it knows where its leader is, and so becomes a voter sooner.
- **Agents follow a leader that hands its role over at once.** An agent moved to a new leader at its next heartbeat, up to thirty seconds later, so after a transfer — by hand, or at the end of a cluster update — the new leader listed it offline until then. The leader that hands over now names the new one to its agents right away.
- **`install.sh` runs without `HOME` in its environment.** The service unit it writes had `$HOME` in a comment, unescaped, and the script runs with `set -u`: run without `HOME` — under `systemd-run`, for instance — it stopped as it wrote the unit, which with `--agent` comes after the join, leaving a registered agent with no service. With `HOME` set, the comment got that directory in place of `$HOME`.
- **The alert module's log lines reach the panel's log as records.** It took its logger before the panel set up logging, so its lines went out as plain text inside the message, all at INFO whatever their level, and with `log_level` at `warn` or `error` none was written — failed notification sends included.
- **Importing a stack from GitHub tells a spent rate limit from a refused token.** GitHub's hourly allowance running out read as an authentication failure that asked for a token, or as a 500; it is `429 GIT_RATE_LIMITED` now, and the page says when the allowance comes back. A path with `#` or `?` in it is fetched as written (`stacks/web#1.yml` fetched `stacks/web` and lost the branch), a file over 8 MiB is refused instead of read to its first 8 MiB, `https://github.com/<owner>/.git` is refused as an address instead of failing as a 500, and the import's errors read in the screen's language.
- **`sudo sfpanel cluster list` names an agent an agent.** Its ROLE column called every node other than the leader and the non-voters a follower, agents included; an agent now reads `agent`.
- **Creating a stack under a name that is taken is a conflict, not a server error:** `409 STACK_ALREADY_EXISTS`, as the import from GitHub answers, where it was `500 COMPOSE_ERROR`.
### Upgrading
- Update every node with the cluster update. A group task run starts once every manager is online and runs 0.81 — until then the start names the managers it waits for — and leaves out a node still on 0.80 as outdated. An agent install needs every manager on the leader's version.
- The managers' Tailscale addresses are recorded once every manager is online on 0.81, and an agent dials them once it runs 0.81 too; until then every node is dialled at its registered address, as before. Where some agents are reached by only some managers, the leader then moves by itself to the manager that reaches the most of them.
## [0.80.0] – 2026-10-31
### Security
- **A request sent to another node no longer carries the operator's credentials.** With a node chosen in the node picker, the request took the access token, the refresh and CSRF cookies, the CSRF token and any `?token=` or `?ticket=` along to the node that ran it, which needs none of them: it is authenticated by the cluster's own signature, and the operator's name still reaches its audit log. That matters once agents exist, since a session token is good on every manager. Sign-in routes now always run on the node the browser is on: signing out with another node selected left the refresh token valid on the node that issued it. `?node=` matched node IDs and names in one pass, so a node named after another node's ID could take its requests; an exact ID now wins.
- **The advanced App Store install's password check uses the cluster's account and the login limit.** On a cluster manager it compared the password with the node's local account, which a password change does not update: after a change the new password was refused and the old one accepted. It now reads the account as a login does, and each try counts against the address's login limit — five in a minute without a success shut the address out for five minutes, logins included — where it had no limit at all. A refusal is now 403, not 401.
- **A compose file that cannot be resolved is not brought up.** The check before `up` that keeps a stack from binding `/etc/sfpanel`, `/var/lib/sfpanel`, `/root/.ssh` or `/etc/sudoers.d` let a stack through when `docker compose config` failed, timed out or gave something it could not read, so a stack that made the resolver fail where `up` did not stepped around it. Such a stack is refused now (`COMPOSE_ERROR`, with compose's reason); stopping, taking down and deleting it still work. The App Store checks the resolved file again after its pull, since the stack's `.env` can be edited while the images download, and a migration's import refuses what it cannot resolve.
### Added
- **Agents: servers the cluster manages without making them members.** An agent runs the same binary with `cluster.role: agent`, outside Raft. It does not vote and holds none of the cluster's secrets — no JWT key, admin account, CA key or internal proxy secret — and has no login, setup page or web UI of its own: you choose it in a manager's node picker, and it serves the node pages — system, files, Docker, logs, terminal and the rest — to its managers alone, each request signed with a key only that agent and its managers hold. Its certificate opens nothing on a manager beyond its own heartbeat and reports, so an agent taken over is no way into the managers or the other agents. The leader registers up to `cluster.max_agents` agents (100 by default).
- **Adding an agent.** **Add agent** on Cluster › Nodes, or `sudo sfpanel cluster token --agent` on a manager, makes an agent token and gives the two commands that use it, the leader's address and the CA fingerprint filled in: `sudo sfpanel agent join <leader>:3629 <token> --ca <fingerprint>` on a server that already runs SFPanel, or `… install.sh | sudo bash -s -- --agent <leader>:3629 <token> --ca <fingerprint>` to install and join in one step; `--name` and `--advertise` set the agent's name and address. The join checks the leader against the fingerprint before the token travels, turns the panel's HTTPS on if it was off, and refuses a server that still holds a manager's cluster files. The dialog waits for the agent and opens it. Join tokens now have a kind — an agent token admits only an agent, a manager token only a manager — and one refused for its kind stays usable. A new cluster takes agents a few minutes after it is created, once its internal proxy secret has settled; until then the join says so and keeps its token.
- **How managers reach an agent.** Requests go to the agent's cluster gRPC port (3629), which takes them only from the managers on its list; event streams, file transfers, terminals and other WebSockets go to its panel (3628) over HTTPS, signed with that agent's key. A relay that could go only in clear, or that the manager holds no key for, is not sent. The agent's panel listens on its advertise address and on loopback only, and answers its managers' addresses and any listed in `cluster.agent_allowed_sources`. It needs no Raft port. Cluster management stays on the managers (`409 CLUSTER_ROUTE_NOT_ON_AGENT` for `/cluster/*` sent to an agent). Keep an agent's clock in step with its managers' (NTP): a relay signed more than thirty seconds off its clock is refused (`502 CLUSTER_AGENT_AUTH_FAILED`).
- **Looking after an agent.** `sudo sfpanel agent status` on the agent shows its managers, the leader it last heard from, the CAs it trusts and its certificate; `sfpanel cluster` commands refuse to run there. Removing it on a manager deletes its key first, and the agent closes its cluster side within seconds; `sudo sfpanel agent reset --yes` then makes it a standalone panel, which asks for an admin account as a fresh install does — the agent dropped the local one when it first started as an agent. A backup restored on an agent keeps its cluster settings, panel port and TLS; a manager's backup is refused there, and an agent's on a manager or a standalone panel. A disband removes the agents first and names any it could not reach, each to be reset on its own server. An advanced App Store install on an agent is checked by the manager it goes through, which sends the agent its signed word instead of the password.
- **A stack on an agent can move to another node, carried by a manager.** An agent holds no other node's key and no node takes anything from it, so the manager the request reaches runs the migration: the agent stops and packages the stack, the manager streams the bundle from the agent's panel to the target without writing it to disk, then tells the agent how the target took it, and the agent applies its disposition, starts the stack again, or leaves it stopped rather than risk two live copies. That manager needs no Docker of its own. An agent's bundle meets stricter rules, so that root on an agent is not root on the target: every risky finding — `privileged`, host namespaces, the Docker socket and the like — must be one the operator acknowledged in the migration dialog, or the target refuses the stack (`COMPOSE_RISKY`); bind data is restored only inside the stack's own directory, and a stack that binds a path outside it is refused; and an image loads only under a tag the target does not have or holds as the same image. Both ends need 0.80, and the pre-flight says when one does not. Every migration now takes its terms — overwrite, acknowledged findings — from the sending manager rather than the bundle, loads only the image tag its stack runs, and caps a bundle's definition files at 64 MiB.
- **Cluster updates go in waves.** The leader looks the release up once and hands it to every node — GitHub allows sixty unauthenticated lookups an hour per address — then updates the agents in waves of the size set on the overview (one by default), the other managers one at a time, and itself last, once another manager voter has taken over its leadership; when none does, it stays as it is and the run says so (a lone manager updates without a handover). Simultaneous, which 0.79 refused on any cluster of two nodes or more, now sends every agent in one wave, the managers still one at a time. Nodes already on the release, and offline ones, are skipped. If leadership moves the run stops, and running it again on the new leader skips the nodes already updated. Its progress, relayed through another node, stays open for two hours.
- **A manager can be converted to an agent, and an agent promoted to a manager, under the same node ID.** Both are in each node's menu on Cluster › Nodes, or `sudo sfpanel cluster convert <node-id>` and `promote <node-id>` on a manager whose service is running. The leader runs them, refuses with the reason when something does not allow it, and the page follows each to the end. The node keeps its name, labels and join time, so alert rules and the audit log go on pointing at it.
- Converting takes a manager other than the leader out of Raft and back in as an agent, provided the managers left keep their quorum and an agent could register now. The server erases its Raft data, the CA key, join tokens, the internal proxy secret, its manager certificate and the update's `config.yaml.bak` and `sfpanel.db.bak`, and keeps its stacks, files and scheduled backups — the answer warns when those backups, taken while it was a manager, hold the cluster's secrets. A conversion that stops part-way leaves the server a manager, or a standalone panel that signs in with the cluster's account. The server held every cluster secret: rotate them afterwards.
- Promoting joins an online agent as a non-voting manager, which votes once its Raft has caught up, and hands it the cluster's JWT key, CA key and admin account. The leader first dials the agent's Raft port (3630 by default), which an agent never needed, and refuses the promotion if it cannot reach it: open it to the managers first.
- **The cluster's secrets can be rotated** on a live cluster, without restarting a node: from Cluster › Security, or with `sudo sfpanel cluster rotate jwt`, `proxy`, `agent-keys` and `ca` on a manager whose service is running. `rotate status` shows where each node stands, by generation and fingerprint and never the secret. A rotation starts only when every node it changes is online and runs a release that can follow it; the refusal names whom it waits for.
- **JWT key:** every manager applies the new key and ends every session it holds, yours included. Terminals and log streams already open stay open, so restart the managers one at a time if someone else may be in one.
- **Internal proxy secret:** the managers take the new secret, switch to it together and accept the old one for two more minutes. Nobody is signed out.
- **Agent keys:** each agent fetches its new key over its own mTLS connection to the leader, and the managers switch to it only once the agent confirms. An offline agent moves when it is back.
- **Cluster CA:** every node trusts both CAs, reissues its certificate from the new one, then drops the old, each step on evidence the leader sees itself. Trust and reissue each have a time limit (30 minutes by default, `--phase-timeout`) past which the rotation goes back by itself, and `rotate abort` stops it until the promote step. Nothing joins, updates, converts or is promoted meanwhile, and join commands printed before it are refused afterwards.
- Rotate after converting or removing a manager, after a backup has left the cluster's servers, or whenever a secret may have leaked: the JWT key first, the CA last. The admin's password, 2FA secret and recovery codes are not rotated; replace them after the JWT key.
- **Cluster › Nodes, the node tree and the overview tell managers from agents.** Nodes is one table for both kinds: each node's kind, group and version, marked Outdated when it is behind the newer of the latest release and the newest version running, with filters by kind, status and group and a search by name, address or label. A group is a `group` label, edited with the others from the node's menu, which offers what that kind of node can do. The sidebar's node tree and the phone's node picker list the managers, then each group of agents, in sections that fold, with search past eight nodes; when the node you have open leaves the cluster, the panel returns to this one and says so. The overview counts managers and agents, points to the offline agents and the nodes behind the latest version, and follows an update wave by wave. The join-token tab is now the Add agent dialog, with manager tokens under "Add a manager instead".
- **Warnings from a leave, a disband or a conversion stay on screen.** The answers carry them with codes (`warnings`): a leave no leader took, the agents a disband could not tell, backups a converted manager took while it was one. The page keeps them in a dialog until you close it, also when the node comes back as a standalone panel.
- **`sudo sfpanel cluster leave --force`** leaves even when the managers left behind could not form a quorum, as the panel's force prompt does; the CLI had no way to.
- **The leader warns of a node whose clock is far from its own.** A heartbeat 20 seconds or more off is logged, at most every ten minutes per node: past thirty seconds, relays to that node are refused.
### Changed
- **The firewall's lockout guard covers the cluster's ports.** On a cluster node, enabling UFW with no allow for them, a deny, reject or limit on one, or deleting the last allow for one is refused as an SSH lockout is (`409 UFW_LOCKOUT`, with `details.cluster_ports`): the cluster gRPC and Raft ports (3629 and 3630 by default) on a manager, the gRPC and panel ports on an agent. The prompt says the node may drop out of the cluster, and lets you go ahead.
- **For API clients.** `POST /cluster/update` takes `wave_size`. In its stream, `started` adds `waves` and `target`, a `{wave, waves, nodes}` event opens each wave, and `complete` adds `leader_restarting`; a node's step is no longer `complete` or `warning` — new are `skipped`, for an offline node, and `transfer`, for the leader's handover — and a node that does not come back on the release is an `error`. `POST /system/update` takes an optional `release` — the one the cluster update looked up — and its quorum refusal is `409 CLUSTER_QUORUM` (was `UPDATE_IN_PROGRESS`). `POST /cluster/disband` sent to a follower is forwarded to the leader. `GET /cluster/nodes` adds `kind`, `POST /cluster/token` takes and returns it, and `GET /cluster/overview` adds `latest_version`. Refusals that are not an ended session no longer answer 401: revoking a join token that is gone answers 404, the advanced install's password check 403, and a git token refused during a stack import 400.
- **Dependencies.** Echo 4.16.3 (#150), whose October advisories concern parts of Echo the panel does not use — its proxy, body-dump, decompression, Basic-auth and CSRF middleware, and the default client-address lookup, which the panel sets itself. vite 8.3.5 and shadcn 4.23, with `npm audit` fixes for hono, @hono/node-server, qs, body-parser, ip-address and postcss-selector-parser, the last by overriding the version `@tailwindcss/typography` pins (#138, #139, #145, #146); what `npm audit` still reports is low-severity, in the deepmerge under the PWA plugin's workbox and under shadcn, which run only while the web app is built. For the desktop app's advisories, the updater crate 2.14.1 with zip 9, and rustls 0.23.46.
### Fixed
These were in 0.79 as well.
- **A disband no longer leaves a manager behind.** The leader stopped as soon as the disband had committed, before the other managers knew it had: of two managers, the follower stayed in a cluster of one that could commit nothing, and of more, a follower that took over stopped as fast in its turn. The leader now waits until the others have taken the disband in, then up to five seconds for them to stop, and its log names any still running; each needs `sudo sfpanel cluster leave --force` on its own server.
- **A disband started on a follower is carried out by the leader.** A follower cannot commit one: it refused, suggesting `?force=true`, and forcing it wiped that follower alone while the others kept it as a voter that would never answer. When there is no leader at all, `?force=true` still disbands just the node it is sent to.
- **A follower can leave the cluster.** Its quorum guard judged the other managers by its own heartbeat table, and managers send heartbeats only to the leader, so every leave from a follower through the panel or the running service was refused as if the others were down; the panel could only force it. A follower now goes by the status the leader keeps in each node's record.
- **A manager that has just stopped counts as down.** The leader's leave and update quorum guards took a manager for online until its last heartbeat was three minutes old, so a leave or an update right after another manager stopped could leave the cluster without a quorum. It is down to them, as to conversions and rotations, once its heartbeat stream to the leader has ended.
- **A leader that leaves takes its voter record with it.** It asked for its successor's address the moment it handed leadership over, found none yet, and stopped with its record still in Raft: of two managers, the one left behind had no quorum. It now waits up to five seconds for the address, and its removal of itself after a handover that timed out is no longer refused. A leave that no leader took says so and names the node to remove on the leader, where `cluster leave` printed "Leader notified of departure." and the panel "Left cluster". A leave with the service stopped removes the cluster's files before it saves the config as standalone, and stops if one will not go.
- **A cluster update counts a failure as one.** A node answers its update request before it streams, so a download, checksum or signature failure in the stream counted as updated, as did a node that did not come back within sixty seconds or came back on its old version after the rollback watchdog restored it; the run went on to the next node. A node now counts as updated only once its own health check answers on the new release within four minutes, and the first failure stops the run — a failed wave before any manager is touched. This holds from the update after this one (see Upgrading).
- **A joining manager becomes a voter once its Raft has caught up.** It was promoted at its first heartbeat, which travels over gRPC, so a manager whose Raft port the leader could not reach became a voter all the same and could cost the cluster its quorum. It is now promoted once its heartbeat shows its Raft within 64 entries of the leader's, and the leader's log says why it waits. A node Raft no longer lists — one whose removal left its record behind — is not added back as a voter.
- **Nothing in a stack migration stops before the target is checked.** A migration stopped and packaged the source even when the target had not answered the pre-flight or could not be sent to, and stopped a stack that binds the panel's own secrets only for the target to refuse it. Both are refused now while the source still runs.
- **Long operations aimed at another node get their five minutes on that node too.** The node that ran a relayed request cut it off after thirty seconds unless it was a compose action, while the node the browser was on waited five minutes: formatting a large filesystem, an apt install or writing a swap file stopped part-way, its command killed with the request. The cluster update's request to each node was cut the same way, so on a slow link a rolling update stopped at the first node whose download took longer.
- **Cluster pages always talk to the manager the browser is on.** With another node still chosen in the node picker, their requests could go to that node, where a manager answered as itself: a Leave there would have taken that manager out of the cluster instead of this one.
- **A cluster created or joined from the panel is fully in place without a restart.** Until the panel restarted, a single node's update skipped its quorum guard, alert rules for chosen nodes fired on every node, and every node, not only the leader, checked GitHub for releases. A join from the panel that failed after opening Raft's files can be retried without a restart too: the retry waited forever on the lock the first attempt kept.
- **The firewall's lockout guard reads allows right.** An outgoing or forwarded allow (`ALLOW OUT`, `ALLOW FWD`) no longer counts as a way in — deleting the last inbound SSH allow went through beside `ufw allow out 22/tcp` — and an allow with a source address, added before UFW is enabled, now counts where enabling was refused as a lockout. Editing a rule into a lockout offers to go ahead, as adding and deleting do; it ended in an error with no way past it.
- **Refusals that are not an ended session no longer sign you out.** Revoking a join token that had been used or had expired, and a git token refused during a stack import, answered 401, which the browser takes for an ended session; they answer 404 and 400 now. A join refused because the cluster was full or the node ID taken no longer spends its token.
- **The update card under Settings → System tells what happened.** It dropped an error the moment it arrived, read every refusal before the stream as "Connection lost", spun forever on a stream that ended without an answer, and after updating another node reloaded as soon as this one answered. It now shows the failure, words the refusal (with the quorum guard's reason), stops on a stream cut short, and waits for the node it updated. The version under the cluster sidebar is the open node's, not the one open when the page loaded.
- Relays no longer leave a connection open on both nodes after every streamed download, upload or progress view. A leader that restarted and took over before replaying its log no longer replaces the internal proxy secret the managers sign with. A backup whose `config.yaml` sets an empty `database.path` is refused before the restore, instead of leaving a panel that will not start.
### Upgrading
- Update every manager first, with the cluster update. This update is run by the leader while it is still on 0.79, so it goes the 0.79 way; the waves, the failure checks and the handover apply from the next update. When it ends, check that every node shows 0.80.0: 0.79 counts an update that failed in its progress stream as done. On a slow link a node still on 0.79 also cuts its own download off after thirty seconds, and the run stops there: update that node from Settings → System with it selected, then run the cluster update again.
- A leave or a disband run on 0.79 may have left a manager that is gone still listed as a voter, or a manager alone in a disbanded cluster. Check `sudo sfpanel cluster list`: remove the first on the leader (`sudo sfpanel cluster remove <node-id>`), and run `sudo sfpanel cluster leave --force` on the second.
- Agents can join once every manager is online and runs 0.80; until then the join says whom it waits for. Open ports 3628 and 3629 on each agent to its managers.
- Once the cluster has agents, never add a manager on 0.79 or roll one back to it: such a manager hands an agent the operator's session token, which is valid on every manager. A join from 0.79 is refused; a binary swapped by hand cannot be.
- A rotation needs every node it changes online and on 0.80. A manager whose environment sets `SFPANEL_JWT_SECRET` takes that key again at every start, undoing a JWT rotation on it: unset it on cluster managers. After a CA rotation, make new join commands.
- An agent has no login, setup page or web UI: work on it from a manager's panel. Its advanced App Store installs go through a manager, which has to run 0.80 to vouch for them.
## [0.79.4] – 2026-10-30
### Fixed
- **A follower's cluster dashboard shows the cluster as the leader sees it.** The overview page's live updates came from the node the browser was on, and only the leader hears the nodes' heartbeats: on any other manager, a few seconds after the page opened, every other node — the leader included — turned offline with no metrics, under a "leader unreachable" banner. The node list and the sidebar, which ask the leader, were right. A follower now passes on the leader's overview, and shows its own, flagged as possibly out of date, only when the leader does not answer.
### Upgrading
- Update every node; the cluster update takes them one at a time.
## [0.79.3] – 2026-10-30
### Fixed
- **Requests a node relays to another node at the same moment no longer cut each other off.** When a node held no live connection to the target — none yet, or one idle for five minutes — requests that arrived together each opened one and closed the one they found, which was the connection another of them had just been given; their retries did the same to each other. All of them failed with "grpc: the client connection is closing", so a page opened on a follower, or on another node chosen in the node picker, came up with 503 and 504 errors, the cluster's node list among them. They now share one connection, and a retry drops only the connection its own request failed on.
- **The phone's node picker comes back once the leader answers again.** The picker at the top of the More drawer asked for the node list once, when the page loaded, and stayed hidden until a reload when that answer was an error. It now asks each time the drawer opens.
### Upgrading
- Update every node; the cluster update takes them one at a time.
## [0.79.2] – 2026-10-30
### Security
- **Rebuilt with Go 1.26.10 and x/net 0.62.** Go 1.26.10 fixes GO-2026-6809: crypto/x509's certificate verification now distrusts compromised intermediate certificates, and the panel's outbound TLS — the release download among it — and its cluster certificate checks go through it. x/net 0.62 fixes two advisories in its HTML package (GO-2026-6866, GO-2026-6860). No line of the panel's own code differs from 0.79.1. The CI vulnerability job had been failing on these since they appeared; the Go advisories that remain are the ones with no upstream fix, as before.
- Web dependencies move to their patched releases — DOMPurify 3.4.17, fast-uri 3.1.9 and serialize-javascript 7.1.4 — which clears the `npm audit` gate. The advisories left are moderate or low and have no fix in range: the `postcss-selector-parser` that `@tailwindcss/typography` pins and the `deepmerge` under the PWA plugin's workbox, both of which run only while the web app is built.
### Upgrading
- Update every node; the cluster update takes them one at a time.
## [0.79.1] – 2026-10-30
### Security
- **A WebSocket the panel refuses is no longer opened behind the refusal.** The check in front of the metrics, container log, container exec, compose log, cluster overview and log stream WebSockets answered a request without a valid ticket, session or token with 401, and then upgraded it all the same: the check reported no error once its answer was written, and every handler went on to the upgrade. A client that read past the 401 had the stream, container exec included. The terminal and AI session WebSockets use another check and were not affected. Update every node.
- **An advanced app install with a wrong password stops at the refusal.** The re-authentication answered 401, and the install went on to the compose checks and, past them, to pulling and starting the stack. A stolen session needed no password for an advanced install.
### Fixed
- **An update the quorum guard refuses stops there.** A single node's update that would leave the cluster without a quorum was answered 409 and then went on to download and install the release. The coordinated cluster update skips this guard by design and is unaffected.
### Upgrading
- Update every node; the cluster update takes them one at a time. Nothing else changes: configuration, cluster traffic and the API stay as they were.
## [0.79.0] – 2026-10-19
### Security
- **A joining server checks it has reached the right cluster before it sends the join token.** `sfpanel cluster join` now takes `--ca sha256:<hex>`, the fingerprint of the cluster CA's public key; `sfpanel cluster token` and the token page print it in the join command, and the web join form asks for it. The joining server asks the leader for its CA key without the token, compares it with the fingerprint, and sends the token only over a connection whose certificate that key signed. The join connection used to verify nothing, so anything on the path could stand in for the leader and take the token. A join without `--ca` is refused.
- **The internal proxy secret becomes a random one held in the cluster's replicated state.** Nodes authenticated the requests they relay to each other with the SHA-256 of the cluster CA certificate, which anyone holding that certificate could compute. Once every member runs this version and accepts the new secret, the leader switches to it; the old one is accepted for two more minutes and refused after that. Each node records the secret it applied, so a restart does not bring the old one back while the node catches up.
- **Cluster gRPC calls are checked against who makes them.** Relaying requests, leaving, reading cluster metrics and subscribing to events are for managers, and a node may remove only itself. Any certificate the cluster CA signed could call everything, including removing another node.
- **Node certificates carry their role**, manager or agent, and the Raft port refuses an agent's. A manager whose certificate predates roles reissues it with the manager role by itself after the update, keeping its key. Nothing issues agent certificates yet; this is groundwork for agent nodes.
### Fixed
- **The update quorum guard counts every voter.** It took quorum as a majority of the voters online, so with five voters and two down it allowed an update that left two. On the leader it now counts every manager voter; a follower still sees only its own heartbeat. The coordinated cluster update is unaffected.
- **Leadership is handed only to an online manager voter** before an update, a leave or a shutdown. Shutdown took the first online node it found, voter or not, and gave up after one failed transfer.
### Upgrading
- Update every node. Proxying between nodes keeps working while versions are mixed, and the secret switch waits until every member runs this version.
- Join commands printed before this version have no `--ca` and are refused; create a new token. A server on this version cannot join a cluster whose leader is older; update the cluster first.
- After the switch — a minute or two after the last node is updated — do not downgrade a single node: an older node signs with the old secret, which the others refuse once the two-minute grace ends. Downgrade the whole cluster or none of it.
## [0.78.22] – 2026-10-18
### Fixed
- **Codex and Gemini install Node.js first when the account has none.** On a host with no npm on the panel's PATH — a Proxmox VE host, or one whose Node.js is nvm's — the terminal's install button stopped at "npm is not installed. Please install Node.js first." Without a system npm it now installs as the account, on the account's own Node.js: its nvm, the npm its login shell finds, or nvm and the current LTS put in place first. Afterwards the account's login shell has to find the tool, or the install says it does not.
- **nvm's default stays on a version that is installed.** The packages page's Node.js button set nvm's default to `lts/*`, which nvm rewrites from every release index it reads. Once a newer LTS shipped, opening the Node.js version list was enough to point the default at a version that is not installed, and node, npm and every tool installed with npm dropped off PATH. The button now pins the version it installs, the list no longer rewrites nvm's aliases, and installing another version keeps the default.
- **A failed install is no longer announced as installed.** The install dialogs — terminal tools, Node.js, Node.js versions, Docker, Tailscale — showed a success notice over a log that had just reported the failure. They now show the failure.
## [0.78.21] – 2026-10-18
### Changed
- **Dependencies.** golang.org/x/crypto 0.58, with x/sys 0.49 and x/text 0.43; docker/go-connections 0.8.2; setup-node 7.1; @playwright/test 1.64.1; and for the desktop app the Tauri API 2.12.4, CLI 2.12.3 and updater plugin 2.14 — its Rust crate as well, since Tauri builds only when the two agree. (#118–#122, #125, #128)
## [0.78.20] – 2026-10-18
### Fixed
- **Alert rule types nothing evaluates are no longer offered or stored.** The rule form offered Service, Login and Package, the server saved them, and nothing ever evaluated them, so such a rule never alerted. The form now offers the types the panel evaluates — CPU, memory, disk and the four container types — and the API refuses any other (`ALERT_RULE_TYPE_NOT_EVALUATED`). A rule of such a type already stored is listed as not alerting and can only be deleted. An OOM rule is stored and listed with the critical severity it always fired with, and a rule scoped to specific nodes must name one. (#132)
- **A container stopped on purpose no longer alerts as down.** `docker stop`, `kill`, `restart`, `rm -f`, `compose stop` and `down`, and the panel's own buttons sent a critical "container down" alert. A stop someone asked for is now told apart by the kill event Docker records before it, and the rule has an option to report those too. The cooldown runs per rule and container, so under a `*` rule one container's alert no longer holds back another's. The restart-loop rule now counts crashes: it counted Docker's restart events, which a restart policy never sends, so it caught manual restarts and never a crash loop. (#135)
- **Cron: the desktop table runs a job now**, as the cards did, and a long command no longer pushes the row's buttons off the screen. (#133)
- **Terminal: a session whose row is gone attaches as its own account.** After a backup restore, a non-root session left without its database row was attached as root and reconnected until it gave up. It is now attached, read and killed through the socket it lives on, and such a session of an account with no rows left is listed too. (#134)
### Changed
- **Dependencies.** echo 4.16.0, a security release whose changes leave untouched everything the panel uses; gopsutil 4.26.9; radix-ui 1.8.1; react-router-dom 7.18.4; globals and eslint-plugin-react-refresh; @playwright/test 1.64; the Tauri CLI and updater plugin; goreleaser-action 7.3. (#122–#131)
## [0.78.19] – 2026-10-17
### Added
- **Tuning fit for a Proxmox VE 9 host.** On Proxmox the tuning page leaves to Proxmox and systemd the guest limits they raise themselves (inotify, aio, map count, `pid_max`, `file-max`) and shows their values. It no longer recommends what does not fit a hypervisor — reverse-path filtering, which an EVPN SDN needs off; BPF JIT hardening; the dirty ratios ZFS and cache-less guest disks never go through; VFS cache pressure, TCP Fast Open, unsaved TCP metrics and a wider port range — and it keeps kernel pointers for root (`kptr_restrict` 1). It also warns of what the Proxmox admin guide warns of: swap on a ZFS zvol, and a ZFS ARC with no limit.
- **The host's own sysctl files are left alone.** A key set in a file under `/etc/sysctl.d` that no package ships, or in `/etc/sysctl.conf`, is no longer recommended or written, and the page lists it with the file. The panel's file used to write over such a file, or lose to it at boot while showing its own value as applied.
### Fixed
- **Tuning recommendations.** `unprivileged_bpf_disabled` is recommended at 2 rather than 1, which the kernel keeps until a reboot, so the rollback could not undo it. TCP's receive buffer max follows the kernel's own, up to 32 MiB, instead of a 16 MiB cap below it. `send_redirects` is turned off for interfaces made later as well, since `all` alone did nothing. `ptrace_scope` is no longer lowered from 2.
## [0.78.18] – 2026-10-17
### Fixed
- **Docker port rules on ports past three digits are moved and kept.** iptables keeps 29 characters of a log prefix, and the one the panel wrote before v0.78.14 lost the space after a four-digit port and the last digit of a five-digit one (32400 was saved as `HPORT=3240`). Those rules stayed pinned to the container's address, and the next change to any Docker rule dropped them from the saved file, so the next reboot opened their ports. A cut prefix now stands for each port it can have been, and the panel takes the one Docker forwards to the rule's container port; a rule whose port cannot be told is left as it is rather than moved to the wrong port. Such rules are always saved, and at start the panel takes back the ones still in the chain but gone from the file. New rules get a log prefix that fits for every port, so the firewall log no longer shows a five-digit port cut short. (#117)
## [0.78.17] – 2026-10-17
### Fixed
- **A restart no longer waits out an open progress view.** Stopping the panel waited for every response in progress, and a progress stream ends only when its page is closed: a cluster update watched in the browser held each node's restart for ten seconds, and the stop then failed with exit status 1 and skipped the steps after it, writing out the audit entries still queued among them. Streams are now cut off as the shutdown begins — the page sees the connection drop, as before, rather than a stream that finished — while ordinary requests still get time to finish, and one that outlasts it no longer stops the rest of the shutdown. A progress view relayed through another node and closed early no longer logs the remote stream as broken.
- **The update's rollback watchdog survives the restart it watches.** The self-update starts a watchdog that puts the previous binary back if the new one never comes up, then restarts the panel. Under systemd that restart stopped the watchdog along with everything else in the panel's service, so a release that failed to start stayed down. The watchdog now runs in a scope of its own, and its verdict is in the journal (`journalctl -u 'sfpanel-update-watchdog-*'`).
## [0.78.16] – 2026-10-16
### Fixed
- **A link tapped in the terminal opens in the Android app.** The terminal's link handler opened an empty window and gave it the address afterwards. In the app's WebView that empty window was the page itself and used up the tap, so the app took what followed for a navigation nobody started and dropped it without a word. The terminal and the container shell now hand the address over in the same call: a browser opens a tab as before, and the app asks to open the link in the phone's browser. The app itself needs no update.
## [0.78.15] – 2026-10-16
### Fixed
- **Alert channels can be added.** The settings page sent a channel's configuration as an object and the server took only a string, so adding a Discord, Telegram or webhook channel failed with "invalid request body" since alerts were introduced in v0.9.0. Both forms are accepted now, and the page's request shape is pinned by a test both sides share. Also: a host-metric rule shows its threshold in the list again, a channel update that leaves out `enabled` no longer re-enables a channel that was off, and a channel whose configuration cannot be read is no longer recorded as delivered. (#116)
- **Claude installed for root is found, and its sessions start.** Root's startup files do not put `~/.local/bin` on PATH, as a normal account's do, so the tools card kept showing Claude as not installed and its sessions could not start. The panel now puts `~/.local/bin` first itself when it checks a tool and when it starts a session, without touching root's dotfiles, and the card suggests a normal account for AI tools. (#115)
## [0.78.14] – 2026-10-15
### Added
- **Proxmox VE hosts are supported.** Installed directly on a Proxmox VE 8 or 9 host, the panel recognises it (the dashboard reads "Proxmox VE 9.x (Debian 13)") and keeps the hypervisor and its guests out of harm's way:
- The network page shows the host's ifupdown2 configuration read-only, with its guests' interfaces grouped apart, and points to the Proxmox UI for changes. The firewall page shows pve-firewall's status and offers no ufw. Docker is not installed on the host; Proxmox recommends it inside a VM or container.
- Networking, corosync, pve-cluster, the Proxmox firewalls and watchdog-mux cannot be stopped from the Services page, and the other Proxmox services need their name typed. A virtual machine's process cannot be killed from the Processes page, which names the VM and points to `qm shutdown`; nor can watchdog-mux, corosync, pmxcfs or the HA services.
- The disk pages set the guests' volumes (zvols, guest LVs, disks a VM has open) and the boot partitions apart, and refuse to format, delete or reuse them.
- Upgrades run `dist-upgrade`, as Proxmox requires. A repository that refuses access, such as the enterprise repository without a subscription, is reported and no longer stops an install. The Logs page reads the journal where there is no syslog file and adds the Proxmox firewall log. The Tuning page leaves the bridge, forwarding and conntrack settings to Proxmox. The memory figure no longer counts the ZFS ARC as used.
- install.sh prints the Proxmox firewall rule instead of ufw advice, and the join commands work as root on a host without sudo.
### Fixed
- **An unconfirmed network change goes back to the configuration from before it was saved.** Saving rewrote the netplan files and Apply then took them as the way back, so a change nobody confirmed "rolled back" to itself. (#114)
- **A new partition table and a fresh file system show at once,** and a new partition can be formatted as it is created. (#113)
- **Docker port rules match the host port, not the container's address,** so a port blocked from the Docker tab stays blocked when the container gets a new address on restart. Rules saved the old way are moved when the panel starts, and a rule nothing names a host port for is shown as not blocking. (#112)
- **Packages with setuid files install and upgrade from the panel** on installs made since v0.13.1, whose service unit forbade them; the setting is dropped at the next start.
- **Upgrades and installs from the Packages page run in a unit of their own:** closing the page or restarting the panel no longer kills apt part-way through, and the page can follow one that is running.
- **"Upgrade all" installs new kernels,** which `apt-get upgrade` kept back, and a configuration file the operator edited no longer stops an upgrade.
- **The package lock check sees apt and dpkg,** which lock with fcntl while the check used flock, and names the process holding it.
- **The Services page refused to stop the panel's own service on screen and stopped it anyway.** Every stop, restart and disable now asks first, and ssh needs its name typed.
- **Disks:** removing a thin pool no longer removes every volume in it, shrinking a logical volume needs confirmation, swap is not written over a device that holds data, and the LVM tab survives LVM's warnings.
- The terminal uses a locale the host has (it forced ko_KR.UTF-8), the panel certificate covers the names /etc/hosts gives the host, Docker port rules added outside the panel survive a panel restart, a Tailscale exit node keeps LAN access, and accepting Tailscale routes that cover a network the host is on is refused.
## [0.78.13] – 2026-10-11
### Fixed
- **Long names on the Docker pages stay in their columns.** A long container name ran over the image and status beside it and the row buttons over the date, and one long image name pushed the size, date and delete button off the images card. Each cell now cuts its text short and shows all of it on hover, and the row buttons have a column of their own, wider on a touch screen.
- **A rolling cluster update waits for each node to restart.** It moved on as soon as a node's heartbeat said online, which it still did for a few seconds while the old process shut down, so on a two-node cluster the leader handed over to a node about to restart and both went down together. It now waits until the node reports a new version. The update is run by the version already installed, so this applies from the update after this one.
- **The full-screen container shell stays on a phone screen.** Since v0.78.0 the expanded shell sat half off the top-left of the screen, with Reconnect out of reach: the build's CSS minifier dropped the rule that undoes the dialog's centring.
- **The notice after installing Docker no longer asks for a restart**, which has not been needed since v0.78.9. (#111)
### Changed
- **Stacks on the Containers page start collapsed.** The ones you open are remembered in the browser, a stack with a container that needs attention starts open, a search or filter opens what it found, and the toolbar has Expand all / Collapse all.
## [0.78.12] – 2026-10-11
### Fixed
- **Disks and network shares the panel mounts reach the host.** Since v0.13 `install.sh` has written `PrivateTmp=true` into the service unit, which gives the panel a mount namespace of its own: a mount made there was invisible to the host, its shells and Docker until the next boot, and a container bound to the mountpoint wrote into the root disk. The unit no longer sets it, the panel drops it from an existing unit when it starts (effective at the next restart), and until then it mounts in the host's namespace. (#106)
- **A firewall rule with a source address opens its destination port.** A rule allowing a subnet to reach port 3628 was saved as the subnet's *source* port 3628, so enabling UFW cut the panel off. Rules added that way before keep the source port and have to be added again. (#105)
- **WireGuard:** an interface brought up before its first peer no longer turns the page into an error, and a new peer's config routes the VPN subnet instead of sending all traffic through a server that does not forward it. (#108)
- **Network:** the interface dialog opens in the mode the config saved, switching to Static fills in only IPv4 addresses rather than pinning SLAAC and link-local IPv6 ones, and gateways are saved as default routes instead of the deprecated `gateway4`/`gateway6`. (#109)
- **Disks:** a new disk without a partition table can be given a GPT table from the Partitions tab, a mount kept across reboots is written to fstab by UUID, the format dialog no longer shows `{{device}}` in its title (nor the cluster disband dialog `{{name}}`), and Docker overlay mounts are hidden from the filesystem list until asked for. (#107)
- **Firewall page:** the port field takes the "80, 443" list its example shows, with TCP or UDP as ufw requires, and the lockout guards read such lists; IPv6 rules are marked; the lockout warning is worded in the panel's language. (#110)
### Changed
- modernc.org/sqlite 1.60.1 and actions/upload-artifact 7.0.2. (#80, #78)
## [0.78.11] – 2026-10-11
### Fixed
- **A self-update that restarted the panel is no longer logged as a failed restart, and the outgoing process no longer panics.** The update and the backup restore restart the panel with `systemctl restart`, run from inside the service it restarts, so stopping the service ended systemctl too, and a restart that worked was read as one that failed: every self-update logged "systemctl restart failed" and exited in the middle of its own shutdown. On that path the update also wrote to its finished response, which panicked on most self-updates; systemd recorded a failed exit, though the new version still started. The outgoing process is the old version, so the change shows from the update after this one.
## [0.78.10] – 2026-10-11
### Fixed
- **A partition in use is no longer deleted or formatted.** A partition that is an LVM physical volume, a RAID member or a ZFS vdev is never mounted itself, so the check that refused mounted partitions let it through. Deleting one removed it from the partition table on disk and then reported an error, so it was gone at the next boot — on an Ubuntu Server LVM install, the volume group holding the root filesystem. Formatting one as NTFS overwrote it on the spot. Both now refuse a device the kernel holds and say so in the panel's language. On NVMe and MMC disks the mounted-partition check looked up the wrong device and never matched.
- **Deleting a partition from the Partitions tab works.** The page sent the partition's name where the server takes its number, so every delete was refused as an invalid partition number.
### Changed
- **Releases carry the Android app.** Each server release gets the signed APK and its checksum from the newest Android release, so the latest release has the app; publishing a new Android release replaces the APK there.
## [0.78.9] – 2026-10-11
### Fixed
- **Docker installed after the panel started is picked up without a restart.** The Docker client was made once at boot, so on a host that had no Docker then, the Docker and stack pages kept answering "Docker is not available" after Packages installed it, until the panel was restarted — and the Services page offers no restart for the panel itself. The panel now connects as soon as the Docker socket appears: on the next Docker request, or within 30 seconds on its own, and the container history, events and volume collectors start then too. (#98)
- **App store "Open app" uses the app's own scheme.** It reused the panel's, so on an HTTPS panel Uptime Kuma (plain HTTP) opened at `https://` and failed. Apps are opened over http unless the catalog marks them as serving TLS themselves, as Nginx Proxy Guard's admin UI does. (#99)
- **Uptime Kuma installs 2.x.** The catalog pinned `:1`, which now opens with a full-screen "outdated" warning. Existing installs keep 1.x; moving one is an edit of its stack image, and 2.0 migrates the database on first start, so back the volume up first. (#100)
- **App READMEs on the detail page.** Images and links in a README's raw HTML resolved against the panel and 404ed; they resolve against the repository now, and links to a repository file open its GitHub page. A badge inside a link printed its markdown. While an install streamed its log the README rebuilt its images on every line, requesting a missing one hundreds of times. A long code line pushed the dialog past its right edge at 1440×900. (#101)
- **Install logs no longer hide the word after "valkey".** The redaction treated any word ending in "key" as a secret name, so `Container npg-valkey Started` printed as `npg-valkey=***`. Values are hidden after `=` or `:` of a secret-named setting (`DB_PASSWORD=`, `SECRET_KEY=`, `x-api-key:`) or after a secret-named flag (`--password`), and words that merely end in "key" are left alone. (#102)
- The stack delete dialog showed the translation key `docker.compose.deleteDataNote`, and a node without Docker said so in English in a Korean panel. A test now checks every literal translation key against both languages. (#103)
- **A SMART self-test the drive is still running is reported as such.** Starting one while another ran showed smartctl's raw "can't start self-test without aborting current test" output as a green "started" notice. The panel now says a test is already running, shows its progress in the SMART dialog with the start buttons disabled, and words a started test as "about N minutes". (Reported on the forum.)
- The README's post-install health check needed `sudo`: `/etc/sfpanel` is readable by root only, so `curl --cacert` failed as a regular user. (#104)
## [0.78.8] – 2026-10-11
### Changed
- **Dependencies, the cluster's consensus library among them.** raft-boltdb 2.4 takes hashicorp/raft from 1.7.3 to 1.8.0 and its log store, bbolt, from 1.3.5 to 1.4.1. Before it shipped this ran on a three-node cluster: started on 0.78.7, upgraded one node at a time with writes from each upgraded node replicating to the older ones, restarted all at once, and rolled one node back to 0.78.7 over logs the new store had written. Every write replicated and no node logged a Raft or store error, so the usual rolling cluster update applies. Also SQLite (modernc 1.59), gRPC 1.84, vitest 5, marked 18.1, and the Tauri 2.12 pair for the desktop app — its npm packages and Rust crates moved together, since the npm side alone fails the desktop build.
- The README explains how to open the macOS app the first time, when macOS calls the un-notarized app damaged. (#68)
## [0.78.7] – 2026-10-10
### Security
- **Rebuilt with Go 1.26.9 and x/net 0.60.** Thirteen advisories were published against the standard library of Go 1.26.8, which every release since 0.72.1 was built with. Among them are an HTTP/2 server crash (an HPACK encoder race), memory exhaustion through HTTP/2 trailers and CPU exhaustion through repeated window updates — in the `net/http` server the panel serves HTTPS from. The fixes landed in 1.26.9, and x/net 0.60 carries the HTTP/2 one as well. No line of the panel's own code differs from 0.78.6. The CI vulnerability job had been failing on these since they appeared; the Go advisories that remain are the ones with no upstream fix, as before.
- Web dependencies move to their patched releases — DOMPurify 3.4.16, brace-expansion, source-map-js and fast-uri — which clears the `npm audit` gate. One moderate advisory remains, in the `postcss-selector-parser` that `@tailwindcss/typography` pins: it runs only while the stylesheet is built, and no release of the plugin takes the fix yet.
## [0.78.6] – 2026-10-10
### Added
**The terminal has its own right-click menu.** A right click in a terminal session raised the browser's menu, and in a tmux session tmux's own pane menu under it — Kill and Respawn one stray click away. It now opens a menu with Copy, Paste and Select text, and the right button no longer reaches tmux. Shift+right-click still opens the browser's menu, which is how a panel served over plain HTTP pastes from a menu: Paste in the new menu reads the clipboard, which browsers allow only over HTTPS. (#69)
**The cluster can be renamed.** The name given at `cluster init` could not be changed. The pencil next to it on the cluster overview renames it from any node (`PUT /cluster/name`); every node shows the new name at once, and `sfpanel cluster status` prints it. (#67)
### Changed
**Every node offers the leader's operations.** Editing a node's labels, handing the leadership to another node and removing a node were offered on the leader's screen only, though the server has always passed a follower's request on to the leader. Every node offers them now, and a follower can take the leadership itself with Transfer leadership on its own row. The leader is never offered for removal; hand its role away first. (#66)
### Fixed
- Dragging over text in a tmux session copied nothing, and the highlight vanished on release. A drag there is tmux's — its mouse mode is what lets the wheel scroll its history — and tmux copies with OSC 52, which the page ignored. The page now takes a tmux session's copies into the clipboard and says so; when the browser refuses a copy it was not asked for by a click, the notice carries a Copy button. A temporary shell, with no tmux in front of it, still ignores OSC 52 so that program output cannot set the clipboard, and tmux's `set-clipboard` is pinned to `external` for the same reason. Shift+drag, or Option+drag on a Mac, selects in the browser instead. (#69)
- Ctrl+Shift+V pasted twice in Chrome and Edge on Windows and Linux: once through the browser's own paste as plain text, and again raw, so a multi-line paste ran its lines in the shell one by one. It now pastes once, the way Ctrl+V does.
## [0.78.5] – 2026-09-29
### Changed
**A long-press selection on the terminal can be adjusted.** 0.78.4 selected a run of text with no spaces in it and offered no way to change that, so a shell prompt such as `user@host:/opt/stacks/app$` — one run — could only be copied whole. The selection now has two handles that drag either end across characters and rows, and holding on after the press and dragging extends it straight away, as a phone's own text selection does. Copy takes exactly what is selected, and the `$` or `#` a prompt ends in is no longer part of the first selection.
## [0.78.4] – 2026-09-29
### Changed
**A long press on the terminal selects the word under the finger and offers to copy it.** Copying a path, a URL or an ID on a phone took opening the text view, finding the text again and dragging selection handles over small type. Now the word is selected in place, with the terminal's own highlight, and a bar offers Copy, Copy line and View all. A word or a line the screen wrapped comes whole, Korean included, and the quotes, brackets and trailing punctuation around a path or URL are left off (`open /opt/a.yml:` copies `/opt/a.yml`). Pressed on an empty row, the long press opens the text view as before. It works the same in the Android app, with no app update.
## [0.78.3] – 2026-09-29
### Added
**Terminal output can be selected and copied on a phone.** The terminal draws into a canvas, so a long press on it selected nothing and a drag scrolled. A long press on the terminal, or Select text in the session menu, now opens the output as text you can select, with a Copy all button. For a tmux session it is the session's whole history, fetched from tmux (`GET /ai/sessions/:id/text`), not just the screen the browser holds. It opens at the latest output, and every line is whole even where the screen wrapped it, Korean included. It works the same in a phone's browser and in the Android app, whose Tools menu opens it too from Android 0.1.6.
### Fixed
- Copy buttons inside a dialog reported "copied" on a panel served over plain HTTP while the clipboard kept its old contents: the dialog's focus trap took focus from the helper the copy relies on there. The copy now happens inside the dialog, and a copy that did not happen is reported as a failure.
## [0.78.2] – 2026-09-29
### Added
**A new server installs and joins a cluster in one line.** `sfpanel cluster token` and the Tokens page print it with the leader's address and the token filled in:
```bash
curl -fsSL https://raw.githubusercontent.com/svrforum/SFPanel/main/scripts/install.sh | sudo bash && sudo sfpanel cluster join <leader-ip>:3629 <token>
```
The installer now waits for the panel to listen before it finishes, so the join that follows reaches it instead of falling back to its offline mode.
### Fixed
**A server joined to a running cluster rejected its own users until it restarted.** Joining without a restart replaces the node's signing key, and login signed with the new key while every request was still checked against the old one. The new node answered 401 to its web sessions and to every `sfpanel cluster` command until `systemctl restart sfpanel`.
**A cluster could lose its password change and its 2FA.** A node that won an election within 30 seconds of booting pushed the admin account it had at join time back into the cluster. The password reverted, 2FA was switched off on every node, and a local admin under another name became a second cluster account. The boot sync now only fills in what the cluster lacks.
**Creating a cluster invalidated the admin's 2FA recovery codes.** They were not carried into the cluster, so every saved code stopped working. They are now.
**A joining server's own admin account kept working after the join.** It could sign in outside the cluster's password and 2FA, and every node accepted its tokens. The new node now takes over the cluster's admin account.
**A node whose cluster failed to start could have its cluster data deleted.** Its Cluster page offered Init and Join. A failed Init deleted the node's Raft data and certificates, and a second click founded a new cluster over the old one. Both now refuse, and the page says to restart the panel.
**On new HTTPS installs, terminals, logs and streams on another node failed until both nodes restarted.** A cluster created or joined without a restart never published the nodes' certificates, so peers reached them over plain HTTP.
**`sfpanel cluster token` run on a follower printed a join command that fails.** It showed the follower's address; it now prints the leader's. The web join form also sent the server's first network interface as its address; it now lets the server pick the one that reaches the leader.
**On a phone, Settings in cluster mode had no password or 2FA section.** The phone menu opens the node's settings, and the page had no way across. A switch at the top now moves between the cluster-wide settings (account, password, 2FA) and this node's.
### Docs
The README now has a guide for adding a server to a cluster: matching versions, the ports to open between nodes, joining without the setup wizard, the web UI path, what joining does to accounts, why two nodes cannot survive a failure, and what the common errors mean. The steps after install use HTTPS, which fresh installs serve.
## [0.78.1] – 2026-09-29
### Fixed
- The working-directory suggestions in the new-session dialog no longer take over the screen on Android. They were a browser-native list, which Android draws as an unstyled sheet over the dialog, the terminal key bar and the keyboard, and which showed a directory twice when it was both recent and a stack. The suggestions are now a list inside the dialog: each directory once with its tags, filtered as you type, scrolling within a fixed height, and usable with the arrow keys, Enter and Escape.
## [0.78.0] – 2026-09-24
### Changed
- Reorganized the dashboard around resource health, actionable alerts, configurable shortcuts, and links to the affected containers and management pages. Mobile sections can collapse; failed refreshes retain the last successful data and expose retry controls.
- Rebuilt resource charts with readable time and percentage axes, data gaps, keyboard and touch selection, series controls, and an accessible data table. Docker CPU charts preserve values above 100%, where 100% represents one CPU core.
- Improved Docker navigation and mobile controls across stacks, containers, images, volumes, and networks. Added resource search, usage filters, sorting, visible container relationships, mobile batch selection, and direct volume file/archive navigation.
- Added container network connection and disconnection controls, plus explicit reconnection, an expanded view, and control keys in the container terminal.
- Stack deployment now previews changes and validates the editor buffer before saving and starting services. Unsaved YAML and environment drafts are retained per stack in memory, with discard and reload protection.
### Fixed
- Late responses from another stack or node no longer replace the active stack editor or service list. Compose validation checks the current YAML without overwriting the saved file.
- Stack Stop preserves containers and networks. Removing containers with Compose Down is a separate, confirmed operation.
- Running but unhealthy containers are marked as needing attention; successful exits are not treated as failures. Docker lists refresh automatically and retain data when a refresh fails.
- Global Docker cleanup reports each resource type's result and refreshes the active list, including after partial failures.
- Container charts retire stale range requests and preserve CPU usage above one core. Touch chart selection keeps vertical page scrolling available.
### Validation
- Frontend production build and 310 unit tests passed.
- 25 browser checks passed, covering Docker workflows, dashboard charts, mobile layouts, and session restoration.
- Backend tests include unsaved Compose validation without file changes and network connections without forced disconnection.
## [0.77.6] – 2026-09-23
### Fixed
**In a two-node cluster, a cluster update left the old leader outside the cluster.** The leader updates itself last, after leaving the cluster, and it asks its own update endpoint to do it. That endpoint refuses an update when taking the node down would leave too few voters — a safeguard against updating nodes one by one by hand — and in a two-node cluster one node down is always too few. So the update was refused, and the old leader stayed running on the old version with no way back into the cluster until someone restarted it. Found while verifying 0.77.4 on a live two-node cluster. The cluster update now bypasses that safeguard, since it has already made the same decision itself, and a leader that has left the cluster and was not restarted by its update now exits so the service manager brings it back.
A node that is already on the latest release is also reported as such during a cluster update, instead of "restarting" and a minute of waiting for a restart that never happens.
## [0.77.5] – 2026-09-23
### Fixed
**A cluster update no longer strands a leader that was already current.** The leader's last step is to leave the cluster and update itself. When there was nothing newer to install, it left the cluster anyway, found the update already applied, and stayed running outside the cluster until someone restarted it by hand. That happens whenever the leader has been updated on its own first — an ordinary sequence now that an update can be started from any node. The leader now checks for a newer release before stepping down, and stays put when there is none. How each run ended is also written to the log, since with the tab closed nobody may be watching it.
## [0.77.4] – 2026-09-23
### Changed
**A cluster update can be started from any node.** The rolling and simultaneous update buttons used to appear only on the leader, and leadership moves on its own — every cluster update ends by handing it to a follower so the old leader can update itself, and restarting the leader for any reason does the same. So the node you set up as "the main one" would, after the first update, no longer show the buttons at all. They now appear on every node, and the request goes to whichever node is currently leading.
That needed one change underneath. Started from a follower, the update's progress travels back through that follower, and the follower is one of the nodes being restarted. The update used to read the dropped connection as "the operator closed the tab" and stop — after the follower, before the leader. It now runs to the end regardless of whether anyone is still watching; the page says so when its progress view is cut off. Closing the tab no longer cancels an update in progress, and a second update started while one is running is refused.
## [0.77.3] – 2026-09-20
### Fixed
**The desktop apps are back in the release.** Windows, macOS and Linux builds were missing from 0.77.1 and 0.77.2: a dependency update moved the updater plugin's JavaScript half to 2.11 while its Rust half stayed on 2.10, and Tauri refuses to build a plugin whose two halves disagree on a minor version. The Rust side now matches. Nothing about the panel changed — if you run the desktop app, this is the build to install; if you use the browser, 0.77.2 is the same software.
## [0.77.2] – 2026-09-20
### Fixed
**Shift+Enter on a phone sent the message instead of breaking the line.** In the mobile key bar, holding Shift and pressing Enter produced the terminal's extended-key form of the combination. tmux, which every AI session runs inside, turns that form back into a plain carriage return before the program sees it — so the key that was supposed to add a second line submitted what was already typed. It now sends a newline, which reaches the session untouched and is what the AI command-line tools bind to inserting one. Desktop keyboards were never affected.
## [0.77.1] – 2026-09-19
### Changed
**The dependency backlog is cleared.** Twenty update proposals had been sitting unmerged since mid-August, all of them stuck behind a vulnerability gate that was red for an unrelated reason and has since been fixed. They are applied together here: the Go modules (protobuf, gopsutil, docker/go-connections, the OpenTelemetry exporter), the pinned GitHub Actions (setup-go, golangci-lint, goreleaser, rust-cache, and the release uploader whose older version dropped a Windows asset in 0.76.0), and the frontend and tooling packages (radix-ui, Tailwind, Vite, vitest, Playwright, and the desktop updater plugin).
Nothing in the panel's own behaviour changes. The user interface is built on radix-ui, which moved several minor versions at once, so the whole test suite and the browser end-to-end checks — dialogs, sheets, tabs, the terminal page — were run against the new versions before this shipped.
---
## [0.77.0] – 2026-09-19
### Changed
**The stack editor no longer refuses what the App Store installs.** Editing a compose file that mounts the Docker socket failed with "binds sensitive host path", which made dozzle, portainer, traefik, dockge, glances, homarr and nginx-proxy-guard impossible to edit after installing them from the panel's own catalog. Counting the whole catalog, 15 apps were affected across six different rules, and moving any of them to another node failed for the same reason.
The check now has two tiers. Mounting the panel's own files stays refused and no answer lifts it: `/etc/sfpanel` holds the JWT signing secret and the cluster CA key, `/var/lib/sfpanel` holds the database, and `/root/.ssh` and `/etc/sudoers.d` are next to them. Everything else it used to refuse — the Docker socket, host namespaces, capabilities, device passthrough — now opens a dialog listing exactly what the stack asks for, and saves when you say yes. The panel hands you a root terminal one tab over, so this was never a wall against an attacker; it was a wall in front of ordinary administration.
The refusal is also enforced where it actually matters. A mount path can arrive through a variable that the `.env` file expands, which no check of the compose text can see, so the resolved configuration is checked once more at the moment a stack is brought up.
### Fixed
**The panel stopped forgetting you on every browser restart.** Logging in issues a refresh cookie good for a week, but the page never tried it: closing the browser dropped the short-lived token and the next visit went straight to the login form with that cookie unused. A page load now attempts one silent renewal before deciding you are logged out. The token still lives only in the tab, which is what keeps a stolen one from outliving it, and the cookie that renews it cannot be read by scripts at all.
A request that never reaches the panel now says so, instead of showing the browser's raw "failed to fetch". If you run the panel behind a reverse proxy and see that message, the proxy is the place to look.
**Two ways the panel's own secrets could have been mounted into a container are closed.** The refusal used to read the compose file as written, so a mount path supplied through a variable and expanded by the `.env` file was invisible to it. The App Store made that reachable without even editing a compose file: several catalog apps ask for a storage path, that answer is written straight into `.env`, and the install deploys without passing the check. Setting such a path to `/etc/sfpanel` would have bind-mounted the panel's signing secret and cluster CA into the app's container. The resolved configuration is now checked at every deploy, the App Store install included, and the check also covers the two other ways compose can reach a host path: a top-level secret or config pointing at a file, and a named volume bound to a device.
**The process list showed a number nobody could act on.** CPU for each process was measured inside a 200-millisecond window, which turned any brief wake-up into a large percentage and put the panel itself at the top of its own list — the walk over every process is the busiest thing on the machine while it runs. CPU is now averaged over the interval between refreshes, so the column matches what `top` reports and the panel no longer outranks the work it is measuring.
**Creating a container asks the same questions as a stack.** `POST /docker/containers` handed its volume list straight to the daemon, so a bind the stack editor would have refused went through unexamined on the sibling route, and `network: host` was not mentioned at all. Both now go through the same two tiers as compose, with the same dialog.
---
## [0.76.1] – 2026-09-18
### Fixed
**터미널 opened a temporary shell instead of your sessions.** A browser that had used the panel before the pages were merged carried a leftover pointer to a temporary terminal tab, and the unified page treated it as the session you were last in. On a phone that meant entering 터미널 landed you in an empty temporary shell while your Claude and Codex sessions sat in the drawer unselected. Worse, the pointer was usually stale: a temporary session is discarded five minutes after its last viewer, and asking the server for one it no longer has makes it create a new one, so the panel was manufacturing the very shell it then showed you.
Temporary tabs are no longer remembered between page loads. A temporary session now exists for one of two reasons only: the server still has it, or you just opened it. Without tmux, where temporary sessions are the whole workflow, the page asks the server which ones are alive and brings those back. The leftover pointers from older versions are cleared the first time you open the page, so this corrects itself on the next visit. Your tmux sessions are what the page opens, and the temporary shell is still one tap away under 도구·계정.
---
## [0.76.0] – 2026-09-17
### Changed
**One terminal, not two.** The 터미널 and AI 코딩 menus served the same activity with two different engines: one opened a shell that died with the browser tab, the other opened tmux sessions that survive a panel restart, run as a chosen account, keep tool logins per profile and remember launch options. Everything worth keeping was on the tmux side, and the operator had to know which menu had it. There is now one 터미널 page, and every session it opens on purpose is a tmux session — a shell by default, or Claude Code, Codex or Gemini CLI as what to start instead. Opening a shell in the folder you used last, as the account you used last, is 새 세션 and Enter.
**A session list you can read across the room.** Sessions sit in a rail on the left, grouped by directory, beside one large pane. Each row is a state dot, the tool's glyph, the title, and a second line only when it has something to say: a session waiting for your input, a tool that has exited back to its shell, a session that ended. Directories keep their place as new ones are added, ended sessions sink to the bottom of their group, and the active row is marked in its tool's colour. On a phone the rail is a drawer, and the button that opens it counts the sessions waiting for you. The rail, the header and the pane share the terminal's own surface, so they read as one console inside the panel.
**Everything the old terminal page could do is still here** — font size, search (Ctrl/Cmd+F), clear, the user@host badge — lifted into a header above the pane, alongside the session's account, directory, profile and launch options, and a menu with the same actions the rail offers. Tool installation, updates, login state and profiles moved to a 도구·계정 panel opened from the bottom of the rail; the launcher links to it when a tool is not installed for the chosen account.
**The old engine stays as the emergency exit.** When tmux is missing or too old, the page says so above the pane, offers to install it, and falls back to the browser-bound sessions of before — listed under a group named 임시 세션 with their lifetime written on it, with any session the server can still reattach listed beneath. When tmux is fine, the same door is one quiet link at the bottom of the tools panel. A session that will vanish always looks different from one that will not.
`/ai` redirects to `/terminal`, so bookmarks and the Android app keep working. The API, the WebSocket routes and the session data are unchanged.
The Android app's ⋯ menu still treats `/terminal` as the plain terminal it used to be, so its 도구·계정 and 세션 액션 items are missing there until the app's next release; everything else in the app works through the redirect.
---
## [0.75.0] – 2026-09-17
### Added
**Start a session the way you want it started.** Opening a folder used to start the CLI bare, so continuing yesterday's conversation or letting the tool work without stopping for permission meant typing a flag by hand — and the flags that only apply at launch could not be typed at all. The new-session dialog now has a 실행 옵션 section: continue the last conversation (or pick from the list), choose how much the tool asks before acting, name a model, and add arguments of your own. The choices are remembered per folder and tool, so the next session in that directory starts the same way, and the collapsed section shows a summary so nothing is ever applied invisibly.
The two CLIs do not agree on shape and the panel absorbs that: Claude takes `--continue`, while Codex continues with a subcommand (`codex resume --last`), which the panel assembles for you. Options are stored on the session as the choices themselves rather than as a command line, so 다시 실행 and 다시 시작 reproduce exactly how a session was started, and a future change to a flag's name cannot turn an old session into an unrunnable command.
**Skipping permission prompts is offered, with the guard rails the tool itself has.** The option carries a warning, asks for confirmation once, and marks the tab of any session running that way. For Claude it is disabled when the run-as account is root, because Claude refuses that combination — the panel says so while you are still looking at the account selector instead of letting the tool die with it after the session opens. Codex has no such rule of its own, so the panel does not invent one.
Every value is checked against the installed tool's own vocabulary before anything starts, and a free-text argument must be a single unquoted token: options reach the tool as separate words, never as a string a shell gets to re-read.
---
## [0.74.1] – 2026-09-17
### Security
- **gRPC updated to 1.83.2.** Three advisories with upstream fixes were open against the version the panel shipped (1.82.1), including a bypass of the xDS RBAC HTTP filter's header matching. The cluster's node-to-node transport is gRPC, so this is a dependency rebuild rather than a change: no line of the panel's own code differs from 0.74.0. `x/net` moves to 0.58.0 with it. The vulnerability gate that caught this had been red since before the previous release; the advisories that remain are the six with no upstream fix, acknowledged as before.
### Fixed
- **A test that failed one run in three.** The mobile-scroll regression test drove a real tmux and waited for a client to appear before sending a wheel event — but a wheel needs the pane's *scrollback*, and `new-session` returns before the pane has any. tmux fixes copy mode's scrollable range at the history size present when the mode opens, so a mode opened on an empty pane stays pinned at the top however many events arrive afterwards, and the test's retry loop was re-sending the wheel into an already-wedged copy mode. It now waits for the history it depends on: 32 consecutive passes where the old order failed six times out of six with the race forced. The behaviour under test is unchanged, and the assertion still goes red when the attach-time mouse option is removed.
- The mouse option that new sessions are created with had no test of its own — the scroll test sets the option itself, so the value in the code could be flipped back with the whole suite staying green. It is pinned now.
---
## [0.74.0] – 2026-09-17
### Added
**Several accounts per CLI, chosen per session.** An operator with two OpenAI accounts — or two Anthropic ones — could not use both from the AI workspace, because each CLI keeps one set of credentials under one configuration directory and every session pointed at that one directory. A session can now run under a named **profile**, which is a configuration directory of its own: create `work` for Codex and the session that uses it signs in separately, keeps its own settings, and leaves the account's normal login untouched. The picker sits in the new-session dialog between the tool and the working directory — after the account, because the account decides which profiles exist — and each entry shows whether it has been signed in and when it was last used. Profiles are listed, created and deleted from the tool's chip, and deleting one says plainly that it signs that account out of that tool and removes its settings; a profile a running session depends on cannot be deleted.
The panel never reads, writes or parses a credential file. Creating a profile creates an empty directory; signing in happens inside the session, through the CLI's own flow. Claude Code and Codex are covered. Gemini is not: no configuration-directory override has been verified for it, so it simply offers no picker rather than offering one that would not work.
### Fixed
- **A profile could have applied to the wrong session, silently.** Measured on the development host: tmux hands a new session the *server's* environment, not the environment of the client that asked for the session — it copies only what `update-environment` names. A profile passed the way the first design intended would therefore have reached only the first session of an account, and worse, a profile placed in the server's environment would have been inherited by every later session, including one created explicitly on the default profile. The operator would have been typing into the wrong account with nothing on screen to say so. The profile now rides on the one command that creates a session, so it applies to that session and to nothing else, and the server's environment never names a profile at all. The same change closes a second path: a session whose creation raced another and was retried would have run on the default credentials while labelled with the profile.
- The profile listing walked its directories by joining strings, so a symbolic link planted at `~/.sfpanel-ai` or one level below would have been followed while every other profile path was already anchored. It now uses the same anchored walk as the rest.
## [0.73.1] – 2026-09-17
### Fixed
- AI terminal history could not be scrolled on mobile: tmux switches the outer terminal to an alternate buffer with no local scrollback, while the panel disabled tmux mouse input. Enable mouse input for new sessions and on every attach to existing sessions. Touch gestures now reach tmux history or the running CLI's mouse handler. Existing sessions do not need to be recreated.
- Web touch scrolling now forwards wheel input in alternate buffers and mouse-tracking applications. Android 0.1.3 already provides this client-side behavior; AI history also requires this server fix.
- Add real tmux PTY and mobile browser regressions that check history movement and return to live output, beyond merely asserting emitted key bytes.
---
## [0.73.0] – 2026-09-14
### Added
**AI 코딩 — Claude Code, Codex and Gemini CLI in sessions that survive.** A new page runs the AI command-line tools on the machine the panel manages, in sessions that keep running when the browser closes, when the network drops, and when the panel itself restarts for an update. Each session is a tmux session started by systemd as its own service, so it lives outside the panel's own process tree; reopening the page attaches to the session that was last open, replays what scrolled past, and puts the cursor back where it was. Two browsers can watch the same session at once, and what one types the other sees.
A session is created by choosing the account it runs as, then the tool, the working directory and a name. The account comes first because it decides the rest: a tool the account has not installed is greyed out, and the directory suggestions are that account's recent directories, the compose stacks under the stacks path, and its home. Running as a normal user rather than root is what makes Claude Code's permission-skipping mode available, and it reuses that account's own login and settings. Tabs can be renamed, killed, and — when the tool exits or the host reboots — restarted with the same tool, directory and account.
A tab whose tool has been quiet for five seconds is marked as waiting, and the number of waiting sessions is prefixed to the browser tab's title, so a long build or a question from the CLI is visible from another page. Only the tab on screen holds a connection, which keeps the cost to one process per open page.
**Install and update the CLIs, per account.** The tool chips along the top of the page show what the selected account's own shell would run, the latest published version, and whether the tool has been logged in. Updating is a button: Claude Code through its official installer, Codex and Gemini through npm, streamed into the output dialog like every other install in the panel.
### Fixed
- **The version shown was not the version that would run.** The packages page resolved a CLI by scanning `/root/.local/bin` and every user's `~/.local/bin` and picking whichever binary was newest on disk. On a machine where two accounts had Claude Code installed, the card showed one account's version while the terminal ran the other's — on the development host, 2.1.265 on the card and 2.1.92 in the root shell. Resolution now happens inside the chosen account's own login shell, so the answer is the account's answer.
- **There was no way to update a CLI from the panel at all.** Once a tool was installed, its card showed a version and nothing else, and the panel never looked up what the current release was. Root's Claude Code on the development host had been sitting at an April build, six months and 178 releases behind, with nothing to say so.
- **A tool installed in an account's home could not be launched at all for some accounts.** The probe and the session both used a non-interactive shell, which never reads `.bashrc` — and `.bashrc` is where `~/.local/bin` joins the path. Debian's root profile returns early for non-interactive shells, so on that account the tools read as missing and a session died with "command not found". Both now run the same interactive login shell the operator gets.
### Changed
- The Claude Code, Codex and Gemini cards have left the packages page, and the six `/api/v1/packages/*` routes behind them are gone; `/api/v1/ai/*` replaces them. The packages page keeps Node.js and Docker and links to the new page.
- tmux 3.2 or newer is required for sessions, which is Ubuntu 22.04 and Debian 12 onward. An older tmux is refused with both versions named rather than failing on a usage error.
---
## [0.72.2] – 2026-09-03
### Performance
The rest of the per-module audit, again measured before and after on the same host.
- **Log streams send frames, not lines.** A container or compose log stream emitted one WebSocket frame per line — a write deadline, a syscall and a TLS record each here, a message event each in the browser — so a 5000-line tail was 5000 frames. Lines are gathered into frames of up to 16 KB, flushed when one fills or 25 ms after the first line went in, which a reader of a live tail cannot notice. The same 5000-line tail is 43 frames and lands in the page in 70–170 ms instead of 240–375 ms; the log viewers split a frame on its newlines, so nothing looks different.
- **The packages page remembers what it asked the CLIs.** Opening it ran five programs to read their versions — node, npm, claude, codex, docker, each a couple of hundred milliseconds of process start-up — for answers that change only when something is installed or switched, and the panel is what does the installing. Answers are kept for ten minutes and dropped by every install and version switch; a second open costs 5 ms per card instead of 25–120. Whether the Docker daemon is running is still asked every time: it is one cheap query and it can change under the panel at any moment.
Measured and left alone: the cluster nodes page repeats two requests the shell already makes on the same cadence — 2 ms of CPU each, every fifteen seconds, not worth the plumbing; and folding the Node version dialog's three `nvm` invocations into one turned out slower, not faster, so it stays as it is.
Two things 0.72.0 could not show live on the development host, verified since in a real browser with the API answers stubbed: an app store category click no longer re-checks installed apps against their registries, and a share whose server has gone quiet shows *server not responding* in its row, no numbers, and stays off the dashboard.
---
## [0.72.1] – 2026-09-03
### Security
- **Rebuilt with Go 1.26.8 and x/crypto 0.56.** Seven advisories against the standard library — `net/http`, `crypto/tls`, `net/url`, `html/template`, `encoding/xml`, `encoding/asn1` — were published against Go 1.25.12, which 0.72.0 was built with; the fixes landed in 1.25.13 and 1.26.6. The panel serves HTTPS from exactly those packages, so this is a rebuild rather than a change: no line of the panel's own code differs from 0.72.0. Go 1.25 has left support, so the module moves to 1.26 instead of the last 1.25 patch, which also takes the x/crypto fix (in its `ssh` package, which the panel does not import, but the gate does not distinguish and should not have to). The CI vulnerability job is what caught it — one red job on an otherwise green run, minutes after the release was cut. The advisories that remain are the ones with no upstream fix, as before.
---
## [0.72.0] – 2026-09-03
### Fixed
- **A network share whose server stopped answering held the whole filesystem listing.** One `df` covered every mount, and `df` on a hard NFS mount whose server is gone does not return — it sits in the kernel until the server comes back. The dashboard asks for the listing every thirty seconds, so every open dashboard had a request hanging for thirty seconds and failing, forever, because a NAS was off. Local filesystems are read with `df -l` now, which never touches a remote mount; each network share is asked separately, with three seconds to answer, and one that does not is still listed — a share that has gone quiet is the thing an operator most needs to see — marked *server not responding* with no numbers, and not asked again for thirty seconds so a dead share cannot pile up stuck processes.
- **The CPU column showed a process's lifetime average, not what it is using now.** The collector took two readings two hundred milliseconds apart, believing the first primed a delta; it did not, because the function it called reports total CPU time over the process's age. A process that burned an hour of CPU last night and is idle now outranked one spiking this second, and the first reading did nothing but cost a boot-time lookup per process. It reads the delta the comment always described, and caches boot time.
### Performance
The second half of the low-spec pass, per module this time. Everything below was measured on a host with 28 containers and 640 processes before and after; the idle numbers from 0.71.1 were already fine, so this is about what a page costs while it is open.
- **The process list costs half what it did.** 440 ms of CPU per listing → 210 ms. The dashboard asks every ten seconds, so one open dashboard now costs about 2% of a core instead of 4.4%. Besides the double reading above, memory percentage re-read `/proc/meminfo` once per process and every username went through the passwd database; total memory is read once and names are kept per uid.
- **The services page no longer asks systemd for the unit-file list on every poll.** `systemctl list-unit-files` costs PID 1 the better part of a second — 830 ms here, against 14 ms for `list-units` — and the page polls every fifteen seconds with a three-second cache, so every poll paid it. The unit-file map answers a question only enable and disable change, and this panel is the one doing those: it is kept for five minutes and dropped by both. A refresh after the cache expires now takes 20 ms.
- **The tuning page reads `/proc/sys` instead of forking `sysctl` sixty-two times.** ~400 ms → 15 ms per open, byte-identical values.
- **Docker volume sizes are measured when the Volumes tab asks, not every five minutes from boot.** 99 volumes was 3.9 seconds of `du` per sweep, 288 sweeps a day, for a number one tab reads. Nothing runs until that tab is opened; then at most once per five minutes, in the background, and the page shows when it was measured.
- **The disk usage tab no longer scans `/` the moment it opens.** `du` over the root filesystem did not finish in thirty seconds here (4.5 million inodes), which is longer than the browser waits — so the tab auto-ran a scan that always died with an error. Root waits for a click; every other path loads as before.
- **The app store checks the registry once per app per visit,** not once per category click: the check was keyed on the app list, and the list is replaced by every click. Opening an app's page runs one `ss` for all its ports instead of one per port.
- **Thumbnails decode two at a time.** A grid mounts six tiles at once and each cache miss decodes the full image before scaling it — a 12 MP JPEG is ~50 MB of pixels, six of them is 300 MB, and on a 512 MB host that is the panel gone. Waiting tiles render a beat later.
- **The driver of a virtual interface is remembered.** Every veth, bridge and tunnel has no driver link in sysfs, so each one fell through to an `ethtool` fork on every refresh of the network status — forty forks on this host, to learn forty times that a veth's driver is "veth".
- **The firewall log is streamed, not buffered.** The filtered source read every matching line of `kern.log` into memory and then piped it into a `tail` process — hundreds of megabytes resident on a host with UFW logging on, for a page that shows a hundred lines. The last N lines are kept as the stream goes by; no second process.
- **The audit log prune uses its index.** Ordering the subquery by id made SQLite sort every unprotected row through a temporary B-tree, every five minutes, over the full 50k rows once the table reaches its cap; ordering by timestamp — the same order, the table is append-only — is served by the existing index with no sort.
Reviewed and left alone, because measuring said so: compose, the Docker hot paths, the app-store catalog, port map, cluster, WebSocket, terminal, auth, cron, settings, alerts, backups, the update checker and the metrics broadcaster. The services page's cold load and fail2ban were on the list and turned out to be systemd and the jail count, not the panel.
---
## [0.71.2] – 2026-08-25
### Performance
- **The Docker page carried the compose editor too.** The same thing 0.71.1 fixed for the file manager, one page over — and only visible once that one was fixed, because measuring again put the stacks page at 5.18 MB where the file manager beside it was now 1.19 MB. Three pieces of it pull the editor in: the stack editor panel, the create dialog and the diff view, all of them fetched before knowing whether anything would be edited. **5.18 MB → 1.55 MB**. The stack list fetches none of the editor now; opening the editor tab fetches it and it works as before.
---
## [0.71.1] – 2026-08-25
### Performance
- **The file manager no longer downloads the editor to show you a directory.** Monaco and its language workers are 3.5 MB, and they were part of the page rather than the editor, so opening a folder fetched the whole thing before knowing whether anything would be edited — which for most visits is never. It arrives when a file is opened instead: **5.49 MB → 1.93 MB** transferred, and 1836 ms → 1565 ms to settle. Listing a directory now fetches none of it; opening a file fetches it and the editor appears as before.
Nothing else needed changing, which was worth measuring rather than assuming. Idle, the panel holds 44 MB and uses two tenths of one percent of a core with 28 containers running; one open dashboard costs 5.3% and 49 MB; the most memory the process has ever held is 59 MB. A directory of 2245 entries lists in 65 ms and moves memory by 1 MB, and downloading a 1.6 GB file leaves the resident set where it started, because it is streamed rather than buffered. The metrics sampler already stops when the last viewer disconnects, so an unattended panel samples nothing at all.
---
## [0.71.0] – 2026-08-24
### Fixed
- **Only the first DNS server was read on Ubuntu 20.04.** `resolvectl` wraps — a value that does not fit continues on the next line with no key in front of it — and the parser read only the lines with a key. systemd 245 prints one server per line, so a host with three resolvers reported one, and the panel showed a DNS configuration that was not the host's. On newer systemd it was the wrapped search domains that disappeared instead. This was the last of four defects in this area recorded as known and unfixed; the previous release resolved the other three.
- **"Reset to Defaults" in system tuning reset nothing.** It removed the panel's sysctl file and reloaded, which re-applies the files that remain — a setting no other file mentions simply keeps whatever is already in the kernel. The host stayed exactly as tuned as it was while the answer said otherwise. What each setting held before the panel changed it is now recorded alongside the change, and put back on reset. A configuration written before this says what actually happened rather than claiming a reset it cannot perform.
- **Work sent to another node was cut off after thirty seconds** although the node receiving it allows five minutes. Formatting a filesystem, installing a package, writing a swap file and tearing down a stack all died partway when aimed at a different node, and because the work is tied to the request that asked for it, the far side was left half-finished with nothing said. The exceptions are listed explicitly, so a genuinely stuck request still cannot hold a connection open.
- **An alert that reached nobody left no trace.** The history recorded a firing only when at least one channel accepted it, so a rule whose channels had all failed — a rotated webhook, a deleted channel — wrote a line to the log and nothing else: the rule went on showing as active and the history stayed empty, which reads as nothing having gone wrong. Every firing is recorded now, and one that was delivered nowhere says so.
---
## [0.70.1] – 2026-08-24
### Fixed
- **Installing a package behaved differently depending on which screen you did it from.** Six handlers ran `apt-get` and no two agreed. Three never set `DEBIAN_FRONTEND=noninteractive`, so a package that asks a debconf question — a kernel upgrade about a config file, iptables-persistent about saving rules — waited on a terminal that does not exist until the five-minute timeout, with nothing in the answer to say why. Two let the command outlive the request that started it. Only one checked whether another package operation was already running, so everywhere else you waited out the timeout instead of being told. They share one path now, and it carries all of it: the lock check, the non-interactive environment, the request's own lifetime, and the `--` that stops a package name beginning with a dash from being read as a flag.
The cause was in the shared code rather than the handlers — nothing there could take an environment and a request lifetime at the same time, so each caller gave up whichever seemed to matter less. A test now walks the tree and fails on any new caller written the old way, naming the file and line.
---
## [0.70.0] – 2026-08-24
### Added
- **Restore straight from a backup the panel already holds.** The backup half was finished — a schedule, retention, run-now, and the archives listed with Download and Delete beside each — while restore only ever accepted an upload, so recovering from a scheduled backup meant downloading it out and posting the same bytes back in. Each archive has a Restore button now. The name is matched against the listing rather than turned into a path, so there is no string that names a file outside the directory and also appears in it.
- **A disk mounted from the panel can be kept after a reboot.** Network shares always wrote an `/etc/fstab` entry; block devices never did, so a data disk attached here quietly disappeared on the next boot while an SMB share attached on the same screen did not. Mounting offers it, on by default, and unmounting offers the reverse — an entry left behind makes the unmount look like it did not take. The entry carries `nofail`, which is the difference between a missing disk and a host that will not boot, and only entries with the panel's own marker are ever touched.
### Fixed
- **Alerts were presented as cluster-wide settings and are not.** The rules and channels live in each node's own database — nothing replicates them — and each node's evaluator reads only its own, so a rule created from the cluster-wide tab existed on no other node. The rule form's node picker made it worse by offering to target one of those other nodes, producing a rule stored where nothing would ever evaluate it, listed as Active for good. Alerts move to the per-node scope, and the picker offers the node you are editing.
- **Copying onto an existing file reported a different error than renaming or uploading onto one**, though all three refuse the same collision with the same status. The interface picks its wording by that code, so the most common collision was the one it could not describe. Copy also had no way through, leaving "delete the destination first" as the only route — a more dangerous operation than the one being asked for; it takes the same overwrite option rename has.
- The API reference was missing the entire network-shares group and a dozen other routes, found by diffing the document against every route the router registers.
---
## [0.69.0] – 2026-08-24
### Added
- **A network change now has to be confirmed, or it is undone.** Applying netplan validated the file's syntax and nothing else, so a perfectly valid configuration that moved the host off the address you were reaching it on took the panel with it — the handler's own comment had named that risk for a long time and settled for what `netplan generate` could catch. The previous files are kept, a sixty-second timer is armed, and they come back unless the panel hears otherwise. Reaching the confirm button is the proof, since it can only be pressed over a connection the change did not break. Files the apply introduced are removed on the way back too, or they would keep overriding the restored ones.
- **Volume groups can be removed.** `DELETE /lvm/vgs/:name` has been implemented and registered since LVM landed, with no caller anywhere — so a volume group created by mistake could not be undone from the panel. Disabled while the group still holds logical volumes, which `vgremove` refuses anyway.
### Fixed
- **Deleting a stack took its data with it, with no way back.** Relative bind mounts live inside the project directory — `./data:/var/lib/postgresql/data` is the ordinary way to persist a database — and deletion was an unrecoverable `RemoveAll` over all of it, while the dialog's only mention of data was a checkbox about named volumes. Unchecking it read as "keep my data" and the data went anyway; on one stack here that is 2.5 GB. Both the compose and app-store paths move the directory to the same trash the file manager uses, recoverable for a week, and the dialog says what actually happens.
- **Restore installed a `config.yaml` it had never read.** The archive's copy was renamed over the live one and the panel exited for systemd, so a corrupt or foreign config took it down at exactly the moment there was no way back in. It is parsed and checked first, by the same rules the loader applies — no stricter, or it would refuse backups that work.
- **Deleting a partition had no in-use check**, though formatting one — right beside it, and less destructive — has had one all along. Deleting the partition behind `/boot` leaves a host that does not come back.
- **Turning `docker.observability` off broke two Docker tabs.** The disabled branch answered with an object wrapping an array where the enabled branch, the client, and the setting's own documentation all say array.
- **A host without Docker answered every Docker route with a bare 404** that is not the envelope the client parses, so the pages reported "Unknown error" instead of saying Docker was missing. Unmatched API paths get a proper answer now, and the Docker prefix gets a reason — but only when Docker really is absent.
- **On a phone in cluster mode there was no way to reach node-scoped settings** — panel update, backup, restore, TLS, tuning and the audit log all live behind a scope reachable only from the desktop sidebar and a right-click menu a phone cannot open.
- The fstab backup discarded its error under a comment saying that was worth not doing silently. It is the operator's only undo if the panel ever writes a valid-but-wrong fstab.
- The dashboard's backup status used a translation key present in neither locale, so a Korean interface read "failed" in English.
---
## [0.68.0] – 2026-08-24
### Fixed
Six operations that failed outright, deleted something quietly, or left the operator with no way forward. Each was reproduced before it was changed.
- **Editing a network interface deleted the default route.** The gateway fields were assigned unconditionally while the addresses, DNS and MTU beside them were all guarded, and the config dialog never filled the box — it opened blank and sent that blank back. Changing an MTU on a static interface therefore removed the host's default gateway, which on a remote machine is the last request the panel serves. Omitting a gateway now leaves the saved one alone; clearing it takes an explicit empty value. The dialog reads the interface's own configuration to fill the field, and where it cannot — a NetworkManager-rendered netplan, for instance — it says nothing about the gateway rather than sending a blank it never filled.
- **Saving netplan deleted the sections it does not model.** The top-level structure was the only one in its family without a catch-all for unknown keys, and the file is rewritten whole, so an MTU change removed `wifis:`, `tunnels:` — where netplan-managed WireGuard lives — `vrfs:` and `modems:`. Permanently: the write is atomic and keeps no backup.
- **Blackhole routes lost their destination.** `blackhole 10.0.0.0/24` was read as a route to a destination literally named "blackhole". Route types are parsed as types now, and a destination that merely begins with the same letters is not mistaken for one.
- **DNS landed on a whichever interface came out of a map first.** Go randomises that, so a netplan file with two ethernets sent the servers somewhere different each time. Sorted by name.
- **Removing an LVM physical volume could never succeed.** `pvs` reports `/dev/sdb1`, and the handler validated that with a rule excluding slashes before adding the prefix itself. Creating one was broken from the other side: the dialog's own placeholder was a string its handler refused. Both accept either form now, using the normalisation the volume-group handler already carried, and it is tested against path traversal.
- **The browser abandoned operations the server allows five minutes.** Every request gave up after thirty seconds, and the subprocess is tied to the request, so `mkfs`, `apt install`, `docker compose down` and the `dd` behind a swap file all died halfway. For swap that was worse than a failure — the half-written file it left was then refused by the handler's own overwrite guard, with no way past it from inside the panel. Those calls wait as long as the server does, and a failed `dd` clears up after itself.
- **Deleting a compose stack removed the definition even when the teardown had failed.** The containers stayed up, the stack disappeared from the list, and the compose file and its `.env` went with it, outside the file manager's trash. It now checks what is actually still running first: a non-zero exit alone is not proof, since `down --rmi all` can remove every container and still fail on a shared image.
- **Deleting an alert channel left rules pointing at nothing.** Nothing reconciled the rules' channel lists, delivery skipped the missing entry silently, and the rule went on reporting itself as active while reaching nobody — rotate a webhook and the alerts stop, with no sign of it. The reference is pruned in the same transaction as the delete.
---
## [0.67.1] – 2026-08-24
### Removed
- **The dashboard's attention strip.** It reported ordinary operation as if it were trouble: Docker emits a `kill` event on every `docker stop`, and exit codes 137 and 143 are SIGKILL and SIGTERM — what stopping a container looks like, not what a crash looks like. On a normal day that is seventy such events, and every row the strip showed on first contact was a `compose down` or a container someone ran by hand. An alarm that is always on is worse than no alarm. Whether a container needs looking at is a question about its *current* state, which the Docker card beside it already answers correctly; a log of past events was a worse second opinion. Worth revisiting from the alerting side if rules start being used, rather than by scraping the event log.
- `GET /system/services` no longer accepts `?state=failed`. It was added for the strip and had no other caller.
Everything else from 0.67.0 stays: the chart fetching the range it displays, peak-preserving reduction, the disk card leading with the fullest filesystem, the Docker card's crash/stop distinction, and backup state on the host card.
---
## [0.67.0] – 2026-08-23
### Added
- **An attention strip.** Three things the panel has been recording on a timer never reached the page an operator actually opens: container events — persisted with exit codes and OOM kills since observability landed, with no frontend caller at all — alerts that fired, evaluated every sixty seconds and readable only through Settings, and failed systemd units. They fold into one short list at the top of the dashboard, deduplicated so a container in a restart loop is one row rather than forty, and it renders nothing at all when there is nothing to say.
- **The Docker card can tell a crash from a deliberate stop.** `stopped = total − running` put a container in a restart loop in the same grey box as one stopped three weeks ago, and painted a running-but-unhealthy container green. Both distinctions were already in the response the page fetched: `State` carries "restarting" and `Status` carries the "(unhealthy)" suffix and the exit code. The five rows now sort trouble first, then by the hourly CPU average the handler computes on every one of those calls and the dashboard was discarding.
- **The disk card measures more than `/`.** It read `disk.Usage("/")` and nothing else, so a media array on `/mnt` — or a share this panel mounted itself — could sit at 99% behind a calm 34%. It leads with the fullest filesystem you can actually fill and keeps `/` on a line of its own. The collector and the disk alert threshold are deliberately untouched: changing what `disk_percent` means would silently change every stored history row and every rule already configured against it.
- **Backup state on the front page** — off, last run, or failed. It was visible only in Settings → Maintenance, and the cost of not knowing is discovered during a restore.
- Metric values carry the colour their bars already had, so 92% and 20% are no longer the same glance.
- `GET /system/services` accepts `?state=failed`, so a thirty-second poll for a number that is almost always zero stops carrying 229 units.
### Fixed
- **The chart's range tabs did not fetch anything.** `1h / 4h / 12h / 24h` was a client-side filter over one payload always requested at the 24h default, so the tab the page opens on drew about five points. The server had accepted `?range=` all along. Measured against the running panel: no range returns 122 points twelve minutes apart, `?range=1h` returns 61 points a minute apart.
- **History reduction threw away spikes, non-deterministically.** Reducing to ~120 points took every Nth sample, discarding eleven of every twelve across a day; a three-minute CPU pin survived roughly one time in four, and which samples survived shifted as old rows aged out — so reloading the page made a spike appear or vanish. It now reduces by time bucket and reports each bucket's peak for each series independently, so a memory spike is not hidden by a quiet CPU beside it. A reduced point is therefore an envelope rather than one observed sample, which is the honest trade for a chart whose job is "did anything spike".
- **The chart no longer accumulates live socket samples.** The server collects once a minute, so a two-second sample added nothing the series did not already have. What it did add was a second source of truth that outgrew its own cap, evicted the history behind it, and stamped its points with the browser's clock while the server's rows carried the host's. The metric cards remain live at two seconds.
---
## [0.66.2] – 2026-08-23
### Fixed
Five things on the dashboard were wrong under a badge that said "live". All five were measured against the running panel rather than reasoned about — over forty seconds `/system/processes` fired five times and `/docker/containers` exactly once.
- **The container summary and both log panes never refreshed.** They were fetched once when the page mounted and never again, so a container stopped from another tab kept showing as running until you navigated away and back. They refresh every thirty seconds now, through the same visibility-aware timer the process table already used, so a hidden tab still costs nothing.
- **The chart threw away its own history.** The metrics socket sends a sample every two seconds and every one was kept, which made the 2880-point cap worth 96 minutes: a tab open longer than that had evicted the 24 hours of history it loaded at mount, and the 24h button drew an hour and a half. Samples are thinned to one every thirty seconds, which makes the cap mean exactly 24 hours and matches the granularity the server's own history has. Nothing is lost to look at — at the shortest range a two-second sample was a third of a pixel.
- **Uptime stopped counting.** It was read once, so a dashboard left open on a second monitor reported the uptime the host had when the page loaded. The socket already carries the server's clock, so the reading is carried forward with no timer, no extra request, and no comparison between two machines' clocks.
- **The recent-logs card opened empty on most hosts.** It defaulted to its firewall tab, which has nothing in it unless ufw logging is on. It picks the system tab when the first firewall read comes back empty — decided once, so a refresh never moves the tab under someone reading it.
- **The process table did not fit a phone.** Four columns overflowed the card by 115px on a 375px screen, hiding the memory column behind a horizontal scroll with nothing on screen to say it was there: the header read "메도" and the values stopped at "1.". The pid column, which nothing on this page acts on, gives way below the small breakpoint.
---
## [0.66.1] – 2026-08-23
### Fixed
- **Grid thumbnails were cropped and blurry.** A wide banner came out as its middle third, enlarged three times over: filling a square tile means cropping whatever does not fit, which removes exactly the part that would have told you which file this is. They fit the tile now, the way every desktop file manager does. The other half of the blur was resolution — a tile is about 150 CSS pixels but a phone draws that with two or three device pixels each, so displays above 1x ask for a 384px thumbnail instead of 192.
### Documentation
- The file manager's frontend spec still described the pre-0.65 page: no trash, no move, no owner or permissions columns, no archives, no phone layout, and a fixed alphabetical order that sortable columns had replaced.
- Corrected the thumbnail sweep comments. Cache age is measured from when a thumbnail was written, not when it was last looked at: a read leaves no mark on the file, and atime is unreliable on a relatime or noatime mount.
---
## [0.66.0] – 2026-08-23
### Added
- **A grid view for the file manager.** The listing could only be read, never looked at: a directory of photos showed two hundred identical icons, so finding one meant opening them in turn. The grid renders real thumbnails — scaled on the server, cached, and keyed on the file's modification time so an edited image gets a new thumbnail rather than a stale one. JPEG, PNG and GIF; anything else falls back to its icon. The list view stays the default and stays better for everything that is not a picture, since it shows size, date, owner and permissions at a glance.
- **Context menus that are actually reachable, including on a phone.** Right-click worked on table rows and on a 40-pixel strip below the listing, and nowhere else — not on the empty space that makes up most of a sparse directory, and the phone layout had no context menu at all. The whole listing area is a target now, with padding below the content so the background menu stays reachable in a directory of any length; the phone's cards and the grid's tiles have the same menu, opened by holding one. Holding tolerates about ten pixels of drift, since a finger resting on glass is never quite still.
- **New File defaults to a text file** — `untitled.txt`, with `.txt` appended if you type a name without an extension — and opens the editor once created, instead of leaving you to find the new empty file and open it yourself.
### Fixed
- The thumbnail endpoint answered an unsupported format with `NOT_TEXT_FILE`, which says the opposite of what is true about a `.txt`: the file is text, and the reason there is no thumbnail is that the panel has no renderer for that format. It sends `UNSUPPORTED_FORMAT`.
- The API reference listed ten file-manager routes when eighteen are registered; `chmod`, `chown`, `archive`, `extract` and the three trash routes had been added without the summary table following.
### Security
- **Thumbnailing is where an image file becomes an attack.** Decoding expands an image into raw memory, and the byte size does not bound that: a 20 MB JPEG declaring 20000×20000 is 1.6 GB of RGBA once decoded, and a handful of concurrent requests takes the panel down. The header is parsed first — dimensions without pixels — and an image over 80 megapixels is refused before a single row is allocated. Confirmed against a crafted file: refused in 10 ms, which is the header path; decoding would have taken seconds.
- Thumbnail reads open with `O_NOFOLLOW`, refusing a leaf symlink in the kernel rather than in a check a local process could race, and the cache lives under `/var/lib/sfpanel` rather than beside the originals, where it would appear in the operator's listings and backups.
---
## [0.65.0] – 2026-08-23
### Added
- **The file manager can write where you actually work.** It could read the whole filesystem and change almost none of it: `/etc`, `/opt`, `/home`, `/var`, `/srv` and `/root` were refused by a prefix rule that gated every write in the module. You could open `/opt/stacks/<app>/docker-compose.yml`, fix a port, press Save, and be told "access to critical system path is not allowed" with no hint where writing *was* allowed — in practice `/tmp`, `/mnt` and `/data`. That rule is gone. Delete keeps a much narrower guard on the directories whose removal stops the machine outright, and writes are refused only under `/etc/sfpanel`, `/root/.ssh` and `/etc/sudoers.d`, which hold the panel's own credentials.
- **A trash.** Deleting was immediate and total, with no undo and no record of what had just gone — on a mounted disk or a network share that is somebody's only copy. Deleted entries wait a week and can be put back, with a dialog showing where each came from. Restoring never overwrites: clobbering a newer file to recover an older one is the opposite of what was asked.
- **Move, at last.** The server has always supported it; the UI only ever renamed within the current directory. Destinations are browsed rather than typed from memory, for move and copy alike.
- **Ownership and permissions.** The listing showed `drwxr-xr-x` — what the owner may do — and never said who the owner is, which leaves the most common homelab problem (a container writing as a uid you cannot touch) visible as a symptom with no cause. Owner and group are columns now, and both permissions and ownership are editable.
- **Archives.** Create `.tar.gz` or `.zip` from a selection, and extract in place.
- Sortable columns, a dotfile toggle, drag-and-drop upload, arrow-key navigation, and the current directory in the URL — Back used to leave the file manager entirely instead of stepping up one level, and a refresh dropped you at `/`.
- **A phone layout.** 891 lines contained one responsive class: six columns do not fit 390 pixels, so size, modified and permissions were off-screen rather than cramped, Upload was past the right edge, and the five row actions were 24-pixel targets with Delete beside Rename.
### Fixed
- **Opening a binary could destroy it.** Every non-directory went to the text editor, so a PNG or a database came back as replacement characters with an enabled Save button — and saving wrote those characters over the original. Images preview, other binaries offer a download, and the read endpoint refuses non-text outright.
- **The editor lost work four ways**: Escape and backdrop clicks discarded edits with no prompt, Ctrl+S opened the browser's Save Page dialog, two tabs on one file ended with the second save silently discarding the first, and "New file" with an existing name emptied that file.
- **Rename and upload overwrote silently** while Copy, on the same screen, refused the identical collision. All three now agree.
- **Bulk delete reported nothing useful.** Forty serial requests behind one spinner, a Cancel button that only cleared the selection while files kept disappearing, and a closing "Deleted 37, 3 failed" that named none of the three. It shows progress, cancels for real, and says what failed.
- Symlinks no longer masquerade as broken files, the breadcrumb names which cluster node you are browsing, and a directory of thousands of entries no longer renders every row.
### Security
- **Archive extraction had a symlink escape.** Entry-path containment alone is not enough: an archive carrying `evil -> /etc/sfpanel` followed by `evil/config.yaml` passes both checks lexically and writes outside the destination. Link entries are refused, directory chains are built one `lstat` at a time so an already-planted link cannot be traversed either, and leaves open `O_NOFOLLOW`. Confirmed by building that archive and watching the bytes land outside before the fix.
- **Upload could overwrite the panel's own database.** The destination directory was validated and the filename joined onto it, but the result never was — `/var/lib/sfpanel` plus `sfpanel.db` reached the live database, and with it the admin account row.
- Extraction is bounded by a total size and an entry count, so an archive that is kilobytes on disk cannot expand to fill the filesystem the panel runs on. `chmod` refuses setuid, setgid and sticky, and refuses to act on a symlink.
## [0.64.2] – 2026-08-23
### Fixed
- **A cluster node could not register its certificate authority with the leader.** Requests forwarded between nodes never declared a content type, so the receiving handler rejected the body outright. Every earlier caller of that path sent a GET with no body, so nothing had exercised it until now — the symptom was a follower repeatedly reporting that the leader had refused its authority, leaving peers reaching it over plain HTTP. Found on a live two-node cluster, not in a test.
## [0.64.1] – 2026-08-23
### Fixed
- **The desktop auto-updater could not download v0.64.0.** Tauri names its bundles from a version field kept by hand in `tauri.conf.json`, while the update manifest builds its URLs from the release tag. The field still said 0.63.1, so the manifest advertised 0.64.0 and pointed at files that did not exist — every desktop update attempt got a 404. The version is now stamped from the tag at build time, so the two cannot diverge again. Server installs were unaffected.
- The tuning end-to-end spec asserted that `net.bridge.*` is always offered. Since 0.63.0 those parameters appear only when `br_netfilter` is loaded, which is not the case on a CI runner, so the suite had been failing since that release.
## [0.64.0] – 2026-08-23
### Added
- **HTTPS on the panel port, set up for you.** Set `server.tls.enabled` and the panel generates its own certificate authority on first boot, issues itself a certificate from it, and serves HTTPS on the same port — no `openssl`, no manual steps. Download the authority from Settings → System, install it once per device, and the browser warning stops for good. `install.sh` turns this on for **fresh installs only**: it never rewrites an existing `config.yaml`, so panels already deployed keep serving plain HTTP until their operator opts in, and setups that terminate TLS at a reverse proxy simply never do.
- The authority lasts ten years, the server certificate one. That split is deliberate: the file you install on your phone and laptop is the authority, so that is what carries the long life, while the certificate itself stays under the 398-day limit platforms enforce. It renews on restart before it expires and reissues if the host's addresses change — the authority is untouched either way, so devices only ever install it once. Docker's per-project bridges are left out of the certificate, or an ordinary `docker compose up` would reissue it.
- Settings → System shows what the panel is serving: both expiry dates, the authority's SHA-256 fingerprint so you can match it against what your device shows after installing it, and every name and address the certificate covers.
- Operators who supply their own `cert_file` and `key_file` keep full control: the panel never renews or replaces that material, and refuses to start rather than quietly serving something else in its place.
### Fixed
- **The web terminal dropped you into a shell with no aliases and no prompt.** `ll` was not found and the root prompt was bare, because the session's `HOME` resolution had two steps that failed for the same reason — on Linux the fallback only re-reads `$HOME`, which systemd does not set for a unit with no `User=`. Every session landed in `/tmp`, where there is no `.bashrc`, and nothing reported an error. The home now comes from the OS user database, and `USER`/`LOGNAME`/`SHELL` are set too.
- **The terminal now names what you are typing into** — the account and host appear in the tab bar, in a warning colour when that is root. In a cluster the same page targets a different machine depending on the node picker, and a root prompt looks identical on all of them.
- **The terminal follows the app theme** instead of staying dark in light mode, and **Ctrl+Shift+C copies the selection** — xterm draws its selection into a canvas, so the browser's own copy never saw it. Paste already worked and was left alone.
- The disk overview no longer opens with Docker volume usage above the disks themselves.
### Internal
- Six latent defects in the cluster code, each reproduced before being changed: a nil dereference across twelve `RaftNode` methods; two discarded errors in certificate issuance, one of which could write an empty private key that only failed later mid-handshake; a discarded marshal error that would apply an empty FSM entry silently; a certificate reload that watched only the certificate file, so a key-only rotation was never noticed; a CA seeding guard that could never repair a missing certificate; and a comment describing self-healing behaviour that does not exist in the code.
- Five separate places used to hardcode a plaintext loopback URL to the panel's own port. They now share one resolver. The update watchdog's copy was the consequential one — ninety seconds of failed health probes rolls back the binary *and* the database, so a panel with TLS on would have reverted every self-update it attempted.
- Peers learn each other's certificate authority through replicated cluster state, public certificates only, over the existing config-set command so that a node on an older build applies it as an ordinary write and preserves it through its own snapshots. Mixed-version clusters need no negotiation: a peer with no replicated authority is simply still reached over HTTP.
### Upgrade notes
- Nothing changes unless you turn TLS on. When you do, **the origin changes**: `http://host:3628` and `https://host:3628` are different origins, so you will be logged out once and per-origin browser state (terminal tabs, font size) resets. Old `http://` bookmarks fail at the transport with no redirect — the panel serves HTTPS only, with no plaintext fallback by design.
- The desktop app cannot connect until the operating system trusts the authority. Install it first.
## [0.63.1] – 2026-08-21
### Fixed
- **The pre-paint theme script was being blocked by the panel's own CSP**, so dark-mode users got a white flash on every page load. `index.html` carries one inline script — the theme applier that runs before the app bundle precisely to prevent that flash — and `script-src` was `'self'` only. The script had been dead in production since the policy was tightened, with a console entry as the sole symptom. It cannot move into the bundle (that runs after parse, which *is* the flash) or take a nonce (the page is a static embedded asset), so the CSP now allows it by hash. The hash is computed at startup from the embedded `index.html` rather than stored as a constant: a constant goes stale the moment the script is edited and fails just as silently as this bug did. `'unsafe-inline'` is not used, so the policy is no weaker than before.
- Note when upgrading: the service worker caches the shell response together with its headers, so an existing browser session keeps the old policy until it refreshes.
## [0.63.0] – 2026-08-21
### Added
- **Network drives — connect an SMB/CIFS or NFS share from a NAS or file server** under Disk. `/etc/fstab` is the only place the configuration lives, so nothing drifts when someone edits it by hand; entries the panel wrote carry a marker comment, and hand-written ones are listed but never rewritten or deleted. Shares can be discovered from a server rather than typed, and the connection is testable before it is saved.
- Two properties shape it. The share is **mounted before anything is written to fstab**, and persisted only once that succeeded — the reverse order saves an entry nobody has proven works, which is how a bad fstab line reaches the next boot. Every entry gets `_netdev` and `nofail`, so an unreachable NAS cannot drop the host into an emergency shell. Passwords never reach fstab (it is world-readable): they go to a 0600 file referenced by `credentials=`, and the option sanitiser rejects `password=`/`credentials=`/`username=` along with whitespace and `#`, which would otherwise forge extra fstab fields or a whole new entry.
- Mount attempts are bounded at 25 seconds, and NFS uses `retry=0` interactively — `mount.nfs` against a dead server otherwise grinds for about two minutes, outliving the browser's own request timeout. fstab keeps the default retry, where `nofail` already makes a slow NAS harmless.
### Fixed
- **The filesystem list now sorts network drives first**, then block devices, then pseudo and container-layer mounts. `df` emits kernel order, which put a freshly attached drive last — below thirty-odd Docker overlay entries, the one place its owner would not look.
- **Server tuning stopped recommending downgrades.** The values were fixed constants, so as distributions raised their defaults the panel began proposing the older, smaller number as an improvement: on a current Ubuntu kernel it offered to cut `vm.max_map_count` from 1048576 to 262144, shrink the `vm.min_free_kbytes` reserve to a quarter, and lower `kernel.unprivileged_bpf_disabled` from 2 to 1 — a write the kernel refuses anyway. Parameters where a larger number is strictly better now keep the host's value when it already meets the recommendation, on the apply path as well as the status view.
- **`rp_filter` no longer asks for strict mode.** Strict reverse-path filtering drops packets whose reply would leave by a different interface — ordinary on a host running Docker bridges or a Tailscale subnet route — and turns a working peer unreachable with nothing in the logs. Loose mode is what systemd ships and still blocks spoofing.
- **`vm.swappiness` accounts for zram.** The old value assumed swap is slow; on a host with a zram device it is not, and a low setting defeats the compressed swap it was configured for.
- `net.ipv4.ip_local_port_range` no longer starts at 1024, where an early outbound connection can squat on a port a service is about to bind. The two `net.bridge` parameters are offered only when `br_netfilter` is loaded, matching the gate the conntrack category already used.
## [0.62.0] – 2026-08-21
### Changed
- **The logo is now vector artwork.** The old mark was a raster illustration — soft gradients, a bevelled cube, a circuit trace and a miniature server rack in mixed stroke weights — and below roughly 32px the detail turned to mush while the interlocked S and F stopped reading as letters, which covers most of where the mark actually appears. The replacement is constructed on a 64-unit grid: hexagon at R=27 with a 4-unit corner radius, monogram 24x30 on a uniform 6-unit stroke, and counters exactly as wide as the stroke so the S doesn't fill in at favicon size. The S is knocked out with `fill-rule="evenodd"`, so it is a real hole and the mark sits correctly on any background; its upper-left — stem plus the top and middle bars — is an F, which is how one shape carries both letters.
- **In-app the mark inherits `currentColor`**, so it picks up the primary token in light and dark, and the wordmark is live text in the app's typeface rather than a bitmap. The old banner baked "SERVER MANAGEMENT PANEL" into the image at a size unreadable in a 240px sidebar; the product name alone carries it now.
- **Icons split into two systems.** Web favicons keep the hexagon, which has to supply its own container. App icons — desktop, PWA, and the iOS home screen, which cannot use transparency — put the same S on a blue rounded square, because the platform already draws a container and nesting a second one only shrinks the letter. Desktop icons were regenerated with the official `tauri icon` CLI.
- READMEs switch banner artwork on `prefers-color-scheme` via `<picture>`.
- `web/public` drops from roughly 754 KB to 27 KB, and the repo banner from 147 KB to 15 KB per variant.
## [0.61.0] – 2026-08-21
### Fixed
- **The sidebar disappeared and came back on every page load of a clustered panel.** The shell assumed "not clustered" on mount, drew the standard sidebar, then swapped it for the cluster one once `/cluster/status` answered — at which point the cluster sidebar issued the same call again and rendered *nothing* until it returned. Two sequential round-trips with no sidebar at all in between. `Layout` now owns cluster status and passes it down, remembering the last answer so a reload draws the right sidebar immediately, and a skeleton holds the slot while a lookup is in flight. Loading, "no cluster" and "lookup failed" are three different states; returning `null` conflated them.
- **A failed node lookup rendered as an empty cluster.** The sidebar substituted an empty array when `/cluster/nodes` failed, so "leader unreachable" looked like "this cluster has no nodes", with nothing on screen to say otherwise. The last good list is kept and the failure is shown, and the local node id now comes from the status payload so it survives a failed lookup.
- **Leader-proxied reads spent the write timeout.** The three endpoints the UI polls on a timer — status, nodes and overview — used the same 10-second budget as an FSM write. That only ever mattered when the leader was unreachable, and then every 15-second poll cycle paid it in full, parking browser connections for ten seconds at a time. Reads now use a 5-second timeout plus a short breaker keyed on the leader address, so an unreachable leader costs one dial per cooldown rather than one per request. The breaker reports a cached failure without probing, which suits a poll that repeats seconds later but would be wrong for a write — FSM writes take a different path (`middleware.ProxyToLeader`) and are unchanged.
## [0.60.1] – 2026-08-14
### Fixed
- The heartbeat stream's new "closed" warning fired on ordinary reconnects — a leader change or a shutdown cancels the stream from this side, which surfaced as `context canceled` and was logged as a problem. Only a peer-side termination is reported now. (Observed immediately after the v0.60.0 rollout.)
## [0.60.0] – 2026-08-14
### Fixed
- **A long-lived cluster eventually wedged its own heartbeat, silently.** The heartbeat RPC is a bidirectional stream and the leader answers every ping with a pong, but the follower never read them. HTTP/2 hands back stream-level flow-control credit only when the application actually reads, so the unread pongs filled the window until the leader's `Send()` blocked — permanently, and inside the branch that processes a received ping, so the leader's own 90-second idle timeout never got the chance to fire. The leader then stopped reading, the follower's sends blocked in turn, its collector stopped, and the node marked *itself* suspect and then offline. Nothing was logged the whole time, because a blocked send never returns an error. Restarting the service was the only recovery. The follower now drains the reply stream, cancelling the stream on error so the existing redial path takes over. Observed on a live two-node cluster: a node sat offline for 16 hours with an empty log.
- **Cluster gRPC connections gained keepalive** (20 s ping, 10 s ack deadline, with a matching server enforcement policy) so a connection killed without the process noticing — an unclean peer death, an idle path losing its NAT entry — fails within ~30 s instead of waiting on kernel retransmit timeouts. This is defence in depth and explicitly *not* the fix for the wedge above: PING frames are not flow controlled, so the transport keeps ACKing them no matter what the application is stuck on.
## [0.59.0] – 2026-08-13
Closes out the deferred items from the v0.58.0 modularity pass.
### Added
- **Unit tests for the frontend's pure-logic modules** — 186 tests over firewall rule validation, the log parsers, the shared formatting/path/status helpers, the navigation registry and the log-view utilities. `vitest` is the only new dependency (25 packages, reusing the existing vite/rolldown tree) and runs in the node environment: no jsdom, no DOM testing library, no component tests. Wired into the existing CI frontend job. It paid for itself immediately — the two parser bugs below are its first catches.
### Fixed
- **Firewall log rows showed a dash instead of the interface for every outbound entry.** The key extractor returns its `-` placeholder for both absent and empty keys, so the intended "IN, else OUT" fallback always stopped at `IN=`'s placeholder and the OUT branch was unreachable.
- **The log-parser registry answered lookups for inherited object keys** (`constructor`, `toString`, …), reporting a parsed view and handing back a prototype member that dies on use. Unreachable today because the backend prefixes custom source ids, but the lookups no longer depend on that.
- **Superseded WebSockets were never retired.** Changing a socket's endpoint left the old socket's close handler seeing a cleared cleanup flag (a close event always arrives in a later task, after the effect re-run reset it), so it armed a reconnect and opened a second socket that overwrote the ref — orphaning one nothing would close while it kept delivering duplicate frames. Connections now carry a generation that every async continuation checks, which also covers React StrictMode leaving two ticket mints in flight during development.
- **Tailscale's installer could be started twice.** The post-install refresh now runs when the output dialog is dismissed (so a successful install no longer unmounts the log before it can be read), which left the install button live until the refresh landed; it is disabled for that window.
### Changed
- The live-log socket now sits on the shared `useWebSocket` hook, which gained endpoint query parameters and a local-node pin; it keeps only the log-specific frame parsing and per-frame batching. `/ws/cluster/overview` — the one WebSocket route with no cluster relay wrapper — pins itself local accordingly.
- The cluster stack list gained the load-failure banner and retry the single-node list already had, both now rendering the same component; Tailscale dropped its private stream dialog for the shared one; `nodeStatusBadge` retires the last duplicated status-color mapping; the shared dialog's strings moved out of the packages namespace into `common.output`, and five keys orphaned by the migration were pruned.
## [0.58.0] – 2026-08-11
A frontend modularity release: a menu-by-menu review of the SPA (8 parallel reviewers, 182 accepted improvement points) followed by a full application pass. No backend changes.
### Changed
- **Every oversized page decomposed.** Packages 1,580→31 lines (per-card files), AlertSettings 866→31 (three sections on typed api methods), DiskLVM 648→78, Logs 1,122→568, Fail2ban 1,270→634, Files 1,311→891, AppStoreDetail 1,108→790, DockerContainers 1,542→1,073, DockerStacks 1,497→1,218, Terminal 802→547 (session component extracted verbatim). 27 new extracted components follow the existing `pages/<area>/components/` precedent.
- **Copy-paste infrastructure single-sourced.** One navigation registry replaces four drifted copies (the More drawer regains dashboard/docker/terminal and drops the double-listed logs; Stacks appears in the datacenter sidebar); node-status colors, restart-wait polling, copy feedback, path helpers, sub-nav tabs, pagination, guide accordions, status pills and the log viewer (Logs + FirewallLogs now share one virtualized, auto-reconnecting viewer) each collapse to one implementation.
- **Dialogs standardize on the shared confirm/prompt primitives** across docker, files, disk, network, firewall, cron and logs pages (in-dialog spinners are replaced by toast/refresh completion; LV deletion gains type-to-confirm).
### Fixed
- **Cluster `?node=` scope was silently dropped by every raw-text stream.** Distribution upgrades, Docker/Node/CLI installs and the Tailscale installer ran on the *local* node even while you were browsing a remote one — and since the package list itself was node-scoped, the UI showed a remote node's pending updates and then upgraded the local host. These streams now route through the api client and execute on the node you are viewing, using the node scope the backend relay already accepted. **Operators upgrading from ≤ v0.57 should note that these actions now target the selected node**, which is what the surrounding UI always claimed.
- **Container log options broke the WebSocket ticket.** Changing tail/timestamps/stream/since built a URL with two `?` separators, so the auth ticket was parsed as part of the last option and the socket was rejected — container logs only connected with default options. Options now travel as proper query parameters (a comment on `buildWsUrl` documents the constraint).
- **Dashboard firewall pill tints never rendered** (`'var(--x)'+'15'` is invalid CSS) — the intended 8% tints now actually paint.
- **Post-join cluster restart polling could 401 forever** (the replicated JWT secret invalidates the browser token; the old loop required an authenticated OK) — restart waits now probe an unauthenticated endpoint with a bounded 5-minute cap, including the previously endless update wait.
- Wrong-TOTP submissions showed "enter your 2FA code" instead of "code invalid" (error-code-based detection now); login/setup errors are translated; the fail2ban "Filter missing" template label became reachable; IPv6 sources are accepted by firewall rule validation; RAID creation lists unused partitions again alongside whole disks; the firewall live-log buffer no longer grows unbounded.
- Nine referenced-but-undefined i18n keys added (caught by a changed-files key sweep); 143 new keys total, en/ko parity exact.
### Removed
- Dead frontend surface: the never-rendered NodeSelector sidebar block, MobileHeader, two unused ui-kit components, 21 orphaned api-client methods (~150 lines) and the dead `AlertRuleType`.
## [0.57.0] – 2026-08-11
The final step of the v1 proxy-credential retirement, plus a permanent real-bundle regression guard for the release verifier.
### Security
- **The legacy v1 static-secret proxy header no longer authenticates anything.** Completing the two-step retirement started in v0.56.0 (send dropped), the receive branch is now removed: a request carrying only `X-SFPanel-Internal-Proxy` is rejected, and only the replay-resistant v2 HMAC authenticates cluster-internal traffic. Peers older than v0.56.0 can no longer relay to a v0.57 node — upgrade them locally first (rolling v0.56 → v0.57 is unaffected; v0.56 senders are already v2-only). The defensive header-strip lists on the relay and gRPC receive paths are kept.
- **The Sigstore bundle verifier is now regression-guarded by a real release bundle.** The actual cosign v3.1.3 bundle from v0.56.0 is vendored as a test fixture and verified against the embedded production Fulcio roots on every test run — the synthetic fixtures share their field model with the verifier and cannot catch the model drifting from real cosign output. Also verified and documented: `install.sh`'s detached-flag verification works under cosign v3 (deprecated-with-warning), with the bundle asset noted as the migration path if cosign v4 removes the flags.
## [0.56.0] – 2026-08-11
A security-hardening release executing the deferred backlog from the v0.55.0 audit: the cluster drops its legacy replayable credential from the wire, release signing gains the standardized Sigstore bundle without abandoning already-deployed verifiers, the largest untested surface (netplan) gets frozen under tests, and the two drifted spec documents are re-synced.
### Security
- **Cluster-internal proxy auth is now v2-only on the wire.** The legacy v1 static-secret header (`X-SFPanel-Internal-Proxy`) is no longer attached to any outbound cluster traffic — HTTP/SSE relay, WebSocket relay, the gRPC loopback hop, rolling cluster updates, leader self-update, and stack migration all authenticate with the replay-resistant v2 HMAC header only. The v1 header was already inert against any peer ≥ v0.11.2 (a present v2 header always short-circuits v1), so this drops a credential that would replay forever if ever captured, without changing behaviour in supported clusters. Inbound v1 validation is retained this release so mixed v0.55/v0.56 clusters interoperate during a rolling upgrade; **it will be removed in the next release.** Peers older than v0.13.2 were already unsupported for cross-version relay.
- **Release signing now dual-publishes the standardized Sigstore bundle.** Every release keeps the legacy `checksums.txt.sig`/`checksums.txt.pem` pair (cosign v2 pin) so already-deployed panels can keep verifying self-updates indefinitely, and additionally publishes `checksums.txt.sigstore.json` (cosign v3 bundle). The in-binary verifier prefers the bundle when the asset exists — parsed offline against the embedded Fulcio roots with the same release-workflow identity pin, ignoring the transparency-log material — and hard-fails on a bad bundle with no fallback to the legacy pair, so stripping or corrupting the bundle can never weaken verification below the current level. `install.sh` continues to verify the legacy pair.
### Tests & docs
- The netplan write path and the resolvectl/`ip route` text parsers (previously the largest untested surface in the repo, ~1,360 lines) now have 16 table-driven test functions freezing current behaviour, including four documented parser quirks kept as-is for later fixes. File discovery gained a test seam (`netplanDir`) — no production behaviour change.
- `docs/specs/frontend-spec.md` and `docs/specs/tech-features.md` re-synced to v0.55.0 (they had drifted since v0.40.0).
## [0.55.0] – 2026-08-11
A security-and-maintenance release: the CI pipeline is fully green again, every open Dependabot PR was reviewed and either merged or superseded, and all fixable dependency advisories are cleared from shipped binaries. Includes the two cluster fixes that had been sitting unreleased on main since July (issue #5).
### Security
- **gRPC bumped to v1.82.1**, clearing GO-2026-6061 — the advisory's vulnerable code paths are reachable through the cluster's mTLS gRPC server (heartbeat/subscribe streams). mTLS already limited exposure to cluster peers; shipped binaries now carry the fixed transport.
- **Go toolchain bumped to 1.25.12**, clearing the crypto/tls advisory GO-2026-5856 from shipped binaries.
- **Indirect dependencies lifted past their advisories** — x/net 0.56.0 (GO-2026-5942), x/text 0.39.0 (GO-2026-5970), OpenTelemetry 1.44.0 (GO-2026-5158). None were on called code paths; the bumps clear the vuln-scan gate and shrink the audit surface.
- **Frontend advisories cleared via `npm audit fix`** — react-router(-dom) 7.18.2 (open-redirect/DoS set), dompurify 3.4.13 (sanitizer config-pollution set), fast-uri 3.1.5, brace-expansion 5.0.9. `npm audit` is now clean including dev dependencies.
### CI & build
- CI is green again: fixed the two `staticcheck` violations (`parser.ParseDir` deprecation in the error-code uniqueness test, an untagged switch in the compose git import) that had kept `go-lint` red since the Go 1.25 toolchain bump.
- The release signing step now pins cosign v2: cosign v3 removed the `--output-signature`/`--output-certificate` flags, and deployed panels verify updates against those legacy artifacts — the bundle-format migration needs its own coordinated release.
- The vuln-scan gate now keys on fixability (`Fixed in: N/A`) instead of the docker/docker module name — unfixable advisories in other modules (x/crypto's openpgp deprecation) no longer wedge CI red, and a future *fixable* docker/docker advisory now correctly fails the gate.
- The PWA service worker keeps Monaco out of the precache under vite 8.1+ (rolldown 1.1 renamed the chunk `monaco-*` → `editor.api2-*`, which slipped past the old ignore pattern and would have added ~3.5 MB to every client's SW install).
### Dependencies
The Dependabot backlog (19 PRs, oldest from June) was reviewed per-PR and merged; superseded PRs were closed with the landing commit referenced. Highlights:
- **Go**: echo 4.15.4 (upstream security backport — SFPanel serves its SPA through its own handler, so the static-file advisory never applied), modernc.org/sqlite 1.56.0, gopsutil 4.26.6, docker/go-connections 0.7.0 (adds idle-connection limits to the long-lived Docker client), OpenTelemetry aligned at 1.44.0.
- **GitHub Actions** (all SHA-pinned, majors verified against this repo's usage): checkout 7.0.1, setup-node 7.0.0, setup-go 6.5.0, goreleaser-action 7.2.2, cosign-installer 4.1.2 (cosign itself pinned to v2 — see above).
- **Web**: vite 8.1.0, @vitejs/plugin-react 6.0.3, @types/node 26, eslint-plugin-react-refresh 0.5.3.
- **Desktop**: tauri crate 2.11.1 paired with @tauri-apps/api 2.11.1 and @tauri-apps/cli 2.11.4; serde_with/rand patch bumps.
- **e2e**: Playwright 1.62.1; typescript aligned with web at ^6.0.3 (the proposed TypeScript 7 jump was declined — nothing in e2e invokes tsc, and web's toolchain needs 6.x).
### Fixed
- **Cluster follower metrics no longer flap with a constant heartbeat-EOF loop.** The leader closed a follower's metrics-heartbeat gRPC stream after 30 s idle, the exact interval at which the follower sends — and because the idle timer resets on each ping *received* while the follower sends on a fixed schedule, any network/scheduling jitter let the timer fire just before the next ping and killed the stream, reconnecting endlessly (~1/min per follower, so a follower's metrics/online status kept dropping on the leader's dashboard). The stream idle timeout is now 3× the send interval and the two values are coupled in one place so they can't drift back into the race.
- **Cluster join no longer fails when the current leader isn't the founding node.** The cluster CA private key was created only on the node that ran `cluster init` and was never replicated, so a join served by any other leader — after a leadership change, or if the founder's `ca.key` was lost — failed to sign the new node's certificate with `load CA: open …/ca.key: no such file or directory` (issue #5), and reinstalling didn't help because the installer never regenerates a CA. The CA key is now replicated through the Raft FSM alongside the JWT secret and materialized to disk on demand, so any elected leader can sign joins; existing clusters self-heal the next time the key-holding node leads. A leader that genuinely has no CA key anywhere now returns an actionable error instead of a raw file-not-found.
---
## [0.54.0] – 2026-06-27
A public-release-readiness pass driven by a whole-project review: security, supply-chain, legal, and onboarding hardening. No new user-facing features.
### Security
- **First-run setup is restricted to loopback/LAN sources.** A fresh install binds `0.0.0.0` with no admin and a public `/auth/setup` route, so a remote host could claim the admin of a root-power panel before the operator. Setup now requires a loopback/RFC1918 source (public hosts use an SSH tunnel); the installer warns when no firewall confines the port, and both READMEs put "restrict + TLS" ahead of "create admin".
- **Password policy.** The single root-equivalent account now requires 12+ characters and rejects a common-password denylist (was an 8-char minimum with no denylist), in both first-run setup and password change.
- **Go toolchain bumped to 1.25.11**, clearing 27 reachable Go-stdlib CVEs from shipped binaries.
### Supply chain & CI
- All GitHub Action `uses` are pinned to commit SHAs; added `dependabot.yml` (gomod, npm, github-actions).
- CI gained a vulnerability-scan gate (`govulncheck` + `npm audit`), least-privilege permissions, and concurrency cancellation; the two tag-triggered release workflows now serialize; Node is unified via `.nvmrc`.
### Legal & onboarding
- Added `THIRD-PARTY-LICENSES.md` (regenerable via `make third-party-licenses`) and now ship it plus `LICENSE` inside the release tarball.
- Added `SECURITY.md` (private disclosure), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, bug/feature/PR templates, and release/CI/license README badges.
### Performance & fixes
- **Monaco no longer loads on the login page** — the 3.5 MB editor left the entry bundle and now rides the lazy Files/Compose chunks.
- One correct PWA manifest ships (the bogus auto-generated `manifest.webmanifest` is disabled); fail2ban error codes moved to `response` constants; cluster WS-relay goroutines recover from panics; the service-name regex no longer accepts a leading hyphen; `crypto/rand` failures are checked; the cluster-join address hint uses the current gRPC port.
---
## [0.53.0] – 2026-06-23
A UI/UX polish pass — dark mode, semantic design tokens, and an end-to-end keyboard-accessibility sweep — plus a terminal-feature hardening campaign driven by a deep review of the PTY/exec WebSocket path.
### Added
- **Dark mode.** The design system's dark theme is now wired up and activated, with the foundation design-system fixes that preceded it.
- **Keyboard accessibility across the SPA.** Every interactive surface gained `focus-visible` rings, icon-only controls gained `aria-label`s (reusing existing i18n keys), click-only rows/cells/headers became keyboard-operable (`role`/`tabIndex`/Enter-Space), and hover-hidden row actions now reveal on keyboard focus.
- **Terminal auto-reconnect.** A dropped WebSocket transparently reconnects to the same live PTY session with bounded exponential backoff; the server replays scrollback so the session resumes instead of leaving a dead terminal.
- **Durable audit trail for the highest-privilege actions.** Opening a host shell (`/ws/terminal`) or a container exec session now writes a queryable `audit_logs` row — these `/ws/*` routes previously bypassed the audit middleware entirely.
- **Mobile touch-scroll for the container shell and log viewers.** `ContainerShell` / `ContainerLogs` / `ComposeLogs` now share the terminal's touch-drag scrollback handler, so output that scrolled past the box is reachable on a phone.
### Changed
- **Hardcoded brand/terminal hex swept to semantic tokens.** The brand-colour sweep plus `Terminal.tsx`'s chrome (tab bar, toolbar, search bar, mobile bar) now follow the theme, so the terminal page is no longer a dark island in light mode.
- **Cluster WS relay keepalive.** The relay now pings the client leg every 30s and re-arms its read deadline on the browser's auto-pong, so a remote terminal streaming output without keystrokes is no longer torn down after ~60s.
- **Terminal tabs are scoped per node.** Persisted tabs are namespaced by node id, so switching nodes shows that node's own tab set instead of reusing the same `session_id` and spawning a duplicate PTY per tab.
- **CSWSH origin check centralized.** The same-origin-or-empty WebSocket check (and its Tauri allowlist) lives once in `internal/common/wsorigin` instead of being copy-pasted across the terminal, websocket and logs handlers.
### Fixed
- **Phase-2 UI correctness bugs:** an invisible copy button, an unsafe firewall rule edit, and a blank loader state.
- **Terminal backend hardening:** ignore degenerate `0×0` resize frames that wedged full-screen TUIs; close a reattach liveness race where a just-exited shell was treated as alive; and replace a fragile Tailwind-class-string DOM query for the active terminal with a stable `data-` attribute.
---
## [0.52.0] – 2026-06-21
Install / update / uninstall lifecycle hardening, plus a couple of cluster-mode robustness fixes. No feature surface changes.
### Added
- **`/api/v1/health` is now a real readiness probe.** It pings the SQLite connection (bounded by a 2s timeout) and returns `503` when the DB is unreachable, instead of always answering `200` regardless of backend state. A reverse proxy, load balancer, or external cluster health-check can now distinguish "process is up" from "process can actually serve requests" and stop routing to a node whose DB has gone away.
- **`install.sh` upgrade rollback + version pinning + signature enforcement.** An upgrade now backs up the live binary before the swap and automatically reverts it (and restarts the service) if the new binary fails to come up, so a bad release can't leave the panel offline. `SFPANEL_VERSION=v0.X.Y` pins the install to a specific tag instead of always-latest (for reproducible installs / staged rollouts), and `SFPANEL_REQUIRE_COSIGN=1` makes a missing or invalid cosign signature a hard failure rather than a warning.
- **`uninstall --purge`.** Plain `uninstall` now preserves config, database, and logs (and prints exactly what it kept, plus the untouched `/opt/stacks` stack data); `--purge` additionally removes `/etc/sfpanel`, `/var/lib/sfpanel`, and `/var/log/sfpanel` for a clean teardown.
### Changed
- **Uninstall is cluster-aware.** When the node being removed is a cluster member, `uninstall` now runs `sfpanel cluster leave` first so the node departs the Raft configuration gracefully instead of being orphaned as a permanently-offline voter that the surviving nodes must prune by hand.
- **Cosign verification tightened.** The signer-identity match is now anchored to `https://github.com/<repo>/.github/workflows/release.yml@refs/tags/v…` (previously a looser substring), and an unsigned download emits a loud, explicit warning rather than passing quietly.
- **Cleaner output and stricter perms.** Install/uninstall messaging no longer prints a live panel URL or systemd-specific hints on hosts without systemd, and the SQLite database plus its `-wal`/`-shm` sidecars are created `0600`.
### Fixed
- **Clear error when a cross-node update is rejected by a too-old peer.** Triggering an update on a remote node whose binary predates the update-relay HMAC auth returned a bare `401`/`403`; the panel now explains that the target node is likely too old to authenticate the relay and that it should be updated locally (SSH + `install.sh`) first.
- **Update auto-rollback no longer silently discards post-snapshot writes.** When the watchdog rolls a failed update back to the previous binary + DB snapshot, it first copies the *live* database aside to `<db>.pre-rollback` (and clears stale `-wal`/`-shm` sidecars), so any writes the failed binary accepted between the snapshot and the rollback are recoverable instead of lost.
- **Update-check surfaces version-compare failures.** A semver comparison error during the update check is now reported (`compare_error`) instead of being swallowed and reported as "no update available".
- **Portable cluster detection in `install.sh`.** Cluster-membership detection during uninstall now uses a pipe instead of a process substitution, so it works under the POSIX `sh` some minimal hosts symlink `bash` to.
---
## [0.51.0] – 2026-06-18
### Added
- **Transfer rate limit (QoS) for stack migration.** The migrate dialog now takes an optional transfer-rate cap (MB/s; 0 or blank = unlimited) so a large node-to-node migration doesn't saturate the link — useful over a metered or shared WAN (e.g. Tailscale). The cap is applied to the bundle push on the source via a cancellable pacing reader; it composes with the existing transfer-progress reporting and is recorded in the migration audit log.
---
## [0.50.0] – 2026-06-18
### Fixed
- **Node-to-node stack migration: shared-volume corruption & data-loss races closed.** Two distinct stacks that share a named/external volume could be migrated to the same target concurrently and race `clearVolume` + extract on the one shared volume, corrupting it (and, under a `delete` disposition, destroying the source over a corrupted target). Restores that touch the same docker volume are now serialized per-volume (a second one 409s). On an acked overwrite, the pre-existing target volume is now archived aside before it is wiped and restored if the import later fails — a failed overwrite no longer destroys the prior tenant's volume data (previously only the stack *definition* was backed up).
- **Migration import hardening.** Restored tar archives now reject device/FIFO nodes and setuid/setgid regular files (a compromised cluster member could otherwise plant a setuid-root binary as root). The resolved compose (after `.env` interpolation) is re-validated on the target, so an edited `.env` can't smuggle `privileged`/`network_mode: host`/`devices` past the raw-text safety check. The import health gate now waits for declared Docker health-checks to report healthy (and catches a crash-looping container) instead of treating a momentary "running" as success.
- **Crash recovery.** Orphaned migration staging (`.mig-pkg-*`/`.migrate-stage-*`) and helper containers left by a killed migration are now swept at boot; a leftover overwrite backup (`.migbak`) is restored when its stack vanished, and is hidden from the stack list instead of surfacing as a phantom project. The migration scratch namespace is reserved at stack-create time.
- **Pre-flight disk check now covers the docker storage filesystem** (where volume + image data actually lands), not just the stacks root — so a migration no longer passes pre-flight then fails with ENOSPC mid-restore. Same-device layouts are summed; separate devices are checked per-portion.
- **Split-brain avoidance.** After a transfer *connection* error (the target detaches its restore + `up` from the connection), the source now probes the target before rolling back — if the stack is running there, the source is left stopped instead of restarting into two live copies.
### Added
- Migration transfer streams byte progress to the SSE client, every terminal outcome is written to the structured audit log, and staging archives are freed incrementally during restore to cut peak target disk use. The migrate dialog now requires a pre-flight to pass before Start.
---
## [0.49.3] – 2026-06-17
### Fixed
- **Proxied remote-node API responses no longer arrive double-compressed.** Completing the 0.49.1/0.49.2 cluster-proxy fixes: a `?node=` request for a non-streaming endpoint (e.g. the dashboard's `/system/overview` host-info card) was gzipped *twice*. The gRPC loopback on the target node forwarded the browser's `Accept-Encoding`, so the target compressed the body, then the forwarding edge node compressed it again — while `Content-Encoding` still announced gzip only once. The browser decoded a single layer and `JSON.parse` choked on the leftover gzip magic byte, leaving the host-info card (and any other proxied JSON) blank. The loopback handler and the HTTP relay now strip `Accept-Encoding`, so the body is compressed exactly once, by the edge node, for the browser.
- **HTTP `?node=` proxy no longer pre-refuses a reachable peer that reads "offline".** The same stale-heartbeat guard removed from the WebSocket relay in 0.49.2 also gated the gRPC/HTTP proxy path: a `?node=` API call returned `503 node is offline` whenever the leader's heartbeat view of the peer was stale — which it routinely is right after a peer restart, and a follower never sees a sibling as online at all. The proxy now lets the gRPC call be the source of truth; a genuinely-down node still fails fast on connection-refused, matching the WS relay and the cluster-stacks aggregator.
---
## [0.49.2] – 2026-06-17
### Fixed
- **WebSocket relay no longer refuses a reachable peer that reads "offline".** Follow-up to 0.49.1: the relay wrapper fast-failed a `?node=` WebSocket with `503 node is offline` whenever the leader's heartbeat view of the peer was `offline` — but in a 2-node cluster that view goes stale right after a restart (and a follower never sees a sibling as online at all), so a perfectly reachable peer's dashboard/logs/terminal could be refused. The relay now attempts the connection and lets the TCP dial fail for a genuinely-down node, matching how the cluster-stacks aggregator already ignores possibly-stale status.
---
## [0.49.1] – 2026-06-17
### Fixed
- **Remote-node live data in cluster mode.** Opening a peer node's dashboard (and its logs / terminal / container exec) failed to stream — the metrics WebSocket 401'd with "authentication failed", so the CPU/memory charts never populated. The single-use WS ticket was being minted on the *target* node (the mint request was node-scoped via `?node=`), but a `?node=` WebSocket always connects to and is authenticated on the local node *before* the cluster relay forwards it — so the local node could not validate a peer-minted ticket. The ticket is now always minted locally; every `?node=` WebSocket stream (metrics, logs, terminal, exec) authenticates and relays correctly.
---
## [0.49.0] – 2026-06-17
Lighter binary and fewer wasted requests — no feature changes.
### Changed
- **Binary ~43 MB → ~33 MB.** Two embedded-weight cuts: (1) Monaco now imports the slim `editor.api` with an explicit language allowlist instead of the full `monaco-editor` barrel, dropping the ~6.9 MB TypeScript language worker that was embedded (via `go:embed`) but never actually spawned — the worker override already routed TypeScript to the base editor worker. The Docker/Files editors are unchanged: YAML/JSON/CSS/HTML and every other mapped language still highlight (only TS IntelliSense, already disabled, is gone). The embedded `web/dist` drops 17 MB → 9.6 MB. (2) The GitHub compose-import path drops `go-git` (and its ~7 MB crypto/diff transitive tree, 26 modules) in favour of a plain `net/http` fetch of the one compose file via the GitHub Contents API — same public / private-PAT / branch behaviour and the same typed auth / not-found errors.
- **Background tabs stop polling.** The cluster sidebar, Cluster Nodes, Processes and Services pages now use the existing `useVisibleInterval` hook (fetch on mount + while the tab is visible, paused while hidden) instead of a `setInterval` that kept firing in unfocused tabs.
---
## [0.48.0] – 2026-06-17
The cluster-wide Docker view (**Cluster › Docker**) becomes a master-detail: selecting a stack opens its full detail — services, editor, logs, and all actions — inline, scoped to the stack's own node, without leaving the page. The cluster sidebar tree now collapses by default to give the detail more room.
### Changed
- **Cluster › Docker is now a master-detail.** The left list groups every node's stacks by node; selecting one shows the same full detail panel as the single-node Docker page (services / editor / logs / up · down · redeploy / update-check / migrate / rollback / delete), scoped to that stack's node. It reuses the single-node page via a `clusterMode` prop rather than duplicating the detail.
- **Migrate, node chip, and refresh follow the route node.** The detail header shows a node chip when operating on a non-local node; migrating uses the stack's own node as the source (and the dialog shows it); the list and detail refresh after an action.
- **The cluster sidebar tree collapses by default** — a 2–3 node tree is mostly empty otherwise, so this reclaims width for content. A user who expands it keeps it expanded.
### Fixed
- **Same-named stacks on different nodes no longer mistarget.** The owning node is now in the URL (`/cluster/stacks/:node/:name`), not only the global node context, so opening e.g. `web` on node A and then `web` on node B reloads the detail for the correct node — previously the panel could keep node A's services/compose while actions hit node B. This was a real data-safety gap for clusters with duplicate stack names.
---
## [0.47.0] – 2026-06-17
A cluster-wide Docker stacks dashboard. In cluster mode a new **Cluster › Docker** tab (under Nodes) lists every node's compose stacks in one view, grouped by node, and lets you open a stack on its node or migrate it elsewhere from one place. The per-node Docker › Stacks page stays single-node.
### Added
- **Cluster › Docker Stacks tab** — a dedicated page aggregating every cluster node's compose stacks, grouped by node with live status. `GET /docker/compose/cluster-stacks` fans out concurrently: the local node listed directly, remote nodes via the cluster proxy. It *tries* each node's proxy (bounded 15s) rather than trusting the local heartbeat status — which on a follower can read a healthy peer as offline — so an unreachable node yields a stable error code and an empty list (never a 500), and a reachable Docker-less node reads as empty. Each node carries its health status for the status dot.
- **Migrate from anywhere.** The per-node migrate action takes an explicit source-node id, so any node's stack can be migrated without first switching the global node context. The migrate dialog shows the source node ("From: …") and routes pre-flight and the stream to it.
- **Node wayfinding.** Opening a node's stack switches the global node context and re-highlights the active node in the cluster sidebar tree; the stack-detail header shows a node chip so destructive actions (stop/down/delete/rollback) can't land on the wrong machine unnoticed. Both lists refresh after a migration completes.
### Changed
- The cluster-wide view moved off the Docker › Stacks page (where it was an in-list toggle) to its own **Cluster › Docker** tab; the Docker › Stacks page is single-node again. Node compose-stack responses now carry the node's health `status`, and node-level fetch errors are stable machine codes mapped to translated copy instead of raw strings.
### Accessibility
- Cluster stack rows and the migrate affordance are real buttons with focus rings — keyboard-reachable and touch-friendly (the migrate icon is always visible, not hover-only), and the cluster view works on mobile. The migrate dialog's target select and disposition group are properly labelled.
---
## [0.46.1] – 2026-06-17
### Fixed
- **Stack migration works on hosts without a `docker0` bridge.** The volume
archive/restore helpers run a throwaway container; with default networking it
attaches to the default bridge, which fails on hosts whose `docker0` is absent
or custom — so a stack with a named volume couldn't migrate even though its
own compose-managed containers run fine. The helpers only tar a mounted volume
and never need a network, so they now run with `--network none`.
---
## [0.46.0] – 2026-06-17
Node-to-node stack migration grows from definitions to **full-fidelity data + image transfer**, with an in-panel UI. Where M1 (v0.45.0) carried only the compose file and `.env`, a migration now also moves every named volume's contents, copied bind-mount data, and the stack's images (always `docker save`/`load`, no registry dependency) — each entry SHA-256-verified end to end, so a `200` from the target implies verified-intact data, which is what makes the source `delete` disposition safe. The whole bundle is staged to disk and streamed (no GB held in memory), and the target's root-run restore path is extensively hardened against a hostile peer manifest. A "Migrate to node" dialog drives the flow from the Docker Stacks page when the panel is in cluster mode.
### Added
- **Stack migration now transfers data and images, not just definitions.** The source archives each named volume (read-only helper-container `tar`), each copied bind directory (host `tar`), and each image (`docker save`) to a temp file, SHA-256s it, then streams the assembled bundle from disk — nothing multi-GB is held in memory. The target streams each data entry back to disk, re-verifies its per-entry SHA, then `docker load`s images and recreates volumes / restores binds **before** `docker compose up`. All volumes are copied (external volumes included); special files (sockets, devices, named pipes, irregular files) and missing bind paths are skipped with a pre-flight warning rather than failing the run. New `migration_data.go` / `migration_import.go` restore primitives in `internal/feature/compose`.
- **Pre-flight sizes the real transfer.** The disk-space block now sums the actual bytes to be copied (`du -sb` per volume/bind via the helper image, `docker image inspect` per image) instead of estimating from definitions, and emits absolute-bind and large-transfer (> 5 GiB) warnings. Sizing is best-effort: an un-sizable entry contributes 0 rather than aborting the migration.
- **"Migrate to node" dialog** (`web/src/pages/docker/components/MigrateStackDialog.tsx`), surfaced from the Docker Stacks page in cluster mode: pick a target node, review pre-flight blocks and warnings, choose the disposition with an explicit overwrite acknowledgement, then watch a live phase timeline. Wired through the API client and types, with English/Korean strings.
### Security
The bundle restored on the target is built from a manifest supplied by another cluster node — integrity-checked (per-entry SHA) but **not trusted** — and is extracted as root. The restore path is hardened accordingly:
- **Manifest identifiers are gated before they reach a `docker` argv.** Volume names and image references in the manifest are validated against leading-alphanumeric allowlist regexes (`validDockerVolume`, `validImageRef`) **before** any `docker exec`, blocking argv flag-smuggling (a value like `-foo` parsed as a flag) and out-of-spec names.
- **Every archive is scanned for escape before extraction.** `validateTarSafe` rejects absolute or `..` member names and absolute / out-of-root symlink and hardlink targets, applied before any `tar -x` (host or helper container). Bind archives are additionally pinned to their own leaf via `tarTopLevelIs`, so a crafted extra/renamed top-level entry can't create or merge into a sibling directory under the shared parent.
- **Absolute binds are deny-listed and never blind-overwritten.** `absBindRestorable` requires an absolute path and refuses `/` and the system trees `/etc`, `/usr`, `/bin`, `/sbin`, `/lib`, `/boot`, `/root`, `/var` (panel DB + Raft/BoltDB state, cron, logs), `/home` (`.ssh/authorized_keys`), `/sys`, `/proc`, `/dev`, `/run`, and `/opt/stacks` — a relative manifest `Host` (which would resolve against the service CWD `/`) is rejected too. An absolute host bind is **never** `RemoveAll`'d; a non-empty target is refused (the operator clears it deliberately) rather than wiped. In-stack binds are confined under the freshly written stack dir and may not resolve to the stack dir itself (which would destroy the just-restored compose definition).
- **Volume overwrite is gated and restores an exact replica.** Restoring into a pre-existing named volume is refused unless the operator acked overwrite; on overwrite the volume is cleared first (`clearVolume`) so the result is an exact copy of the source rather than the migrated files overlaid onto a prior tenant's data. Failed-import cleanup never `down -v`s a colliding pre-existing volume — it removes only the volumes this import freshly created (`createdVols`).
- **In-memory growth is capped and recovery can't be cancelled by the failure that triggered it.** Small in-memory entries (manifest / `.env`) are bounded (`maxSmallEntryBytes`, 16 MiB) while bulk data streams to disk (`maxMigrationBundleBytes` raised to 64 GiB), and rollback / failed-import cleanup runs on a **fresh** `context.Background()` with its own 5-minute timeout, so an op-timeout failure can't no-op its own recovery.
### Fixed
- **Copy buttons work over plain HTTP.** `navigator.clipboard` is only exposed in a secure context (HTTPS/localhost), so every copy button silently failed when the panel was served plain HTTP over a LAN IP (the common reverse-proxy-less setup). A shared `copyText()` helper (`web/src/lib/utils.ts`) uses the Clipboard API in secure contexts and falls back to a hidden `<textarea>` + `execCommand('copy')` otherwise; the cluster join-token, app-store env, Tailscale/WireGuard, and 2FA recovery-code copy actions all route through it.
---
## [0.45.0] – 2026-06-17
Cold node-to-node Docker stack migration (M1, definition-only). An operator can
move a compose stack from one cluster node to another: the source is quiesced
(stopped), its compose file and `.env` are bundled and SHA-256-checksummed, then
pushed to the target over the mTLS internal proxy, where they are restored,
brought up with `docker compose up`, and verified healthy before the source's
fate is decided. A chosen disposition — `retain` (keep the stopped source),
`delete` (tear it down only after the target is confirmed healthy), or `clone`
(restart the source so both run) — is applied last, and any pre-finalize failure
rolls the source back to running. M1 carries stack *definitions* only (compose +
`.env`); bind-mount, named-volume, and image data transfer are deferred to later
milestones, and there is no UI yet — the routes drive a future panel page.
### Added
- **Node-to-node cold stack migration** under `/api/v1/docker/compose`:
`POST /:project/migrate/preflight` and `POST /:project/migrate` (SSE) on the
source, plus `GET /migrate/target-info` and `POST /migrate-import` on the
target. The migrate stream emits ordered phase events — `preflight`,
`quiesce`, `package`, `transfer`, `restore`, `up`, `healthcheck`, `finalize`,
and on failure `rollback`/`error`. Pre-flight runs a cross-node check (target
reachability/arch via `ProxyToNode`, published-port conflict scan with
`start-end` ranges expanded, existing-stack collision) and blocks the run
before anything is stopped. The transfer bundle is a single streamed,
path-safe archive of the compose file and `.env`; the target re-hashes the
whole stream and refuses a bundle whose SHA-256 doesn't match. New
`migration_*.go` set in `internal/feature/compose` (types, manifest +
dispositions, resolved-config extraction, bundle packaging, transport,
pre-flight, import, handler). M1 is definition-only: the manifest's
`MountSpec`/`VolumeSpec`/`ImageSpec` slots exist but carry no data yet (M2/M3).
- The compose handler now resolves the cluster manager **dynamically** via a
mutex-guarded `SetClusterMgr` wired at boot and re-invoked from
`OnManagerActivated`, so a node whose cluster is brought up at runtime (live
`cluster init`/`join` without a restart) can migrate immediately instead of
failing with "cluster is not enabled".
- Migration routes and the SSE phase sequence are documented in
`docs/specs/api-spec.md` and `docs/specs/websocket-spec.md`, with an env-gated
two-node e2e spec (`e2e/tests/stack-migration.spec.ts`) covering a definition
migrate and rollback.
### Security
- **Cross-node relay authentication fixed.** The SSE/HTTP cluster relays strip
the routing `?node=` param from the outbound request, but the v2
internal-proxy MAC was signed over the *inbound* request URI (still carrying
`?node=`). The peer validates the MAC over the node-stripped URI it actually
receives, and `IsInternalProxyRequest` only falls back to v1 when the v2
header is *absent* — not when it fails — so every `?node`-routed SSE/binary
relay 401'd. `setAuthHeaders` now signs `httpReq.URL.RequestURI()` (the
outbound URI the peer validates), restoring cross-node-initiated stack
migration as well as large `?node` file/backup relays
(`internal/api/middleware/proxy.go`).
- **Stack ids are path-traversal-validated** on both the source and the target:
a leading-alphanumeric allowlist regex plus an explicit `..` check, applied to
the `:project` route param and again to the id carried inside the received
bundle, so a hostile bundle can't escape the stacks root on restore.
- **Overwrite is data-safe.** The target refuses (409) to replace an
already-existing stack unless the source operator explicitly acked overwrite;
when it does overwrite, it sets the prior tenant aside and keeps its named
volumes, so a failed import can never destroy pre-existing data.
### Fixed
- **A client disconnect can no longer abort a migration mid-flight.** The
orchestration and import paths run on a detached `context.Background()` with
their own timeouts (25 min migrate, 10 min import) instead of the request
context, so a dropped client or an SSE-relay timeout cannot cancel the
quiesce/transfer/restore — or, critically, the rollback that restores the
source — while the operator is gone.
- **Migrations are serialized per stack id** via a `sync.Map` marker
(`409 Conflict` on a second concurrent run for the same stack), so two runs
can't interleave destructively with one's cleanup wiping the other's healthy
stack.
- **Honest rollback reporting.** A pre-finalize failure now claims "source
restarted" only when the restart actually succeeded; otherwise it emits an
error telling the operator the source must be started manually.
- **The `delete` disposition checks the teardown result.** Source removal now
inspects the `down -v` error instead of swallowing it, so a failed teardown
isn't reported as a clean removal.
- Idle connections are closed after the one-shot bundle push, and the SSE relay
window is widened to 30 min for `/migrate` so the relay outlives a live
migration.
---
## [0.44.0] – 2026-06-15
Post-v0.43.0 audit follow-ups. A multi-agent review of the whole tree, with
every high/medium finding adversarially re-verified against the code before
acting — two flagged items (a cluster "impersonation" gap and an error-code
"status drift") were investigated and deliberately *not* changed because the
recommended fixes were wrong for this codebase.
### Security
- **Cluster: `peers.json` recovery is validated.** `raft.RecoverCluster`
bypasses consensus, so a typo'd, empty, or self-excluding `peers.json` could
silently install an unrecoverable Raft configuration. It is now rejected at
boot (non-empty server set that includes the local node, with parseable
host:port addresses) instead of applied. Also documented why `ProxyRequest`
intentionally preserves the `X-SFPanel-Original-User` header: it carries the
forwarding node's already-authenticated identity for audit attribution, the
RPC is gated on a verified cluster-CA client cert, and every member already
shares the JWT secret — so stripping it would break cross-node audit
attribution without closing any real gap.
- **App Store: fail closed on weak secrets.** Auto-generated stack secrets now
abort the install if `crypto/rand` fails, instead of writing a predictable
zero-derived password into the stack `.env`.
- **Uniform SSE sanitization** across the packages / app-store / system-update
install streams, so error and status lines built from raw Go errors can't
leak ANSI escapes or secret patterns.
### Fixed
- **Pre-update rollback backup is consistent.** The `.bak` taken before a
binary update now uses `VACUUM INTO` instead of a plain copy of the live
WAL-mode DB, so a commit from a still-active background writer can't leave
the watchdog's rollback target stale.
- **App Store install reports pull failures directly** — it checks the
`docker compose pull` exit code instead of failing later at `up -d` with a
vaguer error.
- **Volume-usage cache no longer grows unbounded** — rows for volumes that no
longer exist are pruned each tick (guarded so a transient list failure can't
wipe the cache).
- **Cluster API error codes now match their HTTP status** — node-not-found →
`NOT_FOUND`, node-already-exists → `ALREADY_EXISTS`, token errors →
`INVALID_TOKEN` (were generic `INTERNAL_ERROR` / `INVALID_REQUEST`).
- **Crash screen is localized** (was hardcoded Korean — it renders outside the
i18n context) and the app-store README renderer defensively escapes
interpolated URLs.
### Changed
- **Scoped `errcheck` in CI** for the security/state packages
(`cluster`/`api`/`auth`/`db`) via a dedicated config + `make lint-errcheck`,
catching dropped errors on state-mutating calls — the cluster-join rollback
now logs a failed config restore loudly instead of swallowing it. Repo-wide
`errcheck` stays off (best-effort `_ =` is idiomatic there).
- Error-code string uniqueness is enforced by a test; the SPA and
trusted-proxy helpers were extracted out of `router.go` (690 → 601 lines);
UFW-parse regexes are hoisted to package level.
### Tests / Docs
- The systemd-migration test stubs `daemon-reload` (no real `systemctl`; the
package dropped from a ~25s outlier to ~0s) and asserts the reload is
issued; the app-store catalog test now fails instead of skipping when its
tracked in-repo fixture is missing. `docs/specs/db-schema.md` is refreshed
for the current connection pool (`MaxOpenConns=4`) and the
`schema_migrations`-tracked migration model (35 steps).
---
## [0.43.0] – 2026-06-11
### Security
- **Cluster: closed three trust-boundary gaps.** The WebSocket relay no longer
dials a remote node for an unauthenticated request (empty username), which had
let anyone who knew a node UUID reach a remote container `exec`. The Raft
transport now requires a verified client certificate instead of accepting
certless TLS connections, and heartbeats are bound to the peer certificate CN
so a member can no longer spoof another node's identity.
- **Auth: credential changes now revoke other sessions.** Changing the password
or toggling 2FA deletes the user's other refresh-token chains, so a stolen
chain no longer survives a password rotation. The browser keeps its own
session via the request cookie. Audit log user filters now escape `%`/`_` so
they can't act as wildcards.
- **App Store / Compose: hardened the safety validator.** Bind-mount blocking now
covers `/var/lib/docker` and `/run/containerd` (and the `/var/run` symlink
alias), `security_opt` parsing catches the `=` separator form
(`apparmor=unconfined`), and string-form `privileged` values are caught.
- **Installers: removed a /tmp TOCTOU.** Installer scripts (Docker, Node, Claude,
Tailscale) download to a private `0600` temp file instead of a predictable
`/tmp` name, closing a local race between the hash check and execution.
### Fixed
- **Desktop: restored the Tauri client.** The CSRF double-submit introduced in
the security campaign had silently broken the desktop app since v0.13.10 (its
webview origin can't read the API cookie). Bearer requests with no cookies are
now exempt from CSRF, the webview omits credentials so login passes CORS, the
Tauri origins are allowed on the WebSocket handlers, and the desktop version is
re-aligned so the auto-updater manifest resolves.
- **Backups are now consistent.** Both backup paths snapshot the database with
`VACUUM INTO` (a transactionally consistent copy) instead of a plain copy of a
live WAL-mode file that could be stale or torn; restores reject a backup that
fails a `PRAGMA quick_check`.
- **Docker routes are gated on the socket's presence** so a Docker-less host no
longer registers `/docker` routes that 500 — while a daemon that is merely down
recovers without a panel restart.
- Compose project/`.env` writes are now atomic (`0600`); request logging records
errors on skipped paths; the audit writer rejects submits after shutdown.
### Changed
- **License: MIT → GNU AGPL-3.0.** SFPanel is now licensed under the AGPL-3.0
(`LICENSE` added). Self-hosting, modification and redistribution stay free;
offering a *modified* version as a network service now requires publishing the
corresponding source (§13). Honest self-hosters take on no new obligation. A
separate commercial/closed-source license is available on request
(svrforum.com@gmail.com). Past MIT-licensed commits remain MIT.
- **README: English translation + language switcher.** Added `README.en.md`
(full translation) with a 한국어 · English toggle on both files; refreshed the
Korean README intro (value-prop summary) and the feature table (90+ app store,
cron run logs, 10,000-line terminal scrollback, mobile/PWA).
- **CI: `cmd/` tests now run, and the Playwright e2e suite was revived** (port and
CSRF fixes) with a single-node smoke job.
---
## [0.42.0] – 2026-06-03
### Added
- **App Store: post-install experience.** The install success screen now offers
**Open app** (`http://<host>:<port>`) and **Manage in Docker** (→ the app's
Docker stack), not just Close. The install form shows a live access-URL
preview, and generated passwords get a copy button + a "stored only in the
stack's .env" note.
- **App Store: uninstall.** Installed apps gain an Uninstall action (with a
destructive confirm) wired to a new `DELETE /appstore/apps/:id`
(`docker compose down -v` + remove the stack dir + drop the installed marker).
`GetInstalled` was previously dead UI surface.
- **App Store: deep-linkable detail** — `/appstore/:appId` opens an app directly
and is back-button friendly. Distinct catalog empty states: search-miss (with
"clear search") and load-error (with retry), instead of one ambiguous block.
### Changed
- **App Store catalog moved into this repo** under `appstore/` (was the separate
`svrforum/SFPanel-appstore` repo). It's still fetched at runtime from
`raw.githubusercontent.com/svrforum/SFPanel/main/appstore/`, so adding or
updating an app stays decoupled from panel releases — a catalog-only commit
reaches every panel within the cache TTL, no binary update needed. 45 apps
migrated; the old repo now redirects to the new location.
### Fixed
- **App Store security:** reject newline injection in simple-mode `.env` values.
- De-duplicated the App Store icon-URL helper (was copy-pasted with a hardcoded
base in two places).
## [0.41.0] – 2026-06-03
### Fixed
- **cluster: disband was a no-op after any restart.** The boot path injected the
Raft manager into the handler via a struct literal, bypassing the one code
path (`setManager`) that registers the `SetOnDisband` callback — so a node that
booted into an existing cluster never honored a replicated `CmdDisband`.
"Disband" returned 200 but nothing wiped state or restarted. Register the
callback at boot (`ActivateBootManager`) and add an FSM replay-index guard so a
stale committed `CmdDisband` can't self-destruct the node on restart.
- **cluster: admin account is now synced down to the local DB on disband/leave.**
Account state only flowed local→FSM at init; while clustered, password/2FA/
recovery changes went only to the replicated FSM. Dropping back to standalone
then fell back to the stale pre-cluster local row (login would fail with the
cluster credentials). `syncClusterAdminToLocalDB` now writes the FSM admin into
the local table before the FSM is abandoned.
- **system: update-check used a string `!=` instead of a semver compare,** so a
build whose version string differed from the latest release tag but was newer
falsely reported "update available." Now uses `IsForwardUpdate`, matching the
dashboard overview.
- **install/update hardening:** verify `checksums.txt` with cosign in
`install.sh` (not SHA-256 only); the signature fallback now gates on both the
current and target version so a node past the signing cutoff never accepts an
unsigned update; DB snapshots include the WAL/SHM sidecars and prune only the
timestamped main files; the CLI `sfpanel update` now backs up the binary and
arms the rollback watchdog; the watchdog is systemd-aware (signals the process
on non-systemd hosts); the alert manager runs under `safe.Go`; migrations are
validated as strictly-ascending at boot.
- **mobile: horizontal-scroll and broken detail modals.** The app shell used
`overflow-auto`, so an over-wide child slid the whole page left/right; page
headers, selects, dashboard chart header, and the shadcn tab bar now wrap /
scroll correctly. Detail dialogs (container/stack/network/disk/interface)
stopped clipping label/value pairs (`min-w-0` propagation through Tabs +
per-row truncation). The app shell now tracks `visualViewport.height` so the
mobile soft keyboard no longer pushes the terminal under the keyboard and
scrolls the page.
### Changed
- **settings: consolidated 6 tabs into 4** (Account / System / Alerts / Audit).
Account merges Security (password/2FA/recovery) + language; System merges
Maintenance (update/backup) + the former "Performance" limits (terminal
timeout, upload size). A scope badge ("This node" / "Cluster-wide") clarifies
what you're editing in cluster mode.
## [0.40.0] – 2026-06-03
### Fixed
- **network/wireguard** — apply the down-interface fix (v0.39.0) to the
**interface list** too, not just the single-interface view. The WireGuard page
lists interfaces via the list endpoint, so a stopped interface was still
showing an empty public key / no peers there (and the add-peer client config
read its public key from the list). Extracted a shared `populateWGInterface`
helper used by both endpoints. (Found via end-to-end browser testing.)
## [0.39.0] – 2026-06-03
### Fixed
- **network/wireguard** — a generated client config (and QR) for a **stopped**
interface had an empty server `PublicKey` and missing endpoint port, because
those were read from `wg show`, which only reports data while the interface is
up. `GetInterface` now derives the public key from the config's PrivateKey,
reads the listen port from the config, and parses configured peers from the
config file when the interface is down — so client configs are valid and the
peer list is correct even before the tunnel is started. (Found via end-to-end
browser testing.) Config-peer parsing is tested.
## [0.38.0] – 2026-06-03
### Fixed
- **auth** — **disabling 2FA from the UI now works.** The backend (correctly)
requires the current TOTP code to disable 2FA — a guard against a session-only
attacker downgrading the account — but the Settings → Security flow only
prompted for the password, so disabling 2FA always failed with 400 "Current
2FA code is required". It now prompts for the password **and** the current 2FA
code. (Found via end-to-end browser testing.)
- **auth** — disabling 2FA also **clears the recovery codes** (they're inert
without 2FA), so re-enabling starts from a clean slate.
## [0.37.0] – 2026-06-03
UX follow-up — **loading skeletons + error states, more pages**.
### Changed
- **cron / firewall / docker** — the cron jobs, firewall rules, and Docker
images / volumes / networks list pages now get the same
loading-skeleton / inline-error-with-Retry / empty-state treatment introduced
in v0.33.0, so a failed fetch is distinct from an empty list. Skeletons show
only on first load.
## [0.36.0] – 2026-06-03
UX follow-up — **styled prompt dialogs**.
### Changed
- **ui** — the remaining native `window.prompt()` calls (disable-2FA password,
appstore advanced-install re-auth) are replaced by a styled `usePrompt()`
dialog + app-level `PromptProvider`, with a proper masked password field. No
more raw browser prompts in the destructive/auth flows.
## [0.35.0] – 2026-06-03
Per-feature improvement pass, round 19 (final) — **mobile card fallbacks**.
### Changed
- **ui** — the wide data tables on the **audit log**, **alert history**, and
**firewall port-map** views now collapse to stacked label/value **cards on
small screens** instead of overflowing horizontally. The desktop table is
unchanged (`hidden md:*`); the card list renders the same filtered/paginated
rows with the same badges, links, and actions. No new strings (card labels
reuse the existing column-header keys).
## [0.34.0] – 2026-06-03
Per-feature improvement pass, round 18 — **2FA recovery codes**.
### Added
- **auth** — **2FA recovery codes**: generate a set of one-time codes (Settings →
Security) to log in if you lose your authenticator. On the login screen, "use a
recovery code instead" swaps the TOTP field for a recovery-code field; a valid
code is consumed on use. Codes are stored hashed (SHA-256) and **replicate
through the Raft FSM** for cluster admins — decoupled from the account record
(migration 35 + a new `CmdSetRecoveryCodes`) so a password/TOTP change can't
wipe them. Consuming a cluster admin's code is a leader write; on a follower the
login is refused with a "use the leader node" hint rather than risk a reusable
code. Generation, hashing/normalization, consume-once, and the
password-change-preserves-codes guarantee are all tested.
## [0.33.0] – 2026-06-03
Per-feature improvement pass, round 17 — **loading skeletons + error states**.
### Changed
- **docker / services / process** — the container, service, and process list
pages now distinguish **loading** (a skeleton placeholder), **failed to load**
(an inline red error block with the message + a Retry button), and **genuinely
empty** (the empty state) — previously a swallowed fetch error looked identical
to an empty list. Skeletons only show on first load, so background refreshes
don't flash over existing rows.
## [0.32.0] – 2026-06-03
Per-feature improvement pass, round 16 — **styled confirmation dialogs**.
### Changed
- **ui** — every native `window.confirm()` is replaced by a styled, on-brand
confirmation dialog via a new `useConfirm()` hook + app-level `ConfirmProvider`
(~22 call sites across cluster, disk, docker, files, alerts, security, backup,
tuning, audit, WireGuard, and the compose healthcheck composer). Destructive
actions get a red confirm button; multi-line prompts split into title +
description. One previously hardcoded Korean confirm string is now i18n'd
(`compose.healthcheck.removeConfirm`, both locales).
## [0.31.0] – 2026-06-03
Per-feature improvement pass, round 15 — **cluster overview WebSocket push**.
### Changed
- **cluster** — the cluster dashboard now receives a combined **status + overview
+ recent events** snapshot over `/ws/cluster/overview` instead of polling three
HTTP endpoints every 15s. A single shared sampler per node rebuilds the
snapshot from the local (Raft-replicated) FSM + event bus every 5s and fans it
out to every open dashboard — no per-tab leader RPC. Followers serve their
replicated view flagged `stale` (the UI already renders a banner). The page
keeps one mount-time fetch for instant first paint, then live-updates over the
socket. Snapshot builder + broadcaster lifecycle are race-tested.
## [0.30.0] – 2026-06-03
Per-feature improvement pass, round 14 — **type-to-confirm for destructive ops**.
### Added
- **ui/safety** — a reusable **type-to-confirm dialog**: irreversible actions now
require typing the exact device/array/cluster name before the destructive
button enables. Applied to **disk format**, **partition delete**, **RAID array
delete**, and **cluster disband** (the disband's plain `window.confirm` is
replaced). A stray click can no longer trigger data loss on these flows.
## [0.29.0] – 2026-06-03
Per-feature improvement pass, round 13 — **Docker firewall iptables dedup**.
### Changed
- **firewall** — `GET /firewall/docker` now reads the NAT `DOCKER` chain **once**
and derives both the published-ports view and the reverse-DNAT map from that
single output, instead of running `iptables -t nat -L DOCKER` twice per
request. Parsers were split into pure functions over a pre-fetched listing;
behavior is unchanged. The single-fetch invariant is tested.
## [0.28.0] – 2026-06-03
Per-feature improvement pass, round 12 — **WireGuard peer management**.
### Added
- **network/wireguard** — manage peers from the UI instead of hand-editing raw
config: **generate a keypair** (`POST /network/wireguard/keypair`), **add a
peer** (`POST .../configs/:name/peers`) which appends a validated `[Peer]`
block and applies it live with `wg set` when the interface is up, **remove a
peer** (`DELETE .../configs/:name/peers?public_key=…`), and **toggle boot
autostart** (`wg-quick@<name>` enable/disable). The add-peer flow generates a
client keypair, assembles the client config browser-side (the server never
stores the client private key), and renders it as copyable text + a **QR
code** for mobile import. Public key / preshared key / CIDR / endpoint inputs
are validated server-side; the config append/remove parsing is tested.
## [0.27.0] – 2026-06-03
Per-feature improvement pass, round 11 — **SMART self-tests**.
### Added
- **disk** — **trigger a SMART self-test** (short or long) on a drive (`POST
/disks/:device/smart/test`); the test runs on the device and smartctl returns
an ETA. The SMART view now also parses and shows the drive's **self-test log**
(type, status, pass/fail, power-on hours when run) so prior and just-completed
results are visible. Self-test log parsing is tested.
## [0.26.0] – 2026-06-03
Per-feature improvement pass, round 10 — **scheduled local backups**.
### Added
- **system** — **scheduled backups**: enable a recurring local backup (interval
in hours, retention count) that writes timestamped `tar.gz` archives (DB +
config + compose files) to a `backups/` dir beside the database and prunes to
the retention limit. A background runner (`StartBackupScheduler`) checks every
10 minutes and runs when due; the last run's time/status/error are recorded
(migration 33–34, `backup_schedule`). New endpoints: `GET/PUT
/system/backup/schedule`, `POST /system/backup/schedule/run`, and download/
delete of individual archives (name pattern-validated against traversal). The
Maintenance tab gains a schedule form, run-now, and an archive list with
download/delete. Archive creation, listing, pruning, and the name guard are
tested.
## [0.25.0] – 2026-06-03
Per-feature improvement pass, round 9 — **generic webhook alert channel**.
### Added
- **alert** — a third notification channel type, **webhook**, alongside Discord
and Telegram. It POSTs a JSON body carrying a Slack/Mattermost-compatible
`text` field plus the structured alert fields (title, message, severity,
timestamp), so it works with Slack incoming webhooks or any custom receiver.
Arbitrary http(s) targets are allowed by design (homelab receivers live on the
LAN); the URL is validated and delivery is bounded by the shared 10s client.
URL validation, payload shape, and non-2xx handling are tested.
## [0.24.0] – 2026-06-03
Per-feature improvement pass, round 8 — **metrics sampling optimization**.
### Changed
- **monitor** — the `/ws/metrics` dashboard stream now shares a **single
sampler** instead of each connected viewer running its own 2-second
`GetMetrics` poll (≈5 syscalls per tick per viewer). One background goroutine
samples on the interval and fans the result out to all subscribers; it runs
only while at least one client is attached, drops a tick for any slow client
rather than stalling the rest, and hands a freshly opened dashboard the last
cached sample immediately. No change to the WS payload. Broadcaster lifecycle
is race-tested.
## [0.23.0] – 2026-06-03
Per-feature improvement pass, round 7 — **standalone Docker container creation**.
### Added
- **docker** — create a **standalone container** from the UI (`POST
/docker/containers`) without a compose file or shell: image (pulled if
missing), name, published ports (host/container/proto), environment variables,
volume binds (with read-only), restart policy, network, command, and
auto-start. The form is an explicit, validated subset of the Docker create API
rather than a full HostConfig passthrough; image ref, container name, restart
policy, and host ports are validated server-side (tested).
## [0.22.0] – 2026-06-03
Per-feature improvement pass, round 6 — cluster **rolling-update UX**.
### Changed
- **cluster** — the cluster-wide update view is now a **per-node stepper** with
an overall progress bar (completed / total, failure count) instead of a flat
scrolling log. Each node shows a localized step state (updating, waiting for
restart, transferring leadership, back online, slow restart, skipped, failed)
derived from the structured SSE the backend was already emitting. No backend
change.
## [0.21.0] – 2026-06-03
Per-feature improvement pass, round 5 — deepening the **files** module.
### Added
- **files** — **recursive name search** (`GET /files/search`) under the current
directory, bounded by a result cap and a wall-clock deadline (the response
flags truncation); **copy** of a file or directory tree (`POST /files/copy`,
refuses to clobber, copy-into-self, or write a critical path, and skips
non-regular files so it can't be tricked through a symlink); and
**multi-select delete** in the UI (per-row checkboxes + bulk delete with a
per-item result summary). Copy/search path handling and the copy guards are
tested.
## [0.20.0] – 2026-06-03
Per-feature improvement pass, round 4 — deepening the **process** module.
### Added
- **process** — the process list now carries **parent PID, absolute resident
memory (RSS), and nice value** alongside the existing CPU/mem%. The page gains
a **tree view** (parent→child, built from PPID with a cycle guard), a **renice**
control (clamped −20..19, same protected-PID guards as kill), and two new
**job-control signals — STOP (pause) and CONT (resume)** — in the signal menu.
Renice and the new signals refuse protected PIDs and panel-spawned children,
same as kill; validation is tested.
## [0.19.0] – 2026-06-03
Per-feature improvement pass, round 3 — finishing the "backend exists, UI
missing" gaps and a security fix surfaced while testing the self-update flow.
### Fixed
- **web/security** — streaming POST requests that bypass the JSON api client
(system self-update, backup/restore, image pull, compose up/update, file
upload, and the appstore/packages/tailscale install streams) omitted the
`X-CSRF-Token` header, so `CSRFProtect` rejected every one with **403**. This
silently broke the in-panel self-update (and several install flows) from the
browser. All streaming call sites now route headers through a shared
`api.streamHeaders()` that carries both the Bearer token and the CSRF
double-submit token.
- **compose** — the per-service healthcheck indicator used an indentation-based
line scanner that missed quoted service names, flow-style blocks, and
healthchecks pulled in via YAML anchors + the `<<` merge key. Replaced with a
real YAML parse (also honors `healthcheck: {disable: true}`). Parser tested.
### Added
- **cluster** — **list and revoke pending join tokens** (`GET/DELETE
/cluster/tokens`). The list is redacted (masked value + short fingerprint;
full tokens are never returned) so a mis-issued invite can be invalidated
before use.
- **cluster** — **edit a node's advertised address** (API + gRPC) inline and
**leave the cluster** from the local node's row, with a quorum-loss force
override. Both backend routes existed but had no UI.
- **terminal** — **reattach to a preserved PTY session** (`GET
/terminal/sessions` + a picker). The server kept sessions and scrollback alive
across disconnects, but the frontend always minted a fresh session id, leaving
them unreachable.
## [0.18.0] – 2026-06-03
Per-feature improvement pass, round 2 (deepening existing modules).
### Added
- **disk** — a **Disk Usage explorer** tab: click-to-drill, largest-first size
bars, path bar. Backed by the existing `du` handler (which gains `-x` so
pointing it at `/` stays on one filesystem). The endpoint shipped earlier but
had no UI.
- **audit** — **filter** the log by user (substring), HTTP method, and status
class (2xx/4xx/5xx/≥400), and show the **node** column. Parameterized queries
(injection-safe, tested).
- **alert** — a **node multi-select** for `specific`-scope rules; previously such
rules couldn't target any node from the UI so they never fired.
### Fixed
- **alert** — the history table's Node/Status columns always rendered blank
because the frontend read `node`/`status` while the API returns
`node_id`/`sent_channels`; aligned the type and derive a delivered status.
## [0.17.0] – 2026-06-03
Feature/UX improvements from the 2026-06-02 per-feature review (round 1 of an
ongoing pass — deepening existing modules, no new subsystems).
### Added
- **dashboard** — the metrics-history chart now plots **root-disk usage** (amber
series) alongside CPU and memory. `metrics_history` gains a `disk_percent`
column (migration 32, old rows default 0).
- **services** — **view the unit file** (`systemctl cat`, read-only) via a
Logs/Unit-file toggle in the service dialog. New `GET /system/services/:name/cat`.
- **cron** — **run a job on demand** with captured output. New `POST /cron/:id/run`
(executes the entry via `sh -c`, 5m timeout) + a per-job run-now button/dialog.
- **firewall** — the lockout-guard **force override** is now reachable from the
UI: a 409 from enable/add/delete opens a dialog showing the guard's reason and
re-runs with `force=true` on confirm. API errors now carry HTTP status/code.
### Fixed
- **packages** — streamed installs/upgrades showed a green "completed" even when
the underlying apt/npm/dev-tool command failed; the output dialog now detects
`ERROR:` markers and renders a real failure state.
## [0.16.2] – 2026-06-02
Spec documentation refresh + two small accuracy fixes. A page-by-page review
(dashboard through desktop) brought `docs/specs/{tech-features,frontend-spec,
api-spec}.md` back in line with current behavior — they had drifted since the
2026-04-19 snapshot (cluster tree UI, container observability, dev-tool
installers, port map, scoped audit clear, SSE install/upgrade, etc.).
### Fixed
- **web/src/types/api.ts** — `AuditLogEntry` now includes the `node_id` and
`protected` fields the backend already returns.
- **web/src/pages/Connect.tsx** — the desktop connect screen's example URLs and
placeholder used the dropped legacy `:19443` port; updated to `:3628`.
### Docs
- Refreshed all feature/page/API spec sections to match the shipped code
(no behavioral change to the server).
## [0.16.1] – 2026-06-02
Frontend serving hot-fix. A live UI walkthrough (desktop + mobile) found that an
already-open tab breaks after a panel upgrade: it requests old lazy-chunk hashes,
the SPA fallback served `index.html` (200 `text/html`) for the missing `.js`,
tripping strict MIME checking, and the whole app died behind a generic
ErrorBoundary that a plain refresh couldn't always clear.
### Fixed
- **internal/api/router.go** — `spaHandler` now returns `404` for a missing
concrete asset (a path whose last segment has an extension) instead of falling
back to `index.html`; extensionless client routes still fall back. `index.html`
is served `Cache-Control: no-cache` and hashed `/assets/*` `immutable` so an
upgrade takes effect immediately while assets stay long-cached. Pinned by
`internal/api/spa_handler_test.go`.
- **web/src/main.tsx + components/ErrorBoundary.tsx** — reload once on
`vite:preloadError` (and on a chunk-load error reaching the ErrorBoundary),
guarded to at most one reload per 10s so a genuinely missing chunk can't loop.
- **web/vite.config.ts** — PWA `registerType: 'autoUpdate'` so a new service
worker takes over on the next load instead of waiting on a prompt the app never
surfaced (which had left upgraded panels serving a stale precached shell).
- **web/index.html** — add the standard `mobile-web-app-capable` meta (the
`apple-` one is deprecated and warned on every page).
- **web/src/components/cluster/TreePanel.tsx** — sort the sidebar node list
stably (local node first, then by name) instead of the random Go-map order that
reshuffled it between pages.
## [0.16.0] – 2026-06-02
Module hardening sweep. Stability, security, and optimization fixes across ~20
feature modules from the 2026-06-02 multi-module review. No breaking changes;
additive API only (optional cron `expected_raw`, `CRON_CONFLICT` /
`PROXY_RESPONSE_TOO_LARGE` error codes). Full findings + verification log in
`docs/superpowers/research/2026-06-02-module-review/findings.md`; plan in
`docs/superpowers/plans/2026-06-02-module-hardening-sweep.md`.
### Fixed
- **auth** — serialize refresh-token rotation so two concurrent refreshes of the
same token under WAL no longer deadlock (`SQLITE_BUSY_SNAPSHOT`) into a 500;
record an audit event for unknown-username login failures.
- **alert** — dispatch notifications on a bounded async worker queue so a slow
webhook can't stall the serial docker-events listener or the evaluate ticker;
reserve the cooldown atomically to close a concurrent double-send race.
- **logs** — match the custom-source allowlist on path-segment boundaries (was a
boundary-less prefix that silently depended on caller trailing-slash
discipline); count filtered totals from the in-memory grep result.
- **firewall** — capture the DNAT rule protocol from the regex instead of a
whole-line `udp` substring scan (a mis-detect produced an ineffective DROP);
serialize DOCKER-USER chain edits so concurrent renumbering can't delete the
wrong rule; write fail2ban jail configs atomically.
- **disk** — guard `findMountPoint` against an empty resolved device path (was
mis-gating format/resize via pseudo-fs); bound `smartctl` with a 45s timeout;
accept `/dev/mapper` LVM targets in `ExpandFilesystem`.
- **cluster** — close the opposite relay connection on one-sided failure (was
waiting out a 60s deadline); return a clear error when a proxied response
exceeds the gRPC cap; re-fetch nodes from the live FSM during a rolling update.
- **db** — check `rows.Scan`/`rows.Err` on event, alert, audit, and settings
reads; make audit-clear count+tombstone+delete a single transaction.
- **system/files** — surface restore-restart failures (was fire-and-forget) and
stream the file-edit backup instead of reading the original fully into memory.
- **terminal** — add a WS read deadline + keepalive so half-open clients are
detected promptly instead of at TCP RTO.
### Changed
- **process/services/disk** — collect TTL-cache data without holding the write
lock so a refresh no longer blocks concurrent dashboard readers; move the
services and disk caches onto the Handler.
- **docker** — parallelize image-update checks with a per-image timeout.
- **cluster** — expire pooled gRPC connections on idle rather than creation age;
run background loops via `safe.Go`.
- **cron** — optional `expected_raw` optimistic-concurrency guard against
line-index TOCTOU on update/delete (409 `CRON_CONFLICT` on mismatch; omitting
it preserves prior behavior).
---
## [0.15.3] – 2026-05-24
Security hot-fix. Closes the 4 most operator-impacting Critical findings
from the 2026-05-24 four-domain review (fresh install / single-node
update / cluster lifecycle / cluster runtime). Phase 1 of
`docs/superpowers/plans/2026-05-24-cluster-and-update-hardening.md`.
### Fixed
- **internal/api/middleware/proxy.go** (C1) — `setAuthHeaders` signed the
v2 internal-proxy HMAC over `URL.Path` (path only) while the receiver
in `internal/auth/proxyauth.go` verifies against `URL.RequestURI()`
(path + query). Every cross-node HTTP relay carrying a query string
failed the v2 check and was rejected 401 — `?node=<id>` on
`/files/download?path=…`, `/files/upload?path=…`, `/logs/read?source=…`,
query-bearing `/system/restore`. Now signs over `RequestURI()` so the
signer and verifier consume identical bytes. Pinned by
`TestSetAuthHeaders_V2SignsPathPlusQuery`.
- **internal/feature/auth/handler.go** (C2) — `GetSetupStatus` and
`SetupAdmin` decided "setup required" from the local SQLite `admin`
table only. A node that joined an existing cluster has an empty local
table (admin lives in the Raft FSM), so the bootstrap endpoint stayed
open and would accept an attacker-supplied admin that could overwrite
FSM admin on a later leadership term. Both endpoints now consult the
cluster FSM first and refuse with 409 when an admin already exists.
Single-node installs are unaffected (cluster manager is nil and the
check short-circuits). The test-only `ClusterAccountsFn` seam doc was
tightened to spell out it MUST NOT be set in production.
- **internal/feature/system/handler.go + internal/release/version.go**
(C3) — the update verifier fell through to SHA-256-only when a release
omitted `checksums.txt.sig` / `.pem`, letting an attacker who controls
the GitHub release page strip the signature assets and ship a malicious
tarball under matching checksums. A new `SignatureRequiredSince =
"0.13.0"` constant makes any update targeting v0.13.0 or later abort
unless both signature assets are present. Pre-v0.13.0 targets keep the
SHA-256 fallback (preserves the one-time upgrade path from unsigned
releases).
- **internal/feature/system/handler.go** (C4) — the decompressed sfpanel
binary entry was read with an unbounded `io.ReadAll(tr)`; the on-wire
200 MiB cap does not bound decompression, so a high-ratio gzip bomb
could OOM the host. Capped at 256 MiB (`maxBinaryEntryBytes`, 5× the
real binary) via `io.LimitReader`, aborting cleanly past the cap.
### Operator note
Deploy to both cluster nodes sequentially (transfer leadership between
them), not simultaneously — a fanned-out restart of all voters can delay
leader re-election by ~15–20 s. The C1 fix restores cross-node file
download / log read / restore through `?node=<id>`; the C3 fix means a
self-built release without a Sigstore signature will now refuse to
self-update at v0.13.0+, which is intentional.
---
## [0.15.2] – 2026-05-24
Cluster-update orchestrator bugfix. The v2 internal-proxy nonce cache
treated the JWT middleware → CSRF middleware re-check on the same
inbound request as a "replay", rejecting every cross-node POST that
relied on the internal-proxy bypass. `/cluster/update` was the headline
casualty: peers returned `HTTP 403 CSRF_TOKEN_MISSING` even though the
v1+v2 headers were correctly minted by the gRPC proxy.
Symptoms before the fix:
- GET via cluster proxy (`?node=<id>`) worked, because CSRF middleware
skips safe methods so `IsInternalProxyRequest` was called only once
(from JWT middleware).
- POST via cluster proxy returned 403 — JWT consumed the nonce,
CSRF re-validated, the cache saw it as a replay.
- Web-based "Rolling Update" / "Simultaneous Update" failed for every
cluster topology.
### Fixed
- **internal/auth/proxyauth.go** — `registerNonce` now accepts a
repeated nonce within a 50 ms grace window as a same-request middleware
re-check, rather than rejecting it as a replay. Outside the window it
still rejects (network-replay attackers cannot fit a captured-then-
replayed request inside 50 ms on any realistic link). Added two unit
tests:
- `TestIsInternalProxyRequest_RepeatedCallsWithinRequest` — pins the
same-request bypass that JWT+CSRF re-check depends on.
- `TestIsInternalProxyRequest_ReplayAfterWindowRejected` — pins the
genuine-replay rejection outside the window.
- Existing replay-rejection tests updated to sleep past the grace
window before the replay attempt.
### Operator note
For the immediate upgrade from a pre-0.15.2 follower, the orchestrator
path still hits the bug on the follower's side until the follower is
running 0.15.2. Upgrade one follower manually once via the GitHub
Release tarball (or `gh release download`); subsequent cluster updates
from the web UI work as designed.
---
## [0.15.1] – 2026-05-23
CLI bugfix. `sudo sfpanel cluster <state-changing-op>` (leader-transfer,
token, init, join, leave, remove) all returned 403 `CSRF_TOKEN_MISSING`
because the CLI's `callLocalAPI` helper sent only the Bearer JWT — the
browser flow's CSRF cookie+header pair was missing. Discovered when
trying to drain leadership before a rolling cluster upgrade.
### Fixed
- **cmd/sfpanel** — `callLocalAPI` now attaches a synthetic same-value
CSRF cookie+header pair on state-changing methods so CLI calls satisfy
`CSRFProtect` middleware. Safe methods (GET/HEAD/OPTIONS) continue to
bypass CSRF entirely. Implementation lives in a new
`attachCSRFIfNeeded` helper with three unit tests in
`cluster_commands_test.go`.
---
## [0.15.0] – 2026-05-22
Module-hardening follow-up. The 2026-05-22 branch-level review surfaced
25 issues — one P0 (cluster `GetStatus` nil panic during raft init), the
rest a mix of self-protection gaps, leaked secrets, context-leaks, and
graceful-empty regressions on minimal nodes. All 25 landed as
self-contained commits; this entry summarises the batch. No API breakage.
### Security
- **terminal** — PTY session keys are now bound to the authenticated
user. A cross-user `session_id` (guessed or copy-pasted from another
operator's tab) can no longer attach to a PTY the other operator
started. Empty proxy usernames rejected up front so two cross-node
sessions can't collide on an empty-string key. WS relay propagates the
authenticated username through the cluster proxy with the HMAC-signed
v2 header.
- **alert** — Telegram and Discord delivery errors no longer interpolate
the bot token or webhook URL into the returned error string. New
`ErrChannelError` code; `TestChannel` returns the generic
`"channel delivery failed; check channel configuration"` regardless of
the underlying transport failure.
- **disk** — `Mount`, `Unmount`, and `FormatPartition` now refuse system
mountpoints (`/`, `/etc`, `/var`, `/var/lib/sfpanel`, `/home/*`,
`/boot`, etc.). The lookup is mockable so tests cover the guard.
- **files** — `ReadFile` and `DownloadFile` open with `O_NOFOLLOW` and
refuse leaf symlinks; the previous symlink-aware check only ran on
writes. Write path now uses copy-first backup so a crash mid-write
leaves the original intact.
- **compose** — `ResolveComposeFile` validates the project name (no
path separators, no `..`) before composing the on-disk path; closes
the traversal vector that healthcheck and backup endpoints exposed.
Backup and tmp files are written at `0o600`.
- **appstore** — Compose YAML now written at `0o600` (was the umask
default `0o644`) and the write is atomic via temp + rename so a
half-installed stack can't be picked up by `docker compose`.
- **system** — `RestoreBackup` serialises concurrent calls behind a
mutex, streams archive entries instead of loading the whole tarball
into RAM, runs a WAL checkpoint before swap, and preserves the
original file modes (was clobbering everything to `0o644`).
- **auth** — Access JWT is now generated *before* the refresh token is
marked consumed. A transient JWT-signing failure on retry can no
longer trip the OWASP family-revoke path and lock the operator out
while their refresh chain looks valid client-side.
- **cluster** — `GetStatus` no longer dereferences a nil overview
during raft init. A `/cluster/status` poll racing the first FSM apply
used to crash the node mid-boot.
### Changed
- **terminal** — Orphaned PTY sessions (zero readers attached) are now
reaped 5 minutes after the last reader leaves, regardless of the
operator-tunable `terminal_timeout` setting. The previous behaviour
let a terminal opened and forgotten with no reader keep its PTY alive
for the full inactivity window.
- **disk** — `lsblk`, `smartctl`, `parted`, `mdadm`, and `dd` now honor
`c.Request().Context()`. Client disconnect (browser close, NAT
timeout) kills the subprocess inside ~200 ms instead of letting it
run to the 5-minute Commander timeout.
- **packages** — `install`, `remove`, and `upgrade` now check for the
`dpkg-lock-frontend` holder up front and return `409 Conflict`
immediately with the PID + process name of whatever is holding the
lock. The old behaviour blocked silently inside `apt-get` for as long
as the other process took.
- **firewall** — `EnableUFW`'s lockout precheck now consults
`ufw show added` when UFW is inactive (instead of assuming "no rules
configured = lockout"). Operators who correctly added an SSH ALLOW
before enabling UFW no longer hit a spurious 409.
- **docker** — `PullImage` validates the image reference against the
Docker name grammar and rejects refs longer than 512 chars before
invoking the daemon, so a typo-by-XSS can't trigger an arbitrary
HTTP fetch through the docker socket.
- **compose** — Backup files and tmp files are written at `0o600`
(mirroring the appstore change).
- **alert** — `TestChannel` returns the generic message
`"channel delivery failed; check channel configuration"` with code
`CHANNEL_ERROR` regardless of the underlying transport failure.
### Added
- **`internal/common/sysguard/`** — new package centralising the
self-protection deny-lists: `IsProtectedSystemdUnit` (units that
must never be stopped/disabled/masked through the panel API),
`IsProtectedPID` (PIDs 0/1/2 and the panel process itself), and
`IsPanelChildPID` (pgid-based check for subprocesses sfpanel itself
spawned — apt, docker compose, terminal PTYs). `services` and
`process` migrated; new module deny-list rules go here.
- **`services` self-protection extended** — `dbus.service` and
`systemd-journald.service` join `sfpanel.service` on the no-touch
list. Both are panel-critical: dbus is required for systemctl IPC,
journald is the only path through which the panel reads service logs.
- **`process.KillProcess` refuses any PID in the panel's process
group** — apt, docker compose, terminal PTYs, and anything else
sfpanel spawned share the panel's pgid. The pgid check catches them
in one shot; the existing PID-not-found path covers PIDs that have
already exited.
### Fixed
- **disk, cron** — `ListDisks`, `ListFilesystems`, and the cron handlers
return a graceful empty result (or `503 TOOL_NOT_INSTALLED` for the
cron mutator paths) when the underlying binary is missing. Minimal
cluster followers without `lsblk`/`smartctl`/`crontab` no longer 500
on these list endpoints.
- **logs** — Scanner-drain wait on subprocess teardown is now bounded
by a 2-second deadline. A wedged scanner goroutine can no longer keep
the response open indefinitely while holding the upstream `tail -F`
pipe.
- **websocket** — `ContainerExecWS` synchronises its two relay
goroutines (stdin→docker, docker→stdout) before the handler returns,
closing the race where the response writer was reused after one
goroutine had already started the next request's frame.
- **network/tailscale** — `install` and `up` parent their subprocess
context on `c.Request().Context()`. A client disconnect during
Tailscale install no longer leaves a 10-minute `apt-get install
tailscale` running in the background against a closed pipe.
- **`response.SanitizeOutput`** — now actually strips ANSI escape
sequences. The previous implementation pattern-matched a subset of
the format and was a no-op for the most common CSI sequences;
command output containing colour escapes was passing through to the
client verbatim. Disk, logs, appstore, and websocket call sites
switched to the helper.
Plan reference: `docs/superpowers/plans/2026-05-22-module-hardening-followup.md`.
---
## [0.14.0] – 2026-05-19
End-to-end module hardening pass — closes every P0 from the 2026-05-18 review
(25/25), lands all 8 Phase B systemic sweeps, all 15 Phase C module
correctness items, both Phase D refactor steps, and the Phase E hygiene
sweep. 37 commits, no API breakage besides the documented `portmap` JSON
shape change (`container` → `containers []`).
### Security
- **firewall** — `AddRule` now refuses deny/reject/limit on SSH (22) or the
panel port unless `?force=true`, mirroring the existing `EnableUFW`/
`DeleteRule` lockout guards. Every UFW/fail2ban/iptables/ss mutator runs
`requireTool` at entry so missing tooling on a cluster follower returns
`501 TOOL_NOT_INSTALLED` instead of an opaque 500.
- **packages** — `validPackageName` now anchors to an alphanumeric leading
character, blocking `--reinstall`/`-y`/`--allow-downgrades`-shaped values.
Every `apt-get install/remove/upgrade` call also passes the `--` end-of-
options separator as a second-line defense.
- **websocket** — `MetricsWS` arms keepalive (pong-driven read deadline +
ping ticker) so half-open connections tear down in seconds instead of
minutes. `Upgrader.CheckOrigin` enforces same-origin (or empty
`Origin` for non-browser tooling). Legacy `?token=` WS auth is now
loopback-only — long-lived JWTs in URLs leaked into access/proxy/shell
logs; the modern `?ticket=` (single-use, 60s TTL) flow is the only path
for remote clients.
- **appstore** — Advanced install now requires `{password}` in the body and
re-verifies it against the admin row via bcrypt before writing the
user-submitted compose YAML. Body capped at 1 MB. A stolen JWT alone is
no longer enough to escalate to host root through this endpoint.
- **terminal** — Same-origin guard on the Upgrader. Per-reader bounded
send queue replaces the synchronous fan-out so one slow client can't
head-of-line-block the PTY reader or peer readers (P0-17). PTY reader
goroutine wrapped in `sync.Once` to fix the racy `started` flag (P0-18).
- **auth** — `refresh.go` now logs `slog.Error` on tx.Commit failures and
surfaces 500 (instead of "Session revoked") when the OWASP theft-
detection family-revoke didn't actually persist. `loginAttempts`
sync.Map now drained on a 1h tick so a panel hit by months of internet
scanning doesn't accumulate stale per-IP attempt records.
- **process / services** — `services.Stop/Restart/Disable` refuses to act
on `sfpanel.service` itself unless `?force=true`. `process.KillProcess`
writes structured audit lines with pid/signal/username/path.
### Reliability
- **cluster** — `Manager.ProxySecret()` now caches the sha256(CA cert)
derivation; the proxy middleware hit it on every cross-node request
with a per-call disk read + hash. `ClusterUpdate` SSE now includes the
remote node's response body in the per-node "Update failed" event so
the operator sees the quorum-guard refusal / downgrade refusal text
instead of just an HTTP status. `Leave` logs the specific failure mode
(no leader / dial failed / RPC rejected) and points at
`sfpanel cluster remove` for recovery.
- **system** — `RunUpdate` refuses to take a node offline when doing so
would drop the cluster below Raft quorum. Heartbeat-based check is the
second line of defense for operators who fan out `/system/update`
directly (Ansible, parallel ssh) instead of routing through the
rolling-update orchestrator.
- **portmap** — `PortBinding.Proto` is now plumbed through from Docker;
UDP-only services (DNS, WireGuard, syslog) no longer disappear or pun
onto an unrelated TCP listener. `PortMapRow.Containers` is a slice so
two containers publishing the same host port both surface instead of
last-write-wins.
- **files** — `UploadFile`/`MkDir`/`WriteFile` validate symlink leaves
via `Lstat` + `EvalSymlinks` so a `/tmp → /etc/sfpanel` symlink can
no longer bypass `isCriticalPath`. `logs.AddCustomSource` is the same
shape. `ListDir` now caps at 10000 entries.
- **netplan** — `saveNetplanFile` writes atomically via temp file +
fsync + rename; a power loss mid-write can no longer leave a half-
YAML that `netplan-generate` refuses to parse.
- **settings** — `UpdateSettings` wraps the whole batch in a
transaction; partial application on the third write of five can no
longer leave settings half-applied. Per-key value validators
(`terminal_timeout 0..24h`, `max_upload_size 1..1024 MB`) now reject
out-of-range values before any write starts.
- **safe.Go** — New `internal/common/safe` package wraps every
long-lived background goroutine (history collectors, retention
pruners, terminal cleanup, update checker) with `recover()` + slog
panic logging. A nil deref inside a background loop no longer takes
the whole panel down.
### Performance
- **db** — 4 hot-path indexes added (`audit_logs(username,created_at)`,
`audit_logs(protected,created_at)`, `container_metrics_history(ts)`,
`alert_history(rule_id,created_at)`). `temp_store=MEMORY` PRAGMA
added — retention pruners no longer hit /var/tmp. `PRAGMA optimize`
on shutdown so the next boot reads back learned index-usage stats.
`MaxOpenConns` widened 1 → 4 (WAL-aware step short of full pool
split). New `db.AsyncWriter` drains audit_logs INSERTs through one
bounded queue instead of per-request `go func()` spawns.
- **firewall** — `ListJails` runs `fail2ban-client status <jail>` in
parallel up to GOMAXPROCS; an 8-jail host's panel refresh drops from
~800 ms to the slowest single jail.
- **appstore** — `ListApps` bulk-loads installed-state in one query
instead of N+1; `findFreePort` reads `ss -tlnH` once into a port set
and checks candidates against the set (was 100 subprocess calls
worst case).
- **alert** — Compiled container-pattern regex cached across calls;
the per-event-per-rule path no longer pays a fresh `regexp.Compile`.
- **disk** — `mdadm --detail` runs in parallel per array (was
sequential at ~200 ms/array).
- **terminal** — `ringBuffer.Write` switched from byte-by-byte to
bulk-copy with at most two `copy()` calls per write.
### Hygiene
- **proxy** — `/network/tailscale/install` and `/cluster/update` added
to the streaming-endpoint allowlist. New
`TestStreamingAllowlist_KnownSSEHandlers` CI test enumerates every
SSE handler and asserts each is recognized.
- **exec** — New `Commander.RunCtx` accepts a caller-supplied context
so handlers can propagate `c.Request().Context()` and have a client
disconnect kill the subprocess. New `exec.PrepareScanner` helper
sizes `bufio.Scanner` buffers to 64 KB initial / 1 MB max (was the
default 64 KB ceiling that silently truncated long log lines).
- **request logger** — `/health`, `/system/info`,
`/monitor/metrics`, and all `/ws/*` paths now skip the request log.
An idle panel left open in a browser stops emitting 50k+ noise
lines/day.
### Breaking
- `GET /api/v1/system/portmap` — `container` field renamed to
`containers` and changed from object to array. Frontend updated in
the same release.
---
## [0.13.15] – 2026-05-18
Follow-up to 0.13.14 — closes two issues that had been flagged as
"deferred to follow-up" in the previous release notes.
### Audit
- **`audit_logs.node_id` now stamps the local processing node** instead
of the empty `c.QueryParam("node")` it used to read. The cluster
proxy middleware strips `?node=` before the handler chain proceeds,
so the previous read produced an empty `node_id` on every cluster-
routed write — forensic reviewers could not tell which node served
a given audit row. Both `AuditMiddleware` and `ClearAuditLogs` now
read from a `func() string` resolved per-request against
`mgr.LocalNodeID()`, so a node that joined a cluster mid-process
starts stamping correctly without a restart.
### Cluster
- **Cluster status proxy retries once on stale connections.** The
follower's per-minute poll of `/cluster/nodes` (and other read-side
proxy-to-leader endpoints in the cluster handler) was alternating
503/200 whenever the pooled gRPC connection died between calls.
Confirmed in journal logs: every minute around the connection's
idle timeout, one request 503'd before the next succeeded — leaving
the UI to render "leader unreachable" banners on a perfectly
healthy cluster. `h.proxyToLeader` now mirrors `proxyToNodeGRPC`'s
retry path: on first failure, drop the dead conn, reconnect, retry
once. Added matching `slog component=cluster` lines so operators
can correlate transient 502/504s against the journal. Verified by
hammering `/cluster/nodes` 30× from a follower post-fix — all 30
returned 200.
---
## [0.13.14] – 2026-05-18
Hardening pass on the v0.13.13 follower auto-forward, plus extension
to five more cluster admin endpoints. Three independent code reviews
(security-focused, backend-focused, endpoint-survey) drove the fixes.
### Cluster
- **`X-Forwarded-For` propagated across follower→leader hop.** Before,
every forwarded admin request appeared on the leader as `127.0.0.1`
(the gRPC→loopback HTTP hop's source). The leader's per-IP rate
limiter (`preRecordLoginAttempt`) collapsed every cluster admin
auth onto one bucket, letting a single attacker on one follower
lock out admin authentication cluster-wide. Now `proxyToNodeGRPC`
appends `c.RealIP()` to the existing XFF chain; Echo's IPExtractor
trusts loopback (already in the default trust list), so the
leader's `c.RealIP()` returns the real client IP and the per-IP
ledger keys correctly.
- **Anti-loop guard switched from a forge-able magic header to the
cluster-internal proxy authentication.** Removed
`X-SFPanel-Forwarded-To-Leader`; `ProxyToLeader` now checks
`auth.IsInternalProxyRequest()` (mTLS / proxy-secret authenticated),
so an external client can't disable the anti-loop with a spoofed
header.
- **`LeaderNode()` returns nil when `LeaderID()` still points at the
local node mid-step-down.** Defense against a brief race where
`IsLeader()` flips to false a few ms before `LeaderID()` updates;
otherwise the helper would return the local node and the forwarder
would gRPC-self-call.
- **Cluster proxy failures now log `component=cluster` with target,
address, path, and error.** Operators no longer have to correlate
503/504s against an empty log.
- **`X-SFPanel-Original-Node` propagated** so the leader's audit /
security_event row stamps the cluster node where the user actually
authenticated, not the leader where the change landed. Empty when
the request ran locally. Follows the same trust-boundary pattern
as `X-SFPanel-Original-User` (stripped from external requests,
re-set fresh from `mgr.LocalNodeID()` on the follower→leader hop).
- **Content-Type propagated from the proxied response** instead of
hard-coded `application/json` — needed when future FSM-write
endpoints return other media types.
- **Auto-forward extended to five more cluster admin endpoints**:
`POST /cluster/token` (CreateToken), `DELETE /cluster/nodes/:id`
(RemoveNode), `PATCH /cluster/nodes/:id/labels` (UpdateNodeLabels),
`PATCH /cluster/nodes/:id/address` (UpdateNodeAddress — the
load-bearing port-migration path), and `POST /cluster/leader-transfer`
(TransferLeadership). All were previously returning 503 / "not the
cluster leader" when called from a follower.
### Known issue (not fixed in this release)
- The `audit_logs.node_id` field written by the audit middleware
(not the security_event writer) and by `ClearAuditLogs` still
pulls from `c.QueryParam("node")`, which is always empty after
the proxy middleware strips it. Cleanest fix is to inject the
cluster manager into the audit handler / middleware — defer to a
follow-up release.
---
## [0.13.13] – 2026-05-18
### Cluster
- **FSM-write endpoints auto-forward from follower to leader.** Admin
password change, 2FA verify, and 2FA disable previously returned
`503 "Account changes for cluster admins must run on the leader node.
Switch to node X and retry"` when the operator happened to be logged
into a follower — they had to manually pick the leader from the node
selector and retry. The handler now transparently forwards the request
to the leader via the existing gRPC proxy infrastructure and returns
the leader's response, so any node accepts the request. Includes an
`X-SFPanel-Forwarded-To-Leader` anti-loop guard so a brief leadership
flap during the forward can't ping-pong the request between two peers
that each think the other is leader. New `cluster.Manager.LeaderNode()`
helper and reusable `middleware.ProxyToLeader()` for any future
FSM-write endpoints. The pre-existing `failClusterPersist` 503
fallback is retained for the rare case where no leader is currently
elected (e.g. mid-election).
---
## [0.13.12] – 2026-05-17
Hotfix on top of 0.13.11.
### Settings
- **"Update available" navigation lands on the correct tab in
cluster mode.** The dashboard update banner and the sidebar
version button both routed to a bare `/settings`, which in
cluster mode hides system/tuning/audit behind `?scope=node`
and falls back to the General tab — so clicking "Go to
Settings" from a new-version banner showed the language
picker and no update UI. All three nav sites now use
`/settings?scope=node&tab=system`; in single-node mode the
`scope=node` is ignored and only `tab=system` takes effect,
preserving the existing single-node behaviour.
---
## [0.13.11] – 2026-05-17
Hardening pass across alerting, auth, audit, and settings, plus
two structural changes: the `RunUpdate`/`RestoreBackup` restart
path now degrades gracefully on hosts without systemd (Docker
containers, bare-process installs), and the monolithic Settings
page was split into per-tab lazy modules.
### Alerts
- **Per-rule `node_scope` enforced at evaluate time.** Rules
scoped to a specific node now skip evaluation entirely on
other nodes instead of evaluating-then-discarding; the leader
also stops fanning out per-rule notifications to nodes that
aren't in scope. Channel secrets (`token`, `password`,
`webhook_url`, etc.) are masked on `GET /alert/channels` so
the operator UI never echoes them back in plaintext.
- **`AlertSettings.tsx` toggle preserves channel secrets.**
Flipping `enabled` on a channel previously sent the masked
secret back to the server, blanking the real value. The
toggle now merges the patch with the existing channel record
client-side and skips any field whose value is the mask
placeholder. Missing Korean conntrack-fill alert label added
to `i18n/ko.json`.
- **`AlertSettings.tsx` migrated to i18n keys.** ~80 hardcoded
Korean strings (rule list, channel cards, modal labels) moved
to `t('alerts.*')` for English parity.
### Auth
- **Security events recorded for password change + 2FA setup,
verify, and disable.** Each writes an `audit_log` row tagged
`event_type=security` so the audit tab shows a tamper-evident
trail of credential mutations. Previously these went through
unaudited and only the JWT-revocation side-effect was visible.
### Audit
- **`audit_log_cleared` rows preserved across clears via a
`protected` column.** `DELETE /audit/logs` (clear-all and
range-delete) now skips rows with `protected=1`, so the
"audit log was cleared by X" entry survives subsequent
clears. Range-delete support added: `?before=&after=` now
bounds the delete instead of always wiping everything.
### Settings
- **Cluster-mode tab parity preserved across split.** Per-node
settings (system / tuning / audit) only render when
`?scope=node` is set in cluster mode; global settings
(general / security / alerts) render in the default view.
Single-node deployments see all tabs.
- **`Settings.tsx` split into lazy-loaded per-tab modules.**
The 891-line monolith became a 33-line shell plus six
per-tab files under `pages/settings/` (General, Security,
Maintenance, Performance, Audit, AlertSettings). Each tab's
state + handlers ship in their own chunk via `React.lazy`,
cutting the initial settings bundle. New shared helpers
`useApiAction` (loading + toast boilerplate) and
`saveSetting` cover the common patterns.
### System
- **`RunUpdate` / `RestoreBackup` degrade gracefully without
systemd.** Both handlers previously assumed `systemctl
restart sfpanel` would work, which fails (or — worse, in a
Docker container — talks to the *host's* systemd) on
non-systemd hosts. New `lifecycle.IsSystemdActive()` helper
(checks `/run/systemd/system` per `sd_booted(3)`) branches
the restart strategy: under systemd, keep cycling the unit;
elsewhere, self-exit with code 0 after flushing the response
so the container entrypoint or external supervisor picks up
the new binary / freshly-restored DB. The supervisor-less
message tells the operator the process is going away.
### Docs
- **Dropped stale `healthcheck-composer-polish` plan**
(features shipped in 0.13.0–0.13.7).
---
## [0.13.10] – 2026-05-16
Second-pass sweep on top of 0.13.9 — Opus 4.7 re-reviewed every
sidebar area for the Important + Improvement items that were
deferred from the critical-fix batch. Seven area commits, each
self-contained; this entry summarises the batch.
### Cluster
- **Stale-read indicator on `/cluster/overview` and `/cluster/nodes`.**
Both endpoints now call `raft.VerifyLeader(2s)` and tag the response
with `stale: true` when the leader can't confirm it's still the
leader (e.g. mid-failover). The UI keeps rendering — better stale
than nothing — but operators see a warning band so they don't act
on snapshot data during a partition.
- **`POST /cluster/token` returns real grpc_port + advertise_address.**
Previously the join URL hardcoded the panel's HTTP port (9443), so
copy-pasting the token into `sfpanel cluster join` against a
cluster on the non-default 3629 grpc port failed silently. The
token response now reflects the actual values from
`cluster.grpc_port` and `cluster.advertise_address` in
`config.yaml`; the React token panel reuses them instead of guessing.
- **Cluster events ring: cap `Since()` at `maxEvents`.** A long-lived
follower polling `/cluster/events?since=…` after a panel restart
could request an unbounded slice that allocated several MB.
Result set is now capped at the same `maxEvents` (256) the buffer
itself uses.
- **30-day TTL ceiling on join tokens (constant).** The 0.13.9 fix
hardcoded `30*24*time.Hour`; this release lifts that to a named
`MaxTokenTTL` constant in `internal/cluster/types.go` so the limit
is visible in one place.
- **Node label validation (Kubernetes-style).** `PUT /cluster/nodes/:id`
now rejects label keys/values that violate the K8s
`[a-z0-9A-Z]([-._a-z0-9A-Z]*[a-z0-9A-Z])?` shape; previously any
string was accepted and rendered with broken CSS in the UI.
- **Leader self-update HTTP: explicit context + signed v2 header
snapshot before `Shutdown`.** `ClusterUpdate` on the leader used
to call `client.Do(req)` with no context and write the v2 header
*after* `srv.Shutdown` had begun, racing the listener close. Both
fixed; the self-update path is now deterministic.
- **`sfpanel cluster leader-transfer`** — graceful Raft leadership
hand-off CLI for planned voter restarts. Wraps `raft.LeadershipTransfer`
with a 30 s timeout; previous workflow required killing the leader
and waiting for election.
- **Lint: staticcheck QF1003 tagged-switch on `env.Data.LeaderID`.**
Cleanup of the chained `if`/`else if` in `cluster_commands.go:449`.
### Dashboard
- **Null-safe log slicing.** `data.lines.slice(-8)` crashed the
dashboard when the SSE pushed an empty payload during agent
reconnect. Now `(data.lines ?? []).slice(-8)`.
- **Composite stable keys for log rows.** React was warning about
duplicate keys when the same logline arrived twice in a streaming
burst; now keyed on `${timestamp}-${index}-${first 32 chars}`.
- **Single `getDashboardOverview` call** in `Layout.tsx` instead of
`getSystemInfo` + `checkUpdate` round-trips on every render — the
backend already merged these in 0.13.7.
- **Exact prefix match for `BottomNav` active state.** `/files` no
longer highlights when `/files-anything-else` is the route. The
test is `pathname === to || pathname.startsWith(to + '/')`.
- **`MetricsCard`: always render the track.** `clampedPercent > 0`
let 0% bars vanish entirely; switched to `>= 0` so the empty
state is still visible.
- **`MetricsChart`: chart created once.** uPlot was being torn down
and rebuilt on every `xDomain` change — fine for correctness, an
eyesore on slower machines. The `useEffect` now depends only on
mount; `setData` handles in-place updates.
- **Monitor handler: partial OK instead of 500.** When `psutil`-style
collection failed on one of host/CPU/memory, the whole endpoint
returned 500. Now logs a WARN with `component=monitor` and returns
the partial payload — the UI degrades gracefully to "N/A" cells.
### Docker
- **`ListContainers`: single GROUP BY query** instead of N+1 — for
each project the loop used to issue one `SELECT … WHERE project = ?`
per container. On a host with 30+ containers the projects view
blocked for ~600 ms; now one query, one parse pass.
- **`ContainerLogs.tsx`: separate effects for terminal vs WS.** A
single useEffect was reinitialising xterm.js on every WS reconnect,
losing scrollback. Split into terminal-lifecycle (mount/unmount)
and ws-lifecycle (open/close/reconnect) effects.
- **`ContainerShell.tsx`: `ws.binaryType = 'arraybuffer'`.** Without
this, browsers default to `Blob`, which Chrome on iOS Safari handles
inconsistently — occasional empty frames in the PTY stream.
### AppStore + Files + Cron
- **`appstore` install: atomic project directory creation.** Replaced
`MkdirAll` (idempotent — silently accepts existing directories) with
parent-`MkdirAll` + project-`Mkdir`. Two concurrent
install clicks on the same template no longer race into the same
directory and produce a corrupt half-install.
- **Upload basename blocklist.** The 0.13.9 fix added `.war`/`.ear`
to the extension blocklist; this release adds a basename-level
list (`.htaccess`, `.htpasswd`, `web.config`) — files with no
extension or with the extension stripped that still constitute
RCE on a misconfigured reverse proxy.
- **`CronJobs.tsx`: client-side `isPlausibleCronSchedule`.** Saves
a round-trip on obviously broken schedules (`* * *` etc.) and
removes the 800 ms toast delay operators saw when typing.
### Logs + Processes
- **`process/handler.go`: per-Handler cache.** The 60 s
`top -bn1` cache lived in a package-scope variable, so all unit
tests shared state and the cluster proxy's local-handler dispatch
would occasionally see stale data from a previous test's
`MockCommander` output. Moved onto the `Handler` struct.
- **`Logs.tsx`: debounced search (150 ms).** Typing in the filter
box used to re-run the regex against the full 5500-line buffer
on every keystroke. Now debounced.
- **`Logs.tsx`: slack-window slice.** Buffer slice threshold
raised from 5000 to 5500 to avoid a full-array re-render every
new line at steady-state.
### Network + Disk + Firewall
- **`disk_swap.go`: precheck `req.Path`, refuse to overwrite regular
files.** The previous handler would happily `mkswap` a regular
file, silently corrupting its contents. Now `os.Stat` first and
rejects unless the path doesn't exist or is already a swap file.
- **`firewall_ufw.go`: split rule comment on `" # "` instead of last
`'#'`.** Comments that legitimately contain `#` (e.g.
`# allow webhook callback from #channel`) were being truncated.
- **`network/tailscale.go`: comment fix.** The block comment claimed
the `tailscale up` subprocess was backgrounded; in fact it runs
attached and blocks until the user authenticates. Comment now
matches the code.
### Packages + Terminal + Settings
- **Node version regex tightening.** `^v?\d+(\.\d+)*$` accepted `v1`
(too coarse — npm wouldn't resolve a major-only version against
nvm) and `v1.2.3.4.5` (not a real release). Hoisted into a
package-level `validNodeVersion` with `^v?\d+(\.\d+){0,2}$`.
- **Terminal "Clear" sends Ctrl-L (`\x0c`)** instead of literal
`clear\r`. The previous behaviour was either harmless-but-noisy
(typing `clear` inside vim) or actively wrong (typing `clear`
inside `mysql` REPL, which then ran the SQL `clear` keyword and
reset query history). `\x0c` is the universal terminal clear signal
that every TUI handles correctly.
- **Cluster mode backup + restore warning prompts.** Both
`handleDownloadBackup` and `handleRestoreBackup` in Settings.tsx
now `window.confirm` with cluster-aware copy explaining that
the single-node SQLite snapshot doesn't capture FSM state
(admin/JWT secret/cluster_node).
- **Backup restore polling cap.** Previous restore flow polled
`/auth/setup-status` forever; if the restore left the DB
corrupt, the operator stared at an indefinite spinner. Now
capped at 60 attempts (≈2 minutes) with a `restoreNoReturn`
toast pointing to `journalctl -u sfpanel`.
### Operator notes
- This release is a pure code-cleanup pass. No schema changes, no
new endpoints (beyond the `cluster leader-transfer` CLI), no
config additions. Upgrade in place; no migrations.
---
## [0.13.9] – 2026-05-17
Security + stability sweep across 15 issues surfaced by the per-area
review of every sidebar feature. Each item has its own commit with a
focused regression test where applicable; this entry summarises the
batch.
### Security
- **Files API: tightened path validation + expanded read-protection.**
`validatePath` no longer rejects legitimate filenames containing
`..` (`/var/log/app..log` previously 400'd) and no longer accepts
redundant segments like `/etc/./hostname` or `//etc/passwd` that
the old `strings.Contains(p, "..")` check let through. Read-protection
was previously narrow (`/etc/shadow`, `/etc/gshadow`,
`/etc/sfpanel/`); the admin (or XSS riding their session) could
read `/root/.ssh/id_rsa`, `/etc/sudoers`,
`/var/lib/sfpanel/sfpanel.db` (admin password hashes + JWT secret).
Added entries for sudoers/sudoers.d, SQLite live DB + WAL/SHM, and
prefix rules for `/root/.ssh/`, `/etc/ssh/*_key`, `/home/*/.ssh/`.
- **Settings: allowlist on `PUT /settings` keys.** The endpoint
accepted any key — admin/XSS could poison `appstore_cache`
(unmarshal is unchecked), grow the settings table unbounded, or
overwrite operator-tunable values past sane bounds. Restricted to
`terminal_timeout` and `max_upload_size`; other modules already
write their own keys via direct DB calls.
- **Cluster join tokens: 30-day max TTL.** `POST /cluster/token`
previously accepted any `time.ParseDuration` value (`8760h` →
1 year, `99999h` → ~11 years) and persisted the result.
- **UFW: SSH-lockout guard on enable + rule delete.** `EnableUFW`
refused to flip default-incoming-deny without an existing ALLOW
for SSH (22) or the panel port; `DeleteRule` simulates the
post-delete state and refuses if removing the targeted rule
leaves no access. Pass `?force=true` to override either.
- **Disk LVM/RAID: device-name regex no longer allows `/`.**
The previous regex permitted `sda/anything`, which then became
`/dev/sda/anything` — pointing `mdadm` / `pvcreate` / `pvremove`
at unrelated kernel devices. Added `verifyBlockDevice` stat check
that confirms `/dev/<name>` is actually a block device before
invoking destructive tooling.
### Stability / resource leaks
- **Logs WS: kill subprocess on client disconnect.** The handler
waited on the scanner goroutine after the client gone, but
`scanner.Scan()` blocks on `tail -F`'s pipe — which never EOFs.
Every tab close leaked a `tail -F` (and `grep` in filtered mode)
for the lifetime of the panel. Now kills the primary subprocess
inline, which closes the pipe → scanner exits → cleanup runs.
- **WebSocket exec/logs: ping/pong keepalive.** Half-open WS
connections (browser tab crash, NAT timeout) left the docker
exec session and bridge goroutines alive forever waiting on a
`ReadMessage` that would never return. Added a 60s read deadline
with a 25s ping ticker.
- **useWebSocket hook: clear pending reconnect timer before
re-arming.** Two close paths could schedule reconnects without
clearing the previous handle, leaking timers and double-firing.
- **Docker client cache: invalidate on mutating ops.** The 5 s
containers-list cache went stale across Start/Stop/Restart/Remove/
Pause/Unpause; the UI's ListProjectsWithStatus lagged visibly.
- **Cluster RemoveNode: quorum guard.** Removing a voter from a
1- or 2-voter cluster previously took one click. Now requires
`?force=true` when removal would drop the cluster below current
Raft quorum (N/2 + 1).
- **Packages /upgrade: SSE instead of synchronous.** The old path
used Commander's 5-minute timeout — a real distro upgrade
routinely exceeded that, returning 500 mid-run with the dpkg
lock still held. Now streams output and binds the apt subprocess
to the request context so client disconnect kills it cleanly.
- **Packages install handlers: bind subprocess to request context.**
All 11 sites in `packages/handler.go` swapped
`context.WithTimeout(context.Background, …)` →
`context.WithTimeout(c.Request().Context(), …)`. Client disconnect
now propagates SIGTERM instead of letting the install run to
completion against a closed pipe.
### Cluster correctness
- **Cluster proxy classifier: packages installs + docker images/pull
marked as streaming.** Seven `/packages/install-*` routes plus
`/packages/upgrade` and `/docker/images/pull` were falling through
to the unary gRPC path (30 s timeout, 4 MB recv cap) when invoked
with `?node=remote`. Clicking 'Install Docker' on a remote node
silently timed out half-way.
### UX
- **Settings: Disable 2FA button.** The API and i18n strings shipped
earlier, but the UI only ever offered Reconfigure — once 2FA was
on, operators had no way to turn it off short of editing the
database. Added a destructive button alongside Reconfigure.
- **Terminal: resolve PTY home directory at runtime.** Previously
hardcoded `/root` — on non-root systemd installs the PTY chdir
failed and the shell exited before the operator saw a prompt.
Now prefers `HOME` env, then `os.UserHomeDir()`, then `/tmp`.
- **Packages search: allow multi-word queries.** The previous
package-name regex rejected spaces, so 'redis server' got a 400.
Split into a wider `validateSearchQuery` (apt-cache takes args
via argv so no injection surface).
- **Docker healthcheck composer: SHA against on-disk YAML.** The
composer dialog hashed the Monaco editor's buffer for the
precondition check, so any unsaved edits in the editor made
the server-side SHA mismatch with a misleading 'compose file
changed externally' error. Split out a `diskYaml` state that
mirrors what the server will read.
---
## [0.13.8] – 2026-05-17
Cluster observability hardening. Motivated by a real 2-day outage on
the author's homelab where two voters held diverged uncommitted entries
at the same Raft term and oscillated Follower↔Candidate forever
without any high-priority log line for the operator to alarm on.
### Added
- **`LeaderWatcher` emits ERROR-level `cluster has no leader` once the
cluster has gone 60 s without a leader, repeating every 5 min while
the condition persists.** Pure struct with TDD coverage; a goroutine
in `Manager.Start`/`Init` pumps it on a 15 s tick and exits via the
heartbeat manager's stop channel. External monitoring
(systemd `OnFailure=`, Alertmanager, etc.) finally has something
worth paging on — the underlying `hashicorp/raft` library only emits
WARN-level per-election failures that operators learn to ignore.
- **`sfpanel cluster list`** prints a table of every cluster member
with live role, status, API and gRPC addresses. Requires the local
server to be running (the FSM lives behind raft; a second process
would conflict on the port).
- **`sfpanel cluster status` shows live cluster info when the server
is running** — Raft role (Leader / Follower / Candidate), current
leader ID, peer count broken down by online/suspect/offline. The
previous output (local config only) is preserved as the fallback
for when the server is down.
### Fixed
- **Runbook now documents the 2-voter deadlock recovery procedure.**
`docs/specs/cluster-partition-runbook.md` gains a "Recovery — deadlock
from log divergence" section: identify the newer log via
`last-term=N` vs `last-candidate-term=N-1` in pre-vote rejection
lines, stop both services, start the newer-log node first, the
older-log node catches up via `appendEntries rejected, sending older
logs` truncation. ~10–15 s downtime, no data loss (the diverged
entries were uncommitted, so nothing applied to either FSM).
### Operator notes
- The new ERROR line is `level=ERROR component=cluster
msg="cluster has no leader" seconds_without_leader=N`. Hook it from
Loki / promtail / journald-exporter with a `level=ERROR
component=cluster` filter.
- `sfpanel cluster list` is the canonical "is the cluster healthy"
command going forward. The HTTP API (`/cluster/overview`) has had
this data all along; the CLI just couldn't surface it.
### Internal
- `internal/cluster/CLAUDE.md` sub-guide corrected — the previous
"Heartbeat EOF noise is normal" note conflated two different log
sources (raft library `requestVote` errors vs our application
heartbeat warning); the latter is actually a symptom of cluster
trouble.
---
## [0.13.7] – 2026-05-16
Second build fix for the desktop pipeline. Server code is identical
to 0.13.4–0.13.6; only `.github/workflows/release-desktop.yml`
changed.
### Fixed
- **`latest.json` manifest job now publishes successfully.**
The 0.13.6 desktop builds all succeeded, but the manifest job
bailed with *"Missing Linux signature"*. Two patterns in the
workflow were stale against Tauri 2.10's actual artefact naming:
- Linux: Tauri 2.10 signs the AppImage directly (e.g.
`SFPanel_0.13.7_amd64.AppImage.sig`) — there is no
`.AppImage.tar.gz` wrapper anymore. Updated the collect step
to copy `*.AppImage.sig` and the manifest to point at
`.AppImage` as the updater URL.
- macOS: bundle is named `SFPanel.app.tar.gz` (no version, no
arch infix). Loosened the manifest's signature glob to
`*app.tar.gz.sig` and pointed the URL at `SFPanel.app.tar.gz`.
### Operator notes
- v0.13.6 release page is missing `latest.json` and the Linux
AppImage signature — operators who installed the Linux AppImage
from 0.13.6 won't see auto-update prompts until they re-install
from 0.13.7.
- Existing 0.13.5/0.13.6 installs of Windows/macOS bundles will
still get the auto-update prompt against 0.13.7 once `latest.json`
is published (this release).
---
## [0.13.6] – 2026-05-16
Build fix for the v0.13.5 desktop release. Server code is identical
to 0.13.4/0.13.5; only the desktop bundle's npm dependency pins
changed.
### Fixed
- **Desktop build now succeeds.** The 0.13.5 `Release Desktop`
workflow failed on all three platforms (Linux/Windows/macOS) with
*"Found version mismatched Tauri packages"*: Cargo resolved
`tauri = "2"` to `v2.10.3` while npm's `^2` slid forward to
`@tauri-apps/api v2.11.0`. The Tauri bundler refuses to build
when the npm and Rust crate minors disagree.
Pinned `@tauri-apps/api`, `@tauri-apps/plugin-updater`, and
`@tauri-apps/cli` to `~2.10.0` in `desktop/package.json` and
regenerated `desktop/package-lock.json` so CI's `npm ci` resolves
to 2.10.1 (matching the Cargo side). Any future minor bump now
needs to be done on both sides at once.
### Operator notes
- v0.13.5's release page has only the server tarballs — no desktop
installers, no `latest.json`. Operators who installed 0.13.5 via
the server `.tar.gz` are fine. Desktop users should pull v0.13.6.
- `latest.json` (the auto-update manifest introduced in 0.13.5) ships
for the first time as part of this release.
---
## [0.13.5] – 2026-05-15
Desktop tooling release. Server code is identical to 0.13.4; the
changes are confined to the desktop bundle so the version bump is
visible to operators on the release page (the desktop side has been
drifting behind the server for a long time).
### Changed
- **Desktop bundle now tracks the server version (lockstep).** The
three desktop manifests (`desktop/package.json`,
`desktop/src-tauri/Cargo.toml`,
`desktop/src-tauri/tauri.conf.json`) all jumped from 0.6.2 → 0.13.5.
Going forward, every release tag produces matching server tarballs
and desktop bundles.
### Added
- **Signed auto-update for the desktop app.** Wired in
`tauri-plugin-updater` with a freshly generated ed25519 minisign
key pair. The public key is embedded in `tauri.conf.json`; the
private key + (empty) password live in GitHub Secrets
(`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`).
The release workflow now produces `.sig` + updater archive pairs
for every OS, and a new `manifest` job composes a single
`latest.json` against
`releases/latest/download/latest.json`. Desktop clients poll that
URL, GitHub redirects to the current tag's manifest, and the
built-in updater dialog prompts the user before applying the
signed update. **First release where this is live** — existing
≤0.6.2 installs still need a one-time manual re-download because
the pre-update build has no embedded public key.
### Operator notes
- The desktop release page entry will look different starting now:
installer artefacts use the `0.13.5` prefix (same as the server
tarballs) instead of the historical `0.6.2`.
- `latest.json` is a release asset alongside checksums and bundles.
Don't delete it — it's the auto-update manifest.
- Key recovery: if you ever need to rotate the signing key, stage
a "transition release" first that ships both old and new
signatures. Replacing the public key in `tauri.conf.json`
without that step will leave fielded clients unable to verify
the next update and they'll have to re-install manually.
---
## [0.13.4] – 2026-05-15
Authentication bug-fix release. Three independent paths conspired to
push cluster-mode operators into a login loop where every fresh login
bounced back to /login within a couple of seconds; each is documented
below.
### Fixed
- **Refresh handler ignored the cluster FSM when verifying account
existence.** Account replicated only in the FSM (no row in the local
`admin` table) had every refresh attempt 401 with "User no longer
exists" and the refresh-token row tombstone-deleted — guaranteeing
the next access-token expiry kicked the user back to /login.
`Refresh` now mirrors `Login`'s lookup order (FSM first, local DB
fallback).
- **Four admin-management handlers carried the same FSM-blindness.**
`Get2FAStatus`, `Verify2FA`, `Disable2FA`, and `ChangePassword` read
/ wrote only the local `admin` table. On a cluster-only account
these either lied ("2FA disabled" when the FSM said otherwise) or
silently no-op'd the UPDATE so the user got a "success" response
while no state actually changed. New `loadAdminAccount` /
`persistAdminAccount` helpers route reads through FSM-first lookup
and route writes back to wherever the account lives (FSM goes via
Raft Apply with a 503 + leader hint on followers; local goes UPDATE
+ Raft sync).
- **v2 internal-proxy validator silently rejected every URL with a
query string.** Signers feed path-with-query into
`SignProxyRequestV2` so a captured header cannot be re-targeted to
a different endpoint / query params, but the validator was checking
the MAC against `r.URL.Path` (path component only) — so any
forwarded request whose URL carried a query string flunked v2
validation, the JWT middleware then looked for a Bearer token,
found none (the loopback proxy strips Authorization in favour of
v1/v2 headers), and returned 401 "Authorization header is required".
Dashboard's `/logs/read?source=syslog&lines=8` was the visible
casualty: when a browser had `current_node` pinned to a peer, those
401s were the third path into the login loop. Validator now uses
`r.URL.RequestURI()` so it sees exactly what the signer signed.
### Tests
- 8 new cases — 2 refresh handler (happy + truly-missing-user), 4
admin-account helpers (local read, missing returns ErrNoRows, local
update including NULL-totp, cluster-without-manager refusal), 2 v2
proxy validator (round-trip with query, query-param rebinding
rejected). FSM-positive paths for the cross-cluster flows are
covered by the loopback integration probe in the deployment
runbook; stubbing the concrete `*cluster.Manager` would require
a wider refactor than this fix warrants.
### Operator notes
- **Mixed-version clusters need every node updated** for cross-node
`?node=<peer>` proxy to validate query-string'd URLs. A
follower-only or single-node deployment (or any deployment that
doesn't pin `current_node` to a peer in the browser) is unaffected
by the proxy half of the bug.
- **Browsers stuck in the loop pre-upgrade**: clear
`localStorage["sfpanel_current_node"]` (DevTools → Application →
Local Storage, or `localStorage.removeItem('sfpanel_current_node');
location.reload()` in the Console) to break out without waiting
for the binary update to land on every node.
### Changed
- **Default listening ports moved off the 9xxx block.** New installs
bind 3628 HTTP, 3629 cluster gRPC, and 3630 Raft (gRPC + 1). The
earlier defaults (19443 / 9444 / 9445) collided with common
homeserver workloads. Existing operators see no change unless they
remove the relevant lines from `config.yaml` — `config.go` only
fills in the defaults when the field is absent.
#### Migration for existing operators
Operators upgrading via `sfpanel update` keep their current ports.
To switch to the new defaults:
```yaml
# /etc/sfpanel/config.yaml
server:
port: 3628 # was 19443 (or whatever was set)
cluster:
grpc_port: 3629 # was 9444; Raft transport auto-binds to 3630
```
Then `sudo systemctl restart sfpanel`. For **clustered** deployments
roll one node at a time with ≥ 10s between each (same constraint as
any restart sequence — see CLAUDE.md "Simultaneous restart of all
voters"). Update any reverse-proxy / firewall rules to match before
restarting the front-most node.
---
## [0.13.3] – 2026-05-15
Security hardening (F1 full). XSS-resistant session model and CSRF
protection on every state-changing request.
### Added
- **httpOnly refresh cookie + CSRF double-submit** — refresh tokens
now live in a `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`
cookie that JS cannot read. A separate `sfpanel_csrf` cookie
(JS-readable) is echoed on every POST/PUT/PATCH/DELETE via
`X-CSRF-Token` — a cross-site attacker who tricks a victim's browser
into POSTing to the panel cannot read the cookie cross-origin and
cannot forge the header.
- `POST /api/v1/auth/logout` — revokes the refresh token in the DB
and clears both cookies.
- `Secure` cookie flag derived per-request (`X-Forwarded-Proto` /
`r.TLS`) so the default `:9443` plain-HTTP listener doesn't
silently drop cookies but reverse-proxy-fronted TLS deployments
get the strictest setting.
### Tests
- 12 new cases — 6 CSRF middleware (safe-method exempt, bootstrap
exempt, mismatch rejected, header match accepted, internal proxy
bypass), 6 cookie helpers (hardened flags, Secure tracks scheme,
CSRF cookie JS-readable, ClearAuthCookies MaxAge=-1, entropy +
uniqueness).
### Compatibility
- Refresh handler still accepts the legacy JSON body fallback for one
release so in-flight v0.13.2 sessions don't break on upgrade. Will
be removed in v0.14.0 after the cookie path has baked.
---
## [0.13.2] – 2026-05-15
Comprehensive security audit + stability patches across cluster proxy
auth, refresh token theft detection, CSP, WebSocket auth, and Go/npm
dependency lines.
### Security
- **Cluster proxy v2 header now enforced on HTTP routes** —
`JWTMiddleware` delegates to `auth.IsInternalProxyRequest` so v2
(HMAC + timestamp + nonce) is preferred over v1 static-secret.
Previously WS endpoints used v2 but HTTP fell back to v1, leaving
captured headers replayable indefinitely.
- **Refresh token theft detection (OWASP)** — `refresh_tokens` gains
`family_id` + `consumed_at`. Each login starts a new family; each
rotation tombstones the consumed row. A later replay of the
tombstone triggers "theft detected → revoke entire family" so the
attacker's chain dies. Migrations 24–26.
- **WebSocket auth via single-use ticket** — `POST /api/v1/auth/ws-ticket`
mints a 60s opaque ticket; the JWT no longer lands in WS URLs (and
thus no longer in browser history / reverse-proxy access logs).
Eight WS call sites migrated (Terminal, ContainerShell,
ContainerLogs, ComposeLogs, FirewallLogs, Logs, useWebSocket hook).
Legacy `?token=`