Commit Graph
12 Commits
Author SHA1 Message Date
57_WolveandClaude Opus 5 947c89931d fix(deployments): stop interpolating .env values into a sed script
The set_env/env_set helper escaped its value with
`esc=${val//\/\\}; esc=${esc//|/\|}; esc=${esc//&/\&}` and then
interpolated it into `sed -i -e "s|^KEY=.*|KEY=${esc}|"`. The escaping does
not do what it looks like. Tested on bash 5.2:

  set_env K 'a&b'   ->  K=aK=seedb        (sed expanded & to the whole match)
  set_env K 'a|b'   ->  sed: unknown option to `s'   (rc!=0, aborts under set -e)

So any value containing & is silently corrupted and any value containing the
s||| delimiter kills the run. That is reachable: headscale writes
OIDC_CLIENT_SECRET through this, pocket-id writes REDIRECT_URL, copyparty
writes DATA_DIR. A generated secret or a URL query string hits both cases.

The copies in headscale and pocket-id were additionally mangled when they
were introduced -- `${val//\/\}` (pattern `\/`, a literal SLASH) and a raw
newline inside `printf '%s=%s\n'`. The mangled form is a no-op rather than a
corrupter, so the practical failure mode was the same as the original.

Replace all of them with an awk rewrite that passes the key and value through
the ENVIRONMENT, so the value is never parsed as part of a script and needs no
escaping at all. ENVIRON and index() are POSIX, so busybox awk handles them.
Output goes to a temp file and is copied back with `cat >`, which preserves the
original mode and owner -- a .env holding secrets stays 0600. If awk fails,
set -e aborts before .env is touched, which `sed -i` could not promise.

Verified against plain, a&b, a|b, a\b, p@ss&w|rd\x, R&D, a URL with a query
string, s/foo/bar/, a trailing space and the empty string; plus the
append-when-key-absent path, the file-does-not-exist path, non-target lines
left intact, no line-count drift, and mode preservation.

copyparty/update.sh and the rebuilt copyparty payload are included because
update.sh is embedded; regenerated with build.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:21:07 -05:00
57_WolveandClaude Opus 5 d6542996fc fix(headscale): let a re-run's values reach .env instead of being reverted
deploy.sh deliberately re-reads .env before rendering config.yaml and
headplane.yaml ("so config.yaml substitution sees what's actually
deployed"). That design is right, but .env was seeded only when absent, so
"what's actually deployed" could never change: `set -a; . "$ENV_FILE"`
overwrote every value passed to the run, and `set -a` re-exported the stale
ones into compose as well.

Worse, the prompts at :192-197 run 56 lines BEFORE ENV_FILE is even defined,
so an interactive re-run asked for all six required values and then threw
every answer away. Via automations.sh the same six arrive exported and meet
the same fate. The validation only greps .env for non-empty values, which
the stale ones satisfy, so the run printed DEPLOYED and exited 0.

The case that matters is a rotated OIDC_CLIENT_SECRET: headscale keeps
presenting the retired secret at pocket-id's token endpoint, and
only_start_if_oidc_is_available probes issuer discovery, not the secret, so
nothing fails at deploy time. Either the leaked credential is still live and
the rotation is fiction, or tailnet OIDC login is broken and surfaces later
at some user's `tailscale up`. A corrected HEADSCALE_DOMAIN leaves the LE
cert hostname and the OIDC redirect URI on the typo; newly-supplied
headplane OIDC creds silently leave /admin on API-key login, so the
IdP-group gating the README recommends is never in force.

Record which keys arrived in the environment BEFORE the ":=" defaults erase
the distinction -- writing a blank over a live OIDC secret is exactly the
wrong move. Pre-load .env into keys NOT passed, before prompting, so the
prompts stop asking questions they will discard (and SKIP_PROMPTS=1 no
longer dies demanding values .env already has). Then write only the passed
keys through with a set_env upsert. Secret values are never echoed; a
changed HEADSCALE_DOMAIN warns about the new cert and the redirect URI.

The `. "$ENV_FILE"` stays: once .env carries this run's values it reads back
what was passed, and .env, config.yaml, headplane.yaml and compose agree.

Verified: rotated secret lands and is not echoed; changed domain lands and
warns; new headplane creds land; a re-run passing nothing touches nothing;
SKIP_PROMPTS=1 with a configured .env and no env vars no longer dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:43:58 -05:00
57_Wolve e23557b4fb feat(firewall): add deny-by-default host firewall (harden-firewall.sh)
Add a reusable iptables baseline that hardens hosts with ICMP + SSH
defaults and lets deployments register the ports they need. INPUT is
deny-by-default (loopback, established, ICMP, SSH on the configured port,
plus registered ports); OUTPUT stays open and FORWARD is left untouched so
Docker container networking is unaffected.
Persistence is native -- no boot hook. Rules are saved and restored by the
distro's own package (iptables/ip6tables on Alpine, iptables-persistent on
Debian, iptables-services on Alma) via the new oslib helpers
install_iptables / fw_save_cmd / fw_enable_restore. The saved ruleset
carries the INPUT->sshguard jump, so brute-force protection survives reboot
without the old sshguard-iptables hook.
A self-contained /usr/local/sbin/firewall-apply rebuilds INPUT from
declarative drop-ins under /etc/firewall/ports.d and runs the native save,
so deployments add a port without needing the repo present:
  printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule
  /usr/local/sbin/firewall-apply
- SSH port read live from sshd_config (custom bastion ports just work);
  FW_SSH_SOURCE restricts the source CIDR; FW_ALLOW_PING gates echo
- harden-ssh.sh / harden-jumphost.sh install it when ENABLE_FIREWALL=1
  (default) and skip the sshguard-only hook; ENABLE_FIREWALL=0 keeps it
- cloud-init base.yml / jumphost.yml forward the toggle
- the four stack deploy.sh open_web_ports() register 80/443 via the
  firewall (ufw/firewalld kept as fallback); Docker-published ports bypass
  INPUT, so this is belt-and-braces and self-documenting
- README + cloud-init/README document the mechanism, Docker caveat, and the
  `disable` recovery path
2026-06-12 17:06:25 -05:00
57_Wolve 73cf299417 feat(headscale): allow-all default ACL with lockdown template
Ship allow-everything as the active default (first acl rule), with the
Tailscale default policy translated to headscale's acls format included but
commented for when you lock down. Pre-fix the headscale gotchas: tag:shared
owned by group:admins (not an autogroup), autogroup:self/ssh-check flagged
experimental. Rebuild embedded archive.
2026-06-12 16:32:41 -05:00
57_Wolve 6a3fc68b75 fix(headscale): valid default ACL + document /admin gating
headscale rejects autogroups as tagOwners (only user/group:/tag:), which made
the shipped policy fatal. Replace with a valid allow-all default plus correct
commented examples for tightening. Document gating /admin to a pocket-id
superuser group via the headplane client's Allowed User Groups. Rebuild archive.
2026-06-12 16:17:26 -05:00
57_Wolve 1ca79938cd feat(headscale): add headplane web UI at /admin
Integrate headplane (ghcr.io/tale/headplane) into the headscale stack, served
by Caddy at /admin. API-only (no Docker socket); deploy.sh mints a headscale
API key on first run, generates headplane.yaml, and wires optional OIDC login
via pocket-id (second client, /admin/oidc/callback). Adds HEADPLANE_* env,
compose service, Caddy routing; rebuild embedded archive.
2026-06-12 16:15:34 -05:00
57_Wolve 573785f2cc feat(headscale): add file-based ACL policy
Ship policy.hujson (mounted + installed on first deploy, edits preserved) and
wire policy.mode=file / policy.path in config.yaml. Translate the Tailscale
"grants" default into headscale's legacy "acls" format (self-access, tag:shared,
Tailscale SSH), since headscale 0.28 doesn't support grants. Embed in deploy.sh
and document `headscale policy check`.
2026-06-12 16:04:24 -05:00
57_Wolve 25f20037e9 feat(headscale): pin OIDC PKCE to S256 explicitly
Make pkce.method: S256 explicit in config.yaml (alongside pkce.enabled: true)
and note the pocket-id client must have PKCE enabled too. Rebuild embedded
archive.
2026-06-12 15:55:06 -05:00
57_Wolve 74b34550bc feat(headscale): install host headscale CLI wrapper
Add /usr/local/bin/headscale (generated by deploy.sh) that runs the headscale
CLI inside the container with the compose file path baked in, so `headscale
users list` etc. work from any directory instead of erroring with
"no configuration file provided". Update the post-deploy hints accordingly.
2026-06-12 15:52:35 -05:00
57_Wolve ecad11c416 feat(headscale): print OIDC callback URL after deploy
Surface https://${HEADSCALE_DOMAIN}/oidc/callback in the final DEPLOYED
summary so it's easy to register in the pocket-id OIDC client.
2026-06-12 15:51:11 -05:00
57_Wolve 8fbeb8f6b0 feat: unified launcher, multi-OS hardening, login alerts & auto-updates
Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.
- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates: AUTO_REBOOT=idle reboots only when no SSH active; opt-in
  Alpine stable-branch upgrades (ALLOW_RELEASE_UPGRADE)
- cloud-init: generic base/jumphost + per-deployment, which harden SSH by
  default on fresh VMs
- pocket-id: optional WebFinger block (BASE_DOMAIN), tag v2.8.0
- headscale: fix oidc.expiry schema for 0.28 so the container starts
- Gitea release workflow on tag (TOKEN_GITEA); repo URLs -> Gitea
- README/LICENSE/.gitignore/.gitattributes (force LF)
2026-06-12 15:24:30 -05:00
57_Wolve 7faa9098de feat: unified launcher, multi-OS hardening, login alerts & auto-updates
Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.

- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates with AUTO_REBOOT=idle (reboots only when no SSH active) and
  opt-in Alpine stable-branch upgrades
- generic + per-deployment cloud-init; Gitea release workflow on tag
- README/LICENSE/.gitignore/.gitattributes (force LF); repo URLs -> Gitea
2026-06-12 14:56:02 -05:00