Skip to content

Commit f2c6243

Browse files
authored
client: add one-line installer and its CDN publish workflow (#3867)
## Summary of Changes - Add `client/install.sh` — the `curl … | bash` installer served at `https://get.doublezero.xyz/install`. It checks for Docker (offering to install it), preps the host for GRE (tun/ip_gre modules, firewall + cloud-firewall warnings), prompts for environment and keypair, runs the thin `doublezero` client container with the right capabilities, and connects. - Add `release.install-script.yml` — on pushes to `main` touching the script, it uploads `install.sh` to the `doublezero-install` S3 bucket and invalidates CloudFront, authenticating via GitHub OIDC into a **main-only** deploy role (no static keys). - Mounts the user's keypair read-only to the client's default path, and persists the chosen environment so client and daemon stay in lockstep. The S3/CloudFront hosting infrastructure is documented in the infra repo runbook (`install-script-hosting.md`). ## Diff Breakdown | Category | Files | Lines (+/-) | Net | |--------------|-------|-------------|------| | Core logic | 1 | +220 / -0 | +220 | | CI/build | 1 | +43 / -0 | +43 | | **Total** | 2 | +263 / -0 | +263 | Two additive files: the installer (a self-contained bash script) and a small OIDC publish workflow; no application code touched. <details> <summary>Key files (click to expand)</summary> - `client/install.sh` — host-side installer: preconditions (Linux/amd64/root), Docker detect+install, GRE prep, cloud-firewall detection (AWS/GCP/Azure), keypair bind-mount with path validation + SELinux relabel, `docker run` with `--network host`/`NET_ADMIN`/`NET_RAW`/`/dev/net/tun`, connect, and status. Reads prompts from `/dev/tty` so it works under `curl | bash`, and is fully overridable via env vars for non-interactive use. - `.github/workflows/release.install-script.yml` — OIDC → S3 upload + CloudFront invalidation on `main` changes to the script (`workflow_dispatch` for manual republish). </details> ## Testing Verification - Ran the installer end to end on a clean Ubuntu 24.04 amd64 EC2 host: Docker detection, GRE module prep, AWS cloud-firewall warning, keypair mount, container start, and `doublezero status` all worked; verified both the `curl|bash` (self-sudo) and `sudo bash` invocation paths, and NOPASSWD-sudo detection. - Validated `install.sh` with `bash -n` and the workflow with `actionlint` (clean). - Verified the hosting target out-of-band: `https://get.doublezero.xyz/install` is served by CloudFront (`HTTP 200`, `text/x-shellscript`, valid TLS). The deploy role + bucket + distribution already exist; the workflow's first real run happens on merge to `main`. ## Notes for reviewers - The workflow hardcodes non-secret infra IDs (account, role ARN, bucket, distribution) in its `env:` block rather than using secrets — they're not sensitive and are also captured in the infra runbook. Easy to switch to repo variables if preferred. - This is the first AWS-OIDC workflow in the repo (existing `id-token: write` usages are for PyPI trusted publishing), so it introduces `aws-actions/configure-aws-credentials`.
1 parent 44398a3 commit f2c6243

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: release.install-script
2+
3+
# Publishes client/install.sh to the CDN behind https://get.doublezero.xyz/install.
4+
# Uploads to the doublezero-install S3 bucket and invalidates the CloudFront
5+
# distribution. Auth is GitHub OIDC into a main-only deploy role (no static keys).
6+
# Infra is documented in the infra repo runbook: install-script-hosting.md.
7+
on:
8+
push:
9+
branches: [main]
10+
paths:
11+
- "client/install.sh"
12+
- ".github/workflows/release.install-script.yml"
13+
workflow_dispatch:
14+
15+
permissions:
16+
id-token: write # assume the AWS deploy role via OIDC
17+
contents: read
18+
19+
env:
20+
AWS_REGION: us-east-1
21+
DEPLOY_ROLE: arn:aws:iam::879381273509:role/github-doublezero-install-deploy
22+
BUCKET: doublezero-install
23+
DISTRIBUTION_ID: E393SM6O109RQ3
24+
25+
jobs:
26+
publish:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- uses: aws-actions/configure-aws-credentials@v4
32+
with:
33+
role-to-assume: ${{ env.DEPLOY_ROLE }}
34+
aws-region: ${{ env.AWS_REGION }}
35+
36+
- name: Upload install.sh and invalidate cache
37+
run: |
38+
aws s3 cp client/install.sh "s3://${BUCKET}/install" \
39+
--content-type text/x-shellscript \
40+
--cache-control "public,max-age=120"
41+
aws cloudfront create-invalidation \
42+
--distribution-id "$DISTRIBUTION_ID" \
43+
--paths '/install'

client/install.sh

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env bash
2+
#
3+
# DoubleZero Edge installer
4+
# -------------------------
5+
# Served from https://get.doublezero.xyz/install and run as:
6+
#
7+
# curl -fsSL https://get.doublezero.xyz/install | bash
8+
#
9+
# It checks for Docker (offering to install it), preps the host for GRE,
10+
# loads the user's keypair, runs the thin doublezero client container
11+
# (ghcr.io/malbeclabs/doublezero), and connects.
12+
#
13+
# Non-interactive overrides (env vars):
14+
# DZ_ENV=testnet|devnet|mainnet-beta default: prompt (falls back to mainnet-beta)
15+
# DZ_KEYPAIR=/abs/path/to/id.json default: prompt
16+
# DZ_CONNECT="connect multicast" connect args to run (default: "connect multicast")
17+
# DZ_IMAGE=ghcr.io/malbeclabs/doublezero:latest
18+
# DZ_NAME=doublezero container name
19+
# DZ_ASSUME_YES=1 skip confirmation prompts (e.g. Docker install)
20+
#
21+
# NOTE: connecting requires the host's public IP to have an access pass / allowlisted
22+
# user onchain for the chosen environment. That provisioning is a separate step; if
23+
# `connect` reports an access-pass error, the rest of the setup is still in place.
24+
25+
set -euo pipefail
26+
27+
# ----------------------------------------------------------------------------
28+
# config / defaults
29+
# ----------------------------------------------------------------------------
30+
DZ_IMAGE="${DZ_IMAGE:-ghcr.io/malbeclabs/doublezero:latest}"
31+
DZ_NAME="${DZ_NAME:-doublezero}"
32+
DZ_ENV="${DZ_ENV:-}"
33+
DZ_KEYPAIR="${DZ_KEYPAIR:-}"
34+
DZ_CONNECT="${DZ_CONNECT:-}"
35+
DZ_ASSUME_YES="${DZ_ASSUME_YES:-0}"
36+
KEYPAIR_DEST="/root/.config/doublezero/id.json" # client's default keypair path (container runs as root)
37+
LIVENESS_UDP_PORT=44880
38+
39+
# ----------------------------------------------------------------------------
40+
# pretty output + prompts (read from the terminal, not the curl pipe)
41+
# ----------------------------------------------------------------------------
42+
if [ -t 1 ]; then BOLD=$'\033[1m'; RED=$'\033[31m'; YEL=$'\033[33m'; GRN=$'\033[32m'; RST=$'\033[0m'
43+
else BOLD=; RED=; YEL=; GRN=; RST=; fi
44+
info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$*"; }
45+
warn() { printf '%s!! %s%s\n' "$YEL" "$*" "$RST" >&2; }
46+
die() { printf '%sxx %s%s\n' "$RED" "$*" "$RST" >&2; exit 1; }
47+
48+
# /dev/tty so prompts work under `curl | bash` (where stdin is the script)
49+
TTY=/dev/tty
50+
ask() { # ask "Question" "default" -> echoes answer
51+
local q="$1" def="${2:-}" ans=""
52+
if [ ! -r "$TTY" ]; then echo "$def"; return; fi
53+
if [ -n "$def" ]; then printf '%s%s%s [%s]: ' "$BOLD" "$q" "$RST" "$def" >"$TTY"
54+
else printf '%s%s%s: ' "$BOLD" "$q" "$RST" >"$TTY"; fi
55+
read -r ans <"$TTY" || true
56+
echo "${ans:-$def}"
57+
}
58+
confirm() { # confirm "Question" -> returns 0 if yes
59+
[ "$DZ_ASSUME_YES" = 1 ] && return 0
60+
[ -r "$TTY" ] || return 1
61+
local ans; printf '%s%s%s [y/N]: ' "$BOLD" "$1" "$RST" >"$TTY"
62+
read -r ans <"$TTY" || true
63+
case "$ans" in y|Y|yes|YES) return 0;; *) return 1;; esac
64+
}
65+
66+
# ----------------------------------------------------------------------------
67+
# 1. preconditions
68+
# ----------------------------------------------------------------------------
69+
[ "$(uname -s)" = Linux ] || die "This installer supports Linux hosts only (got $(uname -s)). The client needs host networking + kernel tunnels."
70+
71+
case "$(uname -m)" in
72+
x86_64|amd64) : ;;
73+
*) die "The doublezero image is published for amd64 only; this host is $(uname -m). Run on an x86_64 Linux box." ;;
74+
esac
75+
76+
# root / sudo: run as a normal user and self-elevate only the privileged steps.
77+
SUDO=""
78+
if [ "$(id -u)" -ne 0 ]; then
79+
command -v sudo >/dev/null 2>&1 || die "Need root (for Docker + network capabilities) but sudo is not installed. Re-run as root."
80+
SUDO="sudo"
81+
fi
82+
83+
# Resolve the *human* user's home so the keypair default points at their files
84+
# whether this is invoked as `... | bash` (self-sudo) or `... | sudo bash` (all root).
85+
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
86+
REAL_HOME="$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6)"
87+
fi
88+
REAL_HOME="${REAL_HOME:-$HOME}"
89+
90+
# Prime sudo once up front so later privileged commands don't re-prompt mid-run,
91+
# but only ask for a password if one is actually required ('sudo -n true' succeeds
92+
# silently for NOPASSWD or an already-cached timestamp).
93+
if [ -n "$SUDO" ] && ! $SUDO -n true 2>/dev/null; then
94+
info "Some steps need root; you may be prompted for your password once."
95+
$SUDO -v || die "Could not obtain sudo. Re-run as root, or configure passwordless sudo."
96+
fi
97+
98+
# ----------------------------------------------------------------------------
99+
# 2. docker present? offer install
100+
# ----------------------------------------------------------------------------
101+
if ! command -v docker >/dev/null 2>&1; then
102+
warn "Docker is not installed."
103+
if confirm "Install Docker now via get.docker.com?"; then
104+
info "Installing Docker..."
105+
curl -fsSL https://get.docker.com | $SUDO sh
106+
$SUDO systemctl enable --now docker 2>/dev/null || true
107+
else
108+
die "Docker is required. Install it and re-run."
109+
fi
110+
fi
111+
$SUDO docker info >/dev/null 2>&1 || die "Docker is installed but the daemon isn't reachable. Start it (e.g. 'sudo systemctl start docker') and re-run."
112+
113+
# ----------------------------------------------------------------------------
114+
# 3. host kernel / network prep (host-side; safe to attempt)
115+
# ----------------------------------------------------------------------------
116+
info "Preparing host for GRE tunnels..."
117+
$SUDO modprobe tun 2>/dev/null || warn "Could not load 'tun' module (may be built-in)."
118+
$SUDO modprobe ip_gre 2>/dev/null || warn "Could not load 'ip_gre' module (will auto-load on tunnel create)."
119+
[ -e /dev/net/tun ] || warn "/dev/net/tun is missing; tunnel creation may fail."
120+
121+
# best-effort firewall hints (don't auto-edit the user's firewall)
122+
if command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -qi "Status: active"; then
123+
warn "ufw is active: ensure IP protocol 47 (GRE) and UDP $LIVENESS_UDP_PORT are allowed."
124+
fi
125+
if command -v firewall-cmd >/dev/null 2>&1 && $SUDO firewall-cmd --state 2>/dev/null | grep -qi running; then
126+
warn "firewalld is running: ensure GRE (protocol 47) and UDP $LIVENESS_UDP_PORT are allowed."
127+
fi
128+
129+
# ----------------------------------------------------------------------------
130+
# 4. cloud detection -> warn about provider-level firewall (script can't fix)
131+
# ----------------------------------------------------------------------------
132+
detect_cloud() {
133+
local md="http://169.254.169.254"
134+
# AWS IMDSv2
135+
local tok
136+
tok=$(curl -fsS -m 1 -X PUT "$md/latest/api/token" -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' 2>/dev/null || true)
137+
if [ -n "$tok" ] && curl -fsS -m 1 -H "X-aws-ec2-metadata-token: $tok" "$md/latest/meta-data/instance-id" >/dev/null 2>&1; then echo aws; return; fi
138+
if curl -fsS -m 1 -H 'Metadata-Flavor: Google' "$md/computeMetadata/v1/instance/id" >/dev/null 2>&1; then echo gcp; return; fi
139+
if curl -fsS -m 1 -H 'Metadata: true' "$md/metadata/instance?api-version=2021-02-01" >/dev/null 2>&1; then echo azure; return; fi
140+
echo none
141+
}
142+
CLOUD="$(detect_cloud)"
143+
case "$CLOUD" in
144+
aws) warn "AWS detected. GRE will not work until you (in AWS, NOT on this host): 1) allow inbound IP protocol 47 in the Security Group; 2) DISABLE the ENI source/dest check.";;
145+
gcp) warn "GCP detected. Add a firewall rule allowing IP protocol 47 (gre) to this instance.";;
146+
azure) warn "Azure detected. Add an NSG rule allowing IP protocol 47 to this VM.";;
147+
esac
148+
149+
# ----------------------------------------------------------------------------
150+
# 5. inputs: keypair + environment + connect target
151+
# ----------------------------------------------------------------------------
152+
# environment
153+
if [ -z "$DZ_ENV" ]; then DZ_ENV="$(ask 'DoubleZero environment (testnet/devnet/mainnet-beta)' 'mainnet-beta')"; fi
154+
case "$DZ_ENV" in testnet|devnet|mainnet-beta) : ;; *) die "Invalid DZ_ENV '$DZ_ENV'";; esac
155+
156+
# keypair
157+
if [ -z "$DZ_KEYPAIR" ]; then DZ_KEYPAIR="$(ask 'Path to your DoubleZero keypair (id.json)' "$REAL_HOME/.config/doublezero/id.json")"; fi
158+
# expand ~ and relativize to absolute (against the human user's home)
159+
case "$DZ_KEYPAIR" in "~"*) DZ_KEYPAIR="${REAL_HOME}${DZ_KEYPAIR#\~}";; esac
160+
DZ_KEYPAIR="$(realpath -m "$DZ_KEYPAIR" 2>/dev/null || echo "$DZ_KEYPAIR")"
161+
[ -f "$DZ_KEYPAIR" ] || die "No keypair file at: $DZ_KEYPAIR (a wrong path makes Docker mount an empty dir over id.json)."
162+
163+
# SELinux relabel for the bind mount
164+
MNT_OPT=ro
165+
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" = Enforcing ]; then MNT_OPT=ro,Z; fi
166+
167+
# ----------------------------------------------------------------------------
168+
# 6. run the container (detached, long-lived daemon)
169+
# ----------------------------------------------------------------------------
170+
info "Pulling $DZ_IMAGE ..."
171+
$SUDO docker pull -q "$DZ_IMAGE" >/dev/null
172+
173+
info "Starting doublezero client (env=$DZ_ENV)..."
174+
$SUDO docker rm -f "$DZ_NAME" >/dev/null 2>&1 || true
175+
$SUDO docker run -d --name "$DZ_NAME" \
176+
--restart unless-stopped \
177+
--network host \
178+
--cap-add NET_ADMIN --cap-add NET_RAW \
179+
--device /dev/net/tun \
180+
-e DZ_ENV="$DZ_ENV" \
181+
-v "$DZ_KEYPAIR":"$KEYPAIR_DEST":"$MNT_OPT" \
182+
"$DZ_IMAGE" >/dev/null
183+
184+
# wait for the daemon socket
185+
info "Waiting for the daemon..."
186+
for _ in $(seq 1 30); do
187+
$SUDO docker logs "$DZ_NAME" 2>&1 | grep -q "doublezerod ready" && break
188+
$SUDO docker ps -q --filter "name=^${DZ_NAME}$" | grep -q . || die "Container exited early. Logs: sudo docker logs $DZ_NAME"
189+
sleep 1
190+
done
191+
192+
# ----------------------------------------------------------------------------
193+
# 7. connect (TODO: finalize the verb/args + access-pass flow)
194+
# ----------------------------------------------------------------------------
195+
if [ -z "$DZ_CONNECT" ]; then
196+
DZ_CONNECT="$(ask 'Connect command to run now (Enter to accept, blank-then-Enter to skip)' 'connect multicast')"
197+
fi
198+
if [ -n "$DZ_CONNECT" ]; then
199+
info "Connecting: doublezero $DZ_CONNECT"
200+
# Allocate a pseudo-TTY when our stdout is a terminal so the CLI streams its
201+
# normal output to the screen (without -t, docker exec gives it no TTY and the
202+
# command's progress/result output is suppressed).
203+
EXEC_TTY=""; [ -t 1 ] && EXEC_TTY="-t"
204+
# NOTE: connect requires the host's public IP to have an access pass / allowlisted
205+
# user onchain for $DZ_ENV. If this errors with an access-pass message, that
206+
# provisioning step still needs to happen.
207+
$SUDO docker exec $EXEC_TTY "$DZ_NAME" doublezero $DZ_CONNECT || warn "connect failed (often: no access pass for this IP, or provider firewall/NAT). See notes above."
208+
fi
209+
210+
# ----------------------------------------------------------------------------
211+
# 8. status + management hints
212+
# ----------------------------------------------------------------------------
213+
echo
214+
$SUDO docker exec "$DZ_NAME" doublezero status || true
215+
echo
216+
info "Done. Manage with:"
217+
echo " sudo docker exec -it $DZ_NAME doublezero status # tunnel status"
218+
echo " sudo docker exec -it $DZ_NAME doublezero latency # device latencies"
219+
echo " sudo docker logs -f $DZ_NAME # daemon logs"
220+
echo " sudo docker rm -f $DZ_NAME # stop & remove"

0 commit comments

Comments
 (0)