fix: allow multihost sync to use real h2 (rather than h2c) when using https
Docs / build (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Release Preview / call-reusable-release (push) Has been cancelled
Test / test-nix (push) Has been cancelled
Test / test-win (push) Has been cancelled
Docs / deploy (push) Has been cancelled

This commit is contained in:
Gareth
2026-05-03 23:12:23 -07:00
parent 2faa5224f4
commit d641593142
2 changed files with 72 additions and 11 deletions
+35 -1
View File
@@ -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.
+37 -10
View File
@@ -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{