Skip to content

Commit 1ea5445

Browse files
committed
Support STUN-only demo bootstrap
1 parent 622ea89 commit 1ea5445

8 files changed

Lines changed: 94 additions & 42 deletions

File tree

ARCHITECTURE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ on any L3 path. Every CVM NATs out to the same public IP, so even a
1111
"direct" peer-to-peer flow is hairpinned by the provider edge. The
1212
only thing the CVMs share is outbound internet egress.
1313

14-
Plus one plain Linux box with a public IP — the **external
15-
coordinator** — running `coturn` (STUN/TURN) and a tiny HTTP
16-
signaling broker. This is NAT traversal bootstrap infrastructure only:
17-
once peers have ICE-handshaked, no data passes through it unless TURN
18-
relay fallback is required.
14+
Plus one public NAT traversal bootstrap endpoint running STUN and a tiny
15+
HTTP signaling broker. This is setup infrastructure only: once peers
16+
have ICE-handshaked, no data passes through it. A self-hosted
17+
coordinator can additionally enable TURN relay fallback, but the public
18+
demo service should leave TURN disabled.
1919

2020
```
2121
coturn + signaling

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,16 @@ $EDITOR terraform.tfvars
4141

4242
Fill in the Phala Cloud gateway and NAT traversal bootstrap settings in
4343
`terraform.tfvars`. The easiest first-run path should use a public demo
44-
bootstrap service operated by Phala Cloud. That service lets CVMs
45-
exchange connection setup messages and discover usable network paths; it
46-
should not carry application traffic in the normal case.
44+
bootstrap service operated by Phala Cloud. That demo service should run
45+
signaling plus STUN only. It lets CVMs exchange connection setup
46+
messages and discover usable network paths, but it does not provide TURN
47+
relay capacity and should not carry application traffic.
4748

4849
If a public demo endpoint is not available in your environment yet, use
4950
the self-hosted coordinator in [`coordinator/`](coordinator/) and paste
50-
its `external_*` values.
51+
its `external_*` values. Leave `external_turn_secret` empty for the
52+
public STUN-only demo service; set it only for a self-hosted coordinator
53+
where you intentionally enable TURN fallback.
5154

5255
Then deploy:
5356

@@ -232,7 +235,8 @@ docker build -t service-mesh-mesh-sidecar-check -f mesh-sidecar/Dockerfile .
232235
## Current Limits
233236

234237
- The easiest user experience depends on a hosted NAT traversal
235-
bootstrap endpoint.
238+
bootstrap endpoint. The public demo endpoint should be STUN-only; TURN
239+
relay is a self-hosted or future credentialed service.
236240
Until that is available in your environment, use the self-hosted
237241
coordinator fallback in [`coordinator/`](coordinator/).
238242
- Provider `0.3.0-beta.1` supports stable instance management, but

ROBUSTNESS.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,28 +40,26 @@ ours, they're the parts that *must* be made robust by hand.
4040

4141
### What's there
4242

43-
- one public-IP host running `coturn` (STUN+TURN UDP/TCP) and a Go HTTP
44-
signalling broker
43+
- one public-IP host running STUN and a Go HTTP signalling broker; a
44+
self-hosted coordinator may also enable TURN relay fallback
4545
- the dstack CVMs themselves, which sit behind Phala's provider NAT
4646

4747
### What can break
4848

4949
| failure | impact | recovery |
5050
| --- | --- | --- |
5151
| Coordinator host dies | New peers can't bootstrap. **Existing ICE pairs keep working** (no data flows through this box once handshake is done). New retries from existing peers fail until it's back. | bring it back; peers reconnect on their own. |
52-
| Coordinator ufw / network change | Same as above. | restore ports 3478/udp+tcp, 5349/tcp, 7000/tcp, 49152-49999/udp. |
53-
| TURN shared secret leaks | Anyone can use the box as an open TURN relay (cost / abuse risk). | rotate `TURN_SHARED_SECRET` in coordinator + every CVM env, redeploy. |
52+
| Coordinator firewall / network change | Same as above. | restore the signaling and STUN ports; if self-hosted TURN is enabled, also restore the TURN relay port range. |
53+
| TURN shared secret leaks | Applies only when self-hosted TURN is enabled. Anyone with the secret can use that box as a relay. | rotate `TURN_SHARED_SECRET` in coordinator + every CVM env, redeploy. |
5454
| Signalling broker is unauthenticated | Any external actor can publish/poll messages, spoof candidates, intercept ICE handshakes. Currently low-impact only because we're solo. | gate `/publish` + `/poll` on attestation-derived identity. |
55-
| dstack provider NAT changes type (e.g. cone symmetric) | ICE picks TURN relay path. ~150 ms RTT instead of ~6 ms. **Functionality unchanged.** | none needed; coturn covers this fallback. |
55+
| dstack provider NAT changes type (e.g. cone to symmetric) | With the public STUN-only demo endpoint, direct connection may fail instead of relaying. With self-hosted TURN enabled, ICE can pick the relay path. | use self-hosted TURN or a future credentialed relay service for environments that need relay fallback. |
5656
| Underlying CVM dies | That peer's services drop out. Consul will mark it `failed` after gossip timeout, Envoy LB removes it within seconds. | redeploy; the rest of the cluster is unaffected. |
5757

5858
### Risk shape
5959

60-
Coordinator host is a **single point of failure for NEW joins** and
61-
a SPOF for the TURN-relay fallback path. It is **NOT a SPOF for
62-
established traffic** — established peers ICE-direct and don't
63-
touch it. So dying coordinator = "no new peers can join, and any pair
64-
whose direct path goes down can't fail over to TURN until it's back".
60+
Coordinator host is a **single point of failure for NEW joins**. If
61+
self-hosted TURN is enabled, it is also a SPOF for the relay fallback
62+
path. It is **NOT a SPOF for established direct traffic**.
6563

6664
### Recommended fixes
6765

@@ -348,10 +346,11 @@ service_healthy } }` would silence them entirely.
348346

349347
### Time drift
350348

351-
TURN credentials are time-bound (1-hour TTL in our derivation). If a
352-
CVM clock drifts more than ~minutes from the coordinator's, TURN auth
353-
fails. dstack CVMs run NTP so this isn't a real concern, but worth
354-
noting for the runbook.
349+
TURN credentials are used only when self-hosted TURN fallback is
350+
enabled. They are time-bound (1-hour TTL in our derivation). If a CVM
351+
clock drifts more than a few minutes from the coordinator's, TURN auth
352+
fails. dstack CVMs run NTP so this is not a real concern, but worth
353+
noting for that runbook.
355354

356355
### Configuration drift / inconsistency
357356

cluster-example/cluster.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ variable "external_coordinator_host" { type = string }
144144
variable "external_signaling_url" { type = string }
145145
variable "external_turn_secret" {
146146
type = string
147+
default = ""
147148
sensitive = true
148149
}
149150

cluster-example/terraform.tfvars.example

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ mesh_sidecar_image = "ghcr.io/dstack-tee/service-mesh/mesh-sidecar:latest"
2222
webdemo_image = "ghcr.io/dstack-tee/service-mesh/webdemo:latest"
2323
patroni_image = "ghcr.io/dstack-tee/service-mesh/patroni:latest"
2424

25-
# NAT traversal bootstrap settings. In the productized path these should
26-
# come from Phala Cloud managed service-mesh infrastructure. Until that
27-
# is available in your environment, run coordinator/docker-compose.yaml
28-
# on a public-IP host and use its values here.
25+
# NAT traversal bootstrap settings. The public demo bootstrap service
26+
# should run signaling + STUN only, with TURN relay disabled. Leave
27+
# external_turn_secret empty for that path. Set it only when using a
28+
# self-hosted coordinator that intentionally enables TURN fallback.
2929
# external_coordinator_host = "<managed-or-self-hosted-bootstrap-host>"
3030
# external_signaling_url = "http://<managed-or-self-hosted-bootstrap-host>:7000"
31-
# external_turn_secret = "<hex>"
31+
# external_turn_secret = ""
3232

3333
# Attestation admission is enabled by default. Terraform generates the
3434
# Consul management token when consul_management_token is empty.

compose/coordinator.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ services:
6161
# for retry-join (skipping self).
6262
- COORDINATOR_VIPS=${COORDINATOR_VIPS}
6363
# External coordinator path: coordinator mesh-conn uses the same
64-
# public coturn + signaling endpoint that workers do, so peer-pair
65-
# NAT traversal setup happens in a single shared place.
64+
# public bootstrap endpoint that workers do, so peer-pair NAT
65+
# traversal setup happens in a single shared place.
6666
- SIGNALING_URL=${SIGNALING_URL}
6767
- TURN_HOST=${TURN_HOST}
6868
- TURN_SHARED_SECRET=${TURN_SHARED_SECRET}

mesh-conn/main.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ func loadConfig() *Config {
144144
// - SELF identity comes from /run/instance/info.json written by
145145
// the bootstrap-secrets init container (which read it from the
146146
// dstack SDK's Info() call). Falls back to PEER_ID env.
147-
// - TURN_SHARED_SECRET comes from /run/secrets/turn (a hex blob
148-
// written by bootstrap-secrets via getKey()). Falls back to
149-
// the env value if the file isn't present.
147+
// - TURN_SHARED_SECRET is optional. When present, mesh-conn also
148+
// tries TURN relay candidates. When absent, mesh-conn uses the
149+
// bootstrap host for STUN only and will not relay traffic.
150150
// - PEERS_JSON still comes via env — cluster.tf computes it from
151151
// the `replicas` count and re-applies on topology change,
152152
// which propagates to every CVM via Phala's in-place compose
@@ -196,7 +196,7 @@ func readSelfID() string {
196196
return mustEnv("PEER_ID")
197197
}
198198

199-
// readTurnSecret resolves the TURN shared secret in priority order:
199+
// readTurnSecret resolves the optional TURN shared secret in priority order:
200200
//
201201
// 1. TURN_SHARED_SECRET env (set when using an external coturn whose
202202
// static-auth-secret was configured out-of-band). When this is
@@ -206,16 +206,26 @@ func readSelfID() string {
206206
// coordinator's coturn which reads the same file).
207207
//
208208
// Order matters: env beats file so that "use external coturn" can be
209-
// configured purely at the cluster.tf layer.
209+
// configured purely at the cluster.tf layer. An explicitly empty env
210+
// value is valid and means STUN-only; no TURN relay credentials are
211+
// configured.
210212
func readTurnSecret() string {
211-
if v := os.Getenv("TURN_SHARED_SECRET"); v != "" {
213+
return readTurnSecretFrom("/run/secrets/turn")
214+
}
215+
216+
func readTurnSecretFrom(file string) string {
217+
if v, ok := os.LookupEnv("TURN_SHARED_SECRET"); ok {
218+
if v == "" {
219+
log.Printf("turn shared secret explicitly empty; using STUN-only bootstrap")
220+
return ""
221+
}
212222
log.Printf("turn shared secret loaded from TURN_SHARED_SECRET env (%d bytes)", len(v))
213223
return v
214224
}
215-
if b, err := os.ReadFile("/run/secrets/turn"); err == nil {
225+
if b, err := os.ReadFile(file); err == nil {
216226
s := strings.TrimSpace(string(b))
217227
if s != "" {
218-
log.Printf("turn shared secret loaded from /run/secrets/turn (%d bytes)", len(s))
228+
log.Printf("turn shared secret loaded from %s (%d bytes)", file, len(s))
219229
return s
220230
}
221231
}
@@ -1135,11 +1145,15 @@ func (m *Mesh) dialICE(remoteID string, sess *peerSession, attemptCtx context.Co
11351145
cfg := m.cfg
11361146
var urls []*stun.URI
11371147
if cfg.TurnHost != "" {
1138-
user, pass := turnCreds(cfg.TurnSecret, time.Hour)
11391148
urls = []*stun.URI{
11401149
{Scheme: stun.SchemeTypeSTUN, Host: cfg.TurnHost, Port: 3478, Proto: stun.ProtoTypeUDP},
1141-
{Scheme: stun.SchemeTypeTURN, Host: cfg.TurnHost, Port: 3478, Proto: stun.ProtoTypeUDP, Username: user, Password: pass},
1142-
{Scheme: stun.SchemeTypeTURN, Host: cfg.TurnHost, Port: 3478, Proto: stun.ProtoTypeTCP, Username: user, Password: pass},
1150+
}
1151+
if cfg.TurnSecret != "" {
1152+
user, pass := turnCreds(cfg.TurnSecret, time.Hour)
1153+
urls = append(urls,
1154+
&stun.URI{Scheme: stun.SchemeTypeTURN, Host: cfg.TurnHost, Port: 3478, Proto: stun.ProtoTypeUDP, Username: user, Password: pass},
1155+
&stun.URI{Scheme: stun.SchemeTypeTURN, Host: cfg.TurnHost, Port: 3478, Proto: stun.ProtoTypeTCP, Username: user, Password: pass},
1156+
)
11431157
}
11441158
}
11451159

mesh-conn/validate_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package main
22

33
import (
44
"net"
5+
"os"
6+
"path/filepath"
57
"strings"
68
"testing"
79
"time"
@@ -141,6 +143,38 @@ func TestValidateAllowlist_PortOutOfRange(t *testing.T) {
141143
}
142144
}
143145

146+
func TestReadTurnSecret_EmptyEnvDisablesFileFallback(t *testing.T) {
147+
t.Setenv("TURN_SHARED_SECRET", "")
148+
dir := t.TempDir()
149+
path := filepath.Join(dir, "turn")
150+
if err := os.WriteFile(path, []byte("file-secret\n"), 0o600); err != nil {
151+
t.Fatal(err)
152+
}
153+
if got := readTurnSecretFrom(path); got != "" {
154+
t.Fatalf("empty TURN_SHARED_SECRET should force STUN-only, got %q", got)
155+
}
156+
}
157+
158+
func TestReadTurnSecret_UsesFileWhenEnvUnset(t *testing.T) {
159+
old, had := os.LookupEnv("TURN_SHARED_SECRET")
160+
t.Cleanup(func() {
161+
if had {
162+
os.Setenv("TURN_SHARED_SECRET", old)
163+
} else {
164+
os.Unsetenv("TURN_SHARED_SECRET")
165+
}
166+
})
167+
os.Unsetenv("TURN_SHARED_SECRET")
168+
dir := t.TempDir()
169+
path := filepath.Join(dir, "turn")
170+
if err := os.WriteFile(path, []byte("file-secret\n"), 0o600); err != nil {
171+
t.Fatal(err)
172+
}
173+
if got := readTurnSecretFrom(path); got != "file-secret" {
174+
t.Fatalf("got %q", got)
175+
}
176+
}
177+
144178
func TestValidatePeers_DigestStableUnderReorder(t *testing.T) {
145179
a := []Peer{
146180
{ID: "ctrl", Vip: 1},

0 commit comments

Comments
 (0)