Away from home
An install has up to three addresses: the computer's own LAN address, a secure home name with a real certificate that works on the home network, and - only if the owner turns it on - a second name that works from anywhere. For anybody who would rather not be on the internet at all, Tailscale is the other way out. And because a home's upload is often slow, the server paces what it sends to a phone away from home.
Three addresses
| Address | Where it works | Certificate |
|---|---|---|
http://192.168.1.50:8099 | The home network | None (plain http) |
https://<id>.home.soundstorm.dev:8099 | The home network | Let's Encrypt, trusted everywhere |
https://<id>.net.soundstorm.dev:8099 | Anywhere, once remote access is on | The same certificate, carrying both names |
The id is random and per install. All three are served on the same port: SoundStorm's listener tells
a TLS connection from a plain HTTP request by its first byte (servetls.Listener), so the
address people already have keeps working while the secure one is added.
The home name and its certificate
A trusted certificate needs a name on the internet, and a home server has neither a domain nor a
route an ACME challenge can reach. So SoundStorm runs one small central service, the names service,
which gives each install <id>.home.soundstorm.dev, points it at the install's
private LAN address, and publishes the DNS-01 challenge that lets Let's Encrypt issue a
certificate for a server nothing on the internet can reach. It is the same idea as Plex's
plex.direct, without the account.
- It never carries media. DNS records and a challenge every couple of months cost the same however many films are watched; a relay would not.
- It holds no state. Once a name resolves, it resolves without the service; an outage stops registration and renewal, never a lookup, and renewal starts a month early.
- It only names private addresses for the home name - a trusted certificate on an arbitrary public address would be a phishing kit with the project's name on it.
- Every failure falls back. Until the certificate arrives, or if it never can, the server carries on as before.
The page moves to the secure name only after checking it works: it fetches the name's
/healthz in no-cors mode, which succeeds only if the name resolved, the
connection opened and the certificate verified. Plenty of routers refuse to resolve a public name that
points at a private address (DNS rebinding protection), and on those the page simply stays on http.
Details are on Certificates and names.
Remote access
Turned on under Settings (or with -Remote / --remote at install), remote access
gives the install the second, public name. It is off by default, with a plain warning,
because once it is on the sign-in page faces the internet. The security reviews that had to come first -
the setup code, per-account sign-in throttling, request timeouts, cross-origin checks, the reader fixes -
were done before it shipped.
Turning it on does three things:
- Open the port.
internal/portmapasks the router to forward it, trying PCP, then NAT-PMP, then UPnP. PCP is best where it works, but it carries a client address that a strict router compares with the packet's source - and behind Docker's NAT those differ - so NAT-PMP is the fallback. UPnP's discovery is multicast, which does not cross Docker's bridge, so the installer discovers the router on the host and passes it in, along with the router's address (SOUNDSTORM_GATEWAY): inside the container, the "gateway" is Docker's bridge, which does not answer. The mapping is refreshed on a timer and dropped when remote access is turned off. - Prove it. The names service probes the install at the address the request came from - never an address the install supplies, so it cannot be aimed at anybody else - and expects an answer only that install can compute from its own token. Only then does it publish the public record. "Your port is not open yet" is actionable in a way a silently dead record is not.
- Get the certificate. One certificate carries both names, so the public name does not double each install's draw on the domain's shared Let's Encrypt allowance.
When it cannot work: CGNAT and double NAT
The router's own WAN address says a lot, and portmap asks for it even when the port
mapping succeeded - a router behind carrier-grade NAT opens the port without complaint, on an
address the internet cannot reach. ClassifyWAN judges it:
- In
100.64.0.0/10: carrier-grade NAT. The provider shares one public address between many homes, and no forward can ever be reached. The panel says so and points at Tailscale. - In a private range: double NAT, a router behind another router, where a forward is needed on both.
- Public: the ordinary case. Where no automatic method worked, the panel says exactly which port to forward to which computer and shows when it starts working.
Over IPv6 there is nothing to forward: the public name also carries an AAAA record, published from a call the install makes over IPv6, which proves it has working IPv6. That path stays dormant until the container actually has IPv6, which Docker does not give it by default.
Tailscale
Tailscale connects devices privately with nothing exposed to the internet and nothing to forward, on
any connection - including behind carrier-grade NAT. docker compose --profile tailscale up -d
runs a Tailscale sidecar that puts SoundStorm on a tailnet at
https://<hostname>.<tailnet>.ts.net. On Windows a Start menu shortcut,
Set up Tailscale, walks through it: a window with the steps, a button to Tailscale's key
page, and a box that accepts only something shaped like a Tailscale auth key. On Mac and Linux it is
--tailscale --auth-key ....
Three things were learned by running it:
- Userspace networking is the default, so the sidecar needs no special privileges - which matters on Docker Desktop.
- The proxy target must not be called
soundstorm. The sidecar takes that as its own hostname, and Docker writes a container's own hostname into its hosts file, so the proxy looped back to itself and answered 502 while every container reported healthy. SoundStorm has a second alias,soundstorm-app, and the serve config points there. A CI step guards it. - The serve config's scheme must match how SoundStorm is serving -
https+insecure://orhttp://- or it is another silent 502. And the file is written on every install, Tailscale or not, because Docker answers a missing bind-mount source by creating a directory with that name.
Pacing and slow links
A home's upload is often the narrowest pipe in the whole path. The report that started this work: songs sat at 0:00 for a minute or more on a phone away from home, over a link measured at about 0.3 Mbps. Several causes were found, one after another:
- The picture inside the song. A browser's media engine treats embedded cover art as a
second stream and reads megabytes before playing. Measured at 0.6 Mbps on a real iTunes M4A: 34 seconds
as it is, 2.5 seconds with the header slimmed. Files are never changed; instead an original stream is
sent slim (
internal/stream/slim.go) - a header rebuilt in memory without the tags and artwork, offsets shifted to match, then the original audio fetched by byte range. Seeking still works. - The pipe was full. The server handed each song to the network in full the moment it
was asked, and over a slow link those megabytes queue in the connection, so the next song, a skip and the
covers all wait behind bytes nobody plays. Audio to a device that came in by an away-from-home name
(
*.net.soundstorm.devor*.ts.net) is now paced (internal/stream/pace.go): the header plus eight seconds at once, then 1.5 times the song's own bitrate. At home nothing is paced, and neither are films, whose pieces are asked for one at a time. - Known before the first song. On an away-from-home address the app times 128KB from
/api/probeonce a session; under 1.2 Mbps songs start at 128 kbps MP3 from the first one, remembered on the device for six hours. A song that cannot play six seconds after starting is restarted at 128 kbps anyway. - And back again. While slow, the link is timed again every fifteen minutes in the background; over 3 Mbps the next song is at full quality. A playing song is never changed.
- Covers are card-sized unless asked otherwise (400px by default), and load at low priority behind the song.
Streaming quality can be set per device under Settings → Playback on this device. Always original is a promise: no probe and no fallback. Films have their own setting; the default, smart, sends the original at home and caps at 20 Mbit from an away-from-home name.