Run Gitea as a rootless Podman Quadlet, with git over SSH on the host's sshd
Replaces the docker_container deployment on monoco with a Quadlet systemd user unit on emily, and moves state from /var/lib/gitea to ~/podman-data/gitea. Gitea is SQLite -- the live app.ini on monoco says DB_TYPE = sqlite3 with PATH = /data/gitea/gitea.db -- so there is no postgres role and the database travels inside the data tree. git-over-SSH is the part that could not simply be moved. Port 22 stays the host's sshd, so no client changes its remote, but the second hop does: the Docker-era design sshed into an sshd *inside* the container, and under keep-id that sshd runs as a subuid which cannot read the authorized_keys Gitea writes as tanshu. It is replaced by a `podman exec`, reached through a shim sshd runs as a new `git` login user, a one-command sudoers rule, and an AuthorizedKeysCommand that hands sshd the key file without the ownership check StrictModes would otherwise apply. Two things stopped it starting, and both only showed up when it was run: keep-id runs the container *process* as the uid it maps, not just the file ownership, so the whole container came up as `git` while the image's start-up is written to begin as root. /usr/bin/entrypoint rewrites /etc/passwd, /etc/s6/gitea/setup prepares /data before the run script `su-exec git`s the server, and the image's own CMD is s6-svscan, which has to create .s6-svscan/lock inside root-owned /etc/s6. The container died on the last two -- "sed: can't create temp file '/etc/passwdXXXXXX': Permission denied" and "s6-svscan: fatal: unable to open .s6-svscan/lock: Permission denied" -- and Restart=always turned that into a crash loop, which `is-active` reports as `activating` rather than as a failure. User=0 restores root for the start-up and leaves the mapping alone: the server still runs as git through su-exec, which is what satisfies mustNotRunAsRoot. (nextcloud.container carries the same pair for the same reason.) The `ssh/` directory under /data is only ever a mount point -- the in-image sshd's host keys are mounted over it from ~/podman-data/gitea-sshd -- so it is hidden inside the container and nothing ever chowns it. When Podman has to create it, it lands in the data tree owned by the subuid, and the role's own "nothing here belongs to another user" check fails on it, correctly. Creating it in the role first is what keeps the tree wholly tanshu's. Start, is-active and the health check are now one block whose rescue dumps status, journal and podman logs, because they fail the same way: `systemctl start` reports success as soon as systemd holds the unit, so a container that dies a second later surfaces only as `activating` -- which is exactly how the two failures above were reported, with the reason in the journal and nowhere the play looked. Verified on emily: the play runs to ok=27/changed=7/failed=0, the health check and the stray-owner assertion both pass, and the ssh second hop still returns the container's Gitea 28.1.0 through the wrapper. The role's comments and the readme were corrected where they explained the old failures as EPERM from the root phases: container root holds the usual capabilities inside its user namespace, so those phases succeed and leave subuid-owned files behind, which is the real reason the tree is created up front. The readme's list of "expected" chmod EPERM lines is gone, because the log is clean. The data directory was emptied of the fresh app.ini afterwards and the unit left stopped, ready for the copy from monoco.
This commit is contained in:
1 parent
3d4ccdd6d1
commit
fc70acb66b
14 files changed
+1260
-106
No files matched your search
@@ -0,0 +1,140 @@
|
||||
---
|
||||
# The host half of Gitea's git-over-SSH support.
|
||||
#
|
||||
# Port 22 belongs to the host's sshd and stays that way: clients keep using
|
||||
# git@git.tanshu.com:user/repo.git, and no remote URL changes. Four host-side
|
||||
# pieces make that work:
|
||||
#
|
||||
# 1. a `{{ git_user }}` login user with no interactive access — every key
|
||||
# Gitea accepts carries a forced command, so sshd runs the shim and
|
||||
# nothing else;
|
||||
# 2. {{ shim_path }}, the shim those forced commands invoke;
|
||||
# 3. {{ podman_exec_wrapper }} plus a one-command sudoers rule — how the shim,
|
||||
# running as `{{ git_user }}`, reaches a container owned by
|
||||
# {{ podman_user }};
|
||||
# 4. the sshd and sudoers drop-ins that tie the above together.
|
||||
#
|
||||
# What differs from the Docker-era role is the second hop. It used to be an ssh
|
||||
# into an sshd running *inside* the container; under keep-id that sshd runs as a
|
||||
# subuid and cannot read the authorized_keys Gitea writes as {{ podman_user }},
|
||||
# so the hop is a `podman exec` instead. readme.md, "How git over SSH works
|
||||
# now", is where the reasoning lives.
|
||||
#
|
||||
# Every task here is root-only (users, /usr/local/bin, /etc/ssh, /etc/sudoers),
|
||||
# so each elevates on its own. The play carries no become, because the Quadlet
|
||||
# units have to belong to the connection user.
|
||||
|
||||
- name: Ensure the git group exists
|
||||
ansible.builtin.group:
|
||||
name: "{{ git_user }}"
|
||||
become: true
|
||||
|
||||
# The shell matters even though nobody ever gets one: sshd runs a forced command
|
||||
# through the login user's shell (`/bin/bash -c '...'`), so a nologin shell would
|
||||
# break git-over-SSH outright.
|
||||
#
|
||||
# The uid is deliberately left to the system. On monoco the host git account was
|
||||
# uid 1003 because the container's git account was 1003 and the two shared one
|
||||
# uid space; under keep-id the container's 1003 is mapped onto {{ podman_user }}
|
||||
# instead, so nothing on this host depends on this number any more — not the
|
||||
# key file (owned by name), not the sudoers rule (matched by name), not the
|
||||
# authorized_keys hook (run as {{ podman_user }}).
|
||||
- name: Add the git login user
|
||||
ansible.builtin.user:
|
||||
name: "{{ git_user }}"
|
||||
comment: Gitea SSH login user
|
||||
group: "{{ git_user }}"
|
||||
home: "{{ git_home }}"
|
||||
shell: /bin/bash
|
||||
create_home: true
|
||||
become: true
|
||||
|
||||
# There is deliberately no ~/.ssh for this user any more. Its old contents were
|
||||
# the shim's client key and the authorized_keys Gitea maintains; the key is
|
||||
# obsolete (nothing authenticates over ssh in this design) and the file lives in
|
||||
# the container's data tree now, where Gitea writes it. The migration notes in
|
||||
# readme.md move the file into place.
|
||||
|
||||
- name: Install the git-over-SSH passthrough shim
|
||||
ansible.builtin.template:
|
||||
src: gitea.j2
|
||||
dest: "{{ shim_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
become: true
|
||||
|
||||
- name: Install the container exec wrapper
|
||||
ansible.builtin.template:
|
||||
src: gitea-container-exec.j2
|
||||
dest: "{{ podman_exec_wrapper }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
become: true
|
||||
|
||||
- name: Install the authorized_keys hook sshd runs for the git user
|
||||
ansible.builtin.template:
|
||||
src: gitea-authorized-keys.j2
|
||||
dest: "{{ authorized_keys_command }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
become: true
|
||||
|
||||
# valid with `visudo -cf %s`: a syntax error in /etc/sudoers.d does not fail one
|
||||
# rule, it fails *every* sudo invocation on the host, which on a server reached
|
||||
# by ssh is a nasty way to find out.
|
||||
- name: Let the git user reach the container as {{ podman_user }}
|
||||
ansible.builtin.template:
|
||||
src: sudoers-gitea.j2
|
||||
dest: "{{ sudoers_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0440'
|
||||
validate: /usr/sbin/visudo -cf %s
|
||||
become: true
|
||||
|
||||
- name: Point sshd at Gitea's authorized_keys
|
||||
block:
|
||||
- name: Install the sshd drop-in for the git user
|
||||
ansible.builtin.template:
|
||||
src: sshd-gitea.conf.j2
|
||||
dest: "{{ sshd_dropin_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
register: gitea_sshd_dropin
|
||||
become: true
|
||||
|
||||
# A drop-in that does not parse is not a failed play, it is a host nobody can
|
||||
# log in to after the next reboot: sshd reads the whole directory at start-up.
|
||||
# Validate the resulting configuration before it is ever loaded, and take the
|
||||
# file away again if it does not parse.
|
||||
- name: Validate the resulting sshd configuration
|
||||
ansible.builtin.command: /usr/sbin/sshd -t
|
||||
changed_when: false
|
||||
become: true
|
||||
when: gitea_sshd_dropin.changed
|
||||
rescue:
|
||||
- name: Take the sshd drop-in back out
|
||||
ansible.builtin.file:
|
||||
path: "{{ sshd_dropin_path }}"
|
||||
state: absent
|
||||
become: true
|
||||
|
||||
- name: Fail
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
{{ sshd_dropin_path }} did not validate, so it has been removed and
|
||||
sshd is still running — and still restarting — on its previous
|
||||
configuration. Run `/usr/sbin/sshd -t` on the host to see why.
|
||||
|
||||
# `reload`, not `restart`: a reload re-execs sshd without dropping the connection
|
||||
# this play is running over.
|
||||
- name: Reload sshd
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ sshd_service }}"
|
||||
state: reloaded
|
||||
become: true
|
||||
when: gitea_sshd_dropin.changed
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# sshd's source for the `{{ git_user }}` user's authorized_keys.
|
||||
# Managed by Ansible (gitea/roles/ssh). Do not edit by hand.
|
||||
#
|
||||
# Gitea maintains that file from inside its container, where it runs as
|
||||
# {{ podman_user }} (container uid {{ gitea_uid }} is mapped onto that user by
|
||||
# keep-id), so the file belongs to {{ podman_user }} and not to `{{ git_user }}`.
|
||||
# sshd refuses a file the login user does not own — that is StrictModes — and
|
||||
# StrictModes cannot be relaxed for one user, because sshd_config(5) does not
|
||||
# allow the option inside a Match block. So the file is fed to sshd through
|
||||
# AuthorizedKeysCommand instead: sshd uses whatever this prints, and applies no
|
||||
# ownership check to it.
|
||||
#
|
||||
# Only lines that force the passthrough shim are printed. Gitea's file also
|
||||
# carries an unforced entry for the "Gitea Host Key" that the Docker-era
|
||||
# deployment needed so that its shim could ssh into a second sshd inside the
|
||||
# container. Nothing reads that entry any more, and printing it would hand an
|
||||
# interactive `{{ git_user }}` login on this host — and therefore rootless
|
||||
# Podman, through the sudoers drop-in — to anyone holding a copy of that key,
|
||||
# a backup included.
|
||||
#
|
||||
# The pattern is exactly the shape Gitea writes. If it ever changes,
|
||||
# git-over-SSH stops working while HTTPS keeps serving, and
|
||||
# podman exec {{ container_name }} cat /data/git/.ssh/authorized_keys
|
||||
# shows what Gitea writes now.
|
||||
keys="{{ host_directory }}/git/.ssh/authorized_keys"
|
||||
[ -r "$keys" ] || exit 0
|
||||
grep -E '^command="{{ shim_path }} ' "$keys"
|
||||
exit 0
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# Runs one authorized_keys command inside Gitea's rootless container.
|
||||
# Managed by Ansible (gitea/roles/ssh). Do not edit by hand.
|
||||
#
|
||||
# This runs as {{ podman_user }} — the user whose rootless Podman owns the
|
||||
# container, which is the entire reason the wrapper exists. The `{{ git_user }}`
|
||||
# login user cannot enter another user's rootless container, and there is no
|
||||
# sshd inside the container to hop through any more (see readme.md, "How git
|
||||
# over SSH works now"), so the forced command sshd runs for a git client ends up
|
||||
# here.
|
||||
#
|
||||
# $1 SSH_ORIGINAL_COMMAND — the client's original request, passed through so
|
||||
# Gitea can serve it.
|
||||
# $@ the rest of the authorized_keys line, i.e.
|
||||
# /usr/local/bin/gitea --config=/data/gitea/conf/app.ini serv key-N —
|
||||
# the *container's* Gitea binary, not the host's shim at the same path.
|
||||
#
|
||||
# --user, HOME and GITEA_CUSTOM are what sshd used to supply: it ran the command
|
||||
# as the container's git account, with HOME from /etc/passwd and GITEA_CUSTOM
|
||||
# from /data/git/.ssh/environment. GITEA_CUSTOM and USER are image defaults and
|
||||
# come through `podman exec` on their own; HOME does not, and git looks there for
|
||||
# /data/git/.gitconfig, so it is set explicitly.
|
||||
#
|
||||
# XDG_RUNTIME_DIR is set explicitly because `sudo -u` creates no PAM session, so
|
||||
# Podman would not otherwise find this user's runtime directory — the same reason
|
||||
# caddy/readme.md shows the `sudo -u … systemctl --user` form with it.
|
||||
original_command="$1"
|
||||
shift
|
||||
exec env XDG_RUNTIME_DIR="/run/user/$(id -u)" \
|
||||
podman exec --interactive \
|
||||
--user {{ gitea_uid }}:{{ gitea_gid }} \
|
||||
--env "HOME=/data/git" \
|
||||
--env "SSH_ORIGINAL_COMMAND=$original_command" \
|
||||
{{ container_name }} "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Gitea's git-over-SSH passthrough shim — {{ shim_path }}
|
||||
# Managed by Ansible (gitea/roles/ssh). Do not edit by hand.
|
||||
#
|
||||
# The host's sshd runs this, as the `{{ git_user }}` login user, whenever a
|
||||
# client presents a key whose authorized_keys line is a forced command. Gitea
|
||||
# writes those lines itself, from inside its container, and each one looks like
|
||||
# this:
|
||||
#
|
||||
# command="/usr/local/bin/gitea --config=/data/gitea/conf/app.ini serv key-5",...
|
||||
#
|
||||
# So $@ here is `--config=/data/gitea/conf/app.ini serv key-5`, and $0 is the
|
||||
# path of Gitea's own binary *inside the container*, which the wrapper resolves
|
||||
# in there. The path is a contract, not a knob: it is already baked into every
|
||||
# line of the authorized_keys file copied from monoco.
|
||||
#
|
||||
# `{{ git_user }}` cannot reach rootless containers that belong to
|
||||
# {{ podman_user }}, so the command is handed to {{ podman_exec_wrapper }}, which
|
||||
# sudo runs as {{ podman_user }}. SSH_ORIGINAL_COMMAND — the client's own
|
||||
# git-upload-pack / git-receive-pack request — crosses as an argument, because
|
||||
# sudo strips the environment.
|
||||
#
|
||||
# `sudo -n` on purpose: there is no TTY on this path, so a password prompt would
|
||||
# hang a git push instead of failing it.
|
||||
exec sudo -n -u {{ podman_user }} {{ podman_exec_wrapper }} "$SSH_ORIGINAL_COMMAND" "$@"
|
||||
@@ -0,0 +1,20 @@
|
||||
# git-over-SSH for Gitea's `{{ git_user }}` login user.
|
||||
# Managed by Ansible (gitea/roles/ssh). Do not edit by hand.
|
||||
#
|
||||
# The authorized_keys file is maintained by Gitea from inside its container, as
|
||||
# {{ podman_user }}, so `{{ git_user }}` does not own it — which is precisely what
|
||||
# StrictModes refuses. StrictModes cannot be relaxed for a single user
|
||||
# (sshd_config(5) does not permit it inside a Match block), so the keys are
|
||||
# handed to sshd by {{ authorized_keys_command }} instead, which sshd uses
|
||||
# without any ownership check.
|
||||
#
|
||||
# AuthorizedKeysFile=none turns the file lookup off. Without it sshd would still
|
||||
# read (and log a StrictModes complaint about) ~/.ssh/authorized_keys on every
|
||||
# connection before falling through to the command.
|
||||
#
|
||||
# This drop-in must stay in one file, and the Match block must stay last in it:
|
||||
# sshd applies everything after a Match to the matched connections only.
|
||||
Match User {{ git_user }}
|
||||
AuthorizedKeysFile none
|
||||
AuthorizedKeysCommand {{ authorized_keys_command }}
|
||||
AuthorizedKeysCommandUser {{ podman_user }}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Let Gitea's `{{ git_user }}` login user run exactly one command as
|
||||
# {{ podman_user }}: the wrapper that execs into Gitea's rootless container.
|
||||
# Managed by Ansible (gitea/roles/ssh). Do not edit by hand.
|
||||
#
|
||||
# This is what replaces the ssh hop the Docker-era deployment used. A rootless
|
||||
# container belongs to one user and cannot be entered by another, and no sshd
|
||||
# runs inside the container any more, so the forced command sshd runs for a git
|
||||
# client has to become `podman exec` — run as {{ podman_user }}.
|
||||
#
|
||||
# No arguments are pinned, on purpose. The command line comes from the
|
||||
# authorized_keys lines Gitea maintains (… serv key-N), and that file is already
|
||||
# the thing deciding who may reach which repository; restating its format here
|
||||
# would only add a second place to update whenever Gitea changes it, and pinning
|
||||
# it loosely enough to be useful (… serv *) would not restrict anything the
|
||||
# wrapper does not already fix — container, uid and environment are all baked
|
||||
# into {{ podman_exec_wrapper }}.
|
||||
{{ git_user }} ALL=({{ podman_user }}) NOPASSWD: {{ podman_exec_wrapper }}
|
||||
Reference in new issue
Block a user