diff --git a/docs/src/docs/multihost.md b/docs/src/docs/multihost.md index 41b3f88e..116ae74b 100644 --- a/docs/src/docs/multihost.md +++ b/docs/src/docs/multihost.md @@ -125,9 +125,43 @@ Server manages configuration for all clients: - **Server permissions**: `Read/Write Config` scoped to `*` + `Receive Shared Repos` - **Result**: The server can push config changes (repos and plans) to connected clients +## Reverse Proxy + +When exposing a Backrest server to remote clients, you only need to expose the sync RPC path. All other Backrest endpoints (UI, admin API, metrics, downloads) should remain on your trusted network. + +**Path to expose**: `/v1sync.BackrestSyncService/` + +This is the single bidirectional gRPC/Connect stream peers use to sync. The protocol runs its own post-quantum-safe encrypted transport on top of the connection, but you should still terminate TLS at the proxy. + +Requirements: + +- **HTTP/2 end-to-end** (or h2c to the upstream) — the sync stream is a long-lived bidi stream and will not work over HTTP/1.1. +- **No response buffering** on the proxy. +- **Long timeouts** (hours, not seconds) — the stream is intentionally persistent. +- **No request/response size limits** on the sync path. + +### Caddy + +```Caddyfile +backrest.example.com { + @sync path /v1sync.BackrestSyncService/* + reverse_proxy @sync h2c://127.0.0.1:9898 { + flush_interval -1 + transport http { + read_timeout 24h + write_timeout 24h + } + } +} +``` + +Replace `127.0.0.1:9898` with your Backrest instance's bind address. Any path other than `/v1sync.BackrestSyncService/*` will return 404, keeping the UI and admin API off the public internet. + +If you also want to expose the UI publicly (not recommended without additional auth in front), add a second `reverse_proxy` block without the path matcher — but be aware this also exposes the admin API. + ## Troubleshooting -**Client can't connect**: Verify the Instance URL is reachable from the client. The URL should include the port (default 9898). If using a reverse proxy, ensure it supports HTTP/2 (needed for the bidirectional sync stream) and is configured to allow long polling requests (e.g. 10+ minutes). Disable any proxy timeouts or payload size limits that could interfere with the sync connection. Recommend using a modern reverse proxy like Caddy. +**Client can't connect**: Verify the Instance URL is reachable from the client. The URL should include the port (default 9898). If using a reverse proxy, ensure it supports HTTP/2 (needed for the bidirectional sync stream) and is configured to allow long polling requests (e.g. 10+ minutes). Disable any proxy timeouts or payload size limits that could interfere with the sync connection. Recommend using a modern reverse proxy like Caddy. See the [Reverse Proxy](#reverse-proxy) section above for a working Caddy config. **Pairing fails**: Check that the pairing token hasn't expired and hasn't exceeded its max uses. Generate a new token if needed. diff --git a/internal/api/syncapi/syncclient.go b/internal/api/syncapi/syncclient.go index 7e279e94..dbf050dd 100644 --- a/internal/api/syncapi/syncclient.go +++ b/internal/api/syncapi/syncclient.go @@ -7,7 +7,9 @@ import ( "fmt" "net" "net/http" + "net/url" "slices" + "strings" "sync" "time" @@ -37,16 +39,37 @@ type SyncClient struct { reconnectAttempts int } -func newInsecureClient() *http.Client { - return &http.Client{ - Transport: &http2.Transport{ - AllowHTTP: true, - DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { - return net.Dial(network, addr) +// newSyncHTTPClient builds the HTTP/2 client used to dial a peer's instance URL. +// http:// URLs use h2c prior knowledge (plaintext HTTP/2). https:// URLs do a +// real TLS handshake and require the peer to negotiate "h2" via ALPN, which +// matches what reverse proxies like Caddy serve on their TLS listeners. +func newSyncHTTPClient(instanceURL string) (*http.Client, error) { + u, err := url.Parse(instanceURL) + if err != nil { + return nil, fmt.Errorf("parse instance URL %q: %w", instanceURL, err) + } + switch strings.ToLower(u.Scheme) { + case "http": + return &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, + IdleConnTimeout: 300 * time.Second, + ReadIdleTimeout: 60 * time.Second, }, - IdleConnTimeout: 300 * time.Second, - ReadIdleTimeout: 60 * time.Second, - }, + }, nil + case "https": + return &http.Client{ + Transport: &http2.Transport{ + IdleConnTimeout: 300 * time.Second, + ReadIdleTimeout: 60 * time.Second, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported instance URL scheme %q in %q (expected http or https)", u.Scheme, instanceURL) } } @@ -60,8 +83,12 @@ func NewSyncClient( return nil, errors.New("peer instance URL is required") } + httpClient, err := newSyncHTTPClient(peer.GetInstanceUrl()) + if err != nil { + return nil, err + } client := v1syncconnect.NewBackrestSyncServiceClient( - newInsecureClient(), + httpClient, peer.GetInstanceUrl(), ) c := &SyncClient{