Install script
The exact deployment/install.sh that ships on S3, inlined from the source tree at build time so this page can’t drift from what you actually run. The same script powers both --local (laptop/dev) and --server (Linux server) installs — pick the mode at run time with the flag.
It embeds the systemd unit and sysctl tuning as heredocs — there are no companion files to chase.
Quick flag reference
Section titled “Quick flag reference”| Flag | What it does |
|---|---|
--local | Install for the current user under ~/.local/bin/ (no sudo) and run norsk-ctl init for mkcert-style local TLS |
--server | System-wide install with Docker Engine (if missing), a systemd service, and TLS. Ubuntu LTS, Debian, and Oracle Linux only |
--license <path> | Path to the Norsk license JSON. Required for server installs — staged under /etc/norsk-ctl/licenses/ with its filename kept for later product add --license-file (the daemon config itself carries no license) |
--ip auto | Auto-detect the public IP and use it for the cert SAN |
--public-host <name> | Explicit DNS name or IP for the cert SAN |
--network-mode <mode> | docker (default), hybrid |
--cert-source <src> | self-signed (default), certbot, user |
--domain <name> | DNS name (for certbot) |
--cert-email <addr> | Contact email (for certbot) |
--cert-path / --key-path | Paths (for --cert-source user) |
--admin-user <name> | Override the default admin proxy username |
--proxy-port <n> | Override the default 443 proxy port |
--no-http-redirect | Don’t bind port 80. Disables certbot as a side effect |
--working-directory <path> | Set defaultWorkingDirectory in config.yaml |
--pull-images | Pre-pull Studio + Media + proxy images during install (server only) |
--bin <path> | Use a local binary instead of downloading from S3 |
--version <ver> | Pin to a specific version (download from S3) |
--yes / -y | Skip the confirmation prompt |
--print | Print the install plan and exit; don’t touch the box |
Pass NORSK_ADMIN_PASSWORD in the environment to set the initial proxy user’s password without exposing it on the command line:
read -rs -p 'Admin password: ' NORSK_ADMIN_PASSWORD; echoexport NORSK_ADMIN_PASSWORDsudo --preserve-env=NORSK_ADMIN_PASSWORD bash install.sh --server --license /tmp/license.json --ip autoFor the walkthrough (when to use which flag, picking a cert source, etc.) see Server install — overview.
Full source
Section titled “Full source”Show the full installer
#!/usr/bin/env bash## GENERATED FILE — do not edit.# Source: deployment/install.sh.in + deployment/lib/*.sh# Rebuild: bun run build:bootstrap (check: bun run build:bootstrap:check)## install.sh — the one norsk-ctl installer. Run it with bash (not sh):## curl -fsSL https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl/install.sh | bash## Bare → interactive: it asks Local vs Server, then only for what it needs.# Hands-free:# …/install.sh | bash -s -- --local# curl -fsSL …/install.sh -o install.sh && bash install.sh \# --server --license /tmp/license.json --ip auto --yes## Two modes:# --local This machine (laptop/dev). CLI into ~/.local/bin (no sudo),# then `norsk-ctl init` configures it (mkcert for local TLS).# --server A remote box. CLI system-wide, Docker (only if `docker compose`# is missing), a systemd service, TLS. The script runs as you and# escalates via sudo only for the system-mutation steps (apt,# systemd, /etc and /opt writes). Ubuntu LTS, Debian, or Oracle# Linux for now; on other distros use --print to see the steps.## --pull-images (server only) pre-pulls the proxy images (nginx + oauth2-proxy)# after install. Product images aren't known at bare-install time — they're# pulled when a product's template first launches, or ahead of time with# `norsk-ctl product pull <product>` once the product is registered.## Config (network mode, cert source, license, public host) is owned by# `norsk-ctl init` — this script does system setup and hands off to it.## Flags beat prompts; --yes skips the confirmation. --print shows the plan# without changing anything. --check runs the server preflight (CPU, RAM, free# disk, arch, cgroup v2) against the minimums and exits — handy to vet a box# before committing to an install.## Server minimums: 8 vCPU / 16 GB RAM / 100 GB free disk recommended; cgroup v2# required. Below the recommended line is a warning; below the hard floor (or no# cgroup v2) blocks the install.## Flags:# --local | --server Install mode (else asked).# --license <file> Product licence JSON (V2 envelope). Staged to# /etc/norsk-ctl/licenses/<basename>; register# products afterwards with# `norsk-ctl product add --license-file <staged>`.# This script registers no products itself.# --public-host <host> Address CLIENTS use to reach this box. `--ip` is# an alias; `auto` detects the public IP. Implied by# --domain when --cert-source is certbot.# --network-mode <mode> Docker networking for launched instances (default docker).# --cert-source <src> self-signed (default) | certbot | user.# --domain <fqdn> certbot: the FQDN to issue for. Must already# resolve to this box, with port 80 reachable.# --cert-email <email> certbot: Let's Encrypt account address (required).# --cert-path / --key-path user: paths to your own PEM cert and key.# --admin-user <name> Proxy admin username (default admin).# --proxy-port <port> HTTPS port for the proxy (default 443).# --no-http-redirect Don't bind port 80. Incompatible with certbot.# --no-user-groups Don't add you to the norsk and docker groups.# Those grants are what let you run `norsk-ctl` and# `docker` directly; without them, use# `sudo -u norsk norsk-ctl ...`.# --working-directory <dir> Instance working directory (default /var/norsk-ctl).# --version <ver> Install a specific CLI version (default: latest).# --bin <path> Install from a local binary instead of downloading.# --pull-images Pre-pull the proxy images (server only).# --yes, -y Skip the confirmation prompt.# --print Show the plan and exit; changes nothing.# --check Run the server preflight and exit.# --help, -h This message.## Environment:# NORSK_ADMIN_PASSWORD Proxy admin password (>=8 chars, >=1 digit). Must be# EXPORTED — a plain `VAR=value` assignment is not# inherited by this script, and you'll be prompted# instead. Prompted for if unset.# NORSK_CTL_VERSION Same as --version.# NORSK_CTL_BIN Same as --bin.## A hands-free server install with Let's Encrypt TLS:# read -rs -p 'Admin password: ' NORSK_ADMIN_PASSWORD; echo# export NORSK_ADMIN_PASSWORD# bash install.sh --server --license ./licence.json \# --cert-source certbot --domain host.example.com --cert-email you@example.com --yes
# This installer uses bash features. When it's piped to another shell (e.g.# `curl … | sh`) the shebang is ignored and it runs under that shell, where# the very next line (`set -o pipefail`) — and much below it — fails# cryptically. Detect that up front and tell the user the right command. We# only know bash wasn't the interpreter, not which shell is, so we don't name# one. (We can't reliably re-exec bash on a piped stdin, so we fail clearly.)if [ -z "${BASH_VERSION:-}" ]; then echo "norsk-ctl installer must be run with bash. Re-run with, e.g.:" >&2 echo " curl -fsSL https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl/install.sh | bash" >&2 exit 1fi
set -euo pipefail
S3="${NORSK_CTL_BASE_URL:-https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl}"CHANNEL="${NORSK_CTL_CHANNEL:-latest}"LOCAL_PREFIX="${NORSK_CTL_SHIM_DIR:-$HOME/.local/bin}"
MODE="" # local | server (empty = ask)LICENSE=""PUBLIC_HOST="" # value, or "auto" to detectNETWORK_MODE=""CERT_SOURCE=""DOMAIN=""CERT_EMAIL=""CERT_PATH=""KEY_PATH=""ADMIN_USER=""PROXY_PORT=""WORKING_DIR=""HTTP_REDIRECT=1 # bind port 80 for HTTP→HTTPS redirect (+ certbot HTTP-01); --no-http-redirect to disableUSER_GROUPS=1 # add the invoking user to the norsk + docker groups; --no-user-groups to disableBIN_SRC="${NORSK_CTL_BIN:-}"VERSION="${NORSK_CTL_VERSION:-}"ASSUME_YES=0DO_PRINT=0DO_CHECK=0PULL_IMAGES=0
info() { printf '\033[36m==>\033[0m %s\n' "$*"; }warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }oops() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Read from the controlling terminal, not stdin — stdin is the script itself# under `curl | bash`. Fails clearly if there's no tty (then pass flags).have_tty() { [ -e /dev/tty ] && [ -r /dev/tty ]; }ask() { # ask "Prompt" "default" -> echoes the answer local prompt=$1 default=${2:-} reply have_tty || oops "no terminal for prompts — re-run with flags (e.g. --license, --ip)" if [ -n "$default" ]; then printf '%s [%s]: ' "$prompt" "$default" > /dev/tty else printf '%s: ' "$prompt" > /dev/tty; fi IFS= read -r reply < /dev/tty || true printf '%s' "${reply:-$default}"}ask_secret() { # ask_secret "Prompt" -> echoes the answer (no echo to screen) local prompt=$1 reply have_tty || oops "no terminal to read a password — pass NORSK_ADMIN_PASSWORD in the environment" printf '%s: ' "$prompt" > /dev/tty IFS= read -rs reply < /dev/tty || true printf '\n' > /dev/tty printf '%s' "$reply"}confirm() { # confirm "Question" -> 0 if yes have_tty || oops "no terminal for prompts — re-run with --yes to skip the confirm" # Question on its own line, then `[y/N]:` on the next — easier to read than # appending the prompt to a long question. local reply printf '%s\n[y/N]: ' "$1" > /dev/tty IFS= read -r reply < /dev/tty || true case "$reply" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac}
# Mirror of backend/src/proxy/htpasswd.ts:validatePassword — keep them in# lockstep. We pre-check here so a weak password fails at input time, not# midway through `norsk-ctl init` after Docker + the service user are in.# Print the leading doc block as --help. Anchored on content (not line numbers)# so the generated-file banner the bundler prepends doesn't shift it.usage() { sed -n '/^# install\.sh /,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
# Shared install primitives + preflight. These define detect_platform,# resolve_bin, download_bin, the distro/apt helpers, ensure_ctl (lib/common.sh)# and the capability detectors + preflight_check (lib/preflight.sh).# ── begin lib/common.sh ───────────────────────────────────────────────# shellcheck shell=bash## ensure_ctl + the norsk-ctl install primitives. Shared by install.sh (the# product-less bootstrap) and every product bootstrap. Depends on the including# script for the message helpers (info/warn/oops). See# docs/_planning/product-bootstrap-strategy.md.
# Binary source config — defaults so this lib is self-sufficient when included# by a product bootstrap that didn't set them.: "${S3:=${NORSK_CTL_BASE_URL:-https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl}}": "${CHANNEL:=${NORSK_CTL_CHANNEL:-latest}}": "${VERSION:=${NORSK_CTL_VERSION:-}}": "${BIN_SRC:=${NORSK_CTL_BIN:-}}"
# Set OS/ARCH from the host. The studio/media images are multi-arch, so arm64# and x64 are both supported.detect_platform() { case "$(uname -s).$(uname -m)" in Darwin.arm64 | Darwin.aarch64) OS=darwin; ARCH=arm64 ;; Darwin.x86_64) OS=darwin; ARCH=x64 ;; Linux.aarch64 | Linux.arm64) OS=linux; ARCH=arm64 ;; Linux.x86_64 | Linux.amd64) OS=linux; ARCH=x64 ;; *) oops "no norsk-ctl binary for $(uname -s) $(uname -m)" ;; esac}
# Echo the box's public IP, or fail if nothing answers. Probes an external echo# service rather than the local interfaces, so it reports the address clients# actually reach — on a NATted box those differ. Shared by install.sh.in and# every product bootstrap, both of which offer `--public-host auto`.# The cloud metadata service, where there is one. IMDSv2 only: the bare v1 GET# is disabled on hardened images, so a GET-only probe finds nothing on exactly# the boxes that are configured carefully. Timeouts are short because on a# non-cloud host 169.254.169.254 is simply unrouted, and the install must not# stall waiting for it.detect_ip_metadata() { local base=${NORSK_CTL_IMDS_BASE:-http://169.254.169.254} token ip token=$(curl -fsS --max-time 2 -X PUT "$base/latest/api/token" \ -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' 2>/dev/null) || return 1 [ -n "$token" ] || return 1 ip=$(curl -fsS --max-time 2 -H "X-aws-ec2-metadata-token: $token" \ "$base/latest/meta-data/public-ipv4" 2>/dev/null | tr -d '[:space:]') || return 1 # Empty means the instance has no directly-attached public address (NAT, or a # load balancer in front). Not an answer — fall through rather than baking an # empty host into the cert and every advertised URL. [ -n "$ip" ] || return 1 printf '%s' "$ip"}
detect_ip() { local ip # Metadata first: it reports the address the provider attached, which is what # clients connect to. The echo services report where our outbound traffic # appeared to originate — the same thing only when the box isn't behind NAT. ip=$(detect_ip_metadata) && [ -n "$ip" ] && { printf '%s' "$ip"; return; } for u in https://api.ipify.org https://ifconfig.me; do ip=$(curl -fsS --max-time 5 "$u" 2>/dev/null | tr -d '[:space:]') && [ -n "$ip" ] && { printf '%s' "$ip"; return; } done return 1}
# Where a server install put things, shared by install.sh.in and the product# bootstraps so the two can never describe the layout differently. These are# the same directories exported to the daemon below (the profile.d file and the# systemd unit); the split is not guessable, and without it an operator has to# go hunting to find their own data. State is called out for backups on purpose# — it holds the product registry, the stored product templates and the# instance records, so losing it loses the registered products and the paths to# their licences.print_file_layout() { printf ' \033[1mFiles:\033[0m\n' printf ' Config /etc/norsk-ctl config.yaml, licenses/, certs/\n' printf ' State /var/lib/norsk-ctl database, product registry, templates, instances, proxy\n' printf ' Logs /var/log/norsk-ctl per-instance and proxy logs\n' printf ' Binary /opt/norsk-ctl/bin versioned; /usr/local/bin/norsk-ctl symlinks to it\n' printf '\n' printf ' Back up /etc/norsk-ctl and /var/lib/norsk-ctl to capture config + state.\n'}
# Copy the operator's licence to the daemon-readable location, echoing the# staged path. `product add` runs as the unprivileged norsk user, which cannot# read a file in the operator's home directory; the daemon then skips add-time# validation and stores a path that depends on that file never moving. Copying# as root with norsk ownership removes the condition instead of testing for it —# there is no point in a fresh install where "can norsk read this?" is both# answerable and early, since ensure_ctl is what creates the norsk user.## Must run after ensure_ctl: that creates both the user and /etc/norsk-ctl.# Lands in the daemon's own licences dir (CtlHome.licensesDir), keeping the# operator's filename: backend/src/products/license-store.ts keys staged# licences by basename so one licence can entitle several products, and treats# same-name-same-bytes as reuse. Flattening every licence to `license.json`# here would defeat that — a second, different licence would collide by name.# Cheap format sniff, run at input-validation time so a V1 licence aborts# before the box is touched. Without it the installer does everything — Docker,# service user, systemd, TLS, proxy — and only the final `product add` fails,# leaving a fully-built box with an unusable licence.## Deliberately a marker grep, not a signature check: duplicating envelope# verification in bash would be a second implementation to keep in step, and# the daemon still does the real check at registration. This only has to catch# the one case worth catching early — a genuine V1 file — plus the common typo# (a path pointing at an HTML error page or a truncated download).## An unreadable file passes: bytes we cannot read cannot be classified, the# operator's file may not be readable under sudo at this point, and blocking# there would regress an install shape that works today. Same carve-out the# daemon applies at registration.license_looks_v2() { # license_looks_v2 <file> -> 0 unless definitely not V2 [ -r "$1" ] || return 0 grep -q 'norsk-license-v2' "$1" 2>/dev/null}
# The message a failed sniff should carry, shared so every installer says the# same words as the daemon's own rejection.not_v2_license_message() { # not_v2_license_message <file> printf "license file is not a V2 license envelope: %s — V1 licenses are no longer accepted; contact Norsk support for a reissued license" "$1"}
stage_license() { # stage_license <src> -> echoes the staged path local dest_dir=/etc/norsk-ctl/licenses sudo_cmd="" local dest="$dest_dir/$(basename "$1")" [ "$(id -u)" -eq 0 ] || sudo_cmd=sudo $sudo_cmd install -d -o norsk -g norsk -m 0750 "$dest_dir" # 0600: a licence is a secret and the daemon is its only reader — docker # mounts it as a compose secret and runs as root, so launch is unaffected. # Operators pass their own copy rather than reading this one. $sudo_cmd install -o norsk -g norsk -m 0600 "$1" "$dest" printf '%s' "$dest"}
# The proxy admin password policy, shared by install.sh.in and the product# bootstraps so both entry points enforce it identically. `norsk-ctl init`# re-checks server-side; this exists to fail before the box has been mutated.validate_password() { # validate_password <pw> -> 0 ok, 1 + stderr reason local pw=$1 [ "${#pw}" -ge 8 ] || { printf 'password must be at least 8 characters\n' >&2; return 1; } printf '%s' "$pw" | grep -q '[0-9]' \ || { printf 'password must contain at least one digit\n' >&2; return 1; }}
# Source os-release in a subshell — it defines VERSION/ID/NAME, which would# otherwise clobber this script's own VERSION (the requested norsk-ctl version).# NORSK_CTL_OS_RELEASE overrides the path so the distro logic is unit-testable# on a host without /etc/os-release (macOS CI).distro_id() { local f=${NORSK_CTL_OS_RELEASE:-/etc/os-release}; [ -r "$f" ] && (. "$f"; printf '%s' "${ID:-}"); }# Which package-manager family a distro belongs to (empty = unsupported).# Ubuntu/Debian are apt-based and share Docker's per-distro apt repo; Oracle# Linux (ID=ol) is RHEL-family — dnf plus Docker's CentOS repo. The install# steps branch on this rather than on the raw ID.pkg_family() { case "$(distro_id)" in ubuntu | debian) printf debian ;; ol) printf rhel ;; *) printf '' ;; esac}is_supported_distro() { [ -n "$(pkg_family)" ]; }
resolve_bin() { # echo the binary URL (or pass through an explicit --bin) if [ -n "$BIN_SRC" ]; then printf '%s' "$BIN_SRC"; return; fi local ver=$VERSION [ -n "$ver" ] || ver=$(curl -fsSL "$S3/$CHANNEL") || oops "couldn't read the $CHANNEL channel from S3" printf '%s/%s/norsk-ctl-%s-%s-%s' "$S3" "$ver" "$ver" "$OS" "$ARCH"}
download_bin() { # download_bin <dest> (verifies sha256) local url dest=$1 url=$(resolve_bin) info "downloading $url" curl -fsSL "$url" -o "$dest" || oops "download failed: $url" if curl -fsSL "$url.sha256" -o "$dest.sha256" 2>/dev/null; then local want have want=$(awk '{print $1}' "$dest.sha256") have=$( (sha256sum "$dest" 2>/dev/null || shasum -a 256 "$dest") | awk '{print $1}') [ -n "$want" ] && [ "$want" = "$have" ] || oops "checksum mismatch for the downloaded binary" rm -f "$dest.sha256" fi chmod 0755 "$dest"}
apt_update() { $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update; }apt_install() { $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"; }dnf_install() { $SUDO dnf install -y "$@"; }
# Install OS packages, dispatching on the distro family. Docker itself is# handled by install_docker (the repo setup differs per family); this covers the# smaller deps (certbot, openssl).pkg_install() { case "$(pkg_family)" in debian) apt_install "$@" ;; rhel) dnf_install "$@" ;; *) oops "unsupported distro '$(distro_id)' — cannot install: $*" ;; esac}
# Install Docker Engine + the Compose plugin from Docker's own repo. Called only# when `docker compose` is missing. Branches on the distro family: apt repo for# Ubuntu/Debian, the CentOS dnf repo (binary-compatible) for Oracle Linux.install_docker() { info "installing Docker Engine + Compose plugin" case "$(pkg_family)" in debian) apt_update apt_install ca-certificates curl gnupg $SUDO install -m 0755 -d /etc/apt/keyrings local distro; distro=$(distro_id) # ubuntu | debian — Docker publishes a repo per distro if [ ! -f /etc/apt/keyrings/docker.gpg ]; then curl -fsSL "https://download.docker.com/linux/$distro/gpg" | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg $SUDO chmod 0644 /etc/apt/keyrings/docker.gpg fi local osr=${NORSK_CTL_OS_RELEASE:-/etc/os-release} local codename; codename=$(. "$osr"; printf '%s' "${VERSION_CODENAME:-}") echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$distro $codename stable" \ | $SUDO tee /etc/apt/sources.list.d/docker.list > /dev/null apt_update apt_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; rhel) # Oracle Linux: Docker's CentOS repo is binary-compatible. dnf-plugins-core # supplies `dnf config-manager`; the repo's $releasever resolves to the OL # major, matching the CentOS path Docker publishes. dnf_install dnf-plugins-core $SUDO dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo dnf_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; *) oops "unsupported distro '$(distro_id)' — cannot install Docker" ;; esac $SUDO systemctl enable --now docker docker compose version >/dev/null || oops "Docker installed but 'docker compose' still not working"}
# Ensure certbot is installable, then install it. On RHEL-family it lives in# EPEL, which Oracle ships as oracle-epel-release-el<major>; enable it first.install_certbot() { if [ "$(pkg_family)" = rhel ]; then $SUDO dnf install -y "oracle-epel-release-el$(rpm -E %rhel)" 2>/dev/null \ || $SUDO dnf install -y epel-release \ || warn "couldn't enable EPEL — certbot install may fail" fi pkg_install certbot}
# ensure_ctl — make norsk-ctl installed, configured, and serving on a server.# IDEMPOTENT: returns early when ctl is already installed + configured + serving,# so a second product's bootstrap (or a re-run) does only the product add.## This is the post-confirm system-mutation core of the server install. The# caller (install.sh orchestration, or a product bootstrap) owns the# distro/sudo/preflight checks, input gathering, and the confirm prompt, then# calls ensure_ctl. Inputs arrive as globals: NETWORK_MODE, CERT_SOURCE,# DOMAIN, CERT_EMAIL, CERT_PATH, KEY_PATH, ADMIN_USER, ADMIN_PASSWORD,# WORKING_DIR, PUBLIC_HOST, PROXY_PORT, HTTP_REDIRECT, LICENSE, PULL_IMAGES.ensure_ctl() { if command -v norsk-ctl >/dev/null 2>&1 \ && [ -f "${NORSK_CTL_CONFIG_FILE:-/etc/norsk-ctl/config.yaml}" ] \ && systemctl is-active --quiet norsk-ctl 2>/dev/null; then # This path skips `norsk-ctl init` entirely, so every setting only init # consumes is dropped. Name them: a flag that parses, resolves (--public-host # auto even prints the address it found) and then does nothing is worse than # one that was never accepted, because it reports success. local ignored="" if [ -n "${PUBLIC_HOST:-}" ]; then ignored="$ignored --public-host"; fi if [ -n "${PROXY_PORT:-}" ]; then ignored="$ignored --proxy-port"; fi if [ -n "${ADMIN_PASSWORD:-}" ]; then ignored="$ignored --admin-password"; fi if [ -n "${LICENSE:-}" ]; then ignored="$ignored --license"; fi if [ -n "$ignored" ]; then warn "norsk-ctl is already installed and running — ignoring:$ignored" warn "on a live daemon: 'norsk-ctl config set --public-host <host>' (then relaunch instances), 'norsk-ctl config set --proxy-port <port>', 'norsk-ctl user set <name>'; licences apply per product at 'norsk-ctl product add --license-file <file>'" fi return 0 fi
# Defaults (install.sh applies these during gather; repeated here so ensure_ctl # is self-sufficient when driven by a product bootstrap). : "${NETWORK_MODE:=docker}" : "${CERT_SOURCE:=self-signed}" : "${ADMIN_USER:=admin}" : "${WORKING_DIR:=/var/norsk-ctl}" : "${HTTP_REDIRECT:=1}" : "${PULL_IMAGES:=0}" [ -n "${ADMIN_PASSWORD:-}" ] || oops "ensure_ctl: ADMIN_PASSWORD is required" [ -n "${OS:-}" ] && [ -n "${ARCH:-}" ] || detect_platform
# Escalate. Probe with `sudo -n true` first — if NOPASSWD is configured we # skip the prompt (Vagrant-style provisioning boxes). Otherwise a real # `sudo true` prompts on /dev/tty and primes the credential cache. (We can't # use `sudo -v` — it bypasses NOPASSWD and always prompts, hanging the install # on NOPASSWD machines where stdin has already been drained.) SUDO="" if [ "$(id -u)" -ne 0 ]; then if ! sudo -n true 2>/dev/null; then info "caching sudo credentials (you'll be prompted for your password)" sudo true || oops "sudo authentication failed — aborting" fi SUDO=sudo fi
# Docker — only if the Compose plugin is missing. if docker compose version >/dev/null 2>&1; then info "docker compose already present — skipping Docker install" else install_docker fi [ "$CERT_SOURCE" = certbot ] && install_certbot [ "$CERT_SOURCE" = self-signed ] && pkg_install openssl
# Service user + FHS layout. if ! id -u norsk >/dev/null 2>&1; then $SUDO groupadd -f norsk; $SUDO useradd -m -g norsk -s /bin/bash norsk; fi $SUDO usermod -aG docker norsk # Whoever invoked this script — SUDO_USER if it was wrapped in sudo, otherwise # our own username — gets docker group too, so they can `docker` afterwards, # and the norsk group, which is what lets them run `norsk-ctl` directly. # Membership grants read on /var/lib/norsk-ctl/proxy-secret, and that secret # is the whole of what the CLI needs to reach the daemon — every other part # of a command is served over the API. Without it each operator action needs # `sudo runuser -l norsk -c ...`, which additionally runs the command AS the # service user, so anything referencing the operator's own files (a licence # in their home directory, say) fails on permissions. # # Same bargain as the docker group: norsk group membership is admin over the # daemon, and the daemon is in the docker group, so it is root-equivalent by # transitivity. Grant it to operators, not to service accounts. # Convenience, so declinable: --no-user-groups (USER_GROUPS=0) for a box whose # group membership is managed elsewhere. It only ever affects the OPERATOR — # the daemon's own docker membership above is the service working, not a # convenience, and is not optional. local invoking_user="${SUDO_USER:-$(id -un)}" if [ "$invoking_user" != root ] && [ "$invoking_user" != norsk ]; then if [ "${USER_GROUPS:-1}" = 1 ]; then $SUDO usermod -aG docker "$invoking_user" $SUDO usermod -aG norsk "$invoking_user" # One newgrp is enough: it rebuilds the whole supplementary group set from # the group database and only makes the named group primary. Say so — the # reader's instinctive `newgrp norsk docker` silently discards everything # after the first group (a bogus second argument still exits 0), so it # would look like it worked and do nothing. info "added $invoking_user to the docker and norsk groups (open a new shell, or run 'newgrp norsk' — one call activates both)" else # Naming the commands matters: without them this reads as a working # install until the first `norsk-ctl` or `docker` fails on permissions. warn "left $invoking_user's groups alone (--no-user-groups). To run norsk-ctl and docker as $invoking_user:" warn " sudo usermod -aG norsk $invoking_user" warn " sudo usermod -aG docker $invoking_user" warn " then open a new shell. Until then, use: sudo -u norsk norsk-ctl ..." fi fi $SUDO mkdir -p /etc/norsk-ctl /var/lib/norsk-ctl /var/log/norsk-ctl/instances /var/log/norsk-ctl/proxy \ /opt/norsk-ctl/bin "$WORKING_DIR" /home/norsk $SUDO chown -R norsk:norsk /etc/norsk-ctl /var/lib/norsk-ctl /var/log/norsk-ctl /opt/norsk-ctl "$WORKING_DIR" /home/norsk $SUDO tee /etc/profile.d/norsk-ctl.sh > /dev/null <<'EOF'export NORSK_CTL_CONFIG_DIR=/etc/norsk-ctlexport NORSK_CTL_STATE_DIR=/var/lib/norsk-ctlexport NORSK_CTL_LOG_DIR=/var/log/norsk-ctlEOF $SUDO chmod 0644 /etc/profile.d/norsk-ctl.sh
# Kernel tuning (embedded — no companion files). $SUDO tee /etc/sysctl.d/90-norsk-tuning.conf > /dev/null <<'EOF'net.core.rmem_max=67108864net.core.wmem_max=67108864net.core.rmem_default=67108864net.core.wmem_default=67108864net.core.netdev_max_backlog=65536net.ipv4.tcp_rmem=4096 87380 67108864net.ipv4.tcp_wmem=4096 65536 67108864net.ipv4.tcp_congestion_control=bbrEOF $SUDO sysctl -p /etc/sysctl.d/90-norsk-tuning.conf || warn "some sysctl values were rejected by this kernel (continuing)"
# Binary, system-wide, versioned with an atomic symlink swap. Download lands # in our own tmpdir (we don't own /opt/norsk-ctl/bin), then `install` copies # it into place with the right owner/mode in one atomic operation. local tmp; tmp=$(mktemp) download_bin "$tmp" local norsk_version; norsk_version=$("$tmp" --version 2>/dev/null | tr -d '[:space:]') [ -n "$norsk_version" ] || { rm -f "$tmp"; oops "the binary did not report --version"; } local versioned=/opt/norsk-ctl/bin/norsk-ctl-$norsk_version $SUDO install -o norsk -g norsk -m 0755 "$tmp" "$versioned" rm -f "$tmp" $SUDO ln -sfn "$versioned" /usr/local/bin/norsk-ctl info "installed norsk-ctl $norsk_version"
# Stage the license where product registration can pick it up later — init # itself takes no license; it's supplied at `norsk-ctl product add # --license-file <staged>`. Via stage_license, which keeps the operator's # basename: the daemon's store keys staged licences by it and fatally # rejects same-name-different-bytes, so the fixed license.json name this # used to install made every renewed licence a LICENSE_NAME_CONFLICT at # the next registration — under a name the operator never chose. if [ -n "${LICENSE:-}" ]; then local staged_license staged_license=$(stage_license "$LICENSE") info "license staged at $staged_license — register products with 'norsk-ctl product add --license-file $staged_license'" fi
# Configure via `norsk-ctl init` — the one wizard/flag surface for config. set -- --network-mode "$NETWORK_MODE" \ --proxy-user "$ADMIN_USER" --proxy-password "$ADMIN_PASSWORD" --working-directory "$WORKING_DIR" --force [ -n "${PROXY_PORT:-}" ] && set -- "$@" --proxy-port "$PROXY_PORT" [ "$HTTP_REDIRECT" = 0 ] && set -- "$@" --no-http-redirect case "$CERT_SOURCE" in self-signed) set -- "$@" --cert-source self-signed [ -n "${PUBLIC_HOST:-}" ] && set -- "$@" --public-host "$PUBLIC_HOST" ;; certbot) [ -n "${DOMAIN:-}" ] && [ -n "${CERT_EMAIL:-}" ] || oops "certbot needs --domain and --cert-email" $SUDO certbot certonly --standalone --non-interactive --agree-tos -m "$CERT_EMAIL" -d "$DOMAIN" set -- "$@" --cert-source certbot \ --cert-path "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" \ --key-path "/etc/letsencrypt/live/$DOMAIN/privkey.pem" --public-host "$DOMAIN" ;; user) [ -n "${CERT_PATH:-}" ] && [ -n "${KEY_PATH:-}" ] || oops "user cert source needs --cert-path and --key-path" $SUDO install -d -o norsk -g norsk -m 0750 /etc/norsk-ctl/certs $SUDO install -o norsk -g norsk -m 0640 "$CERT_PATH" /etc/norsk-ctl/certs/cert.pem $SUDO install -o norsk -g norsk -m 0600 "$KEY_PATH" /etc/norsk-ctl/certs/key.pem set -- "$@" --cert-source user --cert-path /etc/norsk-ctl/certs/cert.pem --key-path /etc/norsk-ctl/certs/key.pem [ -n "${PUBLIC_HOST:-}" ] && set -- "$@" --public-host "$PUBLIC_HOST" ;; *) oops "--cert-source must be self-signed | certbot | user" ;; esac
# Init is the one call that still drops to the service user, and it keeps the # LOGIN shell deliberately. Operator commands no longer need either — ctl # detects the installed layout — but detection keys off # /etc/norsk-ctl/config.yaml, which is the very file this call creates. Until # it exists ctl would resolve to the service user's home, so the exports in # /etc/profile.d have to come from somewhere, and `-l` is where. Running as # `norsk` also leaves the config owned by the user that must later read it. # # Quote each arg so metacharacters survive the runuser -c shell string. local quoted="" a for a in "$@"; do quoted="$quoted $(printf '%q' "$a")"; done $SUDO runuser -l norsk -c "/usr/local/bin/norsk-ctl init --no-start-server$quoted"
$SUDO tee /etc/systemd/system/norsk-ctl.service > /dev/null <<'EOF'[Unit]Description=norsk-ctl daemonAfter=network-online.target docker.serviceWants=network-online.target docker.service
[Service]Type=simpleUser=norskGroup=norskExecStart=/usr/local/bin/norsk-ctl serveRestart=on-failureRestartSec=5RestartPreventExitStatus=78Environment=HOME=/home/norskEnvironment=NORSK_CTL_CONFIG_DIR=/etc/norsk-ctlEnvironment=NORSK_CTL_STATE_DIR=/var/lib/norsk-ctlEnvironment=NORSK_CTL_LOG_DIR=/var/log/norsk-ctlAmbientCapabilities=CAP_NET_BIND_SERVICECapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]WantedBy=multi-user.targetEOF $SUDO systemctl daemon-reload $SUDO systemctl enable --now norsk-ctl.service
if [ "$PULL_IMAGES" = 1 ]; then # Pre-pull only the proxy images (nginx + oauth2-proxy) here. Core no longer # has a product-image opinion, and no product is registered yet at this # point, so there are no product images to fetch — a product's images are # pulled when its product template first launches (the pulling-images stage), or # ahead of time with `norsk-ctl product pull <product>` once it's registered. # runuser -l gives the login env (NORSK_CTL_CONFIG_DIR). Non-fatal. info "pre-pulling proxy images (nginx + oauth2-proxy)" $SUDO runuser -l norsk -c "/usr/local/bin/norsk-ctl proxy pull" \ || warn "proxy image pre-pull failed — images will be pulled on first use" fi}# ── end lib/common.sh ───────────────────────────────────────────────# ── begin lib/preflight.sh ────────────────────────────────────────────# shellcheck shell=bash## Server capability preflight: detectors + thresholds + the report. Pure output# plus a PF_FAIL flag — callers decide whether to abort (real install) or just# inform (--print). Shared by install.sh and (later) the product bootstraps.# Reads ARCH (set by the including script's platform detection).
# Server capability thresholds. Below MIN blocks the install (hard floor);# below REC warns but continues. Floors are "install will actually work" lines;# REC mirrors the documented sizing in deployment/*/QUICKSTART.md (Norsk is# CPU/RAM/disk hungry).CORES_MIN=2; CORES_REC=8RAM_MIN_GB=2; RAM_REC_GB=16DISK_MIN_GB=25; DISK_REC_GB=100
# Lower the free-disk floor for known-small boxes (CI runners, the fixture# capture VMs) without faking the detected free space via PREFLIGHT_OVERRIDE —# the transcript still reports the box's real disk. Integer GB; a non-numeric# value is ignored so a typo can't silently disable the floor.case "${NORSK_CTL_DISK_MIN_GB:-}" in '' | *[!0-9]*) : ;; *) DISK_MIN_GB=$NORSK_CTL_DISK_MIN_GB ;;esac
# A box too small or too old fails deep in the install (docker pull runs out of# disk, the daemon won't bind, resource limits silently no-op). Check up front# and fail with a specific message instead. arm64 and x64 are both fully# supported (the studio/media images are multi-arch).# Each detector must succeed (exit 0) even when its source is missing — a# failing command substitution would trip `set -e`. Empty output is fine;# preflight_gather normalises it to 0.detect_cores() { nproc 2>/dev/null || echo 0; }detect_ram_gb() { awk '/^MemTotal:/ { printf "%d", int($2/1024/1024 + 0.5) }' /proc/meminfo 2>/dev/null || true; }# Free space on the filesystem backing Docker's image store (/var/lib/docker);# fall back to / when /var/lib doesn't exist yet. Truncate — be conservative.detect_disk_gb() { local t=/var/lib; [ -d "$t" ] || t=/; df -Pk "$t" 2>/dev/null | awk 'NR==2 { printf "%d", int($4/1024/1024) }' || true; }# The cgroup.controllers file exists only under the cgroup2 unified hierarchy.detect_cgroup() { [ -e /sys/fs/cgroup/cgroup.controllers ] && printf v2 || printf v1; }
# Populate PF_* from the host, overridable for tests via a single env var:# NORSK_CTL_PREFLIGHT_OVERRIDE="cores=8 ram=16 disk=120 cgroup=v2 arch=x64"preflight_gather() { PF_CORES=$(detect_cores); PF_RAM_GB=$(detect_ram_gb); PF_DISK_GB=$(detect_disk_gb) PF_CGROUP=$(detect_cgroup); PF_ARCH=$ARCH if [ -n "${NORSK_CTL_PREFLIGHT_OVERRIDE:-}" ]; then local kv for kv in $NORSK_CTL_PREFLIGHT_OVERRIDE; do case "${kv%%=*}" in cores) PF_CORES=${kv#*=} ;; ram) PF_RAM_GB=${kv#*=} ;; disk) PF_DISK_GB=${kv#*=} ;; cgroup) PF_CGROUP=${kv#*=} ;; arch) PF_ARCH=${kv#*=} ;; esac done fi # Detection can come back empty (no /proc, no df); normalise so the integer # comparisons below don't blow up under `set -e`. case "$PF_CORES" in '' | *[!0-9]*) PF_CORES=0 ;; esac case "$PF_RAM_GB" in '' | *[!0-9]*) PF_RAM_GB=0 ;; esac case "$PF_DISK_GB" in '' | *[!0-9]*) PF_DISK_GB=0 ;; esac}
# Print one numeric line (ok / warn / FAIL) and bump PF_FAIL on a floor miss._pf_num() { # label value min rec unit local label=$1 val=$2 min=$3 rec=$4 unit=$5 if [ "$val" -lt "$min" ]; then printf ' \033[31mFAIL\033[0m %-10s %s %s (minimum %s %s)\n' "$label" "$val" "$unit" "$min" "$unit" PF_FAIL=1 elif [ "$val" -lt "$rec" ]; then printf ' \033[33mwarn\033[0m %-10s %s %s (%s %s recommended)\n' "$label" "$val" "$unit" "$rec" "$unit" else printf ' \033[32mok\033[0m %-10s %s %s\n' "$label" "$val" "$unit" fi}
# Gather + report all server capabilities. Sets PF_FAIL=1 if any hard floor is# missed (or cgroup v2 is absent). Pure output + a flag — callers decide whether# to abort (real install) or just inform (--print).preflight_check() { preflight_gather PF_FAIL=0 printf 'preflight — server capability check:\n' _pf_num "CPU cores" "$PF_CORES" "$CORES_MIN" "$CORES_REC" "vCPU" _pf_num "RAM" "$PF_RAM_GB" "$RAM_MIN_GB" "$RAM_REC_GB" "GB" _pf_num "free disk" "$PF_DISK_GB" "$DISK_MIN_GB" "$DISK_REC_GB" "GB" if [ "$PF_CGROUP" = v2 ]; then printf ' \033[32mok\033[0m %-10s unified hierarchy\n' "cgroup" else printf ' \033[31mFAIL\033[0m %-10s cgroup v2 unified hierarchy required (found v1)\n' "cgroup" PF_FAIL=1 fi # Both arches are supported; report it so --print/--check show the target. printf ' \033[32mok\033[0m %-10s %s\n' "arch" "$PF_ARCH"}# ── end lib/preflight.sh ────────────────────────────────────────────
while [ $# -gt 0 ]; do case "$1" in --local) MODE=local ;; --server) MODE=server ;; --license) LICENSE=$2; shift ;; --public-host | --ip) PUBLIC_HOST=$2; shift ;; --network-mode) NETWORK_MODE=$2; shift ;; --cert-source) CERT_SOURCE=$2; shift ;; --domain) DOMAIN=$2; shift ;; --cert-email) CERT_EMAIL=$2; shift ;; --cert-path) CERT_PATH=$2; shift ;; --key-path) KEY_PATH=$2; shift ;; --admin-user) ADMIN_USER=$2; shift ;; --proxy-port) PROXY_PORT=$2; shift ;; --no-http-redirect) HTTP_REDIRECT=0 ;; --no-user-groups) USER_GROUPS=0 ;; --working-directory) WORKING_DIR=$2; shift ;; --bin) BIN_SRC=$2; shift ;; --version) VERSION=$2; shift ;; --yes | -y) ASSUME_YES=1 ;; --print) DO_PRINT=1 ;; --check) DO_CHECK=1 ;; --pull-images) PULL_IMAGES=1 ;; --help | -h) usage ;; *) oops "unknown option: $1 (see --help)" ;; esac shiftdone
# With certbot, --domain IS the public host: the certbot branch passes# --public-host "$DOMAIN" to `norsk-ctl init` regardless. Default it here, before# the plan is printed and before the gather-phase prompt, so we neither ask for a# value we then discard nor print a plan that omits it.if [ "$CERT_SOURCE" = certbot ] && [ -z "$PUBLIC_HOST" ] && [ -n "$DOMAIN" ]; then PUBLIC_HOST="$DOMAIN"fi
# ── Platform ──────────────────────────────────────────────────────────────detect_platform # sets OS/ARCH; detect_platform + the distro helpers live in lib/common.sh
# Catches "443 is taken by some other web server" up front rather than# letting the proxy container fail to bind midway through the install.# Uses ss (iproute2 — present on Ubuntu/Debian base) and needs root to# surface PID/process.
# Is a TCP port currently bound on this host? (root sees PID/process via -p;# we strip headers via -H. Empty output → free.)port_in_use() { local port="$1" command -v ss >/dev/null 2>&1 || return 1 # treat "ss missing" as free ss -ltnH 2>/dev/null | awk -v pat=":$port\$" '$4 ~ pat {print; exit}' | grep -q .}
# Echo the first port from the argument list that isn't bound. Returns 1# if none are free.pick_free_port() { local p for p in "$@"; do if ! port_in_use "$p"; then printf '%s' "$p" return 0 fi done return 1}
# Used by the input-gathering phase to abort with a helpful message before# the install touches anything. `advice` is the suggested remediation.check_port_free() { local port="$1" local purpose="$2" local advice="$3" command -v ss >/dev/null 2>&1 || return 0 # silently skip if ss missing local listener listener=$(ss -ltnHp 2>/dev/null | awk -v p=":$port\$" '$4 ~ p {print; exit}') if [ -n "$listener" ]; then printf 'port %s is already in use (%s):\n' "$port" "$purpose" >&2 printf ' %s\n\n' "$listener" >&2 oops "$advice" fi}
print_plan() { printf 'norsk-ctl install plan — mode: %s, platform: %s-%s\n\n' "${MODE:-ask}" "$OS" "$ARCH" # The config a dry run can already resolve. Anything still unset is prompted # for later, so "(will ask)" is the honest answer rather than a guessed default. printf ' public host %s\n' "${PUBLIC_HOST:-(will ask)}" printf ' cert source %s\n' "${CERT_SOURCE:-self-signed (default)}" [ "$CERT_SOURCE" = certbot ] && printf ' certbot -d %s -m %s\n' "${DOMAIN:-(missing --domain)}" "${CERT_EMAIL:-(missing --cert-email)}" printf ' licence %s\n' "${LICENSE:-(will ask)}" printf '\n' # When MODE isn't set (bare `--print`), show both halves as a reference. Once # we know which one applies, only the relevant section is worth printing. if [ -z "$MODE" ] || [ "$MODE" = local ]; then printf 'local:\n' printf ' 1. Download the latest CLI binary from S3 and place it in %s.\n' "$LOCAL_PREFIX" printf " 2. Run 'norsk-ctl init' to configure (mkcert local TLS, localhost).\n\n" fi if [ -z "$MODE" ] || [ "$MODE" = server ]; then cat <<'PLAN'server (Ubuntu LTS, Debian, or Oracle Linux; uses sudo per step, not run as root): 1. If 'docker compose' is missing, install Docker Engine + the Compose plugin from Docker's official package repository. 2. Create the 'norsk' service user and FHS dirs under /etc, /var/lib, /var/log. 3. Install the CLI to /opt/norsk-ctl/bin with a /usr/local/bin/norsk-ctl symlink. 4. Apply sysctl tuning and write a systemd unit (norsk-ctl.service). 5. Configure the daemon (via 'norsk-ctl init', TLS: self-signed by default), then enable + start the norsk-ctl systemd unit so it comes up on boot.PLAN [ "$PULL_IMAGES" = 1 ] \ && printf ' 6. Pre-pull the proxy images (--pull-images; product images pull on first launch).\n' printf '\n' fi}
# --check: vet this box against the server minimums and exit. Non-zero if a# hard floor is missed (or cgroup v2 is absent); warnings alone still pass.if [ "$DO_CHECK" = 1 ]; then preflight_check [ "$PF_FAIL" = 1 ] && oops "this box does not meet the minimum server requirements (see FAIL lines above)" info "preflight passed — this box meets the minimum server requirements" exit 0fi
if [ "$DO_PRINT" = 1 ]; then print_plan # Fold the capability check into the plan for server (or bare) --print. It's a # dry run, so report status but never fail — the FAIL lines are the message. if [ "$MODE" = server ] || [ -z "$MODE" ]; then printf '\n'; preflight_check; fi printf '\nNothing above runs under --print.\n' exit 0fi
# ── Mode ────────────────────────────────────────────────────────────────────if [ -z "$MODE" ]; then info "Where are you installing norsk-ctl?" printf ' 1) local — this machine (laptop/dev), no sudo\n' > /dev/tty printf ' 2) server — a remote box (systemd, TLS, needs sudo; Ubuntu/Debian/Oracle Linux)\n' > /dev/tty case "$(ask 'Choose 1 or 2' '1')" in 1 | local) MODE=local ;; 2 | server) MODE=server ;; *) oops "pick 1 (local) or 2 (server)" ;; esacfi
# ── Local install ─────────────────────────────────────────────────────────if [ "$MODE" = local ]; then [ "$PULL_IMAGES" = 1 ] && warn "--pull-images is server-only (local mode doesn't run init) — ignoring" mkdir -p "$LOCAL_PREFIX" TMP=$(mktemp) download_bin "$TMP" mv "$TMP" "$LOCAL_PREFIX/norsk-ctl" info "installed: $LOCAL_PREFIX/norsk-ctl ($("$LOCAL_PREFIX/norsk-ctl" --version 2>/dev/null))" case ":$PATH:" in *":$LOCAL_PREFIX:"*) ;; *) warn "$LOCAL_PREFIX is not on your PATH — \`norsk-ctl\` won't be found until you add it." # $SHELL is the user's login shell; basename so /bin/zsh and /usr/bin/zsh both match. shell=$(basename "${SHELL:-}") case "$shell" in bash) printf ' Run: echo '\''export PATH="%s:$PATH"'\'' >> ~/.bashrc && source ~/.bashrc\n' "$LOCAL_PREFIX" >&2 ;; zsh) printf ' Run: echo '\''export PATH="%s:$PATH"'\'' >> ~/.zshrc && source ~/.zshrc\n' "$LOCAL_PREFIX" >&2 ;; # fish_add_path is the idiomatic and persistent (universal-config) form. fish) printf ' Run: fish_add_path %s\n' "$LOCAL_PREFIX" >&2 ;; *) printf ' Add %s to PATH for your shell (SHELL=%s).\n' "$LOCAL_PREFIX" "${SHELL:-unknown}" >&2 ;; esac ;; esac info "configure it with: norsk-ctl init" exit 0fi
# ── Server install (Ubuntu/Debian/Oracle Linux, sudo'd per step) ───────────[ "$MODE" = server ] || oops "internal: unknown mode '$MODE'"if ! is_supported_distro; then oops "--server currently supports Ubuntu LTS, Debian, and Oracle Linux only. Run with --print to see the steps (a reference for other distros), or use --local."fi# Pre-check sudo so a missing binary fails fast, before we spend time# gathering inputs. The actual credential prompt is deferred until after# confirm() so the user only ever types their password once a valid plan is# locked in. Wrappers (apt_update/apt_install) capture $SUDO at call time.if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null 2>&1 \ || oops "--server needs root privileges and 'sudo' isn't installed. Either install sudo or re-run as root."fiSUDO=""
# Capability preflight before we gather inputs — a too-small or too-old box# should fail here, not deep in `docker pull` or the daemon's first bind.# Warnings (under the recommended line) print and continue, including under# --yes; only a hard floor (or missing cgroup v2) aborts.preflight_check[ "$PF_FAIL" = 1 ] && oops "this box does not meet the minimum server requirements (see FAIL lines above; re-run with --check to re-test on a different box)"
# Gather what init needs — flag, else prompt.[ -n "$LICENSE" ] || LICENSE=$(ask "Path to your license JSON")[ -r "$LICENSE" ] || oops "license file not readable: $LICENSE"license_looks_v2 "$LICENSE" || oops "$(not_v2_license_message "$LICENSE")"if [ "$PUBLIC_HOST" = auto ]; then PUBLIC_HOST=$(detect_ip) || oops "couldn't auto-detect a public IP — pass --ip <host>" info "detected public host: $PUBLIC_HOST"elif [ -z "$PUBLIC_HOST" ]; then PUBLIC_HOST=$(ask "Public host/IP clients use to reach this box (blank = localhost only)" "")fiADMIN_PASSWORD="${NORSK_ADMIN_PASSWORD:-}"if [ -n "$ADMIN_PASSWORD" ]; then validate_password "$ADMIN_PASSWORD" \ || oops "NORSK_ADMIN_PASSWORD doesn't meet the requirements (≥8 characters, at least one digit). Set a stronger value and re-run."else # Re-prompt on failure rather than abort — easier than typing the whole # command again, and the prompt already states the rules. while :; do ADMIN_PASSWORD=$(ask_secret "Admin password (≥8 chars, includes a digit)") [ -n "$ADMIN_PASSWORD" ] || oops "an admin password is required" validate_password "$ADMIN_PASSWORD" && break printf 'try again.\n' >&2 donefiNETWORK_MODE=${NETWORK_MODE:-docker}CERT_SOURCE=${CERT_SOURCE:-self-signed}ADMIN_USER=${ADMIN_USER:-admin}WORKING_DIR=${WORKING_DIR:-/var/norsk-ctl}
# Surface "443 is already in use" up front. Failing here is friendlier than# letting `docker compose up` die with a port-bind error mid-install.PROXY_PORT_EFFECTIVE="${PROXY_PORT:-443}"PROXY_SUGGESTION=$(pick_free_port 8443 9443 18443 28443 38443 || true)if [ -n "$PROXY_PORT_EFFECTIVE" ] && port_in_use "$PROXY_PORT_EFFECTIVE"; then PROXY_LISTENER=$(ss -ltnHp 2>/dev/null | awk -v p=":$PROXY_PORT_EFFECTIVE\$" '$4 ~ p {print; exit}') printf 'port %s is already in use (norsk-ctl proxy / HTTPS):\n' "$PROXY_PORT_EFFECTIVE" >&2 printf ' %s\n\n' "$PROXY_LISTENER" >&2 if [ -n "$PROXY_SUGGESTION" ]; then oops "stop the conflicting process, or re-run with --proxy-port $PROXY_SUGGESTION (verified free) to bind elsewhere." else oops "stop the conflicting process, or re-run with --proxy-port <NNNN> — none of 8443/9443/18443/28443/38443 are free either." fifi
# Port 80: needed when HTTPRedirect is on (binds 80:9080 for the redirect)# or when --cert-source certbot is used (HTTP-01 challenge). certbot also# *requires* HTTPRedirect=on, since it relies on the 80 binding — block the# incompatible combination up front.if [ "$CERT_SOURCE" = certbot ] && [ "$HTTP_REDIRECT" = 0 ]; then oops "--cert-source certbot requires port 80 for the HTTP-01 challenge — --no-http-redirect is incompatible."fiif [ "$HTTP_REDIRECT" = 1 ]; then WHY="proxy HTTPS redirect" [ "$CERT_SOURCE" = certbot ] && WHY="certbot HTTP-01 challenge" check_port_free 80 "$WHY" \ "stop the conflicting process, or re-run with --no-http-redirect to skip the HTTPS redirect (certbot is then unavailable)."fi
if [ "$ASSUME_YES" != 1 ]; then print_plan # Mention sudo in the confirm itself so the user actively opts in — passwordless # sudo (NOPASSWD) makes the later `sudo -v` silent, so this is the only chance # for "you're about to use sudo" to register. prompt="Proceed — install Docker (if needed), a systemd service, and start norsk-ctl?" [ "$(id -u)" -ne 0 ] && prompt="$prompt (will use sudo for each system step)" confirm "$prompt" || { info "aborted — nothing changed"; exit 0; }fi
# System mutation — Docker, the service user + FHS, the binary, init, and# the systemd unit, then optional image pre-pull. ensure_ctl is idempotent:# a re-run on an already-installed box is a no-op.ensure_ctl
# All network modes default to 443; if --proxy-port was passed, include it# explicitly so the user sees the right URL.URL_HOST="${PUBLIC_HOST:-<host>}"if [ -n "$PROXY_PORT" ] && [ "$PROXY_PORT" != 443 ]; then URL="https://$URL_HOST:$PROXY_PORT"else URL="https://$URL_HOST"fi
# Visual banner — bracket the post-install summary in green rules so the# important bits (URL, source command) don't disappear into the apt log# scrollback. Bold cyan on the URL since that's the thing the user clicks.# Note: /etc/profile.d/norsk-ctl.sh only runs in new login shells, so the# current session doesn't see NORSK_CTL_STATE_DIR yet — without sourcing it# the CLI looks for the proxy secret under ~/.norsk-ctl/ and the daemon# redirects with "Redirected to proxy — stale or missing proxy secret".RULE="════════════════════════════════════════════════════════════════"printf '\n\033[32m%s\033[0m\n' "$RULE"printf '\033[1;32m==> install complete\033[0m\n'printf '\n'printf ' \033[1mUI:\033[0m \033[1;36m%s\033[0m (sign in as %s)\n' "$URL" "$ADMIN_USER"printf ' Service: systemctl status norsk-ctl\n'printf ' Logs: journalctl -u norsk-ctl -f\n'printf ' Shell: source /etc/profile.d/norsk-ctl.sh (or open a new shell)\n'printf '\n'print_file_layoutprintf '\033[32m%s\033[0m\n\n' "$RULE"