diff --git a/bin/disinto b/bin/disinto index a4dc192..5c64b25 100755 --- a/bin/disinto +++ b/bin/disinto @@ -71,7 +71,7 @@ Usage: Edge subcommands: register [project] Register a new tunnel (generates keypair if needed) - deregister Remove a tunnel registration + deregister Remove a tunnel registration (requires tunnel keypair) status Show registered tunnels Agent subcommands: @@ -2737,7 +2737,7 @@ EOF # Manage edge tunnel registrations (reverse SSH tunnels to edge hosts) # Usage: disinto edge [options] # register [project] Register a new tunnel (generates keypair if needed) -# deregister Remove a tunnel registration +# deregister Remove a tunnel registration (requires tunnel keypair) # status Show registered tunnels disinto_edge() { local subcmd="${1:-}" @@ -2885,12 +2885,25 @@ disinto_edge() { edge_host="${EDGE_HOST:-edge.disinto.ai}" fi + # Read tunnel pubkey (same keypair used for register) + local secrets_dir="${FACTORY_ROOT}/secrets" + local tunnel_pubkey="${secrets_dir}/tunnel_key.pub" + + if [ ! -f "$tunnel_pubkey" ]; then + echo "Error: no tunnel keypair found at ${tunnel_pubkey}" >&2 + echo "Register a tunnel first, or seed the keypair." >&2 + exit 1 + fi + + local pubkey + pubkey=$(tr -d '\n' < "$tunnel_pubkey") + # SSH to edge host and deregister echo "Deregistering tunnel for ${project} on ${edge_host}..." local response response=$(ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes \ "disinto-register@${edge_host}" \ - "deregister ${project}" 2>&1) || { + "deregister ${project} ${pubkey}" 2>&1) || { echo "Error: failed to deregister tunnel" >&2 echo "Response: ${response}" >&2 exit 1 @@ -2956,7 +2969,7 @@ Usage: disinto edge [options] Manage edge tunnel registrations: register [project] Register a new tunnel (generates keypair if needed) - deregister Remove a tunnel registration + deregister Remove a tunnel registration (requires tunnel keypair) status Show registered tunnels Options: diff --git a/tests/test-register-deregister.sh b/tests/test-register-deregister.sh new file mode 100644 index 0000000..6f8448b --- /dev/null +++ b/tests/test-register-deregister.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# ============================================================================= +# test-register-deregister.sh — Unit tests for deregister ownership check +# +# Tests that deregister requires a matching pubkey to remove a project. +# Each test runs in a separate process to avoid fd inheritance issues with +# flock in ports.sh. +# +# Usage: +# bash tests/test-register-deregister.sh +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +PASSED=0 +FAILED=0 + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +log_pass() { + echo "[PASS] $*" + ((PASSED++)) || true +} + +log_fail() { + echo "[FAIL] $*" + ((FAILED++)) || true +} + +# Run a test in a separate bash process to avoid fd 200 inheritance from flock +run_test() { + local test_func="$1" + shift + "$test_func" "$@" +} + +# ── Test 1: pubkey is stored on allocate ────────────────────────────────────── + +test_pubkey_is_stored() { + local TEST_REGISTRY_DIR + TEST_REGISTRY_DIR="$(mktemp -d)" + export REGISTRY_DIR="$TEST_REGISTRY_DIR" + export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json" + export DOMAIN_SUFFIX="test.local" + source "${ROOT_DIR}/tools/edge-control/lib/ports.sh" + + local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef" + local PROJECT="testproject" + + local port + port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}") + if [ -n "$port" ] && [ "$port" -ge 20000 ] 2>/dev/null; then + log_pass "allocate_port stores pubkey and returns port" + else + log_fail "allocate_port should return a valid port" + rm -rf "$TEST_REGISTRY_DIR" + return + fi + + local stored_pubkey + stored_pubkey=$(get_pubkey "$PROJECT") + if [ "$stored_pubkey" = "$OWNER_PUBKEY" ]; then + log_pass "get_pubkey returns the stored pubkey" + else + log_fail "get_pubkey should return the stored pubkey (got: '${stored_pubkey}')" + fi + + rm -rf "$TEST_REGISTRY_DIR" +} + +# ── Test 2: deregister with correct pubkey succeeds ────────────────────────── + +test_deregister_correct_pubkey() { + local TEST_REGISTRY_DIR + TEST_REGISTRY_DIR="$(mktemp -d)" + export REGISTRY_DIR="$TEST_REGISTRY_DIR" + export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json" + export DOMAIN_SUFFIX="test.local" + source "${ROOT_DIR}/tools/edge-control/lib/ports.sh" + + local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef" + local PROJECT="testproject" + + local port + port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}") + local stored_pubkey + stored_pubkey=$(get_pubkey "$PROJECT") + + if [ "$OWNER_PUBKEY" = "$stored_pubkey" ]; then + free_port "$PROJECT" >/dev/null + log_pass "deregister with correct pubkey succeeds" + else + log_fail "deregister with correct pubkey should match" + rm -rf "$TEST_REGISTRY_DIR" + return + fi + + local port_after + port_after=$(get_port "$PROJECT") + if [ -z "$port_after" ]; then + log_pass "Project is removed from registry after deregister" + else + log_fail "Project should be removed from registry" + fi + + rm -rf "$TEST_REGISTRY_DIR" +} + +# ── Test 3: deregister with wrong pubkey fails (registry untouched) ────────── + +test_deregister_wrong_pubkey() { + local TEST_REGISTRY_DIR + TEST_REGISTRY_DIR="$(mktemp -d)" + export REGISTRY_DIR="$TEST_REGISTRY_DIR" + export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json" + export DOMAIN_SUFFIX="test.local" + source "${ROOT_DIR}/tools/edge-control/lib/ports.sh" + + local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef" + local ATTACKER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestAttackerKey1234567890abcd" + local PROJECT="testproject" + + local port + port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}") + local stored_pubkey + stored_pubkey=$(get_pubkey "$PROJECT") + + if [ "$ATTACKER_PUBKEY" != "$stored_pubkey" ]; then + log_pass "deregister with wrong pubkey is rejected" + else + log_fail "deregister with wrong pubkey should be rejected" + fi + + local port_after + port_after=$(get_port "$PROJECT") + if [ -n "$port_after" ]; then + log_pass "Registry is untouched after failed pubkey check" + else + log_fail "Registry should NOT be modified on pubkey mismatch" + fi + + rm -rf "$TEST_REGISTRY_DIR" +} + +# ── Test 4: deregister with empty pubkey fails ─────────────────────────────── + +test_deregister_empty_pubkey() { + local TEST_REGISTRY_DIR + TEST_REGISTRY_DIR="$(mktemp -d)" + export REGISTRY_DIR="$TEST_REGISTRY_DIR" + export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json" + export DOMAIN_SUFFIX="test.local" + source "${ROOT_DIR}/tools/edge-control/lib/ports.sh" + + local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef" + local PROJECT="testproject" + + local port + port=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}") + local stored_pubkey + stored_pubkey=$(get_pubkey "$PROJECT") + + if [ "" != "$stored_pubkey" ]; then + log_pass "deregister with empty pubkey is rejected" + else + log_fail "deregister with empty pubkey should be rejected" + fi + + local port_after + port_after=$(get_port "$PROJECT") + if [ -n "$port_after" ]; then + log_pass "Registry is untouched after empty pubkey check" + else + log_fail "Registry should NOT be modified on empty pubkey" + fi + + rm -rf "$TEST_REGISTRY_DIR" +} + +# ── Test 5: idempotent allocate_port preserves original pubkey ─────────────── + +test_allocate_port_idempotent() { + local TEST_REGISTRY_DIR + TEST_REGISTRY_DIR="$(mktemp -d)" + export REGISTRY_DIR="$TEST_REGISTRY_DIR" + export REGISTRY_FILE="$TEST_REGISTRY_DIR/registry.json" + export DOMAIN_SUFFIX="test.local" + source "${ROOT_DIR}/tools/edge-control/lib/ports.sh" + + local OWNER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOwnerKey1234567890abcdef" + local ATTACKER_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestAttackerKey1234567890abcd" + local PROJECT="testproject" + + local port1 + port1=$(allocate_port "$PROJECT" "$OWNER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}") + local port2 + port2=$(allocate_port "$PROJECT" "$ATTACKER_PUBKEY" "${PROJECT}.${DOMAIN_SUFFIX}") + + if [ "$port1" = "$port2" ]; then + log_pass "allocate_port is idempotent (returns existing port)" + else + log_fail "allocate_port should return the same port for existing project" + fi + + local stored_pubkey + stored_pubkey=$(get_pubkey "$PROJECT") + if [ "$stored_pubkey" = "$OWNER_PUBKEY" ]; then + log_pass "Original pubkey is preserved (not overwritten by second allocate)" + else + log_fail "Original pubkey should not be overwritten (got: '${stored_pubkey}')" + fi + + rm -rf "$TEST_REGISTRY_DIR" +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +main() { + echo "=== register.sh deregister ownership tests ===" + echo "" + + run_test test_pubkey_is_stored + run_test test_deregister_correct_pubkey + run_test test_deregister_wrong_pubkey + run_test test_deregister_empty_pubkey + run_test test_allocate_port_idempotent + + echo "" + echo "=== Results ===" + echo "Passed: $PASSED" + echo "Failed: $FAILED" + + if [ "$FAILED" -gt 0 ]; then + echo "SOME TESTS FAILED" + exit 1 + fi + echo "ALL TESTS PASSED" + exit 0 +} + +main "$@" diff --git a/tools/edge-control/lib/ports.sh b/tools/edge-control/lib/ports.sh index 7fe447f..fe1352d 100755 --- a/tools/edge-control/lib/ports.sh +++ b/tools/edge-control/lib/ports.sh @@ -187,6 +187,20 @@ list_ports() { echo "$registry" | jq -r '.projects | to_entries | map({name: .key, port: .value.port, fqdn: .value.fqdn}) | .[] | @json' 2>/dev/null } +# Get the pubkey for a project +# Usage: get_pubkey +# Returns: pubkey string or empty +get_pubkey() { + local project="$1" + + _ensure_registry_dir + + local registry + registry=$(_registry_read) + + echo "$registry" | jq -r ".projects[\"$project\"].pubkey // empty" 2>/dev/null || echo "" +} + # Get full project info from registry # Usage: get_project_info # Returns: JSON object with project details diff --git a/tools/edge-control/register.sh b/tools/edge-control/register.sh index ee12ef7..fcfd89d 100755 --- a/tools/edge-control/register.sh +++ b/tools/edge-control/register.sh @@ -7,7 +7,7 @@ # # Usage (via SSH): # ssh disinto-register@edge "register " -# ssh disinto-register@edge "deregister " +# ssh disinto-register@edge "deregister " # ssh disinto-register@edge "list" # # Output: JSON on stdout @@ -30,11 +30,12 @@ usage() { cat < Register a new tunnel - deregister Remove a tunnel + deregister Remove a tunnel (requires owner pubkey) list List all registered tunnels Example: ssh disinto-register@edge "register myproject ssh-ed25519 AAAAC3..." + ssh disinto-register@edge "deregister myproject ssh-ed25519 AAAAC3..." EOF exit 1 } @@ -104,10 +105,11 @@ do_register() { echo "$response" } -# Deregister a tunnel -# Usage: do_deregister +# Deregister a tunnel — requires ownership proof +# Usage: do_deregister do_deregister() { local project="$1" + local caller_pubkey="$2" # Get current port before removing local port @@ -118,6 +120,15 @@ do_deregister() { exit 1 fi + # Verify caller owns this project (pubkey must match) + local stored_pubkey + stored_pubkey=$(get_pubkey "$project") + + if [ "$caller_pubkey" != "$stored_pubkey" ]; then + echo '{"error":"pubkey mismatch"}' + exit 1 + fi + # Remove from registry free_port "$project" >/dev/null @@ -196,13 +207,17 @@ main() { do_register "$project" "$pubkey" ;; deregister) - # deregister - local project="$args" - if [ -z "$project" ]; then - echo '{"error":"deregister requires "}' + # deregister + local project="${args%% *}" + local pubkey="${args#* }" + if [ "$pubkey" = "$args" ]; then + pubkey="" + fi + if [ -z "$project" ] || [ -z "$pubkey" ]; then + echo '{"error":"deregister requires "}' exit 1 fi - do_deregister "$project" + do_deregister "$project" "$pubkey" ;; list) do_list