Skip to content

Instantly share code, notes, and snippets.

@cconstab
Created February 27, 2026 20:47
Show Gist options
  • Select an option

  • Save cconstab/c00d796baa1bbe40bc1fbd94571644c9 to your computer and use it in GitHub Desktop.

Select an option

Save cconstab/c00d796baa1bbe40bc1fbd94571644c9 to your computer and use it in GitHub Desktop.
#!/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