Skip to content

feat: web GUI, offline kubeadm install, live-test fixes - #2

Merged
lam0819 merged 6 commits into
mainfrom
feat/web-gui
Sep 15, 2026
Merged

lam0819 merged 6 commits into
mainfrom
feat/web-gui

Conversation

@lam0819

@lam0819 lam0819 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Web interface served from the same single binary (internal/web)
  • Offline kubeadm install on nodes with no internet, via mirror or tunnel (internal/vm/offline.go, viaproxy.go)
  • Fixes from live DigitalOcean testing (VM/TUI) and their sibling bugs
  • k3s version pinned in e2e so upstream outages can't fail CI
  • Live test report with screenshots (test/do/REPORT.md)

Test plan

  • test/do/do.sh live run on DigitalOcean (see test/do/REPORT.md + screenshots)
  • e2e pinned to fixed k3s version
  • unit tests: internal/vm/setup_test.go, internal/web/server_test.go

…live testing found

Three scenarios were run against real DigitalOcean VMs: a cluster built by
hand and then monitored, a cluster built by k3helper, and a cluster built on
two nodes with egress blocked at the provider firewall. The third needed code
that did not exist; the first two found bugs that unit tests could not.

Offline install. `k3helper bundle k3s --version v1.31.2+k3s1` fetches the
binary, the airgap image archive and the installer on a machine that has a
connection, verifying each against the release sha256 manifest. `vm setup
--bundle` uploads them to every node, puts them where the installer looks, and
runs it with INSTALL_K3S_SKIP_DOWNLOAD. The nodes need SSH from wherever
k3helper runs and a route to each other, and nothing else. Verified with
github and get.k3s.io both unreachable from the nodes.

Agents joined on the wrong address. The join address came from `hostname -I`,
which lists a cloud VM's public address first — the one address an air-gapped
network cannot use. The agents sat retrying "failed to get CA certs" against it
while the server ran perfectly well beside them. It now comes from the targets
file: the operator chose that address and k3helper has just proved it works by
connecting over it. `--join-address` covers the case where the two networks
genuinely differ.

A pinned k3s version, because the channel service went down mid-test.
update.k3s.io served a Traefik default certificate from all three of its
addresses — reproducible from DigitalOcean and from a laptop — so every
`curl -sfL https://get.k3s.io | sh -` on the internet failed TLS verification
and `vm setup` failed with it. `--k3s-version` skips that lookup and fetches
straight from the GitHub release.

`%!w(<nil>)` was the last thing on screen when that install failed: a command
that ran and exited non-zero has no error to wrap.

A healthy managed cluster scored 0% in the TUI, because every host check is
skipped on a kubeconfig cluster and skips counted as "not OK". Seen in a
screenshot of a live cluster. A skip is now outside both halves of the
fraction, so the score reads n/a.

Uploads stream rather than buffering: the image archive is 184MB per node.

test/do/do.sh provisions, air-gaps and destroys the droplets through the
DigitalOcean API. Everything it creates is tagged k3helper-test so teardown
cannot touch anything else, and the token is read from a gitignored
.env.production rather than passed on a command line where `ps` would show it.
Three scenarios against real VMs, the five things they found, and how to
reproduce them. Screenshots are real captures of the TUI against the live
clusters — including the one that showed a healthy managed cluster scoring 0%.
The sandbox and the E2E installed from the "stable" channel, which is a lookup
against update.k3s.io. That service served a Traefik default certificate from
all three of its addresses today, so every `curl -sfL https://get.k3s.io | sh -`
on the internet failed TLS verification — and CI's E2E failed with it, on a
branch that had nothing to do with it.

A pinned version fetches straight from the GitHub release and skips the lookup.
It is also simply better for a test suite: the cluster under test is now the
same one every run, rather than whatever "stable" meant that morning.
… each bug

Each fault the DigitalOcean run turned up is an instance of a class, so the
codebase was searched for the others. Six more, none of them exercised live.

kubeadm had the same join-address bug as k3s. `kubeadm init` defaults the API
server's advertise address to the default route's interface — the public one on
a cloud VM — and the join command handed to every agent is built from it. Same
failure, different installer. The resolver is now shared rather than written
twice, which is also how the k3s path came to have `installFailure` while the
kubeadm path had `exitReason` doing the same job; that is consolidated too.

The TUI dashboard was blank for a kubeconfig cluster. It drew one card per node
in the targets file and a kubeconfig cluster has none, so the skipped host
checks and the reason for them never reached the screen. That is the same bug
as the 0% score one layer up: an empty dashboard reads as "all clear", which is
precisely what those skipped results exist to prevent. Skipped checks are now
counted in the summary line too, instead of leaving "0 ok, 0 warn, 0 fail" on a
cluster where six checks were deliberately not applicable.

cloud-init raced the installer, and this time it is fixed in the product rather
than in the test harness. A fresh cloud image is still replacing
ca-certificates when sshd answers, and every https download on the box fails
TLS verification until it finishes — which reads like a firewall problem and is
not one. The kubeadm path needed it more than k3s: its prerequisites run apt
straight into cloud-init's dpkg lock. Guarded by a presence check so a node
without cloud-init is not delayed, and bounded so a stuck one cannot hang the
install forever.

Three in the offline path, which had only ever run its happy path. `--bundle`
was silently ignored with `--distro kubeadm`, so an operator asking for an
offline install got an online one and found out on an air-gapped node. A
bundle's architecture was recorded in its manifest and never compared to the
node, despite a comment claiming otherwise — the first sign would have been
"cannot execute binary file" after a 260MB upload. And the upload was neither
verified against the hashes already in that manifest nor cleaned up afterwards,
leaving a few hundred megabytes on every node's disk.
…or by tunnel

k3s installs offline from a bundle because it is one binary and one image
archive. kubeadm cannot: it needs apt packages and images from registry.k8s.io,
and neither fits in a file carried in. Two answers, both live-tested against
DigitalOcean droplets with egress blocked at the provider firewall.

--apt-mirror points a node's package manager at a mirror the operator runs.
Most sites with air-gapped Kubernetes already have one, and the useful thing to
do is point at it rather than replace it. Both source formats are rewritten —
24.04's deb822 .sources and older .list entries, missing either leaves half the
sources unreachable — with a backup of every file first, third-party
repositories left alone, and a check afterwards that the rewrite actually
matched something.

--via-proxy is for sites with no mirror either. k3helper already holds an SSH
connection to every node, so the connection carries the traffic backwards: a
listener on the node's loopback, a proxy on this machine, a host allowlist, and
both removed when the install finishes. The proxy resolves names on this side,
which matters because a genuinely cut-off node has no working DNS to resolve
them with. It is off unless asked for and announced every time it is used:
lending an isolated machine a route out is the operator's decision, and in some
environments it is not theirs to make.

Seven things live testing found that the tests could not, in order:

  - The proxy hung on every CONNECT. x/crypto/ssh channels refuse deadlines,
    and http.Server's hijack path interrupts its background read by setting
    one; on a connection that cannot, it waits forever and never replies. The
    proxy now reads the protocol itself, which for a forward proxy is less
    machinery anyway. Plain http worked throughout, which is what made it look
    like a network problem.
  - Bytes a client pipelines after CONNECT were dropped, because the buffered
    reader from Hijack was discarded. curl sends its ClientHello without
    waiting for the 200.
  - The allowlist was too narrow four separate times, each found only by
    running it: the provider's own apt mirror, Ubuntu Pro's ESM endpoint, the
    CDN that pkgs.k8s.io redirects to, and — after a cluster installed
    perfectly and sat NotReady — ghcr.io, where flannel's images live.
  - A refusal suggested a --proxy-allow value with the port still on it, which
    would not have matched.
  - The node reached its own API server through the proxy. NO_PROXY was set for
    containerd and not for the commands, so kubeadm sent cluster traffic to the
    operator's laptop, which refused its own node's address.
  - apt wedged on the dead tunnel when a run was interrupted, holding the apt
    lock for 31 minutes and failing every later run on that node with a lock
    error that pointed nowhere near the cause. It has a timeout now.
  - Waiting for cloud-init is not enough: apt-daily and unattended-upgrades are
    on timers and take the lock later. Every apt-get now waits for it.

The tunnel is install-time only, and the README says so: when vm setup
finishes the cluster has no internet again and cannot pull a workload image.
For anything past the install, point the nodes at an internal registry — and
for a genuinely disconnected site, k3s with --bundle remains the better fit.
The third way to look at a cluster, after the CLI and the TUI, and the same
one underneath: `serve` opens a connection exactly as `doctor` does and the
handlers are thin wrappers over the same functions. An overview with a card
per node, tables for pods, workloads, nodes and events, logs and describe in a
drawer, and Doctor's ranked findings with their remediation.

No Electron, no WebView2, no npm. The assets are embedded, so this is still one
file to copy onto a machine and it runs on Windows, macOS, Linux and in a
container with nothing else installed — about 3MB on top of the binary, against
the few hundred megabytes a bundled browser runtime would cost. There is a
Dockerfile too, FROM scratch, around 17MB.

Read-only, deliberately. Everything lists, describes or tails; any non-GET is
refused. A browser tab that can change a production cluster deserves rather
more thought about who is holding it than a localhost token provides, and none
of that is needed to make a cluster legible, which is what this is for.

Loopback unless told otherwise. A session token, fresh per launch, is the whole
of the authentication — so --host somewhere other than loopback needs
--allow-remote as well, and says why. The token reaches the page in its HTML
rather than staying in the URL, with no-referrer and a content security policy
so nothing carries it away. Nothing from the cluster is ever interpolated into
HTML: pod names and container logs are attacker-influenced in the general case,
and a diagnosis tool that could be made to run script by a pod name would be an
odd thing to hand an operator.

--host and --port behave the way a dev server should. A default port steps past
one already in use, because a previous run still shutting down is not a reason
to refuse to start; an explicit one does not, because somebody who names a port
has a reason.

Reads are memoised for a couple of seconds and a diagnosis for fifteen. Every
one of them is an SSH round trip — the live tests were talking to Singapore,
where a pod list takes the best part of a second — and a browser polling four
panels would otherwise keep a connection permanently busy. Polling stops
entirely while the tab is hidden, rather than SSHing into production every five
seconds until morning.

Verified against a live two-node cluster on DigitalOcean with a crashlooping
pod and a bad image planted in it: the tables, the logs of a previous
container, describe, and the same four findings the CLI reports. A screenshot
caught the one bug worth catching — `display: flex` beats the hidden
attribute, so the drawer sat open over the page with nothing in it.
@lam0819
lam0819 merged commit acc591b into main Sep 15, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants