shardVPN v2: Tailscale exit node with a Lambda control plane - #14
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… for 'never' spelling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9Ja3b9VmnEvmbfK2hX1U8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…de check to Self. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9Ja3b9VmnEvmbfK2hX1U8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9Ja3b9VmnEvmbfK2hX1U8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9Ja3b9VmnEvmbfK2hX1U8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… and none match the hostname.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h, and docs. Publishes an SNS notification when watchdog.sweep records failures (both the normal end-of-sweep path and the region-discovery abort path), covers the `up` launch branch's tags/user_data/pointer write with a test, refuses to reuse a security group carrying ingress rules, fails closed when the signing secret is still the published placeholder, stops the auth key from being duplicated into a userdata.sh comment, makes parse_ttl reject non-string input with ValueError instead of an uncaught TypeError, fixes the archive_file excludes glob so pycache no longer churns the Lambda zip, and documents a working kms:Decrypt verification step in the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code review —
|
… launch path, auth, status, and docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creating a boto3 client resolves credentials before Stubber intercepts, so the suite depended on the developer's AWS configuration: it passed with no config present and failed with `aws login` configured, because botocore's LoginProvider requires botocore[crt]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The autogroup:internet grant was missing entirely — without it an exit node joins, advertises and appears selectable while routing nothing, which cloud-init cannot detect. Also corrects acls/grants, adds the merge warning, and folds four fragments into one complete policy file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guess was 5 MB against a real idle floor of ~45 MB/24h. should_alert fires only below the threshold, so the forgotten-node email could never have sent — with TTL defaulting to never, the safety net was inert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
idle_seconds tested `value > 0`, but a live exit node never emits a zero NetworkOut bucket — its quietest over 24h was 1,386 bytes. Every bucket matched, so idle_for always reported the newest datapoint and read "5m" for a node untouched for days. Now compares against a measured background floor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"absent unknown" told the user nothing. Notifications are read on a lock screen while walking; say what happened and what to do about it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iCloud delivered the initial script then stalled silently — a later update and a fresh marker file both failed to arrive, with no error anywhere. The phone just runs old code, which is miserable to debug against a signature mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The API took a region per request but the client never sent one, so from the phone you were locked to the SSM default — against the design's own reason for making region a request parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds prerequisites and credential setup, calls out that the default region is the author's and should be changed, fixes the circular step ordering, and drops account-specific notes that meant nothing to a stranger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The control plane is a plain HMAC-signed HTTPS endpoint; the iOS client is what ships, not what's needed. Documents the wire format and includes a working shell client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces v1 (Terraform 0.11 + OpenVPN + a hand-run certificate authority) with
an on-demand Tailscale exit node driven from a phone. Nothing runs between
trips.
v1 is deleted, not migrated — its HCL predates 0.12 and won't parse against a
current Terraform, and every AMI, instance type and provider block in it was
stale. It survives in git history on
master.How it works
Three actions —
up,down,status— sent from an iPhone over HTTPS to aLambda that is the whole control plane.
uplaunches a bare AL2023 arm64instance that turns itself into a Tailscale exit node with no inbound ports
before it ever advertises as selectable. An hourly EventBridge sweep is the
only thing running unattended: it reaps expired nodes, terminates duplicates,
reconciles state, and emails about idle ones.
What's here
lambda/shardvpn/tests/botocore.stub.Stubber, patchedurlopen)terraform/docs/ios/shardvpn.js.github/workflows/ci.ymlDesign decisions worth knowing
NONE-auth Function URL. SigV4 from aphone was rejected as unworkable. Every verification failure returns a
byte-identical 403 from a single return site; SSM/KMS unavailability is a
distinct 503, with a written argument for why that isn't an oracle.
email, not killed. Duplicates are killed.
advertisement before advertising, and on failure withdraws, leaves the
tailnet, and self-terminates — because a node that joins and advertises but
doesn't forward looks perfectly healthy from the phone.
aws_ssm_parameterstores values inplaintext even for
SecureString, so the three secrets are placeholderswith
ignore_changesand are populated out of band.RunInstancessplit into arequest-tag-conditioned instance statement plus supporting resources, bounded
to
t4g.*;TerminateInstancesconditioned on the role tag.Describe*andCloudWatch metrics are
Resource: "*"because they support nothing narrower,and that's documented rather than hidden.
docs/superpowers/specs/2026-08-01-shardvpn-v2-design.mdrecords the reasoningbehind every tradeoff, including the ones rejected.
Before this is usable
Two things need a human and live AWS, both in the README:
IAMFullAccessplus a long-livedaccess key, and the
shard-vpn-keysbucket still exists. Until that's done,"no long-lived AWS access keys anywhere" isn't true.
measuring the real idle
NetworkOutbaseline to replace the guessedthreshold, and confirming
Self.ExitNodeOption— the one key inuserdata.shthat is still an unverified guess.CLAUDE.mdlists what's settled, what's open, and the accepted known issues.🤖 Generated with Claude Code