mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Two real bugs the review caught, both verified live against a real build on the Unraid host: 1. entrypoint.sh's chown fired on ANY ownership mismatch, not just a directory the daemon itself created root-owned. A host tree legitimately owned by some other account - an existing CODEMAN_CASES_PATH the README already allows pointing at a normal projects directory, or appdata under a different PUID/PGID convention than the one in use - got silently recursively re-owned with one log line to explain it. Now gated on the target actually being root-owned; anything else is a clean refusal naming the directory, its owner, and PUID/PGID. Start-Codeman.sh also now pre-creates CODEMAN_CASES_PATH the same way it already did CODEMAN_APPDATA_PATH, so Compose never has to materialise a missing bind source as root in the first place - the in-container chown becomes a safety net, not the primary mechanism. 2. The CLI-update chown (chown -R .../node_modules /usr/local/bin) handed the runtime account write access to entrypoint.sh itself (root-owned, executed as root on every container start with CHOWN/DAC_OVERRIDE/SETUID/SETGID) and the node binary - owning the DIRECTORY is enough to rename it aside and drop a replacement, which would let a compromised session arrange for its own script to run as root at the next restart. The four CLIs now install into a dedicated /opt/codeman-cli prefix (NPM_CONFIG_PREFIX); only that directory is chowned, /usr/local stays root-owned throughout. Smaller fixes from the same review: - Start-Codeman.sh's volume-refresh label filter wasn't project-scoped: a second Compose stack on the same host sharing the `codeman-dist` volume KEY could have had ITS volume deleted. Added a com.docker.compose.project filter, resolved from this stack's own `compose config --format json`. - Override-file precedence was backwards (checked .yaml before .yml; Compose actually prefers .yml) - swapped, plus a warning when both exist. - entrypoint.sh's setpriv now also passes --bounding-set -all, so CapBnd actually clears post-drop rather than just CapPrm/CapEff. - A comment on git_head_commit() noting it returns nothing for a worktree checkout (.git as a file), consistent with the script's existing -d .git convention elsewhere. - Doc drift: CLAUDE.md's Docker Compose section still described the old pre-created-and-chowned-by-hand model and didn't mention the root-then-drop entrypoint; the state-files list was missing docker-build-source.json; docs/docker-compose.md and docker/.env.example still had the pre-rename `Coding/codeman` path in one place each. Verified end to end against a real build on the Unraid host: a root-owned bind source is corrected as before; a directory owned by neither root nor PUID:PGID is refused rather than silently rewritten; a correctly-owned directory is left alone entirely; the four CLIs resolve via PATH from /opt/codeman-cli while /usr/local/bin, /usr/local/lib/node_modules and entrypoint.sh itself stay root-owned; CapBnd is fully cleared post-drop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9ZSTEenc8soSu9bTi8Xru
259 lines
11 KiB
Bash
259 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
env_file="$script_dir/.env"
|
|
compose_file="$script_dir/docker-compose.yaml"
|
|
|
|
if [[ ! -f "$env_file" ]]; then
|
|
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
|
|
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Naming a Compose file explicitly disables Compose's automatic discovery of
|
|
# the override file, so it has to be added back by hand. Without this, local
|
|
# customisation in docker-compose.override.yml is silently ignored. The
|
|
# candidates are checked in Compose's own precedence order - measured on
|
|
# Compose v5.5.0 with both present: it uses `.yml` and ignores `.yaml`.
|
|
override_yml="$script_dir/docker-compose.override.yml"
|
|
override_yaml="$script_dir/docker-compose.override.yaml"
|
|
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
|
|
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
|
|
"$override_yml" "$override_yaml" >&2
|
|
fi
|
|
compose_files=(-f "$compose_file")
|
|
for override_file in "$override_yml" "$override_yaml"; do
|
|
if [[ -f "$override_file" ]]; then
|
|
compose_files+=(-f "$override_file")
|
|
printf 'Using Compose override file: %s\n' "$override_file"
|
|
break
|
|
fi
|
|
done
|
|
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
|
|
appdata_path=$(
|
|
"${compose_command[@]}" config --environment |
|
|
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
|
)
|
|
cases_path=$(
|
|
"${compose_command[@]}" config --environment |
|
|
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
|
)
|
|
docker_socket=$(
|
|
"${compose_command[@]}" config --environment |
|
|
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
|
|
)
|
|
|
|
if [[ -z "$appdata_path" ]]; then
|
|
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -d "$appdata_path" ]]; then
|
|
if [[ "$EUID" == '0' ]]; then
|
|
printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2
|
|
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
|
|
exit 1
|
|
fi
|
|
mkdir -p -- "$appdata_path"
|
|
fi
|
|
|
|
if [[ -z "$cases_path" ]]; then
|
|
printf 'Error: CODEMAN_CASES_PATH is not set in %s\n' "$env_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Pre-creating this here, exactly like CODEMAN_APPDATA_PATH above, means Compose
|
|
# never has to materialise a missing bind source itself - which it does as
|
|
# root:root - so the in-container entrypoint's chown never has to run for this
|
|
# path at all. Unlike appdata, an EXISTING cases directory is left exactly as
|
|
# it is: the README explicitly allows pointing this at a normal projects
|
|
# directory the host account already owns, so no ownership check happens here.
|
|
if [[ ! -d "$cases_path" ]]; then
|
|
if [[ "$EUID" == '0' ]]; then
|
|
printf 'Error: Refusing to create CODEMAN_CASES_PATH as root: %s\n' "$cases_path" >&2
|
|
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
|
|
exit 1
|
|
fi
|
|
mkdir -p -- "$cases_path"
|
|
fi
|
|
|
|
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
|
|
:
|
|
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
|
|
:
|
|
else
|
|
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
|
exit 1
|
|
fi
|
|
|
|
export PUID=${owner_ids%%:*}
|
|
export PGID=${owner_ids##*:}
|
|
|
|
if [[ "$PUID" == '0' ]]; then
|
|
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
|
|
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
|
|
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then
|
|
:
|
|
elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then
|
|
:
|
|
else
|
|
printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2
|
|
exit 1
|
|
fi
|
|
|
|
export DOCKER_SOCKET_GID=${socket_ids##*:}
|
|
|
|
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
|
|
if [[ ! -d "$repo_path" ]]; then
|
|
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
|
|
exit 1
|
|
fi
|
|
export CODEMAN_REPO_PATH="$repo_path"
|
|
|
|
# The in-app updater runs `git checkout` and `npm install` against this checkout
|
|
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
|
|
# ("detected dubious ownership") and the update fails at the first step — so warn
|
|
# here, where the fix is obvious, rather than in a failed update hours later.
|
|
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
|
|
if [[ "$repo_owner" != "$PUID" ]]; then
|
|
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
|
|
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -d "$repo_path/.git" ]]; then
|
|
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
|
fi
|
|
|
|
# Reads HEAD without requiring a `git` binary on the host — this script
|
|
# otherwise checks the checkout only by testing for `.git` as a directory, and
|
|
# resolving refs by hand keeps that the same "no host git needed" guarantee.
|
|
# ⚠️ A worktree checkout has `.git` as a FILE (`gitdir: <path>`), not a
|
|
# directory, so this returns nothing there and the volume-refresh check below
|
|
# silently no-ops — consistent with the `-d .git` test used everywhere else in
|
|
# this script, not a special case, but worth knowing if a worktree checkout
|
|
# stops picking up a stale-volume refresh it should have caught.
|
|
git_head_commit() {
|
|
local git_dir="$1/.git" head_ref ref_path
|
|
[[ -d "$git_dir" ]] || return 1
|
|
head_ref=$(cat -- "$git_dir/HEAD" 2>/dev/null) || return 1
|
|
if [[ "$head_ref" == ref:* ]]; then
|
|
ref_path="${head_ref#ref: }"
|
|
if [[ -f "$git_dir/$ref_path" ]]; then
|
|
cat -- "$git_dir/$ref_path"
|
|
else
|
|
# Packed after a `git gc`; the loose ref file above is gone.
|
|
awk -v ref="$ref_path" '$2 == ref { print $1; exit }' "$git_dir/packed-refs" 2>/dev/null
|
|
fi
|
|
else
|
|
printf '%s' "$head_ref"
|
|
fi
|
|
}
|
|
|
|
# Record what the container is about to be built and created FROM. The in-app
|
|
# updater compares these against the release it wants to apply: a release that
|
|
# changes either file cannot be applied by the container restarting itself (a
|
|
# restart reuses the existing image and config), so it is refused and the user
|
|
# is sent back here. Written on every start, so the baseline always describes
|
|
# the container that is actually running. See docs/docker-self-update.md.
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
|
|
elif command -v shasum >/dev/null 2>&1; then
|
|
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
|
|
else
|
|
sha256_of() { printf ''; }
|
|
fi
|
|
|
|
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
|
|
compose_sha=$(sha256_of "$compose_file")
|
|
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
|
|
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
|
|
# dataPath('docker-env-applied.json') as the server inside the container sees it.
|
|
state_dir="$appdata_path/.codeman"
|
|
mkdir -p -- "$state_dir"
|
|
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
|
|
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
|
|
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
|
|
# A root-run start (common on Unraid) would otherwise leave a root-owned
|
|
# `.codeman` on a FIRST start, before the container has created it as PUID,
|
|
# and the unprivileged server could then never write its own state there.
|
|
if [[ "$EUID" == '0' ]]; then
|
|
chown -- "$PUID:$PGID" "$state_dir" "$state_dir/docker-env-applied.json"
|
|
fi
|
|
else
|
|
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
|
fi
|
|
|
|
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
|
|
# the image only while EMPTY, so a rebuilt image's fresh output sits unused
|
|
# behind old volume content until something clears it. The in-app self-updater
|
|
# never hits this — it rebuilds INSIDE the running container, into the very
|
|
# volume already in use — but a `docker compose build` triggered from outside
|
|
# it (this script, after a `git pull`) does: the container comes back up
|
|
# looking unchanged. Detect that here and clear just the affected volume(s) so
|
|
# `--build` below actually takes effect. Best-effort: with no sha256 tool this
|
|
# quietly does nothing, same as the environment-gate block above.
|
|
if [[ -n "$dockerfile_sha" ]]; then
|
|
repo_head=$(git_head_commit "$repo_path" || true)
|
|
lockfile_sha=$(sha256_of "$repo_path/package-lock.json" 2>/dev/null || true)
|
|
source_state_file="$state_dir/docker-build-source.json"
|
|
prev_head=''
|
|
prev_lockfile_sha=''
|
|
if [[ -f "$source_state_file" ]]; then
|
|
prev_head=$(sed -n 's/.*"headCommit": *"\([^"]*\)".*/\1/p' "$source_state_file")
|
|
prev_lockfile_sha=$(sed -n 's/.*"lockfileSha256": *"\([^"]*\)".*/\1/p' "$source_state_file")
|
|
fi
|
|
|
|
volumes_to_refresh=()
|
|
[[ -n "$repo_head" && "$repo_head" != "$prev_head" ]] && volumes_to_refresh+=('codeman-dist')
|
|
[[ -n "$lockfile_sha" && "$lockfile_sha" != "$prev_lockfile_sha" ]] && volumes_to_refresh+=('codeman-node-modules')
|
|
|
|
if [[ ${#volumes_to_refresh[@]} -gt 0 ]]; then
|
|
# Runs even on this script's very first invocation against an EXISTING
|
|
# deployment, deliberately: that deployment's volumes may already be
|
|
# stale (there was no earlier version of this check to have caught it),
|
|
# and clearing an already-empty or nonexistent volume is a harmless
|
|
# no-op, so there is no fresh-install case this needs to avoid.
|
|
printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_refresh[*]}"
|
|
"${compose_command[@]}" down
|
|
# `com.docker.compose.volume` is the volume KEY, not a project-qualified
|
|
# name - a second stack on the same host (a beta instance started with a
|
|
# different COMPOSE_PROJECT_NAME, say) that also declares a volume keyed
|
|
# `codeman-dist` shares that label, and `head -n1` would pick whichever
|
|
# the daemon happens to list first. Scope the lookup to THIS stack's own
|
|
# resolved project name so it can only ever match this stack's volume.
|
|
project_name=$(
|
|
"${compose_command[@]}" config --format json 2>/dev/null |
|
|
sed -n 's/^ "name": "\(.*\)",\{0,1\}$/\1/p' | head -n1
|
|
)
|
|
for key in "${volumes_to_refresh[@]}"; do
|
|
volume_name=$(
|
|
docker volume ls -q \
|
|
--filter "label=com.docker.compose.volume=$key" \
|
|
--filter "label=com.docker.compose.project=$project_name" |
|
|
head -n1
|
|
)
|
|
[[ -n "$volume_name" ]] && docker volume rm -- "$volume_name"
|
|
done
|
|
fi
|
|
|
|
printf '{\n "headCommit": "%s",\n "lockfileSha256": "%s"\n}\n' \
|
|
"$repo_head" "$lockfile_sha" >"$source_state_file.tmp"
|
|
mv -- "$source_state_file.tmp" "$source_state_file"
|
|
if [[ "$EUID" == '0' ]]; then
|
|
chown -- "$PUID:$PGID" "$source_state_file"
|
|
fi
|
|
fi
|
|
|
|
exec "${compose_command[@]}" up --build -d
|