Merge pull request 'fix: edge-control: per-caller attribution for register/deregister (#1094)' (#1098) from fix/issue-1094 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
dev-bot 2026-04-20 20:04:57 +00:00
commit 2c5fb6abc2
3 changed files with 41 additions and 10 deletions

View file

@ -44,6 +44,7 @@ REGISTRY_DIR="/var/lib/disinto"
CADDY_VERSION="2.8.4" CADDY_VERSION="2.8.4"
DOMAIN_SUFFIX="disinto.ai" DOMAIN_SUFFIX="disinto.ai"
EXTRA_CADDYFILE="/etc/caddy/extra.d/*.caddy" EXTRA_CADDYFILE="/etc/caddy/extra.d/*.caddy"
ADMIN_TAG="admin"
usage() { usage() {
cat <<EOF cat <<EOF
@ -57,6 +58,7 @@ Options:
--domain-suffix <suffix> Domain suffix for tunnels (default: disinto.ai) --domain-suffix <suffix> Domain suffix for tunnels (default: disinto.ai)
--extra-caddyfile <path> Import path for operator-owned Caddy config --extra-caddyfile <path> Import path for operator-owned Caddy config
(default: /etc/caddy/extra.d/*.caddy) (default: /etc/caddy/extra.d/*.caddy)
--admin-tag <name> Caller tag for the initial admin key (default: admin)
-h, --help Show this help -h, --help Show this help
Example: Example:
@ -91,6 +93,10 @@ while [[ $# -gt 0 ]]; do
EXTRA_CADDYFILE="$2" EXTRA_CADDYFILE="$2"
shift 2 shift 2
;; ;;
--admin-tag)
ADMIN_TAG="$2"
shift 2
;;
-h|--help) -h|--help)
usage usage
;; ;;
@ -404,8 +410,8 @@ if [ -n "$ADMIN_PUBKEY" ]; then
KEY_TYPE="${ADMIN_PUBKEY%% *}" KEY_TYPE="${ADMIN_PUBKEY%% *}"
KEY_DATA="${ADMIN_PUBKEY#* }" KEY_DATA="${ADMIN_PUBKEY#* }"
# Create forced command entry # Create forced command entry with caller attribution tag
FORCED_CMD="restrict,command=\"${INSTALL_DIR}/register.sh\" ${KEY_TYPE} ${KEY_DATA}" FORCED_CMD="restrict,command=\"${INSTALL_DIR}/register.sh --as ${ADMIN_TAG}\" ${KEY_TYPE} ${KEY_DATA}"
# Replace the pubkey line # Replace the pubkey line
echo "$FORCED_CMD" > /home/disinto-register/.ssh/authorized_keys echo "$FORCED_CMD" > /home/disinto-register/.ssh/authorized_keys

View file

@ -54,13 +54,14 @@ _registry_write() {
} }
# Allocate a port for a project # Allocate a port for a project
# Usage: allocate_port <project> <pubkey> <fqdn> # Usage: allocate_port <project> <pubkey> <fqdn> [<registered_by>]
# Returns: port number on stdout # Returns: port number on stdout
# Writes: registry.json with project entry # Writes: registry.json with project entry
allocate_port() { allocate_port() {
local project="$1" local project="$1"
local pubkey="$2" local pubkey="$2"
local fqdn="$3" local fqdn="$3"
local registered_by="${4:-unknown}"
_ensure_registry_dir _ensure_registry_dir
@ -116,11 +117,13 @@ allocate_port() {
--arg pubkey "$pubkey" \ --arg pubkey "$pubkey" \
--arg fqdn "$fqdn" \ --arg fqdn "$fqdn" \
--arg timestamp "$timestamp" \ --arg timestamp "$timestamp" \
--arg registered_by "$registered_by" \
'.projects[$project] = { '.projects[$project] = {
"port": $port, "port": $port,
"fqdn": $fqdn, "fqdn": $fqdn,
"pubkey": $pubkey, "pubkey": $pubkey,
"registered_at": $timestamp "registered_at": $timestamp,
"registered_by": $registered_by
}') }')
_registry_write "$new_registry" _registry_write "$new_registry"
@ -184,7 +187,7 @@ list_ports() {
local registry local registry
registry=$(_registry_read) registry=$(_registry_read)
echo "$registry" | jq -r '.projects | to_entries | map({name: .key, port: .value.port, fqdn: .value.fqdn}) | .[] | @json' 2>/dev/null echo "$registry" | jq -r '.projects | to_entries | map({name: .key, port: .value.port, fqdn: .value.fqdn, registered_by: (.value.registered_by // "unknown")}) | .[] | @json' 2>/dev/null
} }
# Get full project info from registry # Get full project info from registry

View file

@ -5,6 +5,10 @@
# This script runs as a forced command for the disinto-register SSH user. # This script runs as a forced command for the disinto-register SSH user.
# It parses SSH_ORIGINAL_COMMAND and dispatches to register|deregister|list. # It parses SSH_ORIGINAL_COMMAND and dispatches to register|deregister|list.
# #
# Per-caller attribution: each admin key's forced-command passes --as <tag>,
# which is stored as registered_by in the registry. Missing --as defaults to
# "unknown" for backwards compatibility.
#
# Usage (via SSH): # Usage (via SSH):
# ssh disinto-register@edge "register <project> <pubkey>" # ssh disinto-register@edge "register <project> <pubkey>"
# ssh disinto-register@edge "deregister <project>" # ssh disinto-register@edge "deregister <project>"
@ -37,16 +41,31 @@ AUDIT_LOG="${AUDIT_LOG:-/var/log/disinto/edge-register.log}"
# Captured error from check_allowlist (used for JSON response) # Captured error from check_allowlist (used for JSON response)
_ALLOWLIST_ERROR="" _ALLOWLIST_ERROR=""
# Caller tag (set via --as <tag> in forced command)
CALLER="unknown"
# Parse script arguments (from forced command, not SSH_ORIGINAL_COMMAND)
while [[ $# -gt 0 ]]; do
case $1 in
--as)
CALLER="$2"
shift 2
;;
*)
shift
;;
esac
done
# Append one line to the audit log. # Append one line to the audit log.
# Usage: audit_log <action> <project> <port> <pubkey_fp> # Usage: audit_log <action> <project> <port> <pubkey_fp>
# Fails silently — write errors are warned but never abort. # Fails silently — write errors are warned but never abort.
audit_log() { audit_log() {
local action="$1" project="$2" port="$3" pubkey_fp="$4" local action="$1" project="$2" port="$3" pubkey_fp="$4"
local timestamp caller local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
caller="${SSH_USERNAME:-${SUDO_USER:-${USER:-unknown}}}"
local line="${timestamp} ${action} project=${project} port=${port} pubkey_fp=${pubkey_fp} caller=${caller}" local line="${timestamp} ${action} project=${project} port=${port} pubkey_fp=${pubkey_fp} caller=${CALLER}"
# Ensure log directory exists # Ensure log directory exists
local log_dir local log_dir
@ -176,7 +195,7 @@ do_register() {
# Allocate port (idempotent - returns existing if already registered) # Allocate port (idempotent - returns existing if already registered)
local port local port
port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}") port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}" "$CALLER")
# Add Caddy route for main project domain # Add Caddy route for main project domain
add_route "$project" "$port" add_route "$project" "$port"
@ -216,6 +235,9 @@ do_register() {
do_deregister() { do_deregister() {
local project="$1" local project="$1"
# Record who is deregistering before removal
local deregistered_by="$CALLER"
# Get current port and pubkey before removing # Get current port and pubkey before removing
local port pubkey_fp local port pubkey_fp
port=$(get_port "$project") port=$(get_port "$project")
@ -257,7 +279,7 @@ do_deregister() {
audit_log "deregister" "$project" "$port" "$pubkey_fp" audit_log "deregister" "$project" "$port" "$pubkey_fp"
# Return JSON response # Return JSON response
echo "{\"removed\":true,\"port\":${port},\"fqdn\":\"${project}.${DOMAIN_SUFFIX}\"}" echo "{\"removed\":true,\"port\":${port},\"fqdn\":\"${project}.${DOMAIN_SUFFIX}\",\"deregistered_by\":\"${deregistered_by}\"}"
} }
# List all registered tunnels # List all registered tunnels