Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Public routing
# Public routing and discovery
PORTAL_URL=https://localhost:4017
BOOTSTRAPS=https://localhost:4017
DISCOVERY=true

# Listener ports
API_PORT=4017
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,22 @@ For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).

### Expose Local Service via Tunnel

1. Run your local service.
2. Open the Portal relay site.
3. Click `Add your server` button.
4. Use the generated command to connect your local service.
For a local relay started with `docker compose up`:

```bash
curl -ksSL https://localhost:4017/install.sh | bash
portal expose 3000 --relays https://localhost:4017
```

```powershell
$ProgressPreference = 'SilentlyContinue'
irm https://localhost:4017/install.ps1 | iex
portal expose 3000 --relays https://localhost:4017
```

Replace `https://localhost:4017` with your relay URL when using a hosted relay.
The relay landing page also generates the exact install command for the current relay.
For CLI usage and install details, see [cmd/portal-tunnel/README.md](cmd/portal-tunnel/README.md).

### Use the Go SDK (Advanced)

Expand Down
42 changes: 21 additions & 21 deletions cmd/demo-app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,23 @@ func main() {
}

type demoConfig struct {
relayURLs string
defaultRelays bool
addr string
name string
desc string
tags string
owner string
hide bool
thumbnail string
relayURLs string
discovery bool
addr string
name string
desc string
tags string
owner string
hide bool
thumbnail string
}

func runTCPCommand(args []string) error {
cfg := demoConfig{}

fs := utils.NewFlagSet("demo-app", printTCPUsage)
utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set)", "RELAYS")
utils.BoolFlagEnv(fs, &cfg.defaultRelays, "default-relays", true, "include public registry relays", "DEFAULT_RELAYS")
utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
utils.StringFlag(fs, &cfg.name, "name", "demo-app", "public hostname prefix (single DNS label)")
utils.StringFlag(fs, &cfg.desc, "description", "Portal demo connectivity app", "lease description")
Expand Down Expand Up @@ -77,8 +77,8 @@ func runUDPCommand(args []string) error {
cfg := demoConfig{}
fs := utils.NewFlagSet("demo-app-udp", printUDPUsage)

utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set)", "RELAYS")
utils.BoolFlagEnv(fs, &cfg.defaultRelays, "default-relays", false, "include public registry relays", "DEFAULT_RELAYS")
utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
utils.StringFlag(fs, &cfg.name, "name", "demo-udp", "public hostname prefix (single DNS label)")
utils.StringFlag(fs, &cfg.desc, "description", "Portal demo UDP echo service", "lease description")
utils.StringFlag(fs, &cfg.tags, "tags", "demo,udp,echo", "comma-separated lease tags")
Expand Down Expand Up @@ -131,9 +131,9 @@ func runHelpCommand(args []string) error {

func runTCPDemo(ctx context.Context, cfg demoConfig) error {
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
RelayURLs: utils.SplitCSV(cfg.relayURLs),
DefaultRelayEnabled: cfg.defaultRelays,
Name: cfg.name,
RelayURLs: utils.SplitCSV(cfg.relayURLs),
Name: cfg.name,
Discovery: cfg.discovery,
Metadata: types.LeaseMetadata{
Description: cfg.desc,
Tags: utils.SplitCSV(cfg.tags),
Expand Down Expand Up @@ -170,10 +170,10 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {

func runUDPDemo(ctx context.Context, cfg demoConfig) error {
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
RelayURLs: utils.SplitCSV(cfg.relayURLs),
DefaultRelayEnabled: cfg.defaultRelays,
Name: cfg.name,
UDPEnabled: true,
RelayURLs: utils.SplitCSV(cfg.relayURLs),
Name: cfg.name,
UDPEnabled: true,
Discovery: cfg.discovery,
Metadata: types.LeaseMetadata{
Description: cfg.desc,
Tags: utils.SplitCSV(cfg.tags),
Expand Down Expand Up @@ -273,7 +273,7 @@ func printUDPUsage(w io.Writer) {
[]string{
"demo-app udp",
"demo-app udp --name my-udp-demo",
"demo-app udp --default-relays=true",
"demo-app udp --discovery=true",
},
)
}
10 changes: 5 additions & 5 deletions cmd/portal-tunnel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ portal expose localhost:8080 \
- Bare ports resolve to `127.0.0.1:<port>`.
- `--name` is optional. When omitted, the CLI generates a name for that run.
- `--relays` sets the relay API URLs for that run.
- `--default-relays=false` disables the public registry list for that run.
- `--discovery=false` disables the public registry seed list and the discovery expansion loop for that run.

Flags:

```text
--relays Portal relay API URLs (comma-separated, https only)
--default-relays Include public registry relays
--discovery Include public registry relays and discover additional relay bootstraps
--name Public hostname prefix (single DNS label); auto-generated when omitted
--description Service description metadata
--tags Service tags metadata (comma-separated)
Expand All @@ -54,7 +54,7 @@ Flags:
### `portal list [flags]`

- Prints the relay URLs that the CLI will use for the current invocation.
- `--relays` and `--default-relays=false` follow the same semantics as `portal expose`.
- `--relays` adds explicit relay URLs, and `--default-relays=false` disables the public registry list for the current listing run.

Legacy execution compatibility has been removed:

Expand All @@ -67,7 +67,7 @@ Legacy execution compatibility has been removed:
- `install.sh` installs the downloaded binary as `portal`.
- `install.ps1` installs `portal.exe` for the current Windows user and updates the user `PATH`.
- The installer does not write a config file.
- `portal expose 3000` still works after install because default relays are enabled.
- `portal expose 3000` still works after install because discovery is enabled by default.
- Use `--relays https://portal.example.com` only when you want to target a specific relay explicitly.

## Notes
Expand All @@ -77,7 +77,7 @@ Legacy execution compatibility has been removed:
- The tunnel consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
- Relay startup and reconnect failures are retried independently in the background. A relay that is down does not stop healthy relays from continuing to serve traffic.
- The tunnel starts once relay URLs pass local validation. Remote compatibility checks, lease registration, and reconnects continue in the background until each relay becomes ready.
- The configured relay list is either `public registry + --relays values` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
- With discovery enabled, the configured relay list starts with `public registry + --relays values` and can expand through relay discovery. With `--discovery=false`, only the explicit relay URLs are used. Published public URLs appear only for relays that have registered successfully.
- SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
- When the local service is unreachable, the tunnel returns an HTTP 503 page.
45 changes: 21 additions & 24 deletions cmd/portal-tunnel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,26 @@ func main() {
}

type exposeFlags struct {
relayCSV string
defaultRelays bool
discoveryEnabled bool
privateKey string
name string
desc string
tags string
owner string
thumbnail string
hide bool
targetAddr string
udp bool
udpAddr string
relayCSV string
discovery bool
privateKey string
name string
desc string
tags string
owner string
thumbnail string
hide bool
targetAddr string
udp bool
udpAddr string
}

func runExposeCommand(args []string) error {
flags := exposeFlags{}
fs := utils.NewFlagSet("expose", printExposeUsage)

utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
utils.BoolFlag(fs, &flags.defaultRelays, "default-relays", true, "Include public registry relays")
utils.BoolFlag(fs, &flags.discoveryEnabled, "discovery", false, "Advertise known relay URLs and discover additional relay bootstraps")
utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include public registry relays and discover additional relay bootstraps")
utils.StringFlag(fs, &flags.privateKey, "private-key", "", "Owner private key used to derive a discovery address")
utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata")
Expand Down Expand Up @@ -92,13 +90,12 @@ func runExposeCommand(args []string) error {
defer stop()

exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
RelayURLs: utils.SplitCSV(flags.relayCSV),
DefaultRelayEnabled: flags.defaultRelays,
Name: flags.name,
TargetAddr: flags.targetAddr,
UDPAddr: flags.udpAddr,
UDPEnabled: flags.udp,
Discovery: flags.discoveryEnabled,
RelayURLs: utils.SplitCSV(flags.relayCSV),
Name: flags.name,
TargetAddr: flags.targetAddr,
UDPAddr: flags.udpAddr,
UDPEnabled: flags.udp,
Discovery: flags.discovery,
Metadata: types.LeaseMetadata{
Description: flags.desc,
Tags: utils.SplitCSV(flags.tags),
Expand Down Expand Up @@ -142,7 +139,7 @@ func runListCommand(args []string) error {

relayInputs := utils.SplitCSV(flags.relayCSV)

relayURLs, err := sdk.ResolveRelayURLs(ctx, relayInputs, flags.defaultRelays)
relayURLs, err := utils.ResolvePortalRelayURLs(ctx, relayInputs, flags.defaultRelays)
if err != nil {
return fmt.Errorf("resolve relay urls: %w", err)
}
Expand Down Expand Up @@ -255,7 +252,7 @@ func printExposeUsage(w io.Writer) {
"portal expose 3000",
"portal expose localhost:8080 --name my-app",
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
"portal expose 3000 --relays https://portal.example.com --default-relays=false",
"portal expose 3000 --relays https://portal.example.com --discovery=false",
},
)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/portal-tunnel/relays.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func proxyExposure(ctx context.Context, exposure *sdk.Exposure, serviceName stri
log.Error().Err(udpErr).Msg("udp proxy exited with error")
}
if closeErr != nil {
log.Error().Err(closeErr).Msg("relay shutdown failed")
log.Warn().Err(closeErr).Msg("relay shutdown completed with cleanup errors")
}

if ctx.Err() != nil {
Expand Down Expand Up @@ -126,7 +126,7 @@ func proxyRelayConnections(ctx context.Context, exposure *sdk.Exposure, localAdd
go func(connID int64, relayConn net.Conn) {
defer connWG.Done()
if err := proxyConnection(ctx, localAddr, relayConn); err != nil {
log.Error().Err(err).Int64("conn_id", connID).Msg("proxy connection failed")
log.Debug().Err(err).Int64("conn_id", connID).Msg("proxy connection closed with an I/O error")
}
log.Info().Int64("conn_id", connID).Msg("proxy connection closed")
}(connID, relayConn)
Expand Down
9 changes: 7 additions & 2 deletions cmd/relay-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func runServeCommand(args []string) error {
utils.IntFlagEnv(fs, &cfg.UDPPortCount, "udp-port-count", 0, utils.ParseNonNegativeInt, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled)", "UDP_PORT_COUNT")
utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY_ENABLED")
utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
Expand Down Expand Up @@ -106,10 +106,15 @@ func runServeCommand(args []string) error {
}

func runServer(ctx context.Context, cfg relayServerConfig) error {
bootstraps, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.Bootstraps), cfg.DiscoveryEnabled)
if err != nil {
return fmt.Errorf("resolve discovery bootstraps: %w", err)
}

server, err := portal.NewServer(portal.ServerConfig{
PortalURL: cfg.PortalURL,
OwnerPrivateKey: cfg.OwnerPrivateKey,
Bootstraps: []string{cfg.Bootstraps},
Bootstraps: bootstraps,
ACME: acme.Config{
KeyDir: cfg.KeylessDir,
DNSProvider: cfg.ACMEDNSProvider,
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ services:
# - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
environment:
# Public routing
# Public routing and discovery
PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
BOOTSTRAPS: ${BOOTSTRAPS:-}
DISCOVERY: ${DISCOVERY:-true}

# Listener ports (published to the host below)
API_PORT: ${API_PORT:-4017}
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h

### SDK (`sdk/`)

- `ExposeConfig.DefaultRelayEnabled`: when true, `Expose` fetches the default Portal relay registry, merges it with explicit relay inputs, and normalizes the result
- `ExposeConfig.Discovery`: when true, `Expose` fetches the default Portal relay registry, merges it with explicit relay inputs, normalizes the result, and runs the relay discovery loop
- Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
- `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
- `api_client.go`: internal relay client for control-plane requests, reverse session dialing, and internal QUIC tunnel setup
- `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
- `NewListener` callers provide explicit normalized relay URLs
- Default exposure flow is `Expose{DefaultRelayEnabled: true} -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
- Default exposure flow is `Expose{Discovery: true} -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
- `expose.go`: optional `RunHTTP` helper for serving one handler on both a local HTTP port and the relay listener
- `Expose` keeps one listener per configured relay URL. Relay startup and reconnect failures are retried independently per relay, and successful relays remain available while failed relays keep retrying in the background
- `Exposure.RelayURLs()` returns the configured normalized relay URLs, while `Exposure.PublicURLs()` returns only relays that are currently registered and ready
Expand Down
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ Navigate to `/admin`, toggle UDP transport to "Enabled", and optionally set a ma

```bash
PORTAL_URL=https://example.com
BOOTSTRAPS=
DISCOVERY=true
SNI_PORT=443
ADMIN_SECRET_KEY=your-admin-secret
KEYLESS_DIR=./.portal-certs
Expand Down
8 changes: 4 additions & 4 deletions portal/transport/datagram_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ func (d *ClientDatagram) RunLoop(

conn, err := open(ctx, state)
if err != nil {
log.Warn().
log.Info().
Err(err).
Str("component", "sdk-datagram-plane").
Str("lease_id", state.LeaseID).
Msg("quic session open failed, retrying")
Msg("quic datagram plane unavailable; retrying")
if !utils.SleepOrDone(ctx, 2*time.Second) {
d.session.Stop("listener context closed")
return
Expand All @@ -74,11 +74,11 @@ func (d *ClientDatagram) RunLoop(
if ctx.Err() != nil {
return
}
log.Warn().
log.Info().
Err(err).
Str("component", "sdk-datagram-plane").
Str("lease_id", state.LeaseID).
Msg("quic session bind failed")
Msg("quic datagram plane did not bind cleanly; retrying")
if !utils.SleepOrDone(ctx, time.Second) {
return
}
Expand Down
Loading
Loading