Skip to content

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.

FlagWhat it does
--localInstall 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
--serverSystem-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 autoAuto-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-pathPaths (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-redirectDon’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-imagesPre-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 / -ySkip the confirmation prompt
--printPrint the install plan and exit; don’t touch the box
--help / -hPrint 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:

Terminal window
read -rs -p 'Admin password: ' NORSK_ADMIN_PASSWORD; echo
export NORSK_ADMIN_PASSWORD
sudo --preserve-env=NORSK_ADMIN_PASSWORD bash install.sh --server --license /tmp/license.json --ip auto

For the walkthrough (when to use which flag, picking a cert source, etc.) see Server install — overview.

Show the full installer
deployment/install.sh
#!/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 1
fi
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 detect
NETWORK_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 us
HTTP_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 disable
USER_GROUPS=1 # add the invoking user to the norsk + docker groups; --no-user-groups to disable
BIN_SRC="${NORSK_CTL_BIN:-}"
VERSION="${NORSK_CTL_VERSION:-}"
ASSUME_YES=0
DO_PRINT=0
DO_CHECK=0
PULL_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 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
NORSK_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 daemon
After=network-online.target docker.service
Wants=network-online.target docker.service
[Service]
Type=simple
User=norsk
Group=norsk
ExecStart=/usr/local/bin/norsk-ctl serve
Restart=on-failure
RestartSec=5
RestartPreventExitStatus=78
Environment=HOME=/home/norsk
Environment=NORSK_CTL_CONFIG_DIR=/etc/norsk-ctl
Environment=NORSK_CTL_STATE_DIR=/var/lib/norsk-ctl
Environment=NORSK_CTL_LOG_DIR=/var/log/norsk-ctl
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
EOF
$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-ctl
export NORSK_CTL_STATE_DIR=/var/lib/norsk-ctl
export NORSK_CTL_LOG_DIR=/var/log/norsk-ctl
EOF
$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=67108864
net.core.wmem_max=67108864
net.core.rmem_default=67108864
net.core.wmem_default=67108864
net.core.netdev_max_backlog=65536
net.ipv4.tcp_rmem=4096 87380 67108864
net.ipv4.tcp_wmem=4096 65536 67108864
net.ipv4.tcp_congestion_control=bbr
EOF
$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=8
RAM_MIN_GB=2; RAM_REC_GB=16
DISK_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
shift
done
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 EXIT
BIN_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 0
fi
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 0
fi
# ── 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)" ;;
esac
fi
# ── 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 0
fi
# ── 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."
fi
SUDO=""
# 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)" "")
fi
ADMIN_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
done
fi
NETWORK_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."
fi
PORT_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")
fi
check_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"