Created
February 27, 2026 20:47
-
-
Save cconstab/c00d796baa1bbe40bc1fbd94571644c9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================= | |
| # NoPorts sshnpd Setup Script | |
| # ============================================================================= | |
| # Installs NoPorts via apt, enrolls an APKAM key, writes | |
| # /etc/noports/sshnpd.yaml, and starts sshnpd in the foreground. | |
| # | |
| # Intended to run as root at container start (Ubuntu Docker image). | |
| # | |
| # Required environment variables: | |
| # DEVICE_ATSIGN – atSign of this device e.g. @mydevice | |
| # MANAGER_ATSIGN – atSign(s) permitted to connect (comma-separated) | |
| # APKAM_OTP – One-time password / semi-permanent passcode | |
| # | |
| # Optional environment variables: | |
| # NOPORTS_USER – Linux user to run sshnpd (default: noports) | |
| # DEVICE_NAME – Device identifier (default: hostname) | |
| # RELAY_ATSIGN – Relay / rendezvous atSign (default: @rv_am) | |
| # LOCAL_SSHD_PORT – Port sshd listens on locally (default: 22) | |
| # PERMIT_OPEN – host:port list sshnpd may reach (default: localhost:22) | |
| # ADD_SSH_PUBLIC_KEY – Let manager push SSH public key (default: true) | |
| # NOPORTS_NAMESPACES – APKAM namespaces to request (default: sshnp:rw,sshrvd:rw) | |
| # VERBOSE – Enable verbose logging (default: false) | |
| # DEBUG – Enable debug logging (default: false) | |
| # ALLOW_SUDO – Grant NOPORTS_USER passwordless sudo to root | |
| # (default: true) | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ─── Colour helpers ────────────────────────────────────────────────────────── | |
| RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' | |
| info() { echo -e "${GREEN}[INFO]${NC} $*"; } | |
| warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } | |
| error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } | |
| # ─── Validate required variables ───────────────────────────────────────────── | |
| : "${DEVICE_ATSIGN:?DEVICE_ATSIGN is required (e.g. @mydevice)}" | |
| : "${MANAGER_ATSIGN:?MANAGER_ATSIGN is required (e.g. @mymanager)}" | |
| : "${APKAM_OTP:?APKAM_OTP is required (OTP or SPP from the NoPorts manager)}" | |
| # ─── Defaults ──────────────────────────────────────────────────────────────── | |
| NOPORTS_USER="${NOPORTS_USER:-noports}" | |
| DEVICE_NAME="${DEVICE_NAME:-$(hostname)}" | |
| RELAY_ATSIGN="${RELAY_ATSIGN:-@rv_am}" | |
| LOCAL_SSHD_PORT="${LOCAL_SSHD_PORT:-22}" | |
| PERMIT_OPEN="${PERMIT_OPEN:-localhost:22}" | |
| ADD_SSH_PUBLIC_KEY="${ADD_SSH_PUBLIC_KEY:-true}" | |
| NOPORTS_NAMESPACES="${NOPORTS_NAMESPACES:-sshnp:rw,sshrvd:rw}" | |
| VERBOSE="${VERBOSE:-false}" | |
| DEBUG="${DEBUG:-false}" | |
| ALLOW_SUDO="${ALLOW_SUDO:-true}" | |
| # Derive key file path (@ is replaced with _ by at_activate) | |
| SAFE_ATSIGN="${DEVICE_ATSIGN//@/_}" | |
| USER_HOME="/home/${NOPORTS_USER}" | |
| ATSIGN_KEYS_DIR="${USER_HOME}/.atsign/keys" | |
| ATKEYS_FILE="${ATSIGN_KEYS_DIR}/${SAFE_ATSIGN}_key.atKeys" | |
| NOPORTS_CONF_DIR="/etc/noports" | |
| SSHNPD_YAML="${NOPORTS_CONF_DIR}/sshnpd.yaml" | |
| info "=== NoPorts sshnpd setup starting ===" | |
| info " DEVICE_ATSIGN : ${DEVICE_ATSIGN}" | |
| info " MANAGER_ATSIGN : ${MANAGER_ATSIGN}" | |
| info " DEVICE_NAME : ${DEVICE_NAME}" | |
| info " NOPORTS_USER : ${NOPORTS_USER}" | |
| info " LOCAL_SSHD_PORT : ${LOCAL_SSHD_PORT}" | |
| info " PERMIT_OPEN : ${PERMIT_OPEN}" | |
| info " ALLOW_SUDO : ${ALLOW_SUDO}" | |
| info " Config file : ${SSHNPD_YAML}" | |
| # ─── Step 1 – Create the dedicated sshnpd user ─────────────────────────────── | |
| info "--- Step 1: Creating system user '${NOPORTS_USER}' ---" | |
| if id "${NOPORTS_USER}" &>/dev/null; then | |
| warn "User '${NOPORTS_USER}' already exists – skipping." | |
| else | |
| useradd \ | |
| --system \ | |
| --create-home \ | |
| --shell /bin/bash \ | |
| --comment "NoPorts daemon user" \ | |
| "${NOPORTS_USER}" | |
| info "User '${NOPORTS_USER}' created." | |
| fi | |
| # Set up .ssh directory with correct permissions | |
| mkdir -p "${USER_HOME}/.ssh" | |
| chmod 700 "${USER_HOME}/.ssh" | |
| touch "${USER_HOME}/.ssh/authorized_keys" | |
| chmod 600 "${USER_HOME}/.ssh/authorized_keys" | |
| chown -R "${NOPORTS_USER}:${NOPORTS_USER}" "${USER_HOME}/.ssh" | |
| # ─── Step 2 – Install NoPorts via apt ──────────────────────────────────────── | |
| info "--- Step 2: Installing NoPorts via apt ---" | |
| DEBIAN_FRONTEND=noninteractive apt-get update -qq | |
| DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ | |
| curl \ | |
| gnupg \ | |
| ca-certificates \ | |
| openssh-server \ | |
| sudo | |
| # Add NoPorts public signing key | |
| mkdir -p /usr/share/keyrings | |
| curl -fsSL https://apt.noports.com/noports.pub.asc \ | |
| | gpg --dearmor -o /usr/share/keyrings/noports-archive-keyring.gpg | |
| # Add NoPorts apt repository | |
| echo "deb [signed-by=/usr/share/keyrings/noports-archive-keyring.gpg] \ | |
| https://apt.noports.com/ stable main" \ | |
| > /etc/apt/sources.list.d/noports.list | |
| DEBIAN_FRONTEND=noninteractive apt-get update -qq | |
| DEBIAN_FRONTEND=noninteractive apt-get install -y noports | |
| info "NoPorts installed." | |
| # ─── Step 2b – Configure sudo access ───────────────────────────────────────── | |
| info "--- Step 2b: Configuring sudo access for '${NOPORTS_USER}' ---" | |
| SUDOERS_FILE="/etc/sudoers.d/${NOPORTS_USER}" | |
| if [[ "${ALLOW_SUDO}" == "true" ]]; then | |
| echo "${NOPORTS_USER} ALL=(root) NOPASSWD: ALL" > "${SUDOERS_FILE}" | |
| chmod 440 "${SUDOERS_FILE}" | |
| info "Passwordless sudo to root granted to '${NOPORTS_USER}'." | |
| else | |
| rm -f "${SUDOERS_FILE}" | |
| info "Sudo access disabled for '${NOPORTS_USER}'." | |
| fi | |
| # ─── Step 3 – Enroll APKAM key ─────────────────────────────────────────────── | |
| info "--- Step 3: Enrolling APKAM key for ${DEVICE_ATSIGN} ---" | |
| mkdir -p "${ATSIGN_KEYS_DIR}" | |
| chown -R "${NOPORTS_USER}:${NOPORTS_USER}" "${USER_HOME}/.atsign" | |
| chmod 700 "${ATSIGN_KEYS_DIR}" | |
| if [[ -f "${ATKEYS_FILE}" ]]; then | |
| warn "atKeys file already exists at ${ATKEYS_FILE} – skipping enrollment." | |
| else | |
| su -s /bin/bash -c " | |
| at_activate enroll \ | |
| -a '${DEVICE_ATSIGN}' \ | |
| -s '${APKAM_OTP}' \ | |
| -p noports \ | |
| -k '${ATKEYS_FILE}' \ | |
| -d '${DEVICE_NAME}' \ | |
| -n '${NOPORTS_NAMESPACES}' | |
| " "${NOPORTS_USER}" \ | |
| || error "at_activate enroll failed. Verify APKAM_OTP and DEVICE_ATSIGN." | |
| chmod 600 "${ATKEYS_FILE}" | |
| chown "${NOPORTS_USER}:${NOPORTS_USER}" "${ATKEYS_FILE}" | |
| info "APKAM enrollment complete → ${ATKEYS_FILE}" | |
| info "" | |
| info " *** ACTION REQUIRED ***" | |
| info " Approve this device from the manager machine:" | |
| info " at_activate approve -a ${MANAGER_ATSIGN} --arx noports --drx ${DEVICE_NAME}" | |
| info "" | |
| fi | |
| # ─── Step 4 – Write /etc/noports/sshnpd.yaml ───────────────────────────────── | |
| # YAML keys map directly to the sshnpd configKey paths in the source: | |
| # /atsign/* → atsign: | |
| # /access/* → access: | |
| # /device/* → device: | |
| # /ssh/* → ssh: | |
| # /runtime/* → runtime: | |
| info "--- Step 4: Writing ${SSHNPD_YAML} ---" | |
| mkdir -p "${NOPORTS_CONF_DIR}" | |
| # Build the YAML managers list (supports comma-separated MANAGER_ATSIGN) | |
| MANAGERS_YAML="" | |
| IFS=',' read -ra MANAGER_LIST <<< "${MANAGER_ATSIGN}" | |
| for mgr in "${MANAGER_LIST[@]}"; do | |
| mgr="$(echo "${mgr}" | xargs)" # trim whitespace | |
| MANAGERS_YAML="${MANAGERS_YAML} - \"${mgr}\""$'\n' | |
| done | |
| # Build the YAML permit-open list (supports comma-separated PERMIT_OPEN) | |
| PERMIT_OPEN_YAML="" | |
| IFS=',' read -ra PO_LIST <<< "${PERMIT_OPEN}" | |
| for po in "${PO_LIST[@]}"; do | |
| po="$(echo "${po}" | xargs)" | |
| PERMIT_OPEN_YAML="${PERMIT_OPEN_YAML} - \"${po}\""$'\n' | |
| done | |
| cat > "${SSHNPD_YAML}" <<EOF | |
| # /etc/noports/sshnpd.yaml | |
| # NoPorts sshnpd configuration | |
| # Written by setup-sshnpd.sh – do not edit manually unless you know what you are doing. | |
| # Reference: https://docs.noports.com | |
| # ── atSign Options ──────────────────────────────────────────────────────────── | |
| atsign: | |
| # The atSign of this device daemon | |
| atsign: "${DEVICE_ATSIGN}" | |
| # Absolute path to the atKeys file for this device | |
| keys: "${ATKEYS_FILE}" | |
| # atDirectory domain (rarely needs changing) | |
| root: "root.atsign.org" | |
| # ── Access Control Options ─────────────────────────────────────────────────── | |
| access: | |
| # atSign(s) permitted to open connections to this device | |
| managers: | |
| ${MANAGERS_YAML} | |
| # Comma-separated list of host:port pairs this daemon may forward to. | |
| # Use '*:*' to allow all, or restrict to specific services. | |
| permitopen: | |
| ${PERMIT_OPEN_YAML} | |
| # ── Device Configuration Options ───────────────────────────────────────────── | |
| device: | |
| # Device name – must be unique per manager atSign (alphanumeric / underscores) | |
| name: "${DEVICE_NAME}" | |
| # Set to true to hide this device from the manager's device list | |
| hide: false | |
| # ── SSH / sshd Options ─────────────────────────────────────────────────────── | |
| ssh: | |
| # Port on which sshd is listening locally | |
| sshd-port: ${LOCAL_SSHD_PORT} | |
| # When true, the manager can push an SSH public key to authorized_keys | |
| add-publickeys: ${ADD_SSH_PUBLIC_KEY} | |
| # SSH client to use for outbound connections: openssh | dart | |
| client: "openssh" | |
| # SSH key algorithm: ed25519 | rsa | |
| algorithm: "ed25519" | |
| # ── Runtime Options ─────────────────────────────────────────────────────────── | |
| runtime: | |
| # INFO-level logging | |
| verbose: ${VERBOSE} | |
| # FINEST-level logging (very chatty – use only when debugging) | |
| debug: ${DEBUG} | |
| # Strict request signature verification | |
| # Automatically enabled when a policy-manager is configured | |
| strict: false | |
| EOF | |
| chmod 640 "${SSHNPD_YAML}" | |
| chown root:"${NOPORTS_USER}" "${SSHNPD_YAML}" | |
| info "Config written to ${SSHNPD_YAML}" | |
| # ─── Step 5 – Configure sshd ───────────────────────────────────────────────── | |
| info "--- Step 5: Configuring sshd ---" | |
| # Generate host keys (needed in a fresh container) | |
| ssh-keygen -A | |
| # Restrict sshd to loopback – NoPorts manages all external access | |
| if ! grep -q "^ListenAddress 127.0.0.1" /etc/ssh/sshd_config; then | |
| sed -i 's/^#\?ListenAddress 0\.0\.0\.0/ListenAddress 127.0.0.1/' /etc/ssh/sshd_config | |
| grep -q "^ListenAddress 127.0.0.1" /etc/ssh/sshd_config \ | |
| || echo "ListenAddress 127.0.0.1" >> /etc/ssh/sshd_config | |
| fi | |
| # No password auth – key-based only | |
| sed -i 's/^#\?PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config | |
| grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config \ | |
| || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config | |
| # /run/sshd must exist in Docker containers | |
| mkdir -p /run/sshd | |
| /usr/sbin/sshd -D & | |
| SSHD_PID=$! | |
| info "sshd started (PID ${SSHD_PID}), listening on 127.0.0.1:${LOCAL_SSHD_PORT}" | |
| # ─── Step 6 – Start sshnpd ─────────────────────────────────────────────────── | |
| info "--- Step 6: Starting sshnpd ---" | |
| SSHNPD_BIN="" | |
| for candidate in /usr/bin/sshnpd /usr/local/bin/sshnpd; do | |
| if [[ -x "${candidate}" ]]; then | |
| SSHNPD_BIN="${candidate}" | |
| break | |
| fi | |
| done | |
| [[ -n "${SSHNPD_BIN}" ]] \ | |
| || error "sshnpd binary not found. Check the noports apt package installed correctly." | |
| info "sshnpd binary : ${SSHNPD_BIN}" | |
| info "Config file : ${SSHNPD_YAML}" | |
| # ─── Background restart loop ────────────────────────────────────────────────── | |
| # Runs sshnpd in the background. If it crashes, it is restarted with | |
| # exponential backoff (capped at 60s). The main script then waits, keeping | |
| # the container alive and forwarding signals (SIGTERM/SIGINT) to children. | |
| run_sshnpd() { | |
| local BACKOFF=1 | |
| while true; do | |
| info "Starting sshnpd (backoff=${BACKOFF}s)..." | |
| su -s /bin/bash \ | |
| -c "'${SSHNPD_BIN}' --config '${SSHNPD_YAML}'" \ | |
| "${NOPORTS_USER}" \ | |
| && EXIT_CODE=0 || EXIT_CODE=$? | |
| warn "sshnpd exited with code ${EXIT_CODE}. Restarting in ${BACKOFF}s..." | |
| sleep "${BACKOFF}" | |
| # Exponential backoff, capped at 60 seconds | |
| BACKOFF=$(( BACKOFF * 2 )) | |
| (( BACKOFF > 60 )) && BACKOFF=60 | |
| done | |
| } | |
| run_sshnpd & | |
| SSHNPD_LOOP_PID=$! | |
| info "sshnpd watchdog running in background (PID ${SSHNPD_LOOP_PID})" | |
| # Keep the container alive; propagate SIGTERM/SIGINT cleanly | |
| trap 'info "Shutting down..."; kill "${SSHNPD_LOOP_PID}" 2>/dev/null; exit 0' SIGTERM SIGINT | |
| wait "${SSHNPD_LOOP_PID}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment