build Run the service as a dedicated hito user

The service ran as git, the account that also owns the bare repo and every release. A compromised process could rewrite the code that runs after the next restart. A system user, hito, now runs the service and owns only /srv/hito/data, which holds the database. git keeps building and publishing releases, and it can no longer read the database. setup.sh creates the user idempotently with useradd --system. It moves a database left in shared/ into data/, with its -wal and -shm files, while the service is stopped. It refuses to continue if both locations hold a database. reset now preserves data/ instead of stashing and restoring one file. The unit makes /srv/git inaccessible, because the service never reads it.

Commit
a971dc86854efc54f569c79f81e76215d505346c
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
deploy/hito.service
index ac4cd2f7..b598d70a 100644..100644
@@ -7,15 +7,15 @@
7 7
8 8 [Service]
9 9 Type=simple
10 Removed: User=git
11 Removed: Group=git
10 Added: User=hito
11 Added: Group=hito
12 12 WorkingDirectory=/srv/hito/current
13 13 ExecStart=/srv/hito/current/_build/default/bin/main.exe
14 14 Restart=on-failure
15 15 RestartSec=5
16 16
17 17 # EnvironmentFile= values override Environment= values, whatever the order.
18 Removed: Environment=HITO_DB=sqlite3:/srv/hito/shared/hito.sqlite
18 Added: Environment=HITO_DB=sqlite3:/srv/hito/data/hito.sqlite
19 19 Environment=HITO_PORT=8080
20 20 # HITO_VERSION, rewritten by every deploy.
21 21 EnvironmentFile=/srv/hito/shared/deploy.env
@@ -27,8 +27,8 @@
27 27 NoNewPrivileges=yes
28 28 ProtectSystem=strict
29 29 ProtectHome=yes
30 Removed: ReadWritePaths=/srv/hito/shared
31 Removed: ReadOnlyPaths=/srv/git
30 Added: ReadWritePaths=/srv/hito/data
31 Added: InaccessiblePaths=/srv/git
32 32 PrivateTmp=yes
33 33 PrivateDevices=yes
34 34 CapabilityBoundingSet=
deploy/post-receive
index f8d902f6..920f562d 100755..100755
@@ -1,7 +1,8 @@
1 1 #!/usr/bin/env bash
2 2 #
3 3 # hito post-receive hook. Runs as the git user after every push to the bare
4 Removed: # repo. The git user owns the bare repo and the whole /srv/hito tree, so this
4 Added: # repo. The git user owns the bare repo and /srv/hito (except data/, which
5 Added: # belongs to the hito runtime user), so this
5 6 # hook builds and publishes directly, with no privilege hop. The only
6 7 # privileged action is restarting the service, through one scoped sudoers rule.
7 8 #
@@ -150,7 +151,7 @@
150 151 build_release "${rel}" || { rm -rf "${rel}"; fail "build"; }
151 152 build_secs=$((SECONDS - start))
152 153
153 Removed: ln -sfn "${SHARED}/hito.sqlite" "${rel}/hito.sqlite"
154 Added: ln -sfn "${SRV}/data/hito.sqlite" "${rel}/hito.sqlite"
154 155 record_metadata "${sha}" "${rel}" "${ts}" "${build_secs}"
155 156 flip_current "${rel}"
156 157 err "current -> ${rel}"
deploy/setup.sh
index d8e7dd92..039a3179 100755..100755
@@ -3,12 +3,15 @@
3 3 # hito deployment setup. One root script, three subcommands.
4 4 #
5 5 # sudo bash setup.sh install One-time host setup: take git ownership of
6 Removed: # /srv/hito, build the shared switch as git,
7 Removed: # install the hook, the scoped sudoers rule, and
8 Removed: # the systemd unit from hito.service beside this
9 Removed: # script. Idempotent. Creates no release; the
10 Removed: # first push (or 'deploy') does.
11 Removed: # sudo bash setup.sh deploy Reinstall hito.service if it changed, build
6 Added: # /srv/hito, create the hito runtime user and
7 Added: # its data/ directory, build the shared switch as
8 Added: # git, install the hook, the scoped sudoers rule,
9 Added: # and the systemd unit from hito.service beside
10 Added: # this script. Idempotent. Creates no release;
11 Added: # the first push (or 'deploy') does.
12 Added: # sudo bash setup.sh deploy Ensure the hito user and data/ (moving a
13 Added: # database left in shared/), reinstall
14 Added: # hito.service if it changed, build
12 15 # master as a fresh release on the shared
13 16 # switch, and flip current. Health-checks and
14 17 # rolls back on failure. Use to go live without a
@@ -18,10 +21,13 @@
18 21 # git and 'deploy'. Destructive: all else,
19 22 # including the old switch, is removed.
20 23 #
21 Removed: # Single-account model: the git user owns the bare repo and the whole
22 Removed: # /srv/hito tree, runs the service, and runs the deploy. The only privileged
23 Removed: # action is 'systemctl restart hito', through one scoped sudoers rule. The
24 Removed: # post-receive hook beside this script is the per-push equivalent of 'deploy'.
24 Added: # Two-account model. The git user owns the bare repo and /srv/hito (except
25 Added: # data/), builds every release, and may run one privileged command,
26 Added: # 'systemctl restart hito', through a scoped sudoers rule. The hito system user
27 Added: # runs the service and owns only /srv/hito/data, which holds the database. A
28 Added: # compromised service cannot rewrite releases or the deploy pipeline, and git
29 Added: # cannot read the database. The post-receive hook beside this script is the
30 Added: # per-push equivalent of 'deploy'.
25 31 #
26 32 # Style: Google Shell Style Guide.
27 33
@@ -29,13 +35,17 @@
29 35
30 36 readonly SVC_USER="git"
31 37 readonly SVC_GROUP="git"
38 Added: readonly RUN_USER="hito"
39 Added: readonly RUN_GROUP="hito"
32 40 readonly SRV="/srv/hito"
33 41 readonly BARE="/srv/git/hito.git"
34 42 readonly UNIT="/etc/systemd/system/hito.service"
35 43 readonly SUDOERS="/etc/sudoers.d/hito-deploy"
36 44 readonly SHARED="${SRV}/shared"
37 45 readonly RELEASES="${SRV}/releases"
38 Removed: readonly DB="${SHARED}/hito.sqlite"
46 Added: readonly DATA="${SRV}/data"
47 Added: readonly DB="${DATA}/hito.sqlite"
48 Added: readonly LEGACY_DB="${SHARED}/hito.sqlite"
39 49 readonly SWITCH="${SHARED}/_opam"
40 50 readonly OCAML_COMPILER="ocaml-base-compiler.5.5.0"
41 51 readonly HEALTH_URL="http://127.0.0.1:8080/login"
@@ -79,6 +89,40 @@
79 89 echo " installed ${BARE}/hooks/post-receive"
80 90 }
81 91
92 Added: # Create the unprivileged runtime account once. No login shell, no home.
93 Added: ensure_run_user() {
94 Added: if id -u "${RUN_USER}" >/dev/null 2>&1; then
95 Added: echo " user ${RUN_USER} present"
96 Added: return 0
97 Added: fi
98 Added: useradd --system --user-group \
99 Added: --home-dir "${DATA}" --no-create-home \
100 Added: --shell /usr/sbin/nologin "${RUN_USER}"
101 Added: echo " created system user ${RUN_USER}"
102 Added: }
103 Added:
104 Added: # Create data/, owned by the runtime user and private to it. If the database
105 Added: # still sits in shared/ (the single-account layout), stop the service and move
106 Added: # it, with its SQLite -wal and -shm files, so that no write is lost.
107 Added: ensure_data() {
108 Added: install -d -o "${RUN_USER}" -g "${RUN_GROUP}" -m 0700 "${DATA}"
109 Added: if [[ -f "${LEGACY_DB}" && ! -e "${DB}" ]]; then
110 Added: echo " moving ${LEGACY_DB} -> ${DB} (service stopped)"
111 Added: systemctl stop hito || true
112 Added: local f
113 Added: for f in "${LEGACY_DB}" "${LEGACY_DB}-wal" "${LEGACY_DB}-shm"; do
114 Added: if [[ -e "${f}" ]]; then
115 Added: mv "${f}" "${DATA}/"
116 Added: fi
117 Added: done
118 Added: elif [[ -f "${LEGACY_DB}" ]]; then
119 Added: echo "FATAL: database in both ${LEGACY_DB} and ${DB}; resolve by hand" >&2
120 Added: exit 1
121 Added: fi
122 Added: chown -R "${RUN_USER}:${RUN_GROUP}" "${DATA}"
123 Added: chmod -R u=rwX,go= "${DATA}"
124 Added: }
125 Added:
82 126 install_sudoers() {
83 127 # The one privileged action in the whole pipeline: git may restart the service.
84 128 cat >"${SUDOERS}" <<SUDO
@@ -198,12 +242,17 @@
198 242 echo "== shared and releases directories =="
199 243 mkdir -p "${RELEASES}" "${SHARED}"
200 244 # Adopt a pre-existing in-place DB exactly once.
201 Removed: if [[ -f "${SRV}/hito.sqlite" && ! -e "${DB}" ]]; then
202 Removed: mv "${SRV}/hito.sqlite" "${DB}"
203 Removed: echo " adopted existing hito.sqlite -> ${DB}"
245 Added: # Adopt a pre-existing in-place DB exactly once; ensure_data moves it on.
246 Added: if [[ -f "${SRV}/hito.sqlite" && ! -e "${LEGACY_DB}" && ! -e "${DB}" ]]; then
247 Added: mv "${SRV}/hito.sqlite" "${LEGACY_DB}"
248 Added: echo " adopted existing hito.sqlite -> ${LEGACY_DB}"
204 249 fi
205 250 echo "== take ${SVC_USER} ownership of ${SRV} =="
206 Removed: chown -R "${SVC_USER}:${SVC_GROUP}" "${SRV}"
251 Added: find "${SRV}" -path "${DATA}" -prune -o \
252 Added: -exec chown -h "${SVC_USER}:${SVC_GROUP}" {} +
253 Added: echo "== runtime user and data =="
254 Added: ensure_run_user
255 Added: ensure_data
207 256 echo "== git-owned opam root =="
208 257 mkdir -p "${OPAMROOT_DIR}"
209 258 chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}"
@@ -221,30 +270,34 @@
221 270 echo "FATAL: no shared switch; run 'install' first" >&2
222 271 exit 1
223 272 fi
273 Added: echo "== runtime user and data =="
274 Added: ensure_run_user
275 Added: ensure_data
224 276 echo "== systemd unit =="; install_unit
225 277 echo "== deploy master =="; build_and_flip
226 278 }
227 279
228 280 cmd_reset() {
229 Removed: if [[ ! -f "${DB}" ]]; then
230 Removed: echo "FATAL: no database at ${DB}; aborting before wipe" >&2
281 Added: if [[ ! -f "${DB}" && ! -f "${LEGACY_DB}" ]]; then
282 Added: echo "FATAL: no database at ${DB} or ${LEGACY_DB}; aborting before wipe" >&2
231 283 exit 1
232 284 fi
233 285 local stash
234 286 echo "== stop service =="; systemctl stop hito || true
235 Removed: echo "== stash the database =="
287 Added: echo "== runtime user and data =="
288 Added: ensure_run_user
289 Added: ensure_data
290 Added: echo "== stash the data directory =="
236 291 stash="$(mktemp -d /root/hito-reset-stash.XXXXXX)"
237 Removed: cp -a "${DB}" "${stash}/hito.sqlite" # safety copy, never removed by us
238 Removed: mv "${DB}" "${stash}/hito.sqlite.live"
292 Added: cp -a "${DATA}/." "${stash}/" # safety copy, never removed by us
239 293 echo " DB backup at ${stash}/hito.sqlite"
240 Removed: echo "== wipe /srv/hito contents =="
294 Added: echo "== wipe /srv/hito contents except data/ =="
241 295 # The old switch is intentionally not preserved: it was built under a
242 296 # different opam root and owner. reset rebuilds it fresh as ${SVC_USER}.
243 Removed: find "${SRV}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
297 Added: find "${SRV}" -mindepth 1 -maxdepth 1 ! -path "${DATA}" -exec rm -rf {} +
244 298 echo "== rebuild skeleton, ${SVC_USER}-owned =="
245 299 mkdir -p "${SHARED}" "${RELEASES}"
246 Removed: mv "${stash}/hito.sqlite.live" "${DB}"
247 Removed: chown -R "${SVC_USER}:${SVC_GROUP}" "${SRV}"
300 Added: chown "${SVC_USER}:${SVC_GROUP}" "${SRV}" "${SHARED}" "${RELEASES}"
248 301 mkdir -p "${OPAMROOT_DIR}"
249 302 chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}"
250 303 ensure_switch