It was added to "pick up subuid/subgid mappings" but that's not what it
does — it migrates container storage after a Podman version upgrade.
Subuid/subgid changes are picked up by restarting the Podman socket,
which the script already does. The only effect of running it was stopping
all containers on every platform start.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
podman system migrate explicitly stops all containers, which overrides
the --restart unless-stopped policy set on deployed apps. After compose
up-d brings the infra stack back, any exited hiy-* container is now
restarted automatically.
Same logic added to boot.sh for the on-boot path.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
- Add infra/boot.sh: lightweight startup (no build) that brings up the
Podman stack — used by the systemd unit on every system boot
- start.sh now installs/refreshes hiy.service (a systemd --user unit)
and enables loginctl linger so it runs without an active login session
After the next `infra/start.sh` run the Pi will automatically restart
the stack after a reboot or power cut.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
podman system migrate was stopping all containers immediately (visible in
the terminal output as "stopped <id>" lines), before the build even began.
Moving it to just before compose down/up means running containers stay
alive for the entire duration of the image build.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Old behaviour: compose down → long build → compose up
New behaviour: long build (service stays live) → compose down → compose up
Downtime is now limited to the few seconds of the swap instead of the
entire duration of the Rust/image build.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
The user_apps check was silently failing because sqlx::query_scalar
without an explicit type annotation would hit a runtime decoding error,
which .unwrap_or(None) swallowed — always returning None → 403.
All three DB calls in check_push_access now use match + tracing::error!
so failures are visible in logs instead of looking like a missing grant.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Useful for git-push-only deploys where no external repo URL is needed.
- CreateApp.repo_url: String → Option<String>
- DB schema default: repo_url TEXT NOT NULL DEFAULT ''
- UI validation no longer requires the field
- Label marked (optional) in the form
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Fixes potential silent failure where sqlx::query_scalar couldn't infer
the return type at runtime. Also adds step-by-step tracing so the exact
failure point (no header / bad base64 / key not found / db error) is
visible in `docker compose logs server`.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Replaces SSH as the primary git push path — no key generation needed.
# Admin UI: Users → Generate key (shown once)
git remote add hiy http://hiy:API_KEY@myserver/git/myapp
git push hiy main
What was added:
- api_keys DB table (id, user_id, label, key_hash/SHA-256, created_at)
Keys are stored as SHA-256 hashes; the plaintext is shown once on
creation and never stored.
- routes/api_keys.rs
GET/POST /api/users/:id/api-keys — list / generate
DELETE /api/api-keys/:key_id — revoke
- HTTP Smart Protocol endpoints (public, auth via Basic + API key)
GET /git/:app/info/refs — ref advertisement
POST /git/:app/git-receive-pack — receive pack, runs post-receive hook
Authentication: HTTP Basic where the password is the API key.
git prompts once and caches via the OS credential store.
post-receive hook fires as normal and queues the build.
- Admin UI: API keys section per user with generate/revoke and a
one-time reveal box showing the ready-to-use git remote command.
SSH path (git-shell + authorized_keys) is still functional for users
who prefer it; both paths feed the same build queue.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Full self-contained git push flow — no GitHub required:
git remote add hiy ssh://hiy@myserver/myapp
git push hiy main
What was added:
- Bare git repo per app (HIY_DATA_DIR/repos/<app-id>.git)
Initialised automatically on app create; removed on app delete.
post-receive hook is written into each repo and calls the internal
API to queue a build using the same pipeline as webhook deploys.
- SSH key management
New ssh_keys DB table. Admin UI (/admin/users) now shows SSH keys
per user with add/remove. New API routes:
GET/POST /api/users/:id/ssh-keys
DELETE /api/ssh-keys/:key_id
On every change, HIY rewrites HIY_SSH_AUTHORIZED_KEYS with
command= restricted entries pointing at hiy-git-shell.
- scripts/git-shell
SSH command= override installed at HIY_GIT_SHELL (default
/usr/local/bin/hiy-git-shell). Validates the push via
GET /internal/git/auth, then exec's git-receive-pack on the
correct bare repo.
- Internal API routes (authenticated by shared internal_token)
GET /internal/git/auth -- git-shell permission check
POST /internal/git/:app_id/push -- post-receive build trigger
- Builder: git-push deploys use file:// path to the local bare repo
instead of the app's remote repo_url.
- internal_token persists across restarts in HIY_DATA_DIR/internal-token.
New env vars:
HIY_SSH_AUTHORIZED_KEYS path to the authorized_keys file to manage
HIY_GIT_SHELL path to the git-shell script on the host
Both webhook and git-push deploys feed the same build queue.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Root cause: auth_middleware redirected all non-admins (including logged-in
ones) to /login, and login_page redirected logged-in users back — a loop.
Fix:
- auth_middleware now distinguishes unauthenticated (→ /login?next=) from
logged-in-but-not-admin (→ /denied), breaking the loop entirely
- /denied page's "sign in with a different account" link now goes to /logout
first, so clicking it clears the session before the login form appears
The login_page auto-redirect for logged-in users is restored, which is
required for the Caddy forward_auth flow (deployed apps redirecting through
/login?next=<app-url>).
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
When a non-admin user with a valid session cookie visited an admin-protected
route, auth_middleware redirected them to /login?next=<admin-path>, and
login_page immediately redirected them back because they were "logged in",
causing an infinite redirect loop.
Fix: only skip the login page when the logged-in user is also an admin.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Two bugs:
1. verify() built the login URL with `//login` (double slash) — now `/login`
2. safe_path() rejected absolute https:// next-URLs, so after login the
user was silently dropped at `/` instead of their original app URL.
Replaced safe_path with safe_redirect(next, domain) which allows relative
paths OR absolute URLs whose host is the configured domain (or a subdomain).
safe_path is kept as a thin wrapper (domain="") for the admin-UI middleware
where next is always a relative path.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
runc (used by Podman) always writes memory.swap.max when initializing the
cgroup v2 memory controller, even without explicit --memory flags. On
Raspberry Pi OS this file is absent because swap accounting is disabled
by default in the kernel, causing every container start to fail with:
openat2 …/memory.swap.max: no such file or directory
start.sh now detects this condition early, patches the kernel cmdline
(cgroup_enable=memory cgroup_memory=1 swapaccount=1) in either
/boot/firmware/cmdline.txt (Pi OS Bookworm) or /boot/cmdline.txt
(older releases), and tells the user to reboot once before continuing.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Raspberry Pi OS does not enable swap cgroup accounting by default.
Even --memory-swap=-1 causes runc to write "max" to memory.swap.max,
which fails with ENOENT when the file does not exist.
Removing --memory entirely means runc skips all memory.* cgroup writes.
--cpus is unaffected (uses cpu.max, which is always present).
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Raspberry Pi OS does not enable swap accounting in cgroups by default,
so the memory.swap.max cgroup v2 file does not exist. Setting --memory
without --memory-swap causes runc to write a swap limit to that file,
which fails with ENOENT.
Adding --memory-swap=-1 tells runc to leave swap unlimited, skipping
the memory.swap.max write entirely.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
build.sh calls `podman build` inside the server container.
DOCKER_HOST is a Docker CLI variable; Podman does not use it to
automatically switch to remote mode. Without CONTAINER_HOST set,
Podman runs locally inside the (unprivileged) container, has no
user-namespace support, and lchown fails for any layer file owned
by a non-zero GID (e.g. gid=42 for /etc/shadow).
Setting CONTAINER_HOST=tcp://podman-proxy:2375 makes Podman
automatically operate in remote mode and delegate all operations
to the host Podman service, which has the correct subuid/subgid
mappings and full user-namespace support.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Two root causes for "invalid argument" when chowning non-root UIDs/GIDs
in image layers:
1. Missing uidmap package: without setuid newuidmap/newgidmap binaries,
Podman can only map a single UID (0 → current user) in the user
namespace. Any layer file owned by gid=42 (shadow) or similar then
has no mapping and lchown returns EINVAL. Now install uidmap if absent.
2. Stale Podman service: a service started before subuid/subgid entries
existed silently keeps the single-UID mapping for its lifetime even
after the entries are added and podman system migrate is run. Now
always kill and restart the service on each start.sh run so it always
reads the current subuid/subgid configuration.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
If entries already existed before this script first ran, _HIY_SUBID_CHANGED
stayed 0 and migrate was skipped, leaving Podman storage out of sync with
the namespace mappings and causing lchown errors on layer extraction.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Without entries in /etc/subuid and /etc/subgid, Podman cannot map the
UIDs/GIDs present in image layers (e.g. gid 42 for /etc/shadow) into
the user namespace, causing 'lchown: invalid argument' on layer extraction.
Add a 65536-ID range starting at 100000 for the current user if missing,
then run podman system migrate so existing storage is updated.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
The Dockerfile only needs Cargo.toml, Cargo.lock, and server/src/.
Excluding target/ (Rust artifacts), builder/, docs/, infra/, proxy/,
and .git/ means the daemon receives virtually nothing instead of 1.8 GB.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Rootless processes cannot bind privileged ports (<1024) by default.
Lower net.ipv4.ip_unprivileged_port_start to 80 at startup, and persist
it to /etc/sysctl.conf so the setting survives reboots.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Podman rootless unconditionally resets XDG_RUNTIME_DIR to /run/user/<uid>
if that directory exists, overriding any env var we set. Redirecting to
/tmp is therefore ineffective.
Instead, ensure /run/user/<uid> exists and is owned by the current user
(using sudo if needed), mirroring what PAM/logind does for login sessions.
All Podman runtime state (socket, events, netavark) then works correctly.
Remove the now-unnecessary storage.conf/containers.conf writes and the
inline env override on podman system service.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
make build was looking for Makefile in cwd (repo root) instead of infra/.
Use -C "$SCRIPT_DIR" so it always finds infra/Makefile regardless of where
the script is invoked from.
Add -f flag to podman compose up so it finds infra/docker-compose.yml
from any working directory.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Podman's events engine reads tmp_dir from containers.conf, not from
XDG_RUNTIME_DIR directly. Write both storage.conf and containers.conf
to /tmp/podman-<uid> so no path under /run/user/<uid> is ever used.
Also use `env XDG_RUNTIME_DIR=...` prefix on podman invocation to
override any stale value in the calling shell environment.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Stop relying on conditional checks. Always point XDG_RUNTIME_DIR and
storage.conf runroot to /tmp/podman-<uid> so Podman never touches
/run/user/<uid>, which requires PAM/logind to create.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
SSH sessions may export XDG_RUNTIME_DIR=/run/user/<uid> even when that
directory doesn't exist or isn't writable. Check writability rather than
emptiness before falling back to /tmp/podman-<uid>.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Podman uses XDG_RUNTIME_DIR for its RunRoot, events dirs, and default
socket path. Without it pointing to a writable location, podman fails
with 'mkdir /run/user/<uid>: permission denied' even before the socket
is created. Export it to /tmp/podman-<uid> when unset.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
/run/user/<uid> is created by PAM/logind and doesn't exist in non-login
shells. Fall back to /tmp/podman-<uid> when XDG_RUNTIME_DIR is unset so
mkdir always succeeds.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
systemctl --user fails in non-interactive shells (no D-Bus session bus).
podman system service starts the socket directly without systemd/D-Bus,
backgrounding the process and waiting up to 5 s for the socket to appear.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
start.sh now activates the Podman user socket via systemctl --user if it
isn't running yet, then exports DOCKER_HOST and PODMAN_SOCK so that
podman compose (which delegates to the docker-compose plugin) can connect.
docker-compose.yml mounts ${PODMAN_SOCK} into the socat proxy container
at a fixed internal path (/podman.sock), so it works for both rootful
(/run/podman/podman.sock) and rootless (/run/user/<UID>/podman/podman.sock)
without hardcoding the UID.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Control plane:
- Users and app grants stored in SQLite (users + user_apps tables)
- bcrypt password hashing
- Sessions: HashMap<token, user_id> (in-memory, cleared on restart)
- Bootstrap: first admin auto-created from HIY_ADMIN_USER/HIY_ADMIN_PASS if DB is empty
- /admin/users page: create/delete users, toggle admin, grant/revoke app access
- /api/users + /api/users/:id/apps/:app_id REST endpoints (admin-only)
Deployed apps:
- Every app route now uses Caddy forward_auth pointing at /auth/verify
- /auth/verify checks session cookie + user_apps grant (admins have access to all apps)
- Unauthenticated -> 302 to /login?next=<original URL>
- Authorised but not granted -> /denied page
- Session cookie set with Domain=.DOMAIN_SUFFIX for cross-subdomain auth
Other:
- /denied page for "logged in but not granted" case
- Login page skips re-auth if already logged in
- Cookie uses SameSite=Lax (required for cross-subdomain redirect flows)
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
- New HIY_ADMIN_USER / HIY_ADMIN_PASS env vars control access
- Login page at /login with redirect-after-login support
- Cookie-based sessions (HttpOnly, SameSite=Strict); cleared on restart
- Auth middleware applied to all routes except /webhook/:app_id (HMAC) and /login
- Auth is skipped when credentials are not configured (dev mode, warns at startup)
- Logout link in both dashboard nav bars
- Caddy admin port 2019 no longer published to the host in docker-compose
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Caddy's Caddyfile adapter names servers 'srv0' (not 'hiy'), so
PATCHing /config/apps/http/servers/hiy/routes was a no-op. Now we
query /config/apps/http/servers/ to find the actual server name
before updating routes.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
Without a fallback, an unset DOMAIN_SUFFIX expands to an empty string,
making Caddy parse the site block as a second global options block and
fail to start. Using {:localhost} defaults to localhost.
Using compose-level ${DOMAIN_SUFFIX} substitution only works when docker
compose is run from the same directory as the .env file. env_file loads
the file relative to the compose file, so it works regardless of CWD.
Caddy's email directive requires a non-empty argument. Since ACME_EMAIL
wasn't set, Caddy failed to parse the config. Email is optional for
Let's Encrypt — remove the directive entirely and document it as a
manual opt-in comment.
Caddy's built-in ACME support handles TLS automatically — no CF_API_TOKEN,
no Cloudflare account, no DNS plugin needed. Requires ports 80+443 forwarded
to the Pi and ACME_EMAIL set in infra/.env.
Use printf instead of heredoc for cargo config — heredoc inside a
conditional RUN block confuses Docker's parser (fi becomes an unknown
instruction). The config is always written; unused linker entries are
harmless on native builds.
gcc-aarch64-linux-gnu is an x86→arm64 cross-compiler; it doesn't exist
on arm64 hosts (like the Pi). Only install cross-toolchains and set cargo
linker config when BUILDPLATFORM != TARGETPLATFORM.