diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5b7f25..b7567bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,12 @@ jobs: - name: Lint run: uv run ruff check . + # tests/test_configuration.py runs koncreet.yml under fakeroot when not root. + - name: Install fakeroot + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends fakeroot + - name: Python tests run: uv run pytest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06abeb7..8ae9a3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ uv sync --group dev # Lint uv run ruff check scripts tests -# Test +# Test (the Koncreet apply tests need root or fakeroot; unprivileged without it they skip) uv run pytest # Ansible syntax check diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index cb2c8d6..14663da 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -187,8 +187,9 @@ factory_installer_selection: >- + (['--npm'] if (factory_cfg.profiles.agents | bool) else []) + (['--development'] if (factory_cfg.profiles.development | bool) else []) }} factory_installer_also: >- - {{ (['psutil'] if ((factory_cfg.profiles.agents | bool) - and (factory_cfg.browser_prune.enabled | bool)) else []) + {{ (['koncreet'] if (factory_cfg.start_services | bool) else []) + + (['psutil'] if ((factory_cfg.profiles.agents | bool) + and (factory_cfg.browser_prune.enabled | bool)) else []) + (['obscura', 'supabase'] if (factory_cfg.profiles.fleet_guards | bool) else []) }} # --- Herdr static config ---------------------------------------------------- @@ -361,6 +362,18 @@ factory_tailscale_keyring: /usr/share/keyrings/tailscale-archive-keyring.gpg # package from the stable track. factory_tailscale_version: "" +# --- Koncreet (install only; the operator runs it by hand) ------------------ +# The latest jimididit/koncreet release, verified against the SHA-256 GitHub +# publishes for the asset, with Code Factory's Ubuntu 26.04 patch layered on. +# Root runs it, so the release and its configuration are root-owned, never in +# the account's home. docs/security.md has the manual run. Optional: it is +# resolved only when start_services is true (never in the container image), and +# a failed lookup, digest, or download skips it with a warning. +factory_koncreet: "{{ factory_latest.koncreet.assets[factory_platform] }}" +factory_koncreet_patch: "{{ code_factory_repo }}/patches/koncreet/ubuntu-26.04.patch" +factory_koncreet_prefix: /usr/local/lib/code-factory/koncreet +factory_koncreet_config: /etc/koncreet.conf + # --- Chrome ----------------------------------------------------------------- # auto -> install only when the desktop profile is enabled # true -> always install (headless AXI bridge use without a desktop) diff --git a/ansible/site.yml b/ansible/site.yml index d1c5c26..d61a402 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -110,6 +110,11 @@ when: factory_cfg.profiles.tailscale | bool tags: [tailscale] + - name: Koncreet host hardening toolkit, installed but never run + ansible.builtin.import_tasks: tasks/koncreet.yml + when: "'koncreet' in factory_latest" + tags: [koncreet] + - name: Google Chrome from the official distribution repository ansible.builtin.import_tasks: tasks/browser.yml when: >- diff --git a/ansible/tasks/koncreet.yml b/ansible/tasks/koncreet.yml new file mode 100644 index 0000000..6bce36c --- /dev/null +++ b/ansible/tasks/koncreet.yml @@ -0,0 +1,118 @@ +--- +# Koncreet host hardening: installed on every apply that starts services +# (never in the container image), never run. The operator runs it once, by +# hand (docs/security.md, Host hardening). Root runs it, so the release and +# its configuration are root-owned and outside the account's home, where an +# agent could rewrite what root later executes. +# +# It is optional: any failure here, a digest mismatch included, skips koncreet +# with a warning and leaves the rest of the apply running. A tree that failed +# its digest check is never installed or linked. + +- name: Install koncreet, or skip it with a warning + block: + - name: Hash the Ubuntu 26.04 koncreet patch + ansible.builtin.stat: + path: "{{ factory_koncreet_patch }}" + checksum_algorithm: sha256 + register: factory_koncreet_patch_stat + check_mode: false + + # A new release or a changed patch lands in its own directory, so an unchanged + # pair is a no-op and /usr/local/bin/koncreet only moves once the new tree is + # complete. patch-outcome is written last and marks the tree complete. + - name: Name the koncreet release directory + ansible.builtin.set_fact: + factory_koncreet_dir: >- + {{ factory_koncreet_prefix }}/{{ factory_latest.koncreet.version + }}-{{ factory_koncreet_patch_stat.stat.checksum[:12] }} + + - name: Stat the installed koncreet release + ansible.builtin.stat: + path: "{{ factory_koncreet_dir }}/patch-outcome" + register: factory_koncreet_stat + check_mode: false + + - name: Install the latest koncreet release with the Ubuntu 26.04 patch + when: + - not factory_koncreet_stat.stat.exists + - not ansible_check_mode + become: true + block: + - name: Create the koncreet release directory + ansible.builtin.file: + path: "{{ factory_koncreet_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Fetch the koncreet tarball + ansible.builtin.get_url: + url: "{{ factory_koncreet.url }}" + dest: "{{ factory_koncreet_dir }}.tar.gz" + checksum: "sha256:{{ factory_koncreet.sha256 }}" + mode: "0600" + + - name: Extract koncreet + ansible.builtin.unarchive: + src: "{{ factory_koncreet_dir }}.tar.gz" + dest: "{{ factory_koncreet_dir }}" + remote_src: true + owner: root + group: root + + - name: Remove the koncreet tarball + ansible.builtin.file: + path: "{{ factory_koncreet_dir }}.tar.gz" + state: absent + + # The patch never fails the apply: when upstream already supports 26.04 or + # the patch no longer applies, koncreet installs as released and the + # outcome says which case this release hit. + - name: Layer the Ubuntu 26.04 patch on the release + ansible.builtin.shell: | + cd koncreet || exit 1 + if grep -q '26\.04' lib/os.sh; then + echo "patch skipped: this release already supports Ubuntu 26.04" + elif git apply {{ factory_koncreet_patch | quote }} 2>/dev/null; then + echo "patch applied: Ubuntu 26.04 support layered on this release" + else + echo "patch skipped: it no longer applies, installed as released" + fi | tee ../patch-outcome.new && mv ../patch-outcome.new ../patch-outcome + args: + chdir: "{{ factory_koncreet_dir }}" + environment: + # Outside any repository, so git apply patches files like patch(1). + GIT_CEILING_DIRECTORIES: "{{ factory_koncreet_dir }}" + register: factory_koncreet_outcome + changed_when: true + + - name: Report the koncreet release and patch outcome + ansible.builtin.debug: + msg: "koncreet {{ factory_latest.koncreet.version }}: {{ factory_koncreet_outcome.stdout }}" + + - name: Link koncreet into /usr/local/bin + ansible.builtin.file: + src: "{{ factory_koncreet_dir }}/koncreet/koncreet" + dest: /usr/local/bin/koncreet + state: link + become: true + when: not ansible_check_mode + + - name: Render the fleet koncreet configuration once + ansible.builtin.template: + src: koncreet.conf.j2 + dest: "{{ factory_koncreet_config }}" + owner: root + group: root + mode: "0644" + force: false + become: true + + rescue: + - name: Skip koncreet + ansible.builtin.debug: + msg: >- + WARNING: koncreet skipped, apply continues. {{ ansible_failed_task.name }}: + {{ ansible_failed_result.msg | default('failed') }} diff --git a/ansible/tasks/preflight.yml b/ansible/tasks/preflight.yml index 2e5f66e..58470c0 100644 --- a/ansible/tasks/preflight.yml +++ b/ansible/tasks/preflight.yml @@ -47,7 +47,7 @@ - name: Build the list of Main-owned inputs this run requires ansible.builtin.set_fact: factory_required_inputs: >- - {{ [factory_installer, factory_herdr_spaces_source] + {{ [factory_installer, factory_herdr_spaces_source, factory_koncreet_patch] + (factory_agent_config_files | map(attribute='src') | map('regex_replace', '^', code_factory_repo ~ '/') @@ -103,6 +103,11 @@ ansible.builtin.set_fact: factory_latest: "{{ factory_latest_run.stdout | from_json }}" +- name: Warn about optional tools skipped by the lookup + ansible.builtin.debug: + msg: "{{ factory_latest_run.stderr_lines }}" + when: factory_latest_run.stderr | length > 0 + - name: Show the resolved provisioning plan ansible.builtin.debug: msg: diff --git a/ansible/templates/koncreet.conf.j2 b/ansible/templates/koncreet.conf.j2 new file mode 100644 index 0000000..35f447d --- /dev/null +++ b/ansible/templates/koncreet.conf.j2 @@ -0,0 +1,45 @@ +# Koncreet configuration for this host, rendered once by Code Factory +# (ansible/templates/koncreet.conf.j2). Edit it here: apply never overwrites it. +# Nothing runs koncreet automatically. Run it by hand, in this order; the full +# steps and the lockout recovery are in docs/security.md (Host hardening): +{% if factory_cfg.profiles.tailscale | bool %} +# sudo ufw allow in on tailscale0 +{% endif %} +# sudo koncreet doctor +# sudo koncreet --dry-run apply -c {{ factory_koncreet_config }} +# sudo koncreet apply -c {{ factory_koncreet_config }} +# sudo koncreet ssh apply only once that user can open a new SSH session and run sudo true + +# SSH hardening is the separate last step above, so it is not listed here. +modules=baseline,firewall,fail2ban,updates + +# The account that ran ./factory apply is the operator: it keeps the SSH keys +# it logs in with and is made a sudo user. +{% if ansible_user_id == 'root' %} +# Apply ran as root, so the operator account is unknown. Set both before use: +# user= +# pubkey_file=/home//.ssh/authorized_keys +{% elif ansible_user_id == factory_cfg.user %} +# Apply ran as {{ factory_cfg.user }}, the factory account that runs the agents and +# must not gain sudo through this file, so no sudo user is set. Set both to the +# operator's login before use: +# user= +# pubkey_file=/home//.ssh/authorized_keys +{% else %} +user={{ ansible_user_id }} +pubkey_file={{ ansible_user_dir }}/.ssh/authorized_keys +{% endif %} + +# SSH stays open: koncreet always allows the ports sshd listens on. +{% if factory_cfg.profiles.tailscale | bool %} +# 41641/udp is Tailscale's direct WireGuard port. Tailnet traffic arrives on +# tailscale0, which this file cannot name: run sudo ufw allow in on tailscale0 +# first. +firewall_ports=41641/udp +{% else %} +# The tailscale profile is off, so no Tailscale port or interface is opened. +{% endif %} +firewall_public=false + +fail2ban_services=ssh +auto_reboot=false diff --git a/containers/factory.container.yml b/containers/factory.container.yml index 2e94164..e973a5c 100644 --- a/containers/factory.container.yml +++ b/containers/factory.container.yml @@ -6,7 +6,8 @@ # container genuinely cannot host a native capability: # # start_services: false no systemd/D-Bus inside an ordinary container, so unit -# start/enable operations must be suppressed. +# start/enable operations must be suppressed. Koncreet, +# the host hardening toolkit, is not installed either. # enable_linger: false `loginctl enable-linger` requires a host user manager. # profiles.docker the worker never receives the host Docker socket and # does not run a nested daemon. diff --git a/docs/architecture.md b/docs/architecture.md index 085815c..fad9e05 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,7 @@ Every action is pinned to the commit SHA of its latest release (Dependabot moves ## Primary sources - [Herdr installation](https://herdr.dev/docs/install/), [headless/SSH persistence](https://herdr.dev/docs/persistence-remote/), [session-state limits](https://herdr.dev/docs/session-state/), [config reference](https://herdr.dev/docs/config-reference/). -- [Herdr latest release](https://github.com/herdrdev/herdr/releases/latest). GitHub-hosted assets (herdr, bun, uv, gh, no-mistakes, treehouse, Obscura) are verified against the SHA-256 digest GitHub publishes for each release asset; no claim is made that a release supplies an independent SBOM or signature bundle. +- [Herdr latest release](https://github.com/herdrdev/herdr/releases/latest). GitHub-hosted assets (the tools in [Dependencies](dependencies.md)) are verified against the SHA-256 digest GitHub publishes for each release asset; no claim is made that a release supplies an independent SBOM or signature bundle. - [Node.js release index](https://nodejs.org/dist/index.json). Node assets are verified against the `SHASUMS256.txt` published beside each release. - [rustup stable release](https://static.rust-lang.org/rustup/release-stable.toml). rustup-init is verified against the `.sha256` published beside it. - [Ansible introduction](https://docs.ansible.com/projects/ansible/latest/getting_started/index.html), [checksummed downloads](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/get_url_module.html), [user systemd/D-Bus requirements](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/systemd_service_module.html). diff --git a/docs/dependencies.md b/docs/dependencies.md index eaf1f00..65cf501 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -1,6 +1,6 @@ # Dependencies -Everything the recipe installs, grouped by where it comes from. Nothing is pinned: every `./factory apply` resolves each tool's newest release once and installs exactly that, so re-running apply upgrades an existing host. Every download is verified against the checksum its publisher posts for that exact release, and a release without one fails the apply instead of installing an unverified artifact. The one exception is the three omp marketplace plugins (ponytail, i-have-adhd, caveman): no publisher checksums them. The installer records the releases it resolved in `~/.local/share/code-factory/resolved.json`, which the container smoke compares against; `ansible/tasks/verify.yml` also asserts that the herdr service and omp run the resolved releases. `./factory plan` installs none of this. +Everything the recipe installs, grouped by where it comes from. Nothing is pinned: every `./factory apply` resolves each tool's newest release once and installs exactly that, so re-running apply upgrades an existing host. Every download is verified against the checksum its publisher posts for that exact release, and a release without one fails the apply instead of installing an unverified artifact (the optional Koncreet is skipped with a warning instead). The one exception is the three omp marketplace plugins (ponytail, i-have-adhd, caveman): no publisher checksums them. The installer records the releases it resolved in `~/.local/share/code-factory/resolved.json`, which the container smoke compares against; `ansible/tasks/verify.yml` also asserts that the herdr service and omp run the resolved releases. `./factory plan` installs none of this. ## Repository tooling @@ -23,7 +23,7 @@ Everything the recipe installs, grouped by where it comes from. Nothing is pinne - `agents` profile, omp plugins: ponytail ([DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail)), i-have-adhd ([ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd)) and caveman ([JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman)) from their GitHub marketplaces, installed once and upgraded with `omp plugin upgrade` on every apply. These are the only installs that are not checksum-verified: no publisher posts a checksum for them, so they track each author's default branch and load as agent instructions and hooks. The operator accepted this to keep them at the latest commit. - `development` profile: rustup-init, the version in rustup's [stable release](https://static.rust-lang.org/rustup/release-stable.toml), verified against the `.sha256` published beside it, installing the Rust `stable` toolchain (minimal profile + rustfmt + clippy). Every apply moves the toolchain to the newest stable. -The GitHub lookups use the GitHub API, which allows 60 unauthenticated requests an hour per IP (shared IPs such as CI runners exhaust it); a resolution makes one request per GitHub-hosted tool the host installs, at most seven. Only the tools a run installs are resolved, so a source the host does not use cannot fail it. The lookups authenticate with `GITHUB_TOKEN` from the environment that runs `./factory apply` or `./bootstrap.sh`, else run unauthenticated; the token is sent to the GitHub API only. Container builds take the token as the optional BuildKit secret `github_token` (`docker build --secret id=github_token,env=GITHUB_TOKEN ...`), so it never lands in the image. +The GitHub lookups use the GitHub API, which allows 60 unauthenticated requests an hour per IP (shared IPs such as CI runners exhaust it); a resolution makes one request per GitHub-hosted tool the host installs, at most eight. Only the tools a run installs are resolved, so a source the host does not use cannot fail it. The lookups authenticate with `GITHUB_TOKEN` from the environment that runs `./factory apply` or `./bootstrap.sh`, else run unauthenticated; the token is sent to the GitHub API only. Container builds take the token as the optional BuildKit secret `github_token` (`docker build --secret id=github_token,env=GITHUB_TOKEN ...`), so it never lands in the image. ## Ubuntu packages @@ -43,6 +43,12 @@ The GitHub lookups use the GitHub API, which allows 60 unauthenticated requests - Obscura, the latest [h4ckf0r0day/obscura release](https://github.com/h4ckf0r0day/obscura/releases/latest) for the host's platform, verified against the GitHub release-asset digest (`ansible/tasks/fleet-browsers.yml`). Each release extracts into its own `~/oss-fleet/browsers/obscura-/`. - Supabase CLI, the npm registry's latest `supabase`, installed with `npm install` into `~/oss-fleet/shared-supabase` (`ansible/tasks/fleet_guards.yml`). +## Koncreet + +Every host that starts services (`start_services: true`); the container worker image skips it. + +- [Koncreet](https://github.com/jimididit/koncreet), the latest release's `koncreet.tar.gz`, verified against the GitHub release-asset digest (`ansible/tasks/koncreet.yml`). It installs as root into `/usr/local/lib/code-factory/koncreet/-/` with `/usr/local/bin/koncreet` linked to it, and `patches/koncreet/ubuntu-26.04.patch` is layered on top. It is optional: when its lookup, checksum, or download fails, apply warns and skips it. Apply never runs it; [Host hardening](security.md#host-hardening) has the manual run. + ## Chrome autopruner `agents` profile with `browser_prune.enabled`. diff --git a/docs/security.md b/docs/security.md index c0afbc5..703f507 100644 --- a/docs/security.md +++ b/docs/security.md @@ -35,8 +35,38 @@ Tailscale installation, authentication, and SSH authorization are separate steps This export does not rewrite the current host's firewall, SSH policy, account membership, or credentials. Review those changes separately before applying a new-host profile. +## Host hardening + +Every apply on a host that starts services (not the container worker image) installs [Koncreet](https://github.com/jimididit/koncreet) as `/usr/local/bin/koncreet` and renders `/etc/koncreet.conf` once, but nothing runs it. Koncreet is optional: when its release lookup, checksum, or download fails, apply prints a warning, skips it, and finishes the rest; a release installed earlier stays in place. It is a first-hour hardening toolkit: a sudo user with SSH keys, sysctl, swap, a journald cap, time sync, a ufw default-deny firewall, fail2ban on SSH, unattended security updates, and finally SSH with password and root login turned off. + +Upstream supports Debian 12/13 and Ubuntu 22.04/24.04 only. `patches/koncreet/ubuntu-26.04.patch` adds Ubuntu 26.04: it opens the OS gate and doctor, and restores the last fallback Koncreet uses to find your SSH client address for the fail2ban whitelist: 26.04 keeps no utmp, so `who -m` prints nothing, and the patch asks logind instead. The same change is the `ubuntu-26.04` branch of the [undeemed/koncreet](https://github.com/undeemed/koncreet/tree/ubuntu-26.04) fork; regenerate the patch from there with `git diff main...ubuntu-26.04`. Apply layers the patch on each new release and prints which case it hit: applied; skipped because the release already supports 26.04; or skipped because it no longer applies, in which case Koncreet installs as released and refuses to run on 26.04 until the patch is refreshed. The patch never fails the apply. + +`/etc/koncreet.conf` makes the account that ran `./factory apply` the sudo user, installs the SSH keys it logs in with, and keeps SSH open (Koncreet always allows the ports sshd listens on). With the `tailscale` profile it also opens 41641/udp for Tailscale's direct connections. When apply ran as root, or as the factory account (which runs the agents and must not gain sudo), no sudo user is set: `user=` and `pubkey_file=` stay commented out until you fill in the operator's login. Apply never overwrites it; edit it there. + +Run it once, by hand, from an SSH session you keep open until the last step works: + +1. With the `tailscale` profile: `sudo ufw allow in on tailscale0`, so the tailnet stays reachable once ufw denies incoming traffic. Koncreet keeps existing ufw rules. +2. `sudo koncreet doctor` +3. `sudo koncreet --dry-run apply -c /etc/koncreet.conf`, and read the plan. +4. `sudo koncreet apply -c /etc/koncreet.conf` +5. Open a new SSH session as that user and run `sudo true`. Only when it works: `sudo koncreet ssh apply`. Test one more new session before you close the first. Not `sudo -v`: once the account is in the `sudo` group, `sudo -v` asks for a password even when sudoers grants it NOPASSWD, and a cloud account has none. + +If something goes wrong (from upstream's README): + +| Problem | Fix | +|---------|-----| +| Can't SSH after harden | `sudo koncreet ssh undo` | +| Locked out by ufw | Console: `sudo ufw disable` | +| Banned by fail2ban | `sudo koncreet fail2ban unban YOUR.IP` | +| Undo baseline drop-ins | `sudo koncreet baseline undo` (keeps users/swap/timezone) | +| Need the new user password | `cat /root/USER.koncreet-password` (as root - save it before `ssh apply`) | +| Forced password change fails | `chage -d $(date -I) USER` then reconnect with your key | +| Too many authentication failures | `ssh -o IdentitiesOnly=yes -i ~/.ssh/your_key user@host` | + +Logs: `/var/log/koncreet.log`. Backups: `*.koncreet.bak`. The provider's console, and `tailscale ssh` where Tailscale SSH is enabled, reach the host when sshd does not. + ## Updates -Tools track their latest release, and every download is verified against the checksum its publisher posts for that release (what each source checks is in [Dependencies](dependencies.md)); a release without one is refused. The one exception is the three omp marketplace plugins (ponytail, i-have-adhd, caveman): no publisher checksums them, they track each author's default branch, and they load as agent instructions and hooks. The operator accepted that to keep them at the latest commit. Checksums prove a download is the published artifact; they do not establish that a publisher is trustworthy. Review added tools and installer behavior before adding them. Ubuntu security updates remain an operating-system responsibility rather than freezing an entire vulnerable package index forever. +Tools track their latest release, and every download is verified against the checksum its publisher posts for that release (what each source checks is in [Dependencies](dependencies.md)); a release without one is refused (Koncreet, being optional, is skipped with a warning instead). The one exception is the three omp marketplace plugins (ponytail, i-have-adhd, caveman): no publisher checksums them, they track each author's default branch, and they load as agent instructions and hooks. The operator accepted that to keep them at the latest commit. Checksums prove a download is the published artifact; they do not establish that a publisher is trustworthy. Review added tools and installer behavior before adding them. Ubuntu security updates remain an operating-system responsibility rather than freezing an entire vulnerable package index forever. Back up project repositories and application data separately, using encrypted storage and an application-aware restore procedure. A successful environment bootstrap is not evidence that a database backup is recoverable. diff --git a/patches/koncreet/ubuntu-26.04.patch b/patches/koncreet/ubuntu-26.04.patch new file mode 100644 index 0000000..7a43835 --- /dev/null +++ b/patches/koncreet/ubuntu-26.04.patch @@ -0,0 +1,68 @@ +diff --git a/lib/doctor.sh b/lib/doctor.sh +index 139246c..5771fe4 100644 +--- a/lib/doctor.sh ++++ b/lib/doctor.sh +@@ -25,7 +25,7 @@ koncreet_os_is_supported() { + ;; + ubuntu) + case "$KONCREET_OS_VERSION" in +- 22.04|24.04) return 0 ;; ++ 22.04|24.04|26.04) return 0 ;; + esac + ;; + esac +@@ -43,7 +43,7 @@ cmd_doctor() { + if koncreet_os_is_supported; then + doctor_ok "OS ${KONCREET_OS_ID} ${KONCREET_OS_VERSION} supported" + else +- doctor_fail "OS ${KONCREET_OS_ID:-unknown} ${KONCREET_OS_VERSION:-} not supported (need Debian 12/13 or Ubuntu 22.04/24.04)" ++ doctor_fail "OS ${KONCREET_OS_ID:-unknown} ${KONCREET_OS_VERSION:-} not supported (need Debian 12/13 or Ubuntu 22.04/24.04/26.04)" + fi + + # --- root (apply readiness) --- +diff --git a/lib/os.sh b/lib/os.sh +index 38254c1..10295f6 100644 +--- a/lib/os.sh ++++ b/lib/os.sh +@@ -56,13 +56,13 @@ koncreet_require_supported_os() { + ;; + ubuntu) + case "$KONCREET_OS_VERSION" in +- 22.04|24.04) ok=1 ;; ++ 22.04|24.04|26.04) ok=1 ;; + esac + ;; + esac + if [[ "$ok" -ne 1 ]]; then + log_error "Unsupported OS: ${KONCREET_OS_ID:-unknown} ${KONCREET_OS_VERSION:-}" +- log_error "Koncreet supports Debian 12/13 and Ubuntu 22.04/24.04 only." ++ log_error "Koncreet supports Debian 12/13 and Ubuntu 22.04/24.04/26.04 only." + log_error "Refuse rather than half-apply on ${KONCREET_OS_ID:-unknown}." + exit 2 + fi +diff --git a/lib/sshd.sh b/lib/sshd.sh +index 6686f4a..3a32bbb 100644 +--- a/lib/sshd.sh ++++ b/lib/sshd.sh +@@ -95,7 +95,8 @@ koncreet_valid_ip() { + + # Print the IP of the SSH client that started this session; returns 1 if unknown. + # sudo strips SSH_CONNECTION, so fall back to the environment of our ancestor +-# processes (the login shell still has it), then to who -m. ++# processes (the login shell still has it), then to logind's record of the login ++# session, then to who -m. + koncreet_ssh_client_ip() { + local ip pid="$$" var + ip="${SSH_CONNECTION:-${SSH_CLIENT:-}}" +@@ -109,6 +110,11 @@ koncreet_ssh_client_ip() { + pid="$(awk '/^PPid:/{print $2}' "/proc/$pid/status" 2>/dev/null || true)" + pid="${pid:-0}" + done ++ # Ubuntu 26.04 keeps no utmp, so who -m prints nothing there, but logind still ++ # records the remote host of the login session we run in. ++ if [[ -z "$ip" ]] && command -v loginctl &>/dev/null; then ++ ip="$(loginctl show-session self -p RemoteHost --value 2>/dev/null || true)" ++ fi + if [[ -z "$ip" ]]; then + ip="$(who -m 2>/dev/null | sed -n 's/.*(\(.*\)).*/\1/p' || true)" + fi diff --git a/scripts/install_tools.py b/scripts/install_tools.py index 047a53d..14e8fae 100755 --- a/scripts/install_tools.py +++ b/scripts/install_tools.py @@ -70,7 +70,11 @@ "obscura-{gnu}-linux.tar.gz", {"obscura": "obscura", "obscura-worker": "obscura-worker"}, ), + # Resolved for host hardening (ansible/tasks/koncreet.yml), installed there as root. + "koncreet": ("jimididit/koncreet", "v", "koncreet.tar.gz", {"koncreet": "koncreet/koncreet"}), } +# Resolved when they can be, skipped with a warning when they cannot: they never stop a run. +OPTIONAL = {"koncreet"} # npm tools on the registry's latest version, each installed into its own prefix. NPM_LATEST = { "omp": "@oh-my-pi/pi-coding-agent", @@ -125,6 +129,8 @@ def fetch(url, what): def verified(tool, version, key, name, url, checksum, binaries): """A lock-shaped spec, only when the publisher lists a SHA-256 for the asset.""" + if not re.fullmatch(r"[A-Za-z0-9_.-]+", version): + raise ValueError(f"{tool} release tag {version!r} is not a safe version; refusing it") if not re.fullmatch(r"[0-9a-f]{64}", checksum): raise ValueError( f"{tool} {version} publishes no SHA-256 for {name}; refusing an unverified binary" @@ -137,24 +143,31 @@ def verified(tool, version, key, name, url, checksum, binaries): def resolve_latest(key, names): """Specs for the newest releases of exactly these tools, and nothing else.""" latest = {} + skipped = set() for tool, (repo, prefix, pattern, binaries) in GITHUB_LATEST.items(): if tool not in names: continue - if tool in PRERELEASE_CHANNEL: - releases = json.loads( - fetch(GITHUB_API.format(repo) + "?per_page=10", f"{tool} release") - ) - release = next((r for r in releases if not r["draft"]), None) - if release is None: - raise ValueError(f"{tool} has no published release") - else: - release = json.loads(fetch(GITHUB_API.format(repo) + "/latest", f"{tool} release")) - version = release["tag_name"].removeprefix(prefix) - name = pattern.format(v=version, key=key, **ARCH[key]) - asset = next((a for a in release["assets"] if a["name"] == name), {}) - checksum = (asset.get("digest") or "").removeprefix("sha256:") - url = asset.get("browser_download_url", "") - latest[tool] = verified(tool, version, key, name, url, checksum, binaries) + try: + if tool in PRERELEASE_CHANNEL: + releases = json.loads( + fetch(GITHUB_API.format(repo) + "?per_page=10", f"{tool} release") + ) + release = next((r for r in releases if not r["draft"]), None) + if release is None: + raise ValueError(f"{tool} has no published release") + else: + release = json.loads(fetch(GITHUB_API.format(repo) + "/latest", f"{tool} release")) + version = release["tag_name"].removeprefix(prefix) + name = pattern.format(v=version, key=key, **ARCH[key]) + asset = next((a for a in release["assets"] if a["name"] == name), {}) + checksum = (asset.get("digest") or "").removeprefix("sha256:") + url = asset.get("browser_download_url", "") + latest[tool] = verified(tool, version, key, name, url, checksum, binaries) + except (OSError, ValueError, KeyError) as error: + if tool not in OPTIONAL: + raise + skipped.add(tool) + print(f"install_tools: skipping optional {tool}: {error}", file=sys.stderr) if "node" in names: releases = json.loads(fetch("https://nodejs.org/dist/index.json", "node release")) tag = max((r["version"] for r in releases), key=lambda v: tuple(map(int, v[1:].split(".")))) @@ -198,7 +211,7 @@ def resolve_latest(key, names): if not hashes or not all(re.fullmatch(r"[0-9a-f]{64}", h) for h in hashes): raise ValueError("psutil publishes no SHA-256 digests; refusing an unverified binary") latest["psutil"] = {"version": pypi["info"]["version"], "sha256": hashes} - if unknown := sorted(set(names) - latest.keys()): + if unknown := sorted(set(names) - latest.keys() - skipped): raise ValueError(f"no latest release source for: {', '.join(unknown)}") return latest @@ -509,7 +522,7 @@ def main(): "--also", default="", help="comma-separated extra sources to resolve that Ansible installs itself: " - "obscura, supabase, psutil", + "obscura, supabase, psutil, koncreet", ) parser.add_argument( "--resolve", diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 30ee5fd..d4970d5 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1,9 +1,11 @@ import argparse import importlib.util import json +import os import shutil import subprocess import sys +import tarfile from pathlib import Path import pytest @@ -594,3 +596,172 @@ def test_a_new_chrome_devtools_mcp_release_leaves_the_managed_environment_unchan assert before["CHROME_DEVTOOLS_AXI_MCP_PATH"].endswith( "/chrome-devtools-mcp/current/node_modules/chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" ) + + +def _ansible(tmp_path, *argv, wrapper=()): + return subprocess.run( + [*wrapper, Path(sys.executable).parent / argv[0], *argv[1:]], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + +@pytest.mark.parametrize("start_services", [True, False]) +def test_koncreet_is_resolved_only_on_hosts_that_start_services(tmp_path, start_services): + variables = { + "factory_cfg": { + "start_services": start_services, + "profiles": {"agents": False, "fleet_guards": False}, + "browser_prune": {"enabled": False}, + } + } + result = _ansible( + tmp_path, + "ansible", + "localhost", + "-i", + "localhost,", + "-c", + "local", + "-m", + "ansible.builtin.debug", + "-a", + "var=factory_installer_also", + "-e", + f"@{ROOT / 'ansible/group_vars/all.yml'}", + "-e", + json.dumps(variables), + ) + assert result.returncode == 0, result.stdout + also = json.loads(result.stdout.split("=>", 1)[1])["factory_installer_also"] + assert ("koncreet" in also) is start_services + + +def _koncreet_settings(tmp_path, tailscale, apply_user): + destination = tmp_path / "koncreet.conf" + variables = { + "ansible_user_id": apply_user, + "ansible_user_dir": "/root" if apply_user == "root" else f"/home/{apply_user}", + "factory_cfg": {"user": "coder", "profiles": {"tailscale": tailscale}}, + "factory_koncreet_config": "/etc/koncreet.conf", + } + result = _ansible( + tmp_path, + "ansible", + "localhost", + "-i", + "localhost,", + "-c", + "local", + "-m", + "ansible.builtin.template", + "-a", + f"src={ROOT / 'ansible/templates/koncreet.conf.j2'} dest={destination}", + "-e", + json.dumps(variables), + ) + assert result.returncode == 0, result.stdout + return dict( + line.split("=", 1) + for line in destination.read_text().splitlines() + if line and not line.startswith("#") + ) + + +@pytest.mark.parametrize( + ("tailscale", "apply_user", "ports", "sudo_user"), + [ + (True, "operator", "41641/udp", "operator"), + (False, "operator", None, "operator"), + (True, "coder", "41641/udp", None), + (False, "root", None, None), + ], +) +def test_koncreet_config_follows_the_tailscale_profile_and_never_makes_the_factory_user_sudo( + tmp_path, tailscale, apply_user, ports, sudo_user +): + settings = _koncreet_settings(tmp_path, tailscale, apply_user) + assert settings["modules"] == "baseline,firewall,fail2ban,updates" + assert settings.get("firewall_ports") == ports + assert settings.get("user") == sudo_user + assert settings.get("pubkey_file") == ( + f"/home/{sudo_user}/.ssh/authorized_keys" if sudo_user else None + ) + + +def _run_koncreet_tasks(tmp_path, digest, *flags): + payload = tmp_path / "payload" + payload.write_text("#!/bin/sh\n") + source = tmp_path / "koncreet.tar.gz" + with tarfile.open(source, "w:gz") as archive: + archive.add(payload, arcname="koncreet/koncreet") + (tmp_path / "templates").symlink_to(ROOT / "ansible/templates") + playbook = tmp_path / "playbook.yml" + playbook.write_text( + yaml.safe_dump( + [ + { + "hosts": "localhost", + "connection": "local", + "gather_facts": False, + "tasks": [ + {"ansible.builtin.import_tasks": str(ROOT / "ansible/tasks/koncreet.yml")}, + { + "name": "A later task", + "ansible.builtin.file": { + "path": str(tmp_path / "later"), + "state": "touch", + }, + }, + ], + } + ] + ) + ) + variables = { + "ansible_become": False, + "ansible_user_id": "operator", + "ansible_user_dir": "/home/operator", + "factory_cfg": {"user": "coder", "profiles": {"tailscale": False}}, + "factory_latest": {"koncreet": {"version": "9.9.9"}}, + "factory_koncreet": {"url": source.as_uri(), "sha256": digest}, + "factory_koncreet_patch": str(ROOT / "patches/koncreet/ubuntu-26.04.patch"), + "factory_koncreet_prefix": str(tmp_path / "prefix"), + "factory_koncreet_config": str(tmp_path / "koncreet.conf"), + } + # koncreet.yml hands its files to root. Unprivileged, the chown fails before the + # download is ever checked, so a digest test would pass for the wrong reason. + # Plan mode (--check) creates nothing and needs no root. + wrapper = () if os.geteuid() == 0 or "--check" in flags else ("fakeroot",) + if wrapper and not shutil.which("fakeroot"): + pytest.skip("koncreet.yml chowns to root: run as root or install fakeroot") + return _ansible( + tmp_path, + "ansible-playbook", + "-i", + "localhost,", + str(playbook), + "--extra-vars", + json.dumps(variables), + *flags, + wrapper=wrapper, + ) + + +def test_a_koncreet_tarball_that_fails_its_digest_is_skipped_and_apply_continues(tmp_path): + result = _run_koncreet_tasks(tmp_path, "0" * 64) + assert result.returncode == 0, result.stdout + assert "WARNING: koncreet skipped" in result.stdout + assert "Fetch the koncreet tarball: The checksum for" in result.stdout + assert (tmp_path / "later").exists() + assert not list((tmp_path / "prefix").rglob("patch-outcome")) + assert not list((tmp_path / "prefix").rglob("*.tar.gz")) + assert not (tmp_path / "koncreet.conf").exists() + + +def test_planning_koncreet_installs_nothing_and_warns_of_nothing(tmp_path): + result = _run_koncreet_tasks(tmp_path, "0" * 64, "--check") + assert result.returncode == 0, result.stdout + assert "WARNING" not in result.stdout + assert not (tmp_path / "prefix").exists() diff --git a/tests/test_install_tools.py b/tests/test_install_tools.py index 638d1b5..1000d5d 100644 --- a/tests/test_install_tools.py +++ b/tests/test_install_tools.py @@ -106,6 +106,7 @@ def test_download_rejects_plain_http(tmp_path): "kunchenguid/treehouse": ("v9.9.9", "treehouse-v9.9.9-linux-amd64.tar.gz"), "astral-sh/uv": ("9.9.9", "uv-x86_64-unknown-linux-gnu.tar.gz"), "h4ckf0r0day/obscura": ("v9.9.9", "obscura-x86_64-linux.tar.gz"), + "jimididit/koncreet": ("v9.9.9", "koncreet.tar.gz"), } @@ -179,9 +180,9 @@ def release(tag, name, draft=False): def test_latest_releases_are_pinned_to_the_digests_their_publishers_list(monkeypatch): upstream(monkeypatch) latest = installer.resolve_latest("linux-x86_64", EVERYTHING) - for tool in ("herdr", "bun", "gh", "no-mistakes", "treehouse", "uv", "obscura"): + for tool in ("herdr", "bun", "gh", "no-mistakes", "treehouse", "uv", "obscura", "koncreet"): assert latest[tool]["assets"]["linux-x86_64"]["sha256"] == "a" * 64 - for tool in ("herdr", "bun", "gh", "treehouse", "uv", "obscura"): + for tool in ("herdr", "bun", "gh", "treehouse", "uv", "obscura", "koncreet"): assert latest[tool]["version"] == "9.9.9" assert latest["gh"]["assets"]["linux-x86_64"]["format"] == "tar" assert latest["bun"]["assets"]["linux-x86_64"]["format"] == "zip" @@ -225,13 +226,80 @@ def urlopen(request, **kwargs): installer.resolve_latest("linux-x86_64", {"no-mistakes"}) -@pytest.mark.parametrize("source", [*RELEASES, "node", "rustup-init", "psutil"]) +@pytest.mark.parametrize( + "source", + [*(repo for repo in RELEASES if repo != "jimididit/koncreet"), "node", "rustup-init", "psutil"], +) def test_release_without_a_published_checksum_is_refused(monkeypatch, source): upstream(monkeypatch, unverified=source) with pytest.raises(ValueError, match="refusing an unverified binary"): installer.resolve_latest("linux-x86_64", EVERYTHING) +UNSAFE_TAGS = ["v1.0$(id)", "v1;id", 'v1"x', "v1`id`", "v1|id", "v1 2", "v/1"] + + +@pytest.mark.parametrize("tag", UNSAFE_TAGS) +def test_release_tag_that_is_not_a_plain_version_is_refused(monkeypatch, tag): + monkeypatch.setitem(RELEASES, "herdrdev/herdr", (tag, "herdr-linux-x86_64")) + upstream(monkeypatch) + with pytest.raises(ValueError, match="not a safe version"): + installer.resolve_latest("linux-x86_64", EVERYTHING) + + +def resolve_without_koncreet(capsys): + latest = installer.resolve_latest("linux-x86_64", EVERYTHING) + assert "koncreet" not in latest + assert {"herdr", "uv", "obscura", "node", "psutil"} <= latest.keys() + return capsys.readouterr().err + + +@pytest.mark.parametrize("tag", UNSAFE_TAGS) +def test_koncreet_release_with_an_unsafe_tag_is_skipped_with_a_warning(monkeypatch, capsys, tag): + monkeypatch.setitem(RELEASES, "jimididit/koncreet", (tag, "koncreet.tar.gz")) + upstream(monkeypatch) + assert "not a safe version" in resolve_without_koncreet(capsys) + + +def test_koncreet_release_without_a_published_checksum_is_skipped_with_a_warning( + monkeypatch, capsys +): + upstream(monkeypatch, unverified="jimididit/koncreet") + assert "refusing an unverified binary" in resolve_without_koncreet(capsys) + + +def test_koncreet_release_without_its_asset_is_skipped_with_a_warning(monkeypatch, capsys): + monkeypatch.setitem(RELEASES, "jimididit/koncreet", ("v9.9.9", "renamed.tar.gz")) + upstream(monkeypatch) + assert "refusing an unverified binary" in resolve_without_koncreet(capsys) + + +@pytest.mark.parametrize( + "failure", + [ + installer.urllib.error.HTTPError("https://api.github.com/", 403, "rate limited", {}, None), + installer.urllib.error.URLError("offline"), + ], +) +def test_koncreet_lookup_failure_is_skipped_with_a_warning(monkeypatch, capsys, failure): + upstream(monkeypatch) + fake = installer.urllib.request.urlopen + + def urlopen(request, **kwargs): + if "jimididit/koncreet" in request.full_url: + raise failure + return fake(request, **kwargs) + + monkeypatch.setattr(installer.urllib.request, "urlopen", urlopen) + assert "skipping optional koncreet" in resolve_without_koncreet(capsys) + + +def test_resolve_cli_still_succeeds_when_koncreet_cannot_be_resolved(monkeypatch, capsys, tmp_path): + upstream(monkeypatch, unverified="jimididit/koncreet") + latest = run_cli(monkeypatch, capsys, tmp_path, "--tools", "uv", "--also", "koncreet") + assert set(latest) == {"uv"} + + @pytest.mark.parametrize(("env", "expected"), [("env-token", "Bearer env-token"), ("", None)]) def test_github_token_goes_only_to_the_github_api(monkeypatch, env, expected): seen = []