direxio-deployer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +92 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -0
  4. package/README_zh.md +218 -0
  5. package/SKILL.md +722 -0
  6. package/agents/README.md +25 -0
  7. package/agents/openai.yaml +12 -0
  8. package/bin/direxio-deployer.mjs +375 -0
  9. package/package.json +28 -0
  10. package/references/agent-targets.md +128 -0
  11. package/references/architecture.md +44 -0
  12. package/references/bug-history.md +78 -0
  13. package/references/deployment-lessons.md +218 -0
  14. package/references/deployment-optimization-audit.md +317 -0
  15. package/references/deployment-workflow.md +341 -0
  16. package/references/iam-policy.json +52 -0
  17. package/references/runtime-wiring.md +209 -0
  18. package/references/state-machine.md +46 -0
  19. package/references/token-refresh.md +81 -0
  20. package/references/tooling.md +106 -0
  21. package/references/troubleshooting.md +26 -0
  22. package/references/user-journey.md +75 -0
  23. package/references/verification-recovery.md +84 -0
  24. package/references/voip-turn-runbook.md +154 -0
  25. package/references/windows-deployment-notes.md +119 -0
  26. package/scripts/aws-credentials.sh +195 -0
  27. package/scripts/cloud-init/Caddyfile +48 -0
  28. package/scripts/cloud-init/docker-compose.yml +125 -0
  29. package/scripts/cloud-init/init-tokens.sh +238 -0
  30. package/scripts/cloud-init/user-data.yaml +40 -0
  31. package/scripts/destroy.ps1 +77 -0
  32. package/scripts/destroy.sh +589 -0
  33. package/scripts/lib/aws.sh +73 -0
  34. package/scripts/lib/domain.sh +175 -0
  35. package/scripts/lib/operation_report.sh +240 -0
  36. package/scripts/lib/ops.sh +230 -0
  37. package/scripts/lib/paths.sh +35 -0
  38. package/scripts/lib/state.sh +137 -0
  39. package/scripts/mcp-tools-list.mjs +95 -0
  40. package/scripts/orchestrate.ps1 +112 -0
  41. package/scripts/orchestrate.sh +1126 -0
  42. package/scripts/phases/s0_prereq_aws.sh +39 -0
  43. package/scripts/phases/s1_preflight.sh +72 -0
  44. package/scripts/phases/s2_domain.sh +103 -0
  45. package/scripts/phases/s3_provision.sh +421 -0
  46. package/scripts/phases/s4_bootstrap_stack.sh +38 -0
  47. package/scripts/phases/s5_init_tokens.sh +118 -0
  48. package/scripts/phases/s6_wire_local.sh +1435 -0
  49. package/scripts/phases/s7_verify_e2e.sh +136 -0
  50. package/scripts/pricing-estimate.sh +256 -0
  51. package/scripts/render/render-userdata.sh +86 -0
  52. package/scripts/reset-app-data.sh +40 -0
  53. package/scripts/update.sh +30 -0
  54. package/tests/aws_credentials_test.sh +139 -0
  55. package/tests/connect_daemon_runtime_check_test.sh +120 -0
  56. package/tests/default_paths_test.sh +58 -0
  57. package/tests/destroy_local_bridge_test.sh +154 -0
  58. package/tests/destroy_root_identity_test.sh +91 -0
  59. package/tests/destroy_route53_zone_test.sh +80 -0
  60. package/tests/domain_authoritative_dns_test.sh +49 -0
  61. package/tests/mcp_doctor_runtime_check_test.sh +86 -0
  62. package/tests/mcp_smoke_runtime_check_test.sh +121 -0
  63. package/tests/mcp_tools_runtime_check_test.sh +123 -0
  64. package/tests/npm_skill_distribution_test.sh +95 -0
  65. package/tests/operation_report_test.sh +258 -0
  66. package/tests/orchestrate_status_recovery_test.sh +91 -0
  67. package/tests/phase_timeout_test.sh +88 -0
  68. package/tests/pricing_estimate_test.sh +159 -0
  69. package/tests/render_userdata_remote_nodes_test.sh +40 -0
  70. package/tests/root_volume_tracking_test.sh +41 -0
  71. package/tests/route53_overwrite_guard_test.sh +86 -0
  72. package/tests/route53_zone_auto_create_test.sh +66 -0
  73. package/tests/runtime_summary_check_test.sh +203 -0
  74. package/tests/s6_wire_local_test.sh +405 -0
  75. package/tests/skill_structure_test.sh +298 -0
  76. package/tests/update_reset_ops_test.sh +230 -0
  77. package/tests/user_confirmation_gates_test.sh +152 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bash
2
+ # aws-credentials.sh - import/verify AWS deployment credentials.
3
+ set -euo pipefail
4
+
5
+ HERE=$(cd "$(dirname "$0")" && pwd)
6
+ # shellcheck disable=SC1090
7
+ source "$HERE/lib/aws.sh"
8
+
9
+ usage() {
10
+ cat >&2 <<'EOF'
11
+ Usage:
12
+ scripts/aws-credentials.sh import-csv <aws-access-key.csv> [profile] [region]
13
+ scripts/aws-credentials.sh verify [profile]
14
+
15
+ Default profile: direxio-deployer
16
+ Root identities are allowed when the operator explicitly chooses them.
17
+ EOF
18
+ }
19
+
20
+ aws_credentials_file() {
21
+ printf '%s\n' "${AWS_SHARED_CREDENTIALS_FILE:-$HOME/.aws/credentials}"
22
+ }
23
+
24
+ aws_config_file() {
25
+ printf '%s\n' "${AWS_CONFIG_FILE:-$HOME/.aws/config}"
26
+ }
27
+
28
+ ensure_aws_file() {
29
+ local file=$1
30
+ mkdir -p "$(dirname "$file")"
31
+ chmod 700 "$(dirname "$file")" 2>/dev/null || true
32
+ [ -f "$file" ] || : > "$file"
33
+ chmod 600 "$file" 2>/dev/null || true
34
+ }
35
+
36
+ csv_column_index() {
37
+ local header=$1 wanted=$2
38
+ awk -v header="$header" -v wanted="$wanted" '
39
+ BEGIN {
40
+ n = split(header, cols, ",")
41
+ for (i = 1; i <= n; i++) {
42
+ gsub(/^"|"$/, "", cols[i])
43
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", cols[i])
44
+ if (tolower(cols[i]) == tolower(wanted)) {
45
+ print i
46
+ exit
47
+ }
48
+ }
49
+ }
50
+ '
51
+ }
52
+
53
+ csv_field() {
54
+ local row=$1 index=$2
55
+ awk -v row="$row" -v idx="$index" '
56
+ BEGIN {
57
+ n = split(row, cols, ",")
58
+ value = cols[idx]
59
+ gsub(/^"|"$/, "", value)
60
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
61
+ print value
62
+ }
63
+ '
64
+ }
65
+
66
+ read_csv_credentials() {
67
+ local csv=$1 header row ak_i sk_i token_i access_key secret_key session_token
68
+ [ -f "$csv" ] || {
69
+ echo "CSV file not found: $csv" >&2
70
+ return 1
71
+ }
72
+ IFS= read -r header < "$csv" || return 1
73
+ row=$(tail -n +2 "$csv" | sed '/^[[:space:]]*$/d' | head -n 1)
74
+ [ -n "$row" ] || {
75
+ echo "CSV has no credential row: $csv" >&2
76
+ return 1
77
+ }
78
+ ak_i=$(csv_column_index "$header" "Access key ID")
79
+ [ -n "$ak_i" ] || ak_i=$(csv_column_index "$header" "Access key id")
80
+ sk_i=$(csv_column_index "$header" "Secret access key")
81
+ token_i=$(csv_column_index "$header" "Session token")
82
+ [ -n "$ak_i" ] && [ -n "$sk_i" ] || {
83
+ echo "CSV must contain Access key ID and Secret access key columns" >&2
84
+ return 1
85
+ }
86
+ access_key=$(csv_field "$row" "$ak_i")
87
+ secret_key=$(csv_field "$row" "$sk_i")
88
+ session_token=""
89
+ [ -n "$token_i" ] && session_token=$(csv_field "$row" "$token_i")
90
+ [ -n "$access_key" ] && [ -n "$secret_key" ] || {
91
+ echo "CSV credential values are incomplete" >&2
92
+ return 1
93
+ }
94
+ printf '%s\t%s\t%s\n' "$access_key" "$secret_key" "$session_token"
95
+ }
96
+
97
+ profile_header() {
98
+ local profile=$1 config=${2:-0}
99
+ if [ "$config" = "1" ] && [ "$profile" != "default" ]; then
100
+ printf 'profile %s\n' "$profile"
101
+ else
102
+ printf '%s\n' "$profile"
103
+ fi
104
+ }
105
+
106
+ remove_profile_section() {
107
+ local file=$1 header=$2 tmp
108
+ tmp="$file.tmp.$$"
109
+ awk -v target="[$header]" '
110
+ /^\[/ {
111
+ skip = ($0 == target)
112
+ }
113
+ !skip { print }
114
+ ' "$file" > "$tmp"
115
+ mv "$tmp" "$file"
116
+ chmod 600 "$file" 2>/dev/null || true
117
+ }
118
+
119
+ write_profile() {
120
+ local profile=$1 region=$2 access_key=$3 secret_key=$4 session_token=${5:-}
121
+ local credentials config cred_header config_header
122
+ credentials=$(aws_credentials_file)
123
+ config=$(aws_config_file)
124
+ ensure_aws_file "$credentials"
125
+ ensure_aws_file "$config"
126
+ cred_header=$(profile_header "$profile" 0)
127
+ config_header=$(profile_header "$profile" 1)
128
+ remove_profile_section "$credentials" "$cred_header"
129
+ remove_profile_section "$config" "$config_header"
130
+ {
131
+ printf '[%s]\n' "$cred_header"
132
+ printf 'aws_access_key_id = %s\n' "$access_key"
133
+ printf 'aws_secret_access_key = %s\n' "$secret_key"
134
+ [ -n "$session_token" ] && printf 'aws_session_token = %s\n' "$session_token"
135
+ printf '\n'
136
+ } >> "$credentials"
137
+ {
138
+ printf '[%s]\n' "$config_header"
139
+ printf 'region = %s\n' "$region"
140
+ printf 'output = json\n\n'
141
+ } >> "$config"
142
+ chmod 600 "$credentials" "$config" 2>/dev/null || true
143
+ }
144
+
145
+ verify_env_identity() {
146
+ local access_key=$1 secret_key=$2 session_token=${3:-} arn
147
+ arn=$(AWS_ACCESS_KEY_ID="$access_key" AWS_SECRET_ACCESS_KEY="$secret_key" AWS_SESSION_TOKEN="$session_token" aws_identity_arn)
148
+ [ -n "$arn" ] && [ "$arn" != "None" ] || {
149
+ echo "AWS credentials could not be verified with sts get-caller-identity" >&2
150
+ return 1
151
+ }
152
+ printf '%s\n' "$arn"
153
+ }
154
+
155
+ verify_profile() {
156
+ local profile=$1 arn root_identity=false
157
+ arn=$(AWS_PROFILE="$profile" aws_identity_arn)
158
+ [ -n "$arn" ] && [ "$arn" != "None" ] || {
159
+ echo "AWS profile could not be verified with sts get-caller-identity: $profile" >&2
160
+ return 1
161
+ }
162
+ aws_arn_is_root "$arn" && root_identity=true
163
+ printf 'AWS identity verified: profile=%s root=%s arn=%s\n' "$profile" "$root_identity" "$(aws_redact_arn "$arn")"
164
+ }
165
+
166
+ cmd_import_csv() {
167
+ local csv=${1:-} profile=${2:-direxio-deployer} region=${3:-${AWS_DEFAULT_REGION:-${AWS_REGION:-us-east-1}}}
168
+ local access_key secret_key session_token arn root_identity=false
169
+ [ -n "$csv" ] || {
170
+ usage
171
+ return 1
172
+ }
173
+ IFS=$'\t' read -r access_key secret_key session_token < <(read_csv_credentials "$csv")
174
+ arn=$(verify_env_identity "$access_key" "$secret_key" "$session_token")
175
+ aws_arn_is_root "$arn" && root_identity=true
176
+ write_profile "$profile" "$region" "$access_key" "$secret_key" "$session_token"
177
+ printf 'AWS credentials imported: profile=%s region=%s root=%s arn=%s\n' "$profile" "$region" "$root_identity" "$(aws_redact_arn "$arn")"
178
+ printf 'Credentials file: %s (0600)\n' "$(aws_credentials_file)"
179
+ printf 'Config file: %s (0600)\n' "$(aws_config_file)"
180
+ }
181
+
182
+ case "${1:-}" in
183
+ import-csv)
184
+ shift
185
+ cmd_import_csv "$@"
186
+ ;;
187
+ verify)
188
+ shift
189
+ verify_profile "${1:-${AWS_PROFILE:-direxio-deployer}}"
190
+ ;;
191
+ *)
192
+ usage
193
+ exit 1
194
+ ;;
195
+ esac
@@ -0,0 +1,48 @@
1
+ # Caddyfile - automatic TLS reverse proxy for the Direxio message server.
2
+ #
3
+ # Public exposure is limited to 443 and 80 for ACME redirects. The message
4
+ # server listens only inside the compose network on port 8008.
5
+
6
+ {$DOMAIN} {
7
+ handle /.well-known/matrix/server {
8
+ header Content-Type application/json
9
+ respond `{"m.server":"{$DOMAIN}:443"}` 200
10
+ }
11
+ handle /.well-known/matrix/client {
12
+ header Content-Type application/json
13
+ header Access-Control-Allow-Origin *
14
+ respond `{"m.homeserver":{"base_url":"https://{$DOMAIN}"}}` 200
15
+ }
16
+
17
+ handle_path /.well-known/portal/* {
18
+ header Access-Control-Allow-Origin *
19
+ header Access-Control-Allow-Methods "GET, OPTIONS"
20
+ header Access-Control-Allow-Headers "Content-Type"
21
+ @portal_options method OPTIONS
22
+ respond @portal_options 204
23
+ root * /srv/p2p/wellknown
24
+ file_server
25
+ }
26
+
27
+ handle /healthz {
28
+ rewrite * /_p2p/health
29
+ reverse_proxy message-server:8008
30
+ }
31
+
32
+ handle /_matrix/* {
33
+ reverse_proxy message-server:8008
34
+ }
35
+ handle /_dendrite/* {
36
+ reverse_proxy message-server:8008
37
+ }
38
+ handle /_synapse/* {
39
+ reverse_proxy message-server:8008
40
+ }
41
+ handle /_p2p/* {
42
+ reverse_proxy message-server:8008
43
+ }
44
+
45
+ handle {
46
+ reverse_proxy message-server:8008
47
+ }
48
+ }
@@ -0,0 +1,125 @@
1
+ # docker-compose.yml - cloud-side Direxio message-server stack.
2
+ #
3
+ # Layers: Caddy (public 443/TLS) -> Direxio message-server (Matrix + P2P API).
4
+ # PostgreSQL 18 persists Matrix and P2P business tables. The local agent bridge
5
+ # process is not started in the cloud by default.
6
+
7
+ networks:
8
+ p2p-net:
9
+
10
+ volumes:
11
+ postgres-data:
12
+ message-config:
13
+ message-data:
14
+ caddy-data:
15
+ caddy-config:
16
+
17
+ services:
18
+ postgres:
19
+ image: postgres:18-alpine
20
+ networks: [p2p-net]
21
+ environment:
22
+ POSTGRES_USER: direxio_message_server
23
+ POSTGRES_PASSWORD: direxio_message_server
24
+ POSTGRES_DB: direxio_message_server
25
+ volumes:
26
+ - postgres-data:/var/lib/postgresql
27
+ healthcheck:
28
+ test: ["CMD-SHELL", "pg_isready -U direxio_message_server -d direxio_message_server"]
29
+ interval: 5s
30
+ timeout: 3s
31
+ retries: 30
32
+ restart: unless-stopped
33
+
34
+ message-init:
35
+ image: ${MESSAGE_SERVER_IMAGE}
36
+ networks: [p2p-net]
37
+ depends_on:
38
+ postgres:
39
+ condition: service_healthy
40
+ environment:
41
+ TURN_SECRET: ${TURN_SECRET}
42
+ entrypoint: ["/bin/sh", "-c"]
43
+ command:
44
+ - |
45
+ set -eu
46
+ mkdir -p /etc/direxio-message-server /var/direxio-message-server /var/direxio-message-server/p2p
47
+ if [ ! -f /etc/direxio-message-server/matrix_key.pem ]; then
48
+ /usr/bin/generate-keys -private-key /etc/direxio-message-server/matrix_key.pem
49
+ fi
50
+ CFG=/etc/direxio-message-server/message-server.yaml
51
+ DB="postgres://direxio_message_server:direxio_message_server@postgres/direxio_message_server?sslmode=disable"
52
+ /usr/bin/generate-config -dir /var/direxio-message-server -db "$$DB" -server "${DOMAIN}" > "$$CFG"
53
+ printf '\nclient_api:\n turn:\n turn_shared_secret: "%s"\n turn_user_lifetime: "24h"\n turn_uris:\n - "turn:%s:3478?transport=udp"\n - "turn:%s:3478?transport=tcp"\n' "$$TURN_SECRET" "${DOMAIN}" "${DOMAIN}" >> "$$CFG"
54
+ volumes:
55
+ - message-config:/etc/direxio-message-server
56
+ - message-data:/var/direxio-message-server
57
+ restart: "no"
58
+
59
+ message-server:
60
+ image: ${MESSAGE_SERVER_IMAGE}
61
+ networks: [p2p-net]
62
+ depends_on:
63
+ postgres:
64
+ condition: service_healthy
65
+ message-init:
66
+ condition: service_completed_successfully
67
+ environment:
68
+ P2P_PORTAL_CREDENTIALS_FILE: /var/direxio-message-server/p2p/bootstrap.json
69
+ P2P_PORTAL_PASSWORD: ${P2P_PORTAL_PASSWORD}
70
+ command:
71
+ - --config
72
+ - /etc/direxio-message-server/message-server.yaml
73
+ - --http-bind-address
74
+ - :8008
75
+ volumes:
76
+ - message-config:/etc/direxio-message-server
77
+ - message-data:/var/direxio-message-server
78
+ - /opt/p2p:/opt/p2p
79
+ healthcheck:
80
+ test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:8008/_p2p/health >/dev/null"]
81
+ interval: 10s
82
+ timeout: 5s
83
+ retries: 30
84
+ start_period: 15s
85
+ restart: unless-stopped
86
+
87
+ caddy:
88
+ image: caddy:2
89
+ networks: [p2p-net]
90
+ depends_on:
91
+ message-server:
92
+ condition: service_healthy
93
+ ports:
94
+ - "80:80"
95
+ - "443:443"
96
+ environment:
97
+ - DOMAIN=${DOMAIN}
98
+ - ACME_EMAIL=${ACME_EMAIL}
99
+ volumes:
100
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
101
+ - caddy-data:/data
102
+ - caddy-config:/config
103
+ - /opt/p2p:/srv/p2p:ro
104
+ restart: unless-stopped
105
+
106
+ coturn:
107
+ image: coturn/coturn:latest
108
+ network_mode: host
109
+ restart: unless-stopped
110
+ command:
111
+ - -n
112
+ - --realm=${DOMAIN}
113
+ - --listening-port=3478
114
+ - --min-port=49160
115
+ - --max-port=49200
116
+ - --external-ip=${PUBLIC_IP}
117
+ - --use-auth-secret
118
+ - --static-auth-secret=${TURN_SECRET}
119
+ - --no-cli
120
+ - --no-tls
121
+ - --no-dtls
122
+ - --no-multicast-peers
123
+ - --denied-peer-ip=10.0.0.0-10.255.255.255
124
+ - --denied-peer-ip=172.16.0.0-172.31.255.255
125
+ - --denied-peer-ip=192.168.0.0-192.168.255.255
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+ # init-tokens.sh - wait for message-server bootstrap credentials after compose is up.
3
+ set -euo pipefail
4
+
5
+ P2P_DIR=${P2P_DIR:-/opt/p2p}
6
+ COMPOSE="docker compose -f ${P2P_DIR}/docker-compose.yml --env-file ${P2P_DIR}/.env"
7
+ DOMAIN=${DOMAIN:?DOMAIN is required (e.g. __DOMAIN__)}
8
+ CONTAINER_BOOTSTRAP_FILE=${CONTAINER_BOOTSTRAP_FILE:-/var/direxio-message-server/p2p/bootstrap.json}
9
+ BOOTSTRAP_FILE=${BOOTSTRAP_FILE:-/opt/p2p/bootstrap.json}
10
+ WELLKNOWN_DIR=${WELLKNOWN_DIR:-/opt/p2p/wellknown}
11
+
12
+ log() { echo "[init-tokens] $*" >&2; }
13
+
14
+ env_string() {
15
+ local key=$1
16
+ grep -E "^${key}=" "${P2P_DIR}/.env" 2>/dev/null \
17
+ | tail -1 \
18
+ | cut -d= -f2- \
19
+ || true
20
+ }
21
+
22
+ json_string() {
23
+ local key=$1 file=$2
24
+ grep -oE '"'"$key"'"[[:space:]]*:[[:space:]]*"[^"]+"' "$file" 2>/dev/null \
25
+ | head -1 \
26
+ | sed -E 's/.*:[[:space:]]*"([^"]+)".*/\1/' \
27
+ || true
28
+ }
29
+
30
+ matrix_room_path() {
31
+ printf '%s' "$1" | sed 's/%/%25/g; s/!/%21/g; s/:/%3A/g'
32
+ }
33
+
34
+ container_post_json() {
35
+ local path=$1 json=$2 token=${3:-}
36
+ if [ -n "$token" ]; then
37
+ $COMPOSE exec -T message-server wget -q -O - \
38
+ --header='Content-Type: application/json' \
39
+ --header="Authorization: Bearer ${token}" \
40
+ --post-data="$json" \
41
+ "http://127.0.0.1:8008${path}"
42
+ else
43
+ $COMPOSE exec -T message-server wget -q -O - \
44
+ --header='Content-Type: application/json' \
45
+ --post-data="$json" \
46
+ "http://127.0.0.1:8008${path}"
47
+ fi
48
+ }
49
+
50
+ wait_for_message_server() {
51
+ log "waiting for message-server /_p2p/health ..."
52
+ for i in $(seq 1 90); do
53
+ if $COMPOSE exec -T message-server wget -q -O - http://127.0.0.1:8008/_p2p/health >/dev/null 2>&1; then
54
+ log "message-server is healthy."
55
+ return 0
56
+ fi
57
+ sleep 5
58
+ done
59
+ log "message-server did not become healthy in time"
60
+ return 1
61
+ }
62
+
63
+ copy_bootstrap_file() {
64
+ local tmp
65
+ tmp=$(mktemp)
66
+ if ! $COMPOSE exec -T message-server sh -c "test -s '$CONTAINER_BOOTSTRAP_FILE' && cat '$CONTAINER_BOOTSTRAP_FILE'" > "$tmp" 2>/dev/null; then
67
+ rm -f "$tmp"
68
+ return 1
69
+ fi
70
+ install -m 0600 "$tmp" "$BOOTSTRAP_FILE"
71
+ rm -f "$tmp"
72
+ }
73
+
74
+ bootstrap_has_core_credentials() {
75
+ local password agent_token access_token file=$1
76
+ password=$(json_string password "$file")
77
+ agent_token=$(json_string agent_token "$file")
78
+ access_token=$(json_string access_token "$file")
79
+ [ -n "$password" ] && [ -n "$agent_token" ] && [ -n "$access_token" ]
80
+ }
81
+
82
+ bootstrap_has_real_agent_room() {
83
+ local room file=$1
84
+ room=$(json_string agent_room_id "$file")
85
+ case "$room" in
86
+ !agent:*|"") return 1 ;;
87
+ !*) return 0 ;;
88
+ *) return 1 ;;
89
+ esac
90
+ }
91
+
92
+ bootstrap_portal() {
93
+ local password tmp
94
+ password=${P2P_PORTAL_PASSWORD:-}
95
+ [ -n "$password" ] || password=$(env_string P2P_PORTAL_PASSWORD)
96
+ if [ -z "$password" ]; then
97
+ log "FATAL: P2P_PORTAL_PASSWORD is missing from environment and ${P2P_DIR}/.env"
98
+ return 1
99
+ fi
100
+ tmp=$(mktemp)
101
+ if container_post_json "/_p2p/command" "{\"action\":\"portal.bootstrap\",\"params\":{\"password\":\"${password}\"}}" > "$tmp" 2>/dev/null; then
102
+ log "portal.bootstrap accepted."
103
+ rm -f "$tmp"
104
+ return 0
105
+ fi
106
+ log "portal.bootstrap failed: $(head -c 160 "$tmp" 2>/dev/null)"
107
+ rm -f "$tmp"
108
+ return 1
109
+ }
110
+
111
+ wait_for_core_bootstrap_file() {
112
+ local password agent_token access_token
113
+ log "waiting for ${CONTAINER_BOOTSTRAP_FILE} ..."
114
+ for i in $(seq 1 90); do
115
+ if copy_bootstrap_file; then
116
+ if bootstrap_has_core_credentials "$BOOTSTRAP_FILE"; then
117
+ log "credentials file is ready."
118
+ return 0
119
+ fi
120
+ log "credentials file exists but is missing password/access/agent token"
121
+ fi
122
+ sleep 5
123
+ done
124
+ log "FATAL: ${CONTAINER_BOOTSTRAP_FILE} was not written with complete credentials in time."
125
+ return 1
126
+ }
127
+
128
+ copy_host_bootstrap_to_container() {
129
+ $COMPOSE exec -T message-server sh -c "mkdir -p '$(dirname "$CONTAINER_BOOTSTRAP_FILE")' && cat > '$CONTAINER_BOOTSTRAP_FILE' && chmod 600 '$CONTAINER_BOOTSTRAP_FILE'" < "$BOOTSTRAP_FILE"
130
+ }
131
+
132
+ write_agent_room_to_bootstrap() {
133
+ local room_id=$1
134
+ python3 - "$BOOTSTRAP_FILE" "$room_id" "$DOMAIN" <<'PY'
135
+ import json
136
+ import sys
137
+
138
+ path, room_id, domain = sys.argv[1], sys.argv[2], sys.argv[3]
139
+ with open(path, "r", encoding="utf-8") as fh:
140
+ data = json.load(fh)
141
+ data["agent_room_id"] = room_id
142
+ data.setdefault("domain", domain)
143
+ with open(path, "w", encoding="utf-8") as fh:
144
+ json.dump(data, fh, separators=(",", ":"))
145
+ fh.write("\n")
146
+ PY
147
+ chmod 0600 "$BOOTSTRAP_FILE"
148
+ copy_host_bootstrap_to_container
149
+ }
150
+
151
+ ensure_agent_room() {
152
+ local owner_token agent_user session room_resp join_resp agent_token room_id room_path
153
+ if copy_bootstrap_file && bootstrap_has_real_agent_room "$BOOTSTRAP_FILE"; then
154
+ log "agent_room_id is already present."
155
+ return 0
156
+ fi
157
+
158
+ owner_token=$(json_string access_token "$BOOTSTRAP_FILE")
159
+ if [ -z "$owner_token" ]; then
160
+ log "FATAL: access_token is missing; cannot create agent room"
161
+ return 1
162
+ fi
163
+ agent_user="@agent:${DOMAIN}"
164
+ session=$(mktemp)
165
+ if ! container_post_json "/_p2p/command" '{"action":"agent.matrix_session.create","params":{"device_id":"DIREXIO_DEPLOY_BOOTSTRAP"}}' "$owner_token" > "$session" 2>/dev/null; then
166
+ log "FATAL: agent.matrix_session.create failed: $(head -c 160 "$session" 2>/dev/null)"
167
+ rm -f "$session"
168
+ return 1
169
+ fi
170
+ agent_token=$(json_string access_token "$session")
171
+ if [ -z "$agent_token" ]; then
172
+ log "FATAL: agent.matrix_session.create did not return access_token: $(head -c 160 "$session" 2>/dev/null)"
173
+ rm -f "$session"
174
+ return 1
175
+ fi
176
+ rm -f "$session"
177
+
178
+ room_resp=$(mktemp)
179
+ if ! container_post_json "/_matrix/client/v3/createRoom" "{\"preset\":\"private_chat\",\"visibility\":\"private\",\"name\":\"Direxio Agent\",\"invite\":[\"${agent_user}\"],\"is_direct\":false}" "$owner_token" > "$room_resp" 2>/dev/null; then
180
+ log "FATAL: Matrix createRoom failed: $(head -c 160 "$room_resp" 2>/dev/null)"
181
+ rm -f "$room_resp"
182
+ return 1
183
+ fi
184
+ room_id=$(json_string room_id "$room_resp")
185
+ rm -f "$room_resp"
186
+ if [ -z "$room_id" ]; then
187
+ log "FATAL: Matrix createRoom did not return room_id"
188
+ return 1
189
+ fi
190
+
191
+ room_path=$(matrix_room_path "$room_id")
192
+ join_resp=$(mktemp)
193
+ if ! container_post_json "/_matrix/client/v3/rooms/${room_path}/join" '{}' "$agent_token" > "$join_resp" 2>/dev/null; then
194
+ log "FATAL: agent join failed for ${room_id}: $(head -c 160 "$join_resp" 2>/dev/null)"
195
+ rm -f "$join_resp"
196
+ return 1
197
+ fi
198
+ rm -f "$join_resp"
199
+
200
+ write_agent_room_to_bootstrap "$room_id"
201
+ log "created and persisted agent_room_id=${room_id}"
202
+ }
203
+
204
+ wait_for_complete_bootstrap_file() {
205
+ log "waiting for complete bootstrap credentials with agent_room_id ..."
206
+ for i in $(seq 1 30); do
207
+ if copy_bootstrap_file && bootstrap_has_core_credentials "$BOOTSTRAP_FILE" && bootstrap_has_real_agent_room "$BOOTSTRAP_FILE"; then
208
+ log "complete credentials file is ready."
209
+ return 0
210
+ fi
211
+ sleep 2
212
+ done
213
+ log "FATAL: bootstrap credentials never contained a real agent_room_id."
214
+ return 1
215
+ }
216
+
217
+ write_owner_json() {
218
+ local user_id homeserver
219
+ mkdir -p "$WELLKNOWN_DIR"
220
+ user_id=$(json_string user_id "$BOOTSTRAP_FILE")
221
+ [ -n "$user_id" ] || user_id=$(json_string owner_user_id "$BOOTSTRAP_FILE")
222
+ [ -n "$user_id" ] || user_id="@owner:${DOMAIN}"
223
+ homeserver=$(json_string homeserver "$BOOTSTRAP_FILE")
224
+ [ -n "$homeserver" ] || homeserver="https://${DOMAIN}"
225
+ cat > "${WELLKNOWN_DIR}/owner.json" <<EOF
226
+ {"user_id":"${user_id}","owner_user_id":"${user_id}","display_name":"owner","domain":"${DOMAIN}","homeserver":"${homeserver}"}
227
+ EOF
228
+ chmod 0644 "${WELLKNOWN_DIR}/owner.json"
229
+ }
230
+
231
+ mkdir -p "$(dirname "$BOOTSTRAP_FILE")" "$WELLKNOWN_DIR"
232
+ wait_for_message_server
233
+ bootstrap_portal
234
+ wait_for_core_bootstrap_file
235
+ ensure_agent_room
236
+ wait_for_complete_bootstrap_file
237
+ write_owner_json
238
+ echo "$BOOTSTRAP_FILE"
@@ -0,0 +1,40 @@
1
+ #cloud-config
2
+ # user-data.yaml - runs once on first EC2 boot as cloud-init user-data.
3
+ #
4
+ # Flow: install Docker -> write deployment files -> generate local secrets ->
5
+ # docker compose up -> wait for message-server bootstrap credentials.
6
+
7
+ package_update: true
8
+ package_upgrade: false
9
+
10
+ write_files:
11
+ - path: /opt/p2p/.env
12
+ permissions: '0600'
13
+ content: |
14
+ DOMAIN=__DOMAIN__
15
+ ACME_EMAIL=__ACME_EMAIL__
16
+ MESSAGE_SERVER_IMAGE=__MESSAGE_SERVER_IMAGE__
17
+
18
+ runcmd:
19
+ - |
20
+ TOK=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
21
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 300")
22
+ IP=$(curl -s -H "X-aws-ec2-metadata-token: $TOK" \
23
+ http://169.254.169.254/latest/meta-data/public-ipv4)
24
+ [ -n "$IP" ] || IP=$(curl -s https://api.ipify.org || curl -s https://ifconfig.me)
25
+ grep -q '^PUBLIC_IP=' /opt/p2p/.env || echo "PUBLIC_IP=$IP" >> /opt/p2p/.env
26
+ grep -q '^TURN_SECRET=' /opt/p2p/.env || \
27
+ echo "TURN_SECRET=$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 40)" >> /opt/p2p/.env
28
+ grep -q '^P2P_PORTAL_PASSWORD=' /opt/p2p/.env || \
29
+ echo "P2P_PORTAL_PASSWORD=$(od -An -N4 -tu4 /dev/urandom | awk '{printf "%08d", $1 % 100000000}')" >> /opt/p2p/.env
30
+ - export DOMAIN=$(grep '^DOMAIN=' /opt/p2p/.env | cut -d= -f2)
31
+ - curl -fsSL https://get.docker.com | sh
32
+ - systemctl enable --now docker
33
+ - mkdir -p /opt/p2p/wellknown
34
+ - chmod 700 /opt/p2p
35
+ - |
36
+ set -e
37
+ cd /opt/p2p
38
+ docker compose --env-file .env up -d
39
+ DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2) bash init-tokens.sh
40
+ touch /opt/p2p/.deploy-done