Commit Graph

17 Commits (43d1d5590902fbc78ec3a1a25b655f955c279693)

Author SHA1 Message Date
nexxo a84251260c Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.

And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.

What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.

The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.

A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.

Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:12:29 +02:00
nexxo 336ca9d88c Make host onboarding survive the machine it runs on
tests / pest (push) Failing after 7m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Nine defects in the host pipeline, every one of which either stopped an
install or let a host reach `active` while it could not do its job.

The install-stoppers:

- InstallProxmoxVe hard-coded `bookworm`. A server ordered today boots
  Debian 13 trixie, so it added the PVE 8 repo and keyring to a trixie
  base. The codename now comes from /etc/os-release, the suite and keyring
  are looked up per release, and an unknown one fails naming what it found
  instead of guessing. Keys land in /usr/share/keyrings with Signed-By
  rather than in the deprecated trusted.gpg.d, where they would vouch for
  every other repository on the machine too.

- keyLogin() dialled wg_ip whenever it was filled, but wg_ip is persisted
  inside the allocation lock BEFORE the handshake is ever proven. Attempt
  1 reserved the address and failed at the handshake; every later attempt,
  including a console Retry, died connecting to a tunnel that did not work
  — before reaching the only code that could repair wg0.conf. Recovery
  meant nulling hosts.wg_ip by hand. SSH now uses the tunnel only once the
  `wg_peer` breadcrumb proves a handshake succeeded, which also rescues
  the same shape in RebootIntoPveKernel, ConfigureProxmox,
  CreateAutomationToken and SecureHostFirewall. Not a hole: 22 on the
  public IP is open until SecureHostFirewall runs, that step runs last,
  and by then the tunnel is necessarily proven. And nothing validated the
  hub key/endpoint, so renderConfig() would emit `PublicKey = ` — now
  refused before anything is installed, allocated or written.

- `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the
  interface up without the enablement, so the tunnel did not come back
  after the step-6 reboot; on the next attempt both halves failed with
  "wg0 already exists" and the step retried forever on a working tunnel.
  The three states are handled separately now, and a changed wg0.conf is
  actually applied instead of only written.

- RebootIntoPveKernel removed the Debian kernel with `|| true`, ignored
  update-grub's exit status, and rebooted. A /boot that filled up during
  the full-upgrade could leave a machine only the provider's console can
  reach. The pve kernel image, its initramfs and update-grub's exit status
  are all proven first; anything missing fails loudly and does not reboot.

- ConfigureProxmox ran `ip link show vmbr0` and threw the result away
  (its comment claimed it recorded the absence; it recorded nothing), then
  repeated a rm -f, and always advanced. Proxmox on top of Debian does not
  create vmbr0, so onboarding reported `active` with no bridge. It now
  fails with the default route in the message and deliberately does not
  build the bridge over the primary NIC from a remote shell.

The things that were quietly inert:

- Proxmox applies a guest's firewall rules only while the firewall is
  enabled at datacenter level, and it ships disabled — so applyFirewall()'s
  "80/443 only" rules were inert on every customer VM. Enabling it blindly
  is the opposite trap (input policy defaults to DROP, node firewall
  defaults to on, and its management ipset is seeded from the PUBLIC
  subnet, not the tunnel), so: policy ACCEPT and the node firewall off
  first, master switch last, read back to confirm. nftables stays the one
  owner of the host's input policy.

- role_privs lacked Sys.Modify, which POST /cluster/backup requires
  (pve-manager's PVE/API2/Backup.pm), so RegisterBackup 403'd and failed
  EVERY customer run. And `pveum role add … || true` only ever applied the
  privilege list on the run that created the role — it converges now, above
  the token short-circuit so a replay reaches it.

- The nftables ruleset dropped all ICMP and ICMPv6. On a real host that
  breaks IPv6 outright once the neighbour cache expires and black-holes
  large transfers through PMTUD. Both families accept the types they need,
  policy drop stays, and a DHCP client reply is allowed so a rebind cannot
  lose the IPv4 address.

- api_token_ref used the `encrypted` cast, i.e. APP_KEY, against
  SecretVault's own written rule. One rotation would have made every host's
  Proxmox token undecryptable at once. Moved onto SecretCipher with a
  migration that leaves any value it cannot read exactly as it is and says
  so, rather than destroying a credential that exists nowhere else.

- Every plan version points at template_vmid 9000 and nothing created or
  verified it, so the first paid order died in CloneVirtualMachine after
  the customer had paid. VerifyVmTemplate reports that during onboarding
  instead. It does not build a template: what goes into the golden image is
  a product decision.

Three tests that proved nothing, fixed: `ran(…emergency-open-firewall.sh)`
only ever matched the `chmod 700` above it, because putFile() is not
recorded — the script's contents are now asserted, including that nothing
in it reopens the firewall on a timer or in the background. The port policy
was tested one-sidedly, so a mutation adding `tcp dport { 8006 } accept`
passed the suite; the ruleset is now read as a list of what it opens to an
unrestricted source. And the end-to-end test says in writing that it proves
sequencing only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 00:54:16 +02:00
nexxo 864126ec7f Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
The SSH private key CluPilot deploys to every host is now stored through
SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH)
at all three real consumers — SshTraefikWriter and HostStep::keyLogin.
kuma.password/kuma.totp stay in .env: they authenticate a separate Python
container at boot, and nothing in this app reads them, so the vault would
have nothing to wire them to.

A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings,
makes the non-secret deployment settings that used to force a shell —
DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config
path, the SSH public key, and the monitoring bridge URL — visible and
editable from the console, each wired to every real consumer. WG subnet/hub
IP and Kuma's own connection details stay in .env: the compose file and a
separate container read those before this application ever does.
2026-07-29 00:52:44 +02:00
nexxo ed4167eba3 Close SSH and the Proxmox UI to the world once the tunnel is proven up
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
2026-07-28 23:57:07 +02:00
nexxo 2b1989f53d Move host DNS names off the public zone into vpn-dns's internal hostsdir
tests / pest (push) Failing after 8m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
2026-07-28 23:44:31 +02:00
nexxo 3fad319053 fix(hosts): re-read the host before deciding there is no DNS record
A registration finishing while the purge waited on the run locks wrote
dns_record_id after the purge had already loaded the host, so it skipped the
deletion and then deleted the row holding the only id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:02:18 +02:00
nexxo 7b57ab21cb fix(hosts): retry a failed DNS registration before advancing without a name
The failure may be a lost response to a request that did create the record.
Advancing straight away stored no id, so PurgeHost could never remove it and the
host's management address stayed published. The upsert is idempotent, so a retry
recovers the id; only after the attempts run out does the onboarding continue
without a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:01:16 +02:00
nexxo 952e8e5d2f fix(hosts): number DNS names by the normalised label, not the raw code
Two legacy codes can normalise to the same label (eu_west and eu-west), and
separate counters then handed both of them eu-west-01 — a unique-constraint
failure inside the reservation, which blocks onboarding rather than being a DNS
problem the step can shrug off. Lock, counter and the in-use check all key off
the label now.

(The helper was also called label(), which HostStep already declares as the
step's display name — renamed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:59:58 +02:00
nexxo e96f4c1c1a fix(datacenters): a code becomes a DNS label, so validate it as one
alpha_dash accepted eu_west, -edge and edge-, none of which are valid DNS
labels — every host in such a datacenter would have failed registration and,
because DNS is non-fatal, silently ended up without a name. The console now
requires a proper label, and the step normalises anything created before that
rule so existing rows still get a usable name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:56:50 +02:00
nexxo 9f8659da32 fix(hosts): a failed DNS deletion must not orphan the record
Swallowing it and carrying on deleted the row that held the only reference to
that record, publishing the machine's management address for good. The purge now
fails instead — and is retried, since it re-reads the host and its other steps
are idempotent — so a broken DNS provider becomes a visible failed job rather
than a silent leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:53:14 +02:00
nexxo edf24257b5 fix(hosts): reserve the DNS name under the lock, never reuse it, clean it up
Codex was right that my never-reuse claim did not hold:

- The lock was released with only a candidate in hand, so two concurrent
  onboardings in one datacenter could pick the same name and overwrite each
  other's record. The name is now persisted on the host while still locked.
- The number was derived from the hosts that happen to exist, so removing the
  highest one handed that number straight back out — a cached name would then
  resolve to a different machine. The counter is stored per datacenter, floored
  by what is actually in use so a wiped settings store cannot reissue live names.
- PurgeHost deleted the row that carried the record id, leaving the machine's
  management address published with nothing left to clean it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:51:25 +02:00
nexxo 5db2f7fda7 feat(hosts): give each host a DNS name under the public zone
fsn-01.node.clupilot.com, numbered per datacenter and never reused — removing a
host must not renumber its neighbours onto its name, so the number is stored
rather than derived.

The record points at the host's WireGuard address, not its public IP: the name
exists so an operator can reach a host by name over the VPN, and publishing a
Proxmox host's public address would hand every scanner a target, which is the
one thing this network design avoids.

DNS is convenience, not a prerequisite: a failure logs an event and lets the
onboarding finish rather than stranding a host that is otherwise ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:49:18 +02:00
nexxo 71ca0e1394 feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is
actually connected — traffic counters, source endpoint and last handshake.

Design notes:
- The web container has no wg0, so the page never talks to WireGuard. Live
  state is copied in by SyncVpnPeers on the provisioning queue (the only worker
  whose container owns the interface); the page polls the database and merely
  nudges that job, throttled so many open tabs cannot flood the queue.
- enabled (operator intent) and present (observed on the hub) are stored
  separately, so a sync can never quietly undo a block and drift stays visible
  as "wird angewendet".
- The sync adopts peers the host pipeline registered, naming them after their
  host — otherwise "who is on the VPN" would have blind spots.
- Keypairs are generated in PHP via libsodium rather than shelling out to
  wg genkey (no interface in this container, and it keeps generation testable).
  The private key is shown once and never stored.
- allocateIp() now also considers vpn_peers, which would otherwise be handed a
  tunnel address a host already holds.
- vpn.manage is a new capability held by Owner/Admin only, and it hides the nav
  entry too — a VPN access reaches the management network.

Verified end to end against the real hub, not just mocks: created an access in
the browser, connected with the generated config, watched the console flip to
"Verbunden" with real counters, then blocked it and confirmed the peer was
gone from wg0 and the client could no longer handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:38:12 +02:00
nexxo b65fe69088 fix(engine): drop wg peer on host removal; clear reboot state on deadline fail
- Removing a host now removes its WireGuard hub peer so a freed wg_ip can't
  route to the removed server via a stale peer.
- RebootIntoPveKernel clears reboot_issued/deadline when it times out, so the
  manual retry action actually re-issues a reboot instead of failing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:46:11 +02:00
nexxo 62c4412623 fix(engine): address Codex round 2 (poll budget, host error state, wg race)
- StepResult::poll — polling steps (reboot) wait without consuming the retry
  budget; the step owns its deadline. Reboot maxDuration > deadline.
- Failed runs move a Host subject to 'error' via ProvisioningSubject hook
  (no host stuck 'onboarding').
- ConfigureWireguard allocates + reserves the wg_ip under a global lock;
  unique index on hosts.wg_ip as a backstop against duplicate addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:26:37 +02:00
nexxo 65717bd3bd fix(engine): address Codex review (auth token bootstrap, tunnel recheck, ssh)
- CreateAutomationToken now bootstraps the pveum role/user/token over the
  authenticated SSH session (a fresh host has no API token yet); ProxmoxClient
  is read-only in A.
- ConfigureWireguard re-verifies the handshake on the idempotent replay path,
  never advancing over a dead tunnel.
- PhpseclibRemoteShell treats a missing SSH exit status as failure (255).
- connectWithKey verifies the pinned host key fingerprint on later logins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:20:59 +02:00
nexxo ac3d17cd6a feat(engine): 11-step host onboarding pipeline (SSH -> WG -> Proxmox)
Idempotent steps: validate, ssh-trust (deploy key + scrub password),
prepare base, wireguard (hub peer), install proxmox-ve, reboot-into-pve
(retry-poll), configure, automation token, verify api, register capacity,
complete. StartHostOnboarding action. 22 tests incl. mocked end-to-end +
crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:04:39 +02:00