#!/usr/bin/env bash
# Provisions a Debian 13 (Trixie) container or VM as a Gitea Actions runner
# that does Authenticode code signing for hello-agent via osslsigncode.
#
# Idempotent: safe to re-run. Does NOT generate or import the signing key —
# operators do that out-of-band after provisioning. The script only sets up
# the directory layout, ACLs, runner, and systemd sandbox.
#
# Designed for an unprivileged Incus/LXC container on a hardened host:
#   * No build toolchains. Smallest possible attack surface.
#   * Service unit is heavily sandboxed (Read*Only*Paths, NoNewPrivileges, …).
#   * Outbound network restriction is the LXC HOST's responsibility — the
#     container itself can't enforce it because nothing inside the namespace
#     is privileged enough to load nf_tables. Configure on the host.
#
# Usage:
#   sudo ./provision.sh \
#     --gitea-url https://gitea.example.com \
#     --runner-token <token>

set -euo pipefail

# ---- pinned versions (mirror .gitea/workflows/build-windows.yml where they overlap) ----
RUNNER_VERSION="0.2.11"
NODE_MAJOR="20"            # act_runner spawns Node for JS actions (upload/download-artifact)

# ---- defaults ----
RUNNER_NAME="$(hostname)-helloagent-sign"
RUNNER_LABELS="self-hosted,linux,signing"
SERVICE_USER="hello-signer"
PKI_DIR="/etc/pki/hello-agent"
GITEA_URL=""
RUNNER_TOKEN=""

# ---- arg parse ----
while [[ $# -gt 0 ]]; do
    case "$1" in
        --gitea-url)     GITEA_URL="$2";     shift 2 ;;
        --runner-token)  RUNNER_TOKEN="$2";  shift 2 ;;
        --runner-name)   RUNNER_NAME="$2";   shift 2 ;;
        --runner-labels) RUNNER_LABELS="$2"; shift 2 ;;
        --service-user)  SERVICE_USER="$2";  shift 2 ;;
        -h|--help)
            sed -n '2,20p' "$0"
            exit 0 ;;
        *) echo "Unknown arg: $1" >&2; exit 2 ;;
    esac
done

[[ "$EUID" -eq 0 ]] || { echo "Run as root (use sudo)." >&2; exit 1; }
[[ -n "$GITEA_URL" && -n "$RUNNER_TOKEN" ]] \
    || { echo "Missing --gitea-url or --runner-token" >&2; exit 2; }

. /etc/os-release
case "${ID}-${VERSION_ID:-}" in
    debian-13|debian-trixie) ;;
    *)
        echo "WARNING: tested only on Debian 13 (Trixie). You're on $PRETTY_NAME."
        sleep 3 ;;
esac

log() { printf '\n==> %s\n' "$*"; }

# ---- 1. apt packages (deliberately minimal — no compilers on a signing host) ----
log "Installing apt packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends \
    osslsigncode openssl ca-certificates \
    curl wget git \
    sudo gnupg

# Node.js (act_runner spawns node for JS actions like actions/download-artifact)
if ! command -v node >/dev/null; then
    log "Installing Node.js ${NODE_MAJOR} LTS"
    curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
    apt-get install -y --no-install-recommends nodejs
fi

# Sanity-check osslsigncode. Debian 13 ships 2.9, which has -ts (RFC 3161).
ver="$(osslsigncode --version 2>&1 | awk '/^osslsigncode/ {print $2; exit}')"
if [[ -z "$ver" ]]; then
    echo "could not parse osslsigncode version" >&2; exit 1
fi
log "osslsigncode $ver OK"

# ---- 2. dedicated runner user ----
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
    log "Creating system user $SERVICE_USER"
    # No login shell on purpose: this user only runs systemd's exec, never logs in.
    useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER"
fi
RUNNER_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"

# ---- 3. PKI directory ----
# Layout:
#   /etc/pki/hello-agent/
#     chain.pem       leaf || intermediate || root         0444 root:root
#     codesign.key    PEM private key (or PKCS#11 stub)    0400 root:hello-signer
#
# Why root owns the key file but hello-signer can read it: prevents the
# runner user from rewriting / deleting the key (rotate operations require
# root), while still letting osslsigncode open it for signing.
log "Preparing PKI directory at $PKI_DIR"
install -d -m 0755 -o root -g root "$PKI_DIR"

# Touch stub files if they don't exist yet so systemd's ReadOnlyPaths
# resolves cleanly on first start. Operator overwrites these post-provision.
[[ -f "$PKI_DIR/chain.pem"    ]] || install -m 0444 -o root -g root            /dev/null "$PKI_DIR/chain.pem"
[[ -f "$PKI_DIR/codesign.key" ]] || install -m 0400 -o root -g "$SERVICE_USER" /dev/null "$PKI_DIR/codesign.key"

# Re-assert ACLs unconditionally — defends against an operator copying files
# in with overly-permissive umask.
chmod 0755 "$PKI_DIR"
chown root:root        "$PKI_DIR/chain.pem";    chmod 0444 "$PKI_DIR/chain.pem"
chown root:"$SERVICE_USER" "$PKI_DIR/codesign.key"; chmod 0400 "$PKI_DIR/codesign.key"

# ---- 4. act_runner ----
RUNNER_DIR=/var/lib/gitea-runner
mkdir -p "$RUNNER_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR"

if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
    log "Downloading act_runner $RUNNER_VERSION"
    case "$(uname -m)" in
        x86_64)  arch_label="amd64" ;;
        aarch64) arch_label="arm64" ;;
        *) echo "Unsupported arch: $(uname -m)" >&2; exit 1 ;;
    esac
    curl -fsSL -o "$RUNNER_DIR/act_runner" \
        "https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-${arch_label}"
    chmod +x "$RUNNER_DIR/act_runner"
    chown "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR/act_runner"
fi

if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
    log "Registering runner with $GITEA_URL (labels: $RUNNER_LABELS)"
    sudo -u "$SERVICE_USER" -H bash -c "
        cd '$RUNNER_DIR' && \
        ./act_runner register --no-interactive \
            --instance '$GITEA_URL' \
            --token    '$RUNNER_TOKEN' \
            --name     '$RUNNER_NAME' \
            --labels   '$RUNNER_LABELS'
    "
fi

# ---- 5. systemd unit (heavily sandboxed) ----
#
# Why these flags: the signing runner does almost nothing — pulls a PE file,
# calls osslsigncode, uploads. So we can lock it down far more than the
# rustdesk build runner.
#
# Notable omissions:
#   * NO MemoryDenyWriteExecute=yes — Node.js (V8 JIT) needs w+x mappings.
#     Action runners that invoke JS actions (download-artifact etc.) break
#     under MDWX. The other sandbox flags still cover the realistic
#     post-exploitation paths.
#   * PrivateDevices=yes is fine for software-key signing. If you migrate to
#     a USB hardware token (YubiKey via opensc-pkcs11), set PrivateDevices=no
#     and add a DeviceAllow= line for /dev/bus/usb/<bus>/<dev>.
log "Installing systemd unit"
cat > /etc/systemd/system/gitea-act-runner.service <<EOF
[Unit]
Description=Gitea Actions runner (hello-agent code signing)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=${SERVICE_USER}
WorkingDirectory=${RUNNER_DIR}
ExecStart=${RUNNER_DIR}/act_runner daemon
Restart=on-failure
RestartSec=5

# --- sandbox ---
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @debug @mount @cpu-emulation @obsolete @raw-io @reboot @swap

# --- filesystem access ---
ReadWritePaths=${RUNNER_DIR}
ReadOnlyPaths=${PKI_DIR}

# --- network ---
# Pull-mode runner: never binds. Disallow listening implicitly via
# RestrictAddressFamilies (no AF_PACKET, etc.). The host firewall enforces
# *which* outbound destinations are reachable.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

LimitNOFILE=65535
TasksMax=512

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable gitea-act-runner.service
systemctl restart gitea-act-runner.service

log "Done."
cat <<EOF

----------------------------------------------------------------
Next steps (manual, on this host):

  1. Import the signing key + cert chain.

     The provisioning intentionally does NOT pull these from anywhere —
     keys must move under operator control. Once you have them locally:

        # Public chain (leaf || intermediate || root):
        install -m 0444 -o root -g root \\
            /path/to/chain.pem ${PKI_DIR}/chain.pem

        # Private key:
        install -m 0400 -o root -g ${SERVICE_USER} \\
            /path/to/codesign.key ${PKI_DIR}/codesign.key

        # Sanity-check the cert subject, EKU, and expiry:
        openssl x509 -in ${PKI_DIR}/chain.pem -noout \\
            -subject -enddate -ext extendedKeyUsage

     Required: extendedKeyUsage MUST contain "Code Signing" and NOTHING ELSE.

  2. Smoke-test signing as the runner user (uses an empty PE — fails fast
     but proves osslsigncode can read the key):

        sudo -u ${SERVICE_USER} osslsigncode sign \\
            -certs ${PKI_DIR}/chain.pem \\
            -key   ${PKI_DIR}/codesign.key \\
            -h sha256 \\
            -in /usr/bin/osslsigncode -out /tmp/signtest.exe \\
            && echo "OK: signing key reachable" \\
            || echo "FAIL: check perms and PEM format"

  3. Confirm the runner came online:

        systemctl status gitea-act-runner
        journalctl -u gitea-act-runner -n 50 --no-pager
        # Then check ${GITEA_URL} > Site Admin > Actions > Runners
        # for "${RUNNER_NAME}" with labels "${RUNNER_LABELS}"

  4. Lock the LXC HOST firewall down. Outbound from the container should
     reach ONLY:
        - your Gitea instance               (HTTPS, your Gitea host)
        - the RFC 3161 timestamp authority  (HTTP, e.g. timestamp.digicert.com)
        - apt + node mirrors                (HTTPS, only during provisioning;
                                             revoke after first successful run)
     Drop all inbound. Configure on the host (nftables / Proxmox firewall /
     Incus proxy device) — the container can't enforce this on itself.

----------------------------------------------------------------
EOF
