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 the CLI for the current user under ~/.local/bin/ (no sudo) and stop — you configure it yourself with norsk-ctl init. Every other flag in this table is server-only, and passing one with --local is an error |
--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> | Host port the proxy binds. Overrides the default 443 |
--external-port <n> | Port clients arrive on when another reverse proxy fronts this box (e.g. 443 while --proxy-port is 8443). Every advertised URL names it instead of the bound port |
--no-http-redirect | Don’t bind a port for the HTTP→HTTPS redirect. Disables certbot as a side effect (HTTP-01 needs port 80) |
--http-redirect-port <n> | Move the redirect listener off 80 instead of dropping it |
--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 |
--help / -h | Print the flag list and exit. Works in the piped form, so it needs no download: curl -fsSL …/install.sh | bash -s -- --help |
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). Puts the CLI into ~/.local/bin (no# sudo) and stops; you configure it yourself afterwards with# `norsk-ctl init` (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.## Every configuration flag below — --license, --public-host, --cert-*,# --admin-user, the ports, --working-directory, --pull-images — belongs to the# server install. On a local one there is nothing to apply them to, so they are# refused rather than ignored.## --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, CPU flags, 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# and, on x64, AVX2 required — the binary is Bun-compiled and Bun crashes on a# CPU without it. Below the recommended line is a warning; below the hard floor# (or no cgroup v2, or no AVX2) blocks the install.## Flags:# --local | --server Install mode (else asked).# --license <src> Server only. Product licence JSON (V2 envelope). A path, or# `secret://<id>` (AWS Secrets Manager) or# `s3://<bucket>/<key>`, which are fetched with the# `aws` CLI. 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).# --external-port <port> Port CLIENTS reach this box on, when another# reverse proxy fronts it (e.g. 443 while# --proxy-port is 8443). Every advertised URL — the# oauth2 redirect and each instance's own prefix —# names this port instead of the bound one.# --no-http-redirect Don't bind a port for the HTTP→HTTPS redirect.# Incompatible with certbot, which needs port 80.# --http-redirect-port <p> Move the HTTP→HTTPS redirect listener off 80,# rather than dropping it (default 80).# --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 <src> Install from a local binary instead of downloading.# Also accepts `s3://<bucket>/<key>` and# `secret://<id>`, as --license does.# --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=""EXTERNAL_PORT="" # port clients arrive on when another reverse proxy fronts usHTTP_REDIRECT_PORT="" # host port for the HTTP→HTTPS redirect listener (default 80)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
# Every server-only flag the operator actually typed, in their own spelling —# --ip and --public-host are one setting, and the refusal should echo back what# they wrote. Local mode installs a binary and runs no `norsk-ctl init`, so all# of these would otherwise be dropped in silence.SERVER_ONLY_FLAGS=""
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; }
# 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.# --help. The bundler expands @@usage@@ into a usage() carrying this file's own# leading doc block, embedded rather than read back off disk: this script ships# as `curl … | bash`, where $0 is "bash" and there is no file to read. It used to# sed "$0", so --help printed a sed error in the one invocation we document.usage() { cat <<'NORSK_CTL_USAGE'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). Puts the CLI into ~/.local/bin (no sudo) and stops; you configure it yourself afterwards with `norsk-ctl init` (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.
Every configuration flag below — --license, --public-host, --cert-*,--admin-user, the ports, --working-directory, --pull-images — belongs to theserver install. On a local one there is nothing to apply them to, so they arerefused rather than ignored.
--pull-images (server only) pre-pulls the proxy images (nginx + oauth2-proxy)after install. Product images aren't known at bare-install time — they'repulled 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 planwithout changing anything. --check runs the server preflight (CPU, RAM, freedisk, arch, CPU flags, cgroup v2) against the minimums and exits — handy to veta box before committing to an install.
Server minimums: 8 vCPU / 16 GB RAM / 100 GB free disk recommended; cgroup v2and, on x64, AVX2 required — the binary is Bun-compiled and Bun crashes on aCPU without it. Below the recommended line is a warning; below the hard floor(or no cgroup v2, or no AVX2) blocks the install.
Flags: --local | --server Install mode (else asked). --license <src> Server only. Product licence JSON (V2 envelope). A path, or `secret://<id>` (AWS Secrets Manager) or `s3://<bucket>/<key>`, which are fetched with the `aws` CLI. 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). --external-port <port> Port CLIENTS reach this box on, when another reverse proxy fronts it (e.g. 443 while --proxy-port is 8443). Every advertised URL — the oauth2 redirect and each instance's own prefix — names this port instead of the bound one. --no-http-redirect Don't bind a port for the HTTP→HTTPS redirect. Incompatible with certbot, which needs port 80. --http-redirect-port <p> Move the HTTP→HTTPS redirect listener off 80, rather than dropping it (default 80). --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 <src> Install from a local binary instead of downloading. Also accepts `s3://<bucket>/<key>` and `secret://<id>`, as --license does. --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 --yesNORSK_CTL_USAGE 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/prompt.sh ───────────────────────────────────────────────# shellcheck shell=bash## Terminal prompts, shared by the standalone installer and every product# bootstrap. These lived in install.sh.in, which is why the product bootstraps# could not interview anyone: `ask` did not exist in them, so a bare# `curl … | bash` had nothing to fall back on and died on the first missing flag.## Reads come from the controlling terminal, never stdin — under `curl | bash`# stdin IS the script. Callers must gate on have_tty (or ASSUME_YES) so a run# with nobody to answer fails with a flag hint rather than blocking on a# terminal that isn't there.
# Openability, not permission bits. /dev/tty exists and is mode 0666 in a# container with no controlling terminal, in a systemd unit, under cloud-init# and in CI — `[ -r /dev/tty ]` says yes in every one of them and open() then# fails with ENXIO. Testing it with a real (discarded) open is the only answer# that matches what the prompts below are about to do.have_tty() { (: < /dev/tty) 2> /dev/null; }
ask() { # ask "Prompt" "default" -> echoes the answer local prompt=$1 default=${2:-} reply="" # Unattended (--yes) resolves before the tty is involved: take the default, # or stop when none was supplied — never block a scripted run on a prompt. # Arity, not emptiness: the public-host default is deliberately blank. if [ "${ASSUME_YES:-0}" = 1 ]; then [ $# -ge 2 ] || oops "'$prompt' has no default — with --yes, pass it as a flag (e.g. --license, --ip)" printf '%s' "$default" return fi 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. # Initialised, not merely declared: a read that fails leaves it UNSET, and the # case below then trips set -u instead of simply declining. 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}# ── end lib/prompt.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:-}}"# Where a local install puts the CLI. install.sh.in sets its own copy; defaulted# here so a product bootstrap's --local needs no extra wiring.: "${LOCAL_PREFIX:=${NORSK_CTL_SHIM_DIR:-$HOME/.local/bin}}"# How many versioned binaries the local install keeps for rollback. Matches# `norsk-ctl upgrade`'s own retention.: "${NORSK_KEEP_VERSIONS:=3}"
# 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"}
# `secret://<id>` (AWS Secrets Manager) and `s3://<bucket>/<key>` sources for# --license and --bin, resolved to a local file. Anything else passes through# unchanged, so a plain path costs nothing.## This is what the AWS installer generation existed for: it wrapped the distro# installer purely to resolve these two forms and delegate. Resolving them here# means one installer serves a laptop, a bare box and an EC2 instance whose# licence lives in Secrets Manager.## The resolved file keeps the SOURCE's basename, not a fixed label: the server# install stages a licence by basename (stage_license), so a fixed name would# flatten every customer's licence onto one path and revive the renewal# collision that staging exists to avoid.
# The scratch dir resolved sources land in. Named from `$$` — the SHELL's pid,# which bash keeps stable inside `$( )` — so a `dir=$(resolve_source …)` call in# a command substitution and the caller's cleanup name the same directory. A# `trap … EXIT` set inside that subshell would fire the moment the substitution# ended, taking the file with it before the caller could read it.source_tmp_dir() { local dir="${TMPDIR:-/tmp}/norsk-source.$$" [ -d "$dir" ] || { mkdir -p "$dir" || oops "could not create a scratch directory at $dir"; } chmod 0700 "$dir" printf '%s' "$dir"}
# Remove it. Callers that resolve sources should `trap clean_source_tmp EXIT`.clean_source_tmp() { rm -rf "${TMPDIR:-/tmp}/norsk-source.$$"; }
resolve_source() { # resolve_source <label> <uri-or-path> -> echoes a local path local label=$1 src=$2 case "$src" in secret://*|s3://*) ;; *) printf '%s' "$src"; return 0 ;; esac command -v aws >/dev/null 2>&1 \ || oops "the aws CLI is required to resolve a secret:// or s3:// $label source ($src). Install it (apt-get install -y awscli, or AWS's unified installer) or pass a local path." local dir dst dir=$(source_tmp_dir) case "$src" in secret://*) local secret_id=${src#secret://} dst="$dir/$(basename "$secret_id")" # >&2: this function's STDOUT is its return value, and every caller reads # it through `$( )`. An includer's `info` prints to stdout, so a progress # line here would be captured as part of the path. info "resolving $label from Secrets Manager: $secret_id" >&2 # Created 0600 BEFORE anything is written, so a secret is never briefly # world-readable between create and chmod. : > "$dst" && chmod 0600 "$dst" aws secretsmanager get-secret-value --secret-id "$secret_id" --query SecretString --output text > "$dst" \ || oops "could not read $label from Secrets Manager: $secret_id" ;; s3://*) dst="$dir/$(basename "$src")" info "resolving $label from S3: $src" >&2 : > "$dst" && chmod 0600 "$dst" aws s3 cp "$src" "$dst" >/dev/null || oops "could not download $label from S3: $src" chmod 0600 "$dst" ;; esac printf '%s' "$dst"}
# 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"}
# What the downloaded binary calls itself, or a diagnosis of why it cannot say.## Written inline as `v=$("$bin" --version 2>/dev/null | tr -d ...)` this was the# line that killed a server install in silence: under `set -e` + pipefail a# binary that RUNS but exits non-zero fails the assignment and takes the script# down before the `[ -n "$v" ] || oops` on the next line ever runs — with the# binary's own stderr already sent to /dev/null. All the operator sees is# "==> downloading <url>" and then their shell prompt back. The old guard only# ever fired for a binary that exits 0 and prints nothing, which is the rarer# half of the problem.probe_binary_version() { # probe_binary_version <file> -> echoes the version # NORSK_BIN_SOURCE is where download_bin got it: the URL names the version and # the architecture, which is most of the diagnosis when a binary will not run. # The scratch file it was staged in tells the operator nothing. local bin=$1 out="" status=0 err detail src=${NORSK_BIN_SOURCE:-$1} err=$(mktemp) out=$("$bin" --version 2>"$err") || status=$? out=$(printf '%s' "$out" | tr -d '[:space:]') detail=$(head -c 400 "$err" 2>/dev/null || true) rm -f "$err" if [ "$status" -ne 0 ]; then # 128+n is death by signal n. 132 (SIGILL) is the one worth naming: our # binaries are Bun-compiled for x86-64-v3, so a CPU without AVX2 cannot run # them at all — nothing about the install is wrong, and no amount of # re-running fixes it. if [ "$status" -eq 132 ]; then detail="illegal instruction (SIGILL) — this CPU is missing AVX2, which the binary needs${detail:+; $detail}" fi oops "the binary from $src exited $status when asked for --version${detail:+ — $detail}" fi [ -n "$out" ] || oops "the binary from $src did not report --version" printf '%s' "$out"}
download_bin() { # download_bin <dest> (verifies sha256) local url dest=$1 url=$(resolve_bin) NORSK_BIN_SOURCE=$url # read back by probe_binary_version, which sees only the staged file # A local file: an explicit `--bin /path/to/norsk-ctl`, or the scratch copy # resolve_source has already fetched an s3:// or secret:// source into. curl # takes URLs, not bare paths ("URL rejected: No host part in the URL"), so # every form of --bin died here rather than installing. if [ -f "$url" ]; then info "installing from $url" cp "$url" "$dest" chmod 0755 "$dest" return fi 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"}
# ── Local install ───────────────────────────────────────────────────────────# A laptop/dev install: the CLI in the user's own bin dir, state under# ~/.norsk-ctl, no sudo, no systemd, no /etc. Shared by install.sh --local and# every product bootstrap's --local, which is what a Mac or a dev box wants —# the server path needs Linux (require_linux_server).## This half runs on macOS, where /bin/bash is 3.2: no mapfile, no associative# arrays, no ${var^^}.
# Trim the local version archive to the newest $NORSK_KEEP_VERSIONS binaries,# the same retention the server install and `norsk-ctl upgrade` use. We own# every name in this directory and they are all `norsk-ctl-<version>`, so# ordering `ls -t` output is safe here.prune_local_versions() { local bin_dir=$1 kept=0 f # shellcheck disable=SC2045 # names here are ours: norsk-ctl-<version>, no spaces for f in $(ls -t "$bin_dir" 2>/dev/null); do case "$f" in norsk-ctl-*) ;; *) continue ;; esac kept=$((kept + 1)) if [ "$kept" -gt "$NORSK_KEEP_VERSIONS" ]; then rm -f "$bin_dir/$f"; fi done}
# Put the binary in place and say so. Nothing else — the caller decides whether# to configure it (install.sh hands off to `norsk-ctl init`; a product bootstrap# runs init itself, since it has to register a product afterwards).## The layout mirrors the server install's, one level down: versioned binaries in# a bin dir, a `current` symlink naming the live one, and a PATH-visible shim# pointing at `current`. That is what gives a laptop a version archive to roll# back to, and it is the layout `norsk-ctl upgrade` detects — a single flat# binary here is why `upgrade` used to refuse every local install. The shim# points at `current` rather than at the versioned file so a later upgrade is# one symlink swap and never has to touch $LOCAL_PREFIX again.install_ctl_local() { local ctl_home=$HOME/.norsk-ctl local bin_dir=$ctl_home/bin mkdir -p "$bin_dir" "$LOCAL_PREFIX" # Stage inside bin_dir so the move into place is a same-filesystem rename. local tmp; tmp=$(mktemp "$bin_dir/.incoming-XXXXXX") download_bin "$tmp" local norsk_version norsk_version=$(probe_binary_version "$tmp") || { rm -f "$tmp"; exit 1; } local versioned=$bin_dir/norsk-ctl-$norsk_version mv "$tmp" "$versioned" chmod 0755 "$versioned" ln -sfn "$versioned" "$ctl_home/current" # -f replaces whatever is there, including the unversioned regular file every # box installed before this layout has — that is the migration. ln -sfn "$ctl_home/current" "$LOCAL_PREFIX/norsk-ctl" prune_local_versions "$bin_dir" info "installed: norsk-ctl $norsk_version ($LOCAL_PREFIX/norsk-ctl -> $versioned)" 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. local shell; 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}
# Where a local install put things — the answer to "what do I back up?" for a# laptop, as print_file_layout is for a server.print_file_layout_local() { printf ' \033[1mFiles:\033[0m\n' printf ' Everything %s config.yaml, licenses, state, logs, proxy\n' "${NORSK_CTL_STORE_DIR:-$HOME/.norsk-ctl}" printf ' Binaries %s/bin/norsk-ctl-<version> last %s kept, `current` names the live one\n' \ "$HOME/.norsk-ctl" "$NORSK_KEEP_VERSIONS" printf ' On PATH %s/norsk-ctl -> %s/current\n' "$LOCAL_PREFIX" "$HOME/.norsk-ctl" printf '\n'}
# ensure_ctl_local — the local counterpart of ensure_ctl: installed, configured,# and ready for a product to be registered against it.## IDEMPOTENT: an existing config.yaml is left alone (a re-run should not rewrite# the operator's settings or rotate their proxy password), same bargain as# ensure_ctl's early return.## Inputs, as globals, exactly as ensure_ctl takes them: NETWORK_MODE,# CERT_SOURCE, ADMIN_USER, ADMIN_PASSWORD, PUBLIC_HOST, PROXY_PORT,# EXTERNAL_PORT, HTTP_REDIRECT, HTTP_REDIRECT_PORT, WORKING_DIR.ensure_ctl_local() { : "${NETWORK_MODE:=docker}" # self-signed, not mkcert: mkcert means installing a CA into the system trust # store, which is a real change to the machine and not one to make on the # operator's behalf without being asked. `--cert-source mkcert` opts in. : "${CERT_SOURCE:=self-signed}" : "${ADMIN_USER:=admin}" : "${HTTP_REDIRECT:=1}" [ -n "${ADMIN_PASSWORD:-}" ] || oops "ensure_ctl_local: ADMIN_PASSWORD is required" [ -n "${OS:-}" ] && [ -n "${ARCH:-}" ] || detect_platform
# `product add` RUNS the product's control-plane image, so the engine has to be # up now, not at first launch. `docker compose version` only proves the CLI is # installed — on a Mac with Docker Desktop shut down that passes and the # registration then fails on a socket nobody mentioned. docker info > /dev/null 2>&1 \ || oops "Docker isn't running — start Docker Desktop or OrbStack and re-run (registering a product runs its container)"
# Same three ports as a server install: the proxy is a container publishing # them, so a laptop with something on 443 collides just the same. local -a wanted wanted=("8333:the daemon:" "${PROXY_PORT:-443}:the proxy:--proxy-port") [ "$HTTP_REDIRECT" = 0 ] \ || wanted=("${wanted[@]}" "${HTTP_REDIRECT_PORT:-80}:the HTTP→HTTPS redirect:--http-redirect-port") check_ports_free "${wanted[@]}" || oops "$( printf '%s\n' \ "the ports above are in use — nothing has been installed." \ " move ours with the flag shown, drop the bump with --no-http-redirect," \ " or re-run with NORSK_CTL_SKIP_PORT_CHECK=1 to install anyway." )"
install_ctl_local
local cfg=${NORSK_CTL_CONFIG_FILE:-${NORSK_CTL_STORE_DIR:-$HOME/.norsk-ctl}/config.yaml} if [ -f "$cfg" ]; then warn "already configured at $cfg — keeping it (delete it, or run 'norsk-ctl init --force', to start over)" return 0 fi
set -- --network-mode "$NETWORK_MODE" --cert-source "$CERT_SOURCE" \ --proxy-user "$ADMIN_USER" --proxy-password "$ADMIN_PASSWORD" --no-start-server [ -n "${PUBLIC_HOST:-}" ] && set -- "$@" --public-host "$PUBLIC_HOST" [ -n "${PROXY_PORT:-}" ] && set -- "$@" --proxy-port "$PROXY_PORT" [ -n "${EXTERNAL_PORT:-}" ] && set -- "$@" --external-port "$EXTERNAL_PORT" [ "$HTTP_REDIRECT" = 0 ] && set -- "$@" --no-http-redirect [ -n "${HTTP_REDIRECT_PORT:-}" ] && set -- "$@" --http-redirect-port "$HTTP_REDIRECT_PORT" [ -n "${WORKING_DIR:-}" ] && set -- "$@" --working-directory "$WORKING_DIR" [ "$CERT_SOURCE" = user ] && set -- "$@" --cert-path "$CERT_PATH" --key-path "$KEY_PATH" "$LOCAL_PREFIX/norsk-ctl" init "$@"}
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}
# The host OS, through a seam so the OS-dependent branches are testable from any# machine (same reason NORSK_CTL_OS_RELEASE exists for distro_id).host_os() { printf '%s' "${NORSK_CTL_UNAME:-$(uname -s)}"; }
# Refuse a server install on anything that isn't Linux, and say what to do# instead. install.sh's --server has always refused (is_supported_distro), but# the product bootstraps went straight into ensure_ctl: on a Mac that reached# `pkg_install openssl` and failed with "unsupported distro ''" — after asking# for a sudo password, naming neither the OS nor an alternative. macOS gets its# own line because it is the case that actually happens: a colleague trying the# getting-started one-liner on a laptop.require_linux_server() { local os; os=$(host_os) [ "$os" = Linux ] && return 0 if [ "$os" = Darwin ]; then oops "$( printf '%s\n' \ "a server install needs Linux — this is macOS." \ "On a Mac, install norsk-ctl locally and register the product against it:" \ " curl -fsSL ${S3}/install.sh | bash -s -- --local" \ " norsk-ctl init" \ " norsk-ctl product add --license-file <your-license.json>" \ "(or pass --local to this script, which does all three)" \ "Docker Desktop or OrbStack must be running for the product containers." )" fi oops "a server install needs Linux (Ubuntu LTS, Debian, or Oracle Linux) — this is $os"}
# Names whatever is listening on a TCP port, or echoes nothing when it's free.# `ss` first (iproute2, present on every distro we install on), netstat as the# fallback; the last column carries the users:(("name",pid=…)) description, which# is the part an operator needs. Run through SUDO because a socket's owning# process is hidden from other users.listeners_on() { # listeners_on <port> local port=$1 tool="" command -v ss > /dev/null 2>&1 && tool=ss [ -n "$tool" ] || { command -v netstat > /dev/null 2>&1 && tool=netstat; } [ -n "$tool" ] || return 0 ${SUDO:-} "$tool" -ltnp 2> /dev/null | awk -v pat=":$port\$" '$4 ~ pat { print $NF }'}
# check_ports_free <port>:<what> … — 0 when every port is free, else it names# each conflict and returns 1.## Called before the install mutates anything: a box whose 8333 (a leftover# daemon) or 443/80 (any other web server) is taken used to get Docker, a# service user, /etc/norsk-ctl, the binary and an enabled unit before failing —# and the failure named neither the port nor the process holding it.## Every port is reported, not just the first, so one run yields one fix list.# NORSK_CTL_SKIP_PORT_CHECK=1 overrides: an operator may be about to stop# whatever holds the port, and a preflight that can't be overridden is a gate.# True when something is listening on the port. "No tool to ask with" counts as# free — see listeners_on.port_in_use() { # port_in_use <port> [ -n "$(listeners_on "$1")" ]}
# Echo the first port from the list that nothing is listening on; 1 if none are.pick_free_port() { # pick_free_port <port> … local p for p in "$@"; do if ! port_in_use "$p"; then printf '%s' "$p"; return 0; fi done return 1}
# Candidate ports to offer when ours is taken, per flag that can move it. The# daemon's own port has no entry: nothing relocates it (8333 in use is almost# always a stale daemon, where the holder's identity is the whole answer).suggest_port_for() { # suggest_port_for <flag> case "$1" in --proxy-port) pick_free_port 8443 9443 18443 28443 38443 ;; --http-redirect-port) pick_free_port 8080 8081 18080 ;; *) return 1 ;; esac}
check_ports_free() { # check_ports_free <port>:<what>[:<flag-that-moves-ours>] … [ "${NORSK_CTL_SKIP_PORT_CHECK:-0}" = 1 ] && return 0 local spec port rest what flag holder suggestion conflicts=0 for spec in "$@"; do port=${spec%%:*} rest=${spec#*:} what=${rest%%:*} # Third field is optional; without it `rest` and `what` are the same string. if [ "$rest" = "$what" ]; then flag=""; else flag=${rest#*:}; fi holder=$(listeners_on "$port") [ -n "$holder" ] || continue conflicts=1 warn "port $port ($what) is already in use by: $holder" # Suggest only a port we have just checked is free — an operator following # advice into a second conflict has been sent round the same loop twice. if [ -n "$flag" ] && suggestion=$(suggest_port_for "$flag"); then warn " move ours: $flag $suggestion (verified free)" fi done [ "$conflicts" = 0 ] || return 1 return 0}
# Write the systemd unit and put the daemon behind it.## `enable` and `restart`, NOT `enable --now`: --now starts a stopped unit but# leaves a running one alone, so a re-run — which is how a box is upgraded —# installed the new binary and left the OLD daemon serving. That failure is# invisible in the obvious place and brutal everywhere else: the stale daemon# holds :8333, so the new process exits EADDRINUSE and restart-loops, while the# CLI's health probe passes against the stale one and every command after it# fails on a proxy secret that daemon has never seen.## restart also starts a stopped unit, so it subsumes what --now was for. Reads# SUDO from ensure_ctl.install_service_unit() { $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 norsk-ctl.service $SUDO systemctl restart norsk-ctl.service}
# 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, EXTERNAL_PORT, HTTP_REDIRECT,# HTTP_REDIRECT_PORT, 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 "${EXTERNAL_PORT:-}" ]; then ignored="$ignored --external-port"; fi if [ -n "${HTTP_REDIRECT_PORT:-}" ]; then ignored="$ignored --http-redirect-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 config set --external-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
# Ports, before anything is installed. 8333 is the daemon's; the proxy binds # PROXY_PORT (443 by default) and, unless declined, the redirect listener's # port. Naming the flags in the refusal matters: without them the operator's # only visible options are "stop the other service" or "give up". local -a wanted=("8333:the daemon:" "${PROXY_PORT:-443}:the proxy:--proxy-port") [ "$HTTP_REDIRECT" = 0 ] \ || wanted+=("${HTTP_REDIRECT_PORT:-80}:the HTTP→HTTPS redirect (and certbot HTTP-01):--http-redirect-port") check_ports_free "${wanted[@]}" || oops "$( printf '%s\n' \ "the ports above are in use — nothing has been installed." \ " move ours: --proxy-port <port>, --http-redirect-port <port>" \ " drop the bump: --no-http-redirect (skips the HTTP→HTTPS listener)" \ " fronted by another proxy? also pass --external-port <the port clients use>" \ " or stop whatever holds them, or re-run with NORSK_CTL_SKIP_PORT_CHECK=1" )"
# 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 # Secrets live here (proxy-secret, the cookie secret, the generated proxy # config). The norsk group keeps traversal to read proxy-secret; others none. $SUDO chmod 0750 /var/lib/norsk-ctl $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=$(probe_binary_version "$tmp") || { rm -f "$tmp"; exit 1; } 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" [ -n "${EXTERNAL_PORT:-}" ] && set -- "$@" --external-port "$EXTERNAL_PORT" [ "$HTTP_REDIRECT" = 0 ] && set -- "$@" --no-http-redirect [ -n "${HTTP_REDIRECT_PORT:-}" ] && set -- "$@" --http-redirect-port "$HTTP_REDIRECT_PORT" 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"
install_service_unit
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; }# yes | no | unknown. The binary is Bun-compiled and Bun crashes on x64 CPUs# without AVX2 (oven-sh/bun#30613, every version after v1.3.8): several GB of# runaway allocation, a segfault, and the SIGILL its panic handler traps with.# Nothing about the install is wrong when that happens, and no amount of# re-running fixes it, so it belongs with cgroup v2 as a hard floor.## "unknown" is a third answer, not a synonym for "no": no /proc/cpuinfo (a Mac# running --check, a stripped container) means we did not measure, and a floor# may only fail on a measurement.detect_avx2() { # NORSK_CTL_CPUINFO overrides the source, for tests local src=${NORSK_CTL_CPUINFO:-/proc/cpuinfo} [ -r "$src" ] || { printf unknown; return; } # x86 lists its capabilities on a `flags` line; aarch64 has `Features` and no # `flags` line at all. No flags line means we did not measure an x86 CPU, # which is not the same as measuring one that lacks AVX2 — and a hard floor # may only fail on a measurement. Without this an arm64 CI runner, whose # preflight override names arch=x64, was refused an install. grep -qm1 '^flags' "$src" || { printf unknown; return; } # Whole flag, and the trailing boundary must include end-of-line: requiring # whitespace on both sides refused a CPU whose flags line happened to end with # avx2. Leading whitespace is safe — every flag follows the "flags : " label. grep -qm1 -E '^flags.*[[:space:]]avx2([[:space:]]|$)' "$src" && printf yes || printf no}
# 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; PF_AVX2=$(detect_avx2) 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#*=} ;; # 1 | 0 | -1 (unknown), so the seam stays one numeric word per key. avx2) case "${kv#*=}" in 1) PF_AVX2=yes ;; 0) PF_AVX2=no ;; *) PF_AVX2=unknown ;; esac ;; 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" # aarch64 has no AVX at all and needs none — the question is x64-only. if [ "$PF_ARCH" = x64 ]; then case "$PF_AVX2" in yes) printf ' \033[32mok\033[0m %-10s AVX2\n' "cpu flags" ;; no) printf ' \033[31mFAIL\033[0m %-10s AVX2 required, and this CPU does not have it — the norsk-ctl binary is Bun-compiled, and Bun crashes on non-AVX2 CPUs (oven-sh/bun#30613)\n' "cpu flags" PF_FAIL=1 ;; *) printf ' \033[33mwarn\033[0m %-10s unknown (no readable /proc/cpuinfo) — AVX2 is required on x64\n' "cpu flags" ;; esac fi}# ── end lib/preflight.sh ────────────────────────────────────────────
server_only() { SERVER_ONLY_FLAGS="$SERVER_ONLY_FLAGS $1"; }
# A local install puts the CLI on the PATH and stops: configuration belongs to# `norsk-ctl init`, which it does not run, and licences to `product add`. A# server-only flag here is a misunderstanding of what is about to happen — the# operator believes they configured a box, and nothing did. Refuse rather than# warn: a warning scrolls past in a `curl … | bash` and the install still# reports success.reject_server_only_flags() { [ -n "$SERVER_ONLY_FLAGS" ] || return 0 oops "server-only flag(s) on a local install:$SERVER_ONLY_FLAGS — a local install only puts the CLI on your PATH. Pass --server to set up a box, or drop the flags and configure this one afterwards with 'norsk-ctl init' (licences are supplied per product, at 'norsk-ctl product add --license-file <file>')."}
while [ $# -gt 0 ]; do case "$1" in --local) MODE=local ;; --server) MODE=server ;; --license) server_only "$1"; LICENSE=$2; shift ;; --public-host | --ip) server_only "$1"; PUBLIC_HOST=$2; shift ;; --network-mode) server_only "$1"; NETWORK_MODE=$2; shift ;; --cert-source) server_only "$1"; CERT_SOURCE=$2; shift ;; --domain) server_only "$1"; DOMAIN=$2; shift ;; --cert-email) server_only "$1"; CERT_EMAIL=$2; shift ;; --cert-path) server_only "$1"; CERT_PATH=$2; shift ;; --key-path) server_only "$1"; KEY_PATH=$2; shift ;; --admin-user) server_only "$1"; ADMIN_USER=$2; shift ;; --proxy-port) server_only "$1"; PROXY_PORT=$2; shift ;; --external-port) server_only "$1"; EXTERNAL_PORT=$2; shift ;; --no-http-redirect) server_only "$1"; HTTP_REDIRECT=0 ;; --http-redirect-port) server_only "$1"; HTTP_REDIRECT_PORT=$2; shift ;; --no-user-groups) server_only "$1"; USER_GROUPS=0 ;; --working-directory) server_only "$1"; 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) server_only "$1"; PULL_IMAGES=1 ;; --help | -h) usage ;; *) oops "unknown option: $1 (see --help)" ;; esac shiftdone
if [ "$MODE" = local ]; then reject_server_only_flags; fi
# A --bin or --license naming an AWS source (secret:// | s3://) is fetched to a# 0700 scratch dir; the licence's is a secret, so the dir goes on the way out# whichever path exits. Both resolve lazily — a plain path never creates it.trap clean_source_tmp EXITBIN_SRC=$(resolve_source binary "$BIN_SRC")
# 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.)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 # Only draw the menu for someone who can answer it, and to stderr rather than # /dev/tty — the same two rules the product bootstraps' menu follows. These # lines wrote straight to /dev/tty, which is not always writable: under # `bash -s -- --yes` with no terminal the redirection failed and set -e killed # the run with a raw "/dev/tty: Device not configured" instead of taking the # default the flag asked for. `ask` itself resolves --yes and no-terminal. if have_tty && [ "$ASSUME_YES" != 1 ]; then info "Where are you installing norsk-ctl?" printf ' 1) local — this machine (laptop/dev), no sudo\n' >&2 printf ' 2) server — a remote box (systemd, TLS, needs sudo; Ubuntu/Debian/Oracle Linux)\n' >&2 fi 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 reject_server_only_flags # again: this MODE may have come from the interview, not --local install_ctl_local 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")# secret:// and s3:// resolve to a local file here; a plain path passes through.LICENSE=$(resolve_source licence "$LICENSE")[ -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}
# Ports, before the confirm prompt: failing here is friendlier than letting# `docker compose up` die with a bind error mid-install. ensure_ctl checks the# same three ports itself (it is also reached from the product bootstraps and the# marketplace first-boot script) — one implementation, two call sites, so the# check an operator sees first and the one that guards the mutation cannot# disagree.## certbot *needs* port 80 for its HTTP-01 challenge, so --no-http-redirect is# incompatible with it — that is a flag contradiction rather than a busy port,# and it is caught before the check runs.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."fiPORT_SPECS=("8333:the daemon:" "${PROXY_PORT:-443}:the proxy / HTTPS:--proxy-port")if [ "$HTTP_REDIRECT" = 1 ]; then WHY="the proxy HTTPS redirect" [ "$CERT_SOURCE" = certbot ] && WHY="the certbot HTTP-01 challenge" PORT_SPECS+=("${HTTP_REDIRECT_PORT:-80}:$WHY:--http-redirect-port")ficheck_ports_free "${PORT_SPECS[@]}" || oops "$( printf '%s\n' \ "the ports above are in use — nothing has been installed." \ " stop whatever holds them, move ours with the flag shown," \ " drop the HTTP→HTTPS bump entirely with --no-http-redirect (certbot then unavailable)," \ " or re-run with NORSK_CTL_SKIP_PORT_CHECK=1 to install anyway.")"
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. A blank public host is a real# answer to the prompt — "localhost only" — so it resolves to localhost rather# than a <host> placeholder the operator would have to interpret.URL_HOST="${PUBLIC_HOST:-localhost}"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 ' 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_layout# The finish line, last and on its own. Reference material (the layout above)# can scroll; the address the operator opens next must be the thing their eye# lands on when the install stops, so nothing follows it but the closing rule.printf '\033[32m%s\033[0m\n' "$RULE"printf '\033[1;32m==> UI:\033[0m \033[1;36m%s\033[0m\n' "$URL"printf ' sign in as \033[1m%s\033[0m with the password you set\n' "$ADMIN_USER"printf '\033[32m%s\033[0m\n\n' "$RULE"