| 1 | package sdk |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base32" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "net" |
| 11 | "net/http" |
| 12 | "net/url" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/gosuda/portal-tunnel/v2/portal/auth" |
| 18 | "github.com/gosuda/portal-tunnel/v2/portal/identity" |
| 19 | "github.com/gosuda/portal-tunnel/v2/types" |
| 20 | "github.com/gosuda/portal-tunnel/v2/utils" |
| 21 | ) |
| 22 | |
| 23 | const ( |
| 24 | defaultDialTimeout = 5 * time.Second |
| 25 | defaultRequestTimeout = 15 * time.Second |
| 26 | defaultHandshakeTimeout = 15 * time.Second |
| 27 | defaultLeaseTTL = 2 * time.Minute |
| 28 | defaultRenewBefore = 30 * time.Second |
| 29 | defaultReadyTarget = 2 |
| 30 | defaultRetryWait = 3 * time.Second |
| 31 | defaultHTTPShutdownTimeout = 5 * time.Second |
| 32 | ) |
| 33 | |
| 34 | var errRelayIncompatible = errors.New("relay is incompatible") |
| 35 | |
| 36 | // resetTransport tears down the cached HTTP client and TLS config so the next |
| 37 | // API call creates fresh TCP connections. Call this after detecting a system |
| 38 | // sleep/wake cycle where pooled connections are almost certainly dead. |
| 39 | func (l *listener) resetTransport() { |
| 40 | if l.httpTransport != nil { |
| 41 | l.httpTransport.CloseIdleConnections() |
| 42 | } |
| 43 | l.httpClient = nil |
| 44 | l.httpTransport = nil |
| 45 | l.tlsConfig = nil |
| 46 | } |
| 47 | |
| 48 | func (l *listener) initHTTPTransport(ctx context.Context) error { |
| 49 | if l.httpClient != nil { |
| 50 | return nil |
| 51 | } |
| 52 | |
| 53 | bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout) |
| 54 | defer cancel() |
| 55 | |
| 56 | tlsConfig, httpClient, httpTransport, err := utils.NewHTTPTLSClient(bootstrapCtx, l.relayURL, l.requestTimeout) |
| 57 | if err != nil { |
| 58 | return err |
| 59 | } |
| 60 | |
| 61 | var domainResp types.DomainResponse |
| 62 | if err := utils.HTTPDoAPIPath(ctx, httpClient, l.relayURL, http.MethodGet, types.PathSDKDomain, nil, nil, &domainResp); err != nil { |
| 63 | httpTransport.CloseIdleConnections() |
| 64 | err = fmt.Errorf("check relay compatibility: %w", err) |
| 65 | var netErr net.Error |
| 66 | var apiErr *types.APIRequestError |
| 67 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) { |
| 68 | return err |
| 69 | } |
| 70 | if errors.As(err, &apiErr) && apiErr.StatusCode >= 500 { |
| 71 | return err |
| 72 | } |
| 73 | return fmt.Errorf("%w: %w", errRelayIncompatible, err) |
| 74 | } |
| 75 | protocolVersion := strings.TrimSpace(domainResp.ProtocolVersion) |
| 76 | if protocolVersion != types.SDKVersion { |
| 77 | httpTransport.CloseIdleConnections() |
| 78 | return fmt.Errorf("%w: relay sdk protocol version mismatch: relay=%q client=%q", errRelayIncompatible, protocolVersion, types.SDKVersion) |
| 79 | } |
| 80 | |
| 81 | l.releaseVersion = strings.TrimSpace(domainResp.ReleaseVersion) |
| 82 | |
| 83 | l.httpClient = httpClient |
| 84 | l.httpTransport = httpTransport |
| 85 | l.tlsConfig = tlsConfig |
| 86 | return nil |
| 87 | } |
| 88 | |
| 89 | func (l *listener) buildHopRoutes(hopPath []types.RelayDescriptor, publicHostname, routeHostname string, echConfigList []byte) ([]types.HopRoute, string, error) { |
| 90 | if len(hopPath) < 2 { |
| 91 | return nil, "", errors.New("multi-hop requires at least entry and exit relay urls") |
| 92 | } |
| 93 | hopRoutes := make([]types.HopRoute, 0, len(hopPath)-1) |
| 94 | var previousHopToken string |
| 95 | for i := 0; i < len(hopPath)-1; i++ { |
| 96 | token, err := identity.DeriveToken( |
| 97 | l.identity, |
| 98 | "hop-token", |
| 99 | publicHostname, |
| 100 | strconv.Itoa(i), |
| 101 | hopPath[i].APIHTTPSAddr, |
| 102 | hopPath[i+1].APIHTTPSAddr, |
| 103 | ) |
| 104 | if err != nil { |
| 105 | return nil, "", err |
| 106 | } |
| 107 | forwardToken := "hpt_" + token |
| 108 | route := types.HopRoute{ |
| 109 | RelayURL: hopPath[i].APIHTTPSAddr, |
| 110 | ForwardRelay: hopPath[i+1], |
| 111 | ForwardToken: forwardToken, |
| 112 | } |
| 113 | if i == 0 { |
| 114 | route.PublicHostname = publicHostname |
| 115 | route.RouteHostname = routeHostname |
| 116 | route.HostnameHash = utils.HostnameHash(publicHostname) |
| 117 | route.ECHConfigList = bytes.Clone(echConfigList) |
| 118 | route.Metadata = l.metadataSnapshot() |
| 119 | route.Metadata.Hide = true |
| 120 | } else { |
| 121 | route.MatchToken = previousHopToken |
| 122 | } |
| 123 | hopRoutes = append(hopRoutes, route) |
| 124 | previousHopToken = forwardToken |
| 125 | } |
| 126 | return hopRoutes, previousHopToken, nil |
| 127 | } |
| 128 | |
| 129 | func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, []types.HopRoute, string, string, error) { |
| 130 | var exitHopToken string |
| 131 | var publicHostname string |
| 132 | var routeHostname string |
| 133 | var rootHostname string |
| 134 | var hopRoutes []types.HopRoute |
| 135 | multiHop := l.route.MultiHop() |
| 136 | var hopPath []types.RelayDescriptor |
| 137 | streamLease := !udpEnabled && !tcpEnabled |
| 138 | registerIdentity := l.identity |
| 139 | if len(multiHop) > 0 { |
| 140 | if !streamLease { |
| 141 | return types.RegisterResponse{}, nil, "", "", errors.New("multi-hop requires stream lease") |
| 142 | } |
| 143 | if len(multiHop) < 2 { |
| 144 | return types.RegisterResponse{}, nil, "", "", errors.New("multi-hop requires at least entry and exit relay urls") |
| 145 | } |
| 146 | if l.relaySet == nil { |
| 147 | return types.RegisterResponse{}, nil, "", "", errors.New("multi-hop relay set is unavailable") |
| 148 | } |
| 149 | |
| 150 | now := time.Now().UTC() |
| 151 | hopPath = make([]types.RelayDescriptor, 0, len(multiHop)) |
| 152 | for i, relayURL := range multiHop { |
| 153 | desc, ok := l.relaySet.OverlayRelayDescriptor(relayURL, now) |
| 154 | if !ok { |
| 155 | return types.RegisterResponse{}, nil, "", "", fmt.Errorf("multi-hop relay %d descriptor is unavailable", i) |
| 156 | } |
| 157 | hopPath = append(hopPath, desc) |
| 158 | } |
| 159 | |
| 160 | rootHostname = utils.PortalRootHost(hopPath[0].APIHTTPSAddr) |
| 161 | } else { |
| 162 | rootHostname = utils.PortalRootHost(l.relayURL.String()) |
| 163 | } |
| 164 | |
| 165 | var err error |
| 166 | publicHostname, err = utils.LeaseHostname(l.identity.Name, rootHostname) |
| 167 | if err != nil { |
| 168 | return types.RegisterResponse{}, nil, "", "", err |
| 169 | } |
| 170 | if streamLease { |
| 171 | routeToken, err := identity.DeriveToken(l.identity, "ech-route", publicHostname, rootHostname) |
| 172 | if err != nil { |
| 173 | return types.RegisterResponse{}, nil, "", "", err |
| 174 | } |
| 175 | routeSum := sha256.Sum256([]byte(routeToken)) |
| 176 | routeLabel := "ech-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(routeSum[:20])) |
| 177 | routeHostname, err = utils.LeaseHostname(routeLabel, rootHostname) |
| 178 | if err != nil { |
| 179 | return types.RegisterResponse{}, nil, "", "", err |
| 180 | } |
| 181 | } |
| 182 | var echConfigList []byte |
| 183 | if streamLease { |
| 184 | _, echConfigList, err = l.tenantECHMaterials(publicHostname, routeHostname) |
| 185 | if err != nil { |
| 186 | return types.RegisterResponse{}, nil, "", "", err |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | if len(multiHop) > 0 { |
| 191 | var err error |
| 192 | hopRoutes, exitHopToken, err = l.buildHopRoutes(hopPath, publicHostname, routeHostname, echConfigList) |
| 193 | if err != nil { |
| 194 | return types.RegisterResponse{}, nil, "", "", err |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | registerReq := types.RegisterChallengeRequest{ |
| 199 | Identity: registerIdentity, |
| 200 | Metadata: l.metadataSnapshot(), |
| 201 | TTL: int(ttl / time.Second), |
| 202 | UDPEnabled: udpEnabled, |
| 203 | TCPEnabled: tcpEnabled, |
| 204 | HopToken: exitHopToken, |
| 205 | } |
| 206 | if streamLease && len(multiHop) == 0 { |
| 207 | registerReq.RouteHostname = routeHostname |
| 208 | registerReq.HostnameHash = utils.HostnameHash(publicHostname) |
| 209 | registerReq.ECHConfigList = bytes.Clone(echConfigList) |
| 210 | } |
| 211 | |
| 212 | var challenge types.RegisterChallengeResponse |
| 213 | if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegisterChallenge, registerReq, nil, &challenge); err != nil { |
| 214 | return types.RegisterResponse{}, nil, "", "", err |
| 215 | } |
| 216 | |
| 217 | authority, err := identity.NewLocalAuthority(l.identity) |
| 218 | if err != nil { |
| 219 | return types.RegisterResponse{}, nil, "", "", err |
| 220 | } |
| 221 | signature, err := authority.SignEthereumPersonalMessage(challenge.SIWEMessage) |
| 222 | if err != nil { |
| 223 | return types.RegisterResponse{}, nil, "", "", err |
| 224 | } |
| 225 | |
| 226 | var resp types.RegisterResponse |
| 227 | if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{ |
| 228 | ChallengeID: challenge.ChallengeID, |
| 229 | SIWEMessage: challenge.SIWEMessage, |
| 230 | SIWESignature: signature, |
| 231 | ReportedIP: utils.ResolvePublicIP(ctx), |
| 232 | }, nil, &resp); err != nil { |
| 233 | return types.RegisterResponse{}, nil, "", "", err |
| 234 | } |
| 235 | registeredIdentity, err := identity.NormalizeIdentity(resp.Identity) |
| 236 | if err != nil { |
| 237 | _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes) |
| 238 | return types.RegisterResponse{}, nil, "", "", err |
| 239 | } |
| 240 | if registeredIdentity.Key() != registerIdentity.Key() { |
| 241 | _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes) |
| 242 | return types.RegisterResponse{}, nil, "", "", errors.New("relay returned mismatched lease identity") |
| 243 | } |
| 244 | return resp, hopRoutes, publicHostname, routeHostname, nil |
| 245 | } |
| 246 | |
| 247 | func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string) (types.RenewResponse, error) { |
| 248 | var resp types.RenewResponse |
| 249 | req := newRenewRequest(ttl, accessToken, utils.ResolvePublicIP(ctx), l.metadataSnapshot()) |
| 250 | if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRenew, req, nil, &resp); err != nil { |
| 251 | return types.RenewResponse{}, err |
| 252 | } |
| 253 | return resp, nil |
| 254 | } |
| 255 | |
| 256 | func newRenewRequest(ttl time.Duration, accessToken, reportedIP string, metadata types.LeaseMetadata) types.RenewRequest { |
| 257 | return types.RenewRequest{ |
| 258 | AccessToken: accessToken, |
| 259 | TTL: int(ttl / time.Second), |
| 260 | ReportedIP: reportedIP, |
| 261 | Metadata: metadata.Copy(), |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func (l *listener) unregisterLease(ctx context.Context, accessToken string, hopRoutes []types.HopRoute) error { |
| 266 | hopErr := l.unregisterHopRoutes(ctx, hopRoutes) |
| 267 | err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{ |
| 268 | AccessToken: accessToken, |
| 269 | }, nil, nil) |
| 270 | return errors.Join(hopErr, err) |
| 271 | } |
| 272 | |
| 273 | func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, routes []types.HopRoute) (string, int, error) { |
| 274 | if l.relaySet == nil { |
| 275 | return "", 0, errors.New("multi-hop relay set is unavailable") |
| 276 | } |
| 277 | authority, err := identity.NewLocalAuthority(l.identity) |
| 278 | if err != nil { |
| 279 | return "", 0, err |
| 280 | } |
| 281 | |
| 282 | now := time.Now().UTC() |
| 283 | for i := len(routes) - 1; i >= 0; i-- { |
| 284 | route := routes[i] |
| 285 | desc, ok := l.relaySet.OverlayRelayDescriptor(route.ForwardRelay.APIHTTPSAddr, now) |
| 286 | if !ok { |
| 287 | return "", 0, fmt.Errorf("multi-hop forward relay %d descriptor is unavailable", i) |
| 288 | } |
| 289 | route.ForwardRelay = desc |
| 290 | route.FirstSeenAt = expiresAt.Add(-30 * time.Second) |
| 291 | if i == 0 { |
| 292 | route.Metadata = l.metadataSnapshot() |
| 293 | route.Metadata.Hide = true |
| 294 | } |
| 295 | route, err := auth.SignHopRoute(http.MethodPost, route, authority, expiresAt) |
| 296 | if err != nil { |
| 297 | return "", 0, err |
| 298 | } |
| 299 | relayURL, err := url.Parse(route.RelayURL) |
| 300 | if err != nil { |
| 301 | return "", 0, fmt.Errorf("parse hop route relay url: %w", err) |
| 302 | } |
| 303 | |
| 304 | bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout) |
| 305 | _, client, transport, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout) |
| 306 | cancel() |
| 307 | if err != nil { |
| 308 | return "", 0, err |
| 309 | } |
| 310 | var hopResp types.HopRouteResponse |
| 311 | err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodPost, types.PathSDKHop, route, nil, &hopResp) |
| 312 | transport.CloseIdleConnections() |
| 313 | if err != nil { |
| 314 | return "", 0, err |
| 315 | } |
| 316 | if route.MatchToken != "" || route.RouteHostname == "" { |
| 317 | continue |
| 318 | } |
| 319 | if hopResp.AccessToken == "" { |
| 320 | return "", 0, errors.New("entry relay did not return access token") |
| 321 | } |
| 322 | if hopResp.SNIPort <= 0 { |
| 323 | return "", 0, errors.New("entry relay did not return sni port") |
| 324 | } |
| 325 | return hopResp.AccessToken, hopResp.SNIPort, nil |
| 326 | } |
| 327 | return "", 0, errors.New("entry hop route did not return access token") |
| 328 | } |
| 329 | |
| 330 | func (l *listener) unregisterHopRoutes(ctx context.Context, routes []types.HopRoute) error { |
| 331 | var unregisterErr error |
| 332 | authority, err := identity.NewLocalAuthority(l.identity) |
| 333 | if err != nil { |
| 334 | return err |
| 335 | } |
| 336 | for _, route := range routes { |
| 337 | route, err := auth.SignHopRoute(http.MethodDelete, route, authority, time.Time{}) |
| 338 | if err != nil { |
| 339 | unregisterErr = errors.Join(unregisterErr, err) |
| 340 | continue |
| 341 | } |
| 342 | relayURL, err := url.Parse(route.RelayURL) |
| 343 | if err != nil { |
| 344 | unregisterErr = errors.Join(unregisterErr, fmt.Errorf("parse hop route relay url: %w", err)) |
| 345 | continue |
| 346 | } |
| 347 | |
| 348 | bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout) |
| 349 | _, client, transport, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout) |
| 350 | cancel() |
| 351 | if err != nil { |
| 352 | unregisterErr = errors.Join(unregisterErr, err) |
| 353 | continue |
| 354 | } |
| 355 | err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodDelete, types.PathSDKHop, route, nil, nil) |
| 356 | transport.CloseIdleConnections() |
| 357 | if err != nil { |
| 358 | unregisterErr = errors.Join(unregisterErr, err) |
| 359 | } |
| 360 | } |
| 361 | return unregisterErr |
| 362 | } |