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 for the current user under ~/.local/bin/ (no sudo) and run norsk-ctl init for mkcert-style local TLS
--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>Override the default 443 proxy port
--no-http-redirectDon’t bind port 80. Disables certbot as a side effect
--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

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