feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/forwarder"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/registry"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRegistrationBody = 64 << 10
|
||||
maxHandoffBody = 64 << 10
|
||||
maxRoutingBody = 1 << 20
|
||||
maxFirstFrame = 128 << 10
|
||||
)
|
||||
|
||||
type ControlPlane interface {
|
||||
ResolveDeviceRoute(context.Context, string, string) (controlplane.RouteResolution, error)
|
||||
ResolvePhoneRoute(context.Context, string, string) (controlplane.RouteResolution, error)
|
||||
ResolveTenantRoute(context.Context, string, int64) (controlplane.RouteResolution, error)
|
||||
ResolveHandoffRoute(context.Context, string, string) (controlplane.RouteResolution, error)
|
||||
AuthorizeDevice(context.Context, string, string) (controlplane.SnapshotResponse, error)
|
||||
AuthorizationSnapshot(context.Context, string, int64) (controlplane.SnapshotResponse, error)
|
||||
AuthorizePhone(context.Context, string, string) (controlplane.PhoneAuthorization, error)
|
||||
ConsumePhoneHandoff(context.Context, any) (controlplane.ConsumedHandoff, error)
|
||||
CompletePhoneHandoff(context.Context, string, string, string, string) error
|
||||
RevokePhone(context.Context, string, string, string) error
|
||||
RegisterDevice(context.Context, string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ControlPlane ControlPlane
|
||||
Registry *registry.Registry
|
||||
AllowedPWAOrigins []string
|
||||
SourceHashSecret []byte
|
||||
TrustedProxyRanges []netip.Prefix
|
||||
AppVersion string
|
||||
NodeID string
|
||||
ForwardSecret []byte
|
||||
HandoffSecret []byte
|
||||
Forwarder *forwarder.Forwarder
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
controlPlane ControlPlane
|
||||
registry *registry.Registry
|
||||
origins map[string]struct{}
|
||||
sourceSecret []byte
|
||||
proxies []netip.Prefix
|
||||
appVersion string
|
||||
nodeID string
|
||||
forwardSecret []byte
|
||||
handoffSecret []byte
|
||||
forwarder *forwarder.Forwarder
|
||||
now func() time.Time
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func New(config Config) (*Service, error) {
|
||||
if config.ControlPlane == nil || config.Registry == nil || len(config.SourceHashSecret) < 32 ||
|
||||
strings.TrimSpace(config.NodeID) == "" || len(config.ForwardSecret) < 32 || len(config.HandoffSecret) < 32 {
|
||||
return nil, errors.New("service requires control plane, registry, and independent 32-byte secrets")
|
||||
}
|
||||
origins := make(map[string]struct{}, len(config.AllowedPWAOrigins))
|
||||
for _, origin := range config.AllowedPWAOrigins {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("service PWA origins must be exact origins")
|
||||
}
|
||||
origins[origin] = struct{}{}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return nil, errors.New("service requires an exact PWA origin allowlist")
|
||||
}
|
||||
if config.Now == nil {
|
||||
config.Now = time.Now
|
||||
}
|
||||
service := &Service{
|
||||
controlPlane: config.ControlPlane,
|
||||
registry: config.Registry,
|
||||
origins: origins,
|
||||
sourceSecret: append([]byte(nil), config.SourceHashSecret...),
|
||||
proxies: append([]netip.Prefix(nil), config.TrustedProxyRanges...),
|
||||
appVersion: strings.TrimSpace(config.AppVersion),
|
||||
nodeID: strings.TrimSpace(config.NodeID),
|
||||
forwardSecret: append([]byte(nil), config.ForwardSecret...),
|
||||
handoffSecret: append([]byte(nil), config.HandoffSecret...),
|
||||
forwarder: config.Forwarder,
|
||||
now: config.Now,
|
||||
}
|
||||
service.upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
EnableCompression: false,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
_, allowed := service.origins[origin]
|
||||
return allowed
|
||||
},
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) Handler() http.Handler { return s.cors(http.HandlerFunc(s.serveHTTP)) }
|
||||
|
||||
func (s *Service) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := forwarder.VerifyIncoming(r, s.nodeID, s.forwardSecret, s.now()); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay forwarding authentication failed", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
s.handleHealth(w, r)
|
||||
case "/ws/daemon":
|
||||
s.handleDaemonWebSocket(w, r)
|
||||
case "/ws/phone":
|
||||
s.handlePhoneWebSocket(w, r)
|
||||
case "/api/devices/register":
|
||||
s.handleDeviceRegistration(w, r)
|
||||
case "/api/pwa/handoff/exchange":
|
||||
s.handlePhoneHandoff(w, r)
|
||||
default:
|
||||
s.handleDataPlane(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin != "" {
|
||||
if _, allowed := s.origins[origin]; !allowed {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "origin is not allowed", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Neko-Phone-Token, X-Neko-Route-Handle")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
if origin == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "origin is required", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "nyan~",
|
||||
"service": "nekonest-cloud-relay",
|
||||
"server_version": s.appVersion,
|
||||
"protocol_version": protocol.CurrentProtocolVersion,
|
||||
"transport_mode": protocol.TransportSealed,
|
||||
})
|
||||
}
|
||||
|
||||
func readBounded(body io.ReadCloser, limit int64) ([]byte, error) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, errors.New("request body exceeds limit")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleDeviceRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := readBounded(r.Body, maxRegistrationBody)
|
||||
if err != nil || !json.Valid(body) {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRegistrationDisabled, "invalid registration request", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
bootstrap := strings.TrimSpace(r.Header.Get("X-Neko-Bootstrap"))
|
||||
if bootstrap == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "bootstrap credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
result, err := s.controlPlane.RegisterDevice(r.Context(), bootstrap, s.sourceHash(r), json.RawMessage(body))
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "registration is temporarily unavailable"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (s *Service) handlePhoneHandoff(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if _, allowed := s.origins[origin]; !allowed {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "PWA origin is not allowed", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
body, err := readBounded(r.Body, maxHandoffBody)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "invalid phone handoff", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Ticket string `json:"ticket"`
|
||||
PWAOrigin string `json:"pwa_origin"`
|
||||
Name string `json:"name"`
|
||||
PhoneEd25519Public string `json:"phone_ed25519_public"`
|
||||
PhoneX25519Public string `json:"phone_x25519_public"`
|
||||
IdentityFingerprint string `json:"identity_fingerprint"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || decoder.Decode(&struct{}{}) != io.EOF || request.PWAOrigin != origin {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "invalid phone handoff", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolveHandoffRoute(r.Context(), request.Ticket, request.PWAOrigin)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone handoff route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
consumed, err := s.controlPlane.ConsumePhoneHandoff(r.Context(), request)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorPhoneCredentialInvalid, "phone handoff was rejected"))
|
||||
return
|
||||
}
|
||||
snapshot, err := s.controlPlane.AuthorizationSnapshot(r.Context(), consumed.TenantID, consumed.PlacementGeneration)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "tenant route is unavailable"))
|
||||
return
|
||||
}
|
||||
tenant, err := s.registry.Accept(r.Context(), snapshot)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "tenant authorization could not be verified", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
phoneID, phoneToken, routeHandle := deriveHandoffCredentials(
|
||||
s.handoffSecret, consumed.HandoffID, consumed.IdentityFingerprint,
|
||||
)
|
||||
if err := tenant.SyncPhone(
|
||||
phoneID,
|
||||
consumed.Name,
|
||||
controlplane.SHA256Hex(phoneToken),
|
||||
consumed.PhoneEd25519Public,
|
||||
consumed.PhoneX25519Public,
|
||||
); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "phone identity could not be created", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
if err := s.completePhoneHandoff(
|
||||
r.Context(), consumed.HandoffID, phoneID,
|
||||
controlplane.SHA256Hex(phoneToken), controlplane.SHA256Hex(routeHandle),
|
||||
); err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone handoff completion is indeterminate"))
|
||||
return
|
||||
}
|
||||
// Completion records only a pending principal. Do not cache the route yet:
|
||||
// the first request must prove possession to the control plane, which then
|
||||
// activates the principal and advances the authorization revision.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"phone_id": phoneID,
|
||||
"phone_token": phoneToken,
|
||||
"route_handle": routeHandle,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleDaemonWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// Daemons do not send browser Origin. If a caller does, require the same
|
||||
// exact allowlist as the PWA rather than accepting an arbitrary website.
|
||||
if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" {
|
||||
if _, allowed := s.origins[origin]; !allowed {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "origin is not allowed", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
}
|
||||
upgrader := s.upgrader
|
||||
upgrader.CheckOrigin = func(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
_, allowed := s.origins[origin]
|
||||
return allowed
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
first, firstType, firstFrame, err := readFirstFrame(conn, 10*time.Second)
|
||||
if err != nil || first.Type != protocol.MsgRegisterDevice {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "expected device authentication", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
deviceID, _ := first.Payload["device_id"].(string)
|
||||
token, _ := first.Payload["token"].(string)
|
||||
if strings.TrimSpace(deviceID) == "" || strings.TrimSpace(token) == "" {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "device credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolveDeviceRoute(r.Context(), deviceID, controlplane.SHA256Hex(token))
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "device route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardWebSocket(conn, r, route, firstType, firstFrame)
|
||||
return
|
||||
}
|
||||
tenant, err := s.authorizeDevice(r.Context(), deviceID, token, false)
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "device route is unavailable"))
|
||||
return
|
||||
}
|
||||
tenant.Engine().ServeDaemonConn(conn, first)
|
||||
}
|
||||
|
||||
func (s *Service) handlePhoneWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
first, firstType, firstFrame, err := readFirstFrame(conn, 15*time.Second)
|
||||
if err != nil || first.Type != protocol.MsgSubscribe {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "expected phone subscription", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
routeHandle := requestRouteHandle(r, first)
|
||||
phoneToken := requestPhoneToken(r, first)
|
||||
if routeHandle == "" || phoneToken == "" {
|
||||
writeWebSocketError(conn, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone route and credential are required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolvePhoneRoute(r.Context(), routeHandle, controlplane.SHA256Hex(phoneToken))
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardWebSocket(conn, r, route, firstType, firstFrame)
|
||||
return
|
||||
}
|
||||
tenant, err := s.authorizePhone(r.Context(), routeHandle, phoneToken, false)
|
||||
if err != nil {
|
||||
writeWebSocketError(conn, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone route is unavailable"))
|
||||
return
|
||||
}
|
||||
cloned := r.Clone(r.Context())
|
||||
cloned.Header = r.Header.Clone()
|
||||
cloned.Header.Set("X-Neko-Phone-Token", phoneToken)
|
||||
tenant.Engine().ServePhoneConn(conn, cloned, first)
|
||||
}
|
||||
|
||||
func readFirstFrame(conn *websocket.Conn, timeout time.Duration) (*protocol.NekoMessage, int, []byte, error) {
|
||||
conn.SetReadLimit(maxFirstFrame)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
messageType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var message protocol.NekoMessage
|
||||
if err := json.Unmarshal(data, &message); err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
return &message, messageType, data, nil
|
||||
}
|
||||
|
||||
func (s *Service) forwardHTTP(w http.ResponseWriter, r *http.Request, route controlplane.RouteResolution) {
|
||||
if s.forwarder == nil || route.Local || route.EndpointRef == "" || route.RelayNodeID == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay route is unavailable", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
if err := s.forwarder.ForwardHTTP(w, r, route.EndpointRef, route.RelayNodeID); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay forwarding failed", true, http.StatusServiceUnavailable))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) forwardWebSocket(
|
||||
client *websocket.Conn, r *http.Request, route controlplane.RouteResolution,
|
||||
firstType int, firstFrame []byte,
|
||||
) {
|
||||
if s.forwarder == nil || route.Local || route.EndpointRef == "" || route.RelayNodeID == "" {
|
||||
writeWebSocketError(client, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay route is unavailable", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
target, response, err := s.forwarder.DialWebSocket(r.Context(), r, route.EndpointRef, route.RelayNodeID)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
writeWebSocketError(client, apiError(protocol.ServiceErrorRouteUnavailable, "internal Relay forwarding failed", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
defer target.Close()
|
||||
_ = forwarder.Tunnel(r.Context(), client, target, firstType, firstFrame)
|
||||
}
|
||||
|
||||
func (s *Service) authorizeDevice(ctx context.Context, deviceID, token string, allowCached bool) (*registry.Tenant, error) {
|
||||
digest := controlplane.SHA256Hex(token)
|
||||
response, err := s.controlPlane.AuthorizeDevice(ctx, strings.TrimSpace(deviceID), digest)
|
||||
if err != nil {
|
||||
if allowCached && controlUnavailable(err) {
|
||||
if tenant, ok := s.registry.ByDeviceID(deviceID); ok {
|
||||
return tenant, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := s.registry.Accept(ctx, response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
for _, device := range tenant.Payload().Devices {
|
||||
if device.DeviceID == deviceID && constantTimeEqual(device.CredentialHash, digest) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("authorized device missing from signed snapshot")
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *Service) authorizePhone(ctx context.Context, routeHandle, phoneToken string, allowCached bool) (*registry.Tenant, error) {
|
||||
tokenHash := controlplane.SHA256Hex(phoneToken)
|
||||
authorization, err := s.controlPlane.AuthorizePhone(ctx, routeHandle, tokenHash)
|
||||
if err != nil {
|
||||
if allowCached && controlUnavailable(err) {
|
||||
if tenant, ok := s.registry.ByPhoneRoute(routeHandle); ok {
|
||||
return tenant, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := s.controlPlane.AuthorizationSnapshot(ctx, authorization.TenantID, authorization.PlacementGeneration)
|
||||
if err != nil {
|
||||
if allowCached && controlUnavailable(err) {
|
||||
if tenant, ok := s.registry.ByPhoneRoute(routeHandle); ok {
|
||||
return tenant, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := s.registry.Accept(ctx, snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := tenant.Payload()
|
||||
if payload.TenantID != authorization.TenantID || payload.PlacementGeneration != authorization.PlacementGeneration {
|
||||
return nil, errors.New("phone authorization and signed placement disagree")
|
||||
}
|
||||
if err := tenant.SyncPhone(
|
||||
authorization.Phone.PhoneID, authorization.Phone.Name, tokenHash,
|
||||
authorization.Phone.Ed25519Public, authorization.Phone.X25519Public,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.registry.BindPhoneRoute(routeHandle, tenant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleDataPlane(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/attachments/") && r.Method == http.MethodGet {
|
||||
routeHint := strings.TrimSpace(r.URL.Query().Get("route"))
|
||||
if tenant, ok := s.registry.ByRouteHint(routeHint); ok {
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
tenantID, generation, err := s.registry.DecodeRouteHint(routeHint)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "attachment route is unavailable", false, http.StatusNotFound))
|
||||
return
|
||||
}
|
||||
route, err := s.controlPlane.ResolveTenantRoute(r.Context(), tenantID, generation)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "attachment route is unavailable"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
snapshot, err := s.controlPlane.AuthorizationSnapshot(r.Context(), tenantID, generation)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "attachment route is unavailable"))
|
||||
return
|
||||
}
|
||||
tenant, err := s.registry.Accept(r.Context(), snapshot)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "attachment route could not be restored", true, http.StatusServiceUnavailable))
|
||||
return
|
||||
}
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if routeHandle := strings.TrimSpace(r.Header.Get("X-Neko-Route-Handle")); routeHandle != "" {
|
||||
phoneToken := requestPhoneToken(r, nil)
|
||||
if phoneToken == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
if _, cached := s.registry.ByPhoneRoute(routeHandle); !cached {
|
||||
route, err := s.controlPlane.ResolvePhoneRoute(r.Context(), routeHandle, controlplane.SHA256Hex(phoneToken))
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorPhoneCredentialInvalid, "phone credential was rejected"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
}
|
||||
tenant, err := s.authorizePhone(r.Context(), routeHandle, phoneToken, true)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorPhoneCredentialInvalid, "phone credential was rejected"))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/phones/revoke" {
|
||||
s.handlePhoneRevoke(w, r, tenant, phoneToken)
|
||||
return
|
||||
}
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := readBounded(r.Body, maxRoutingBody)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "invalid device request", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
deviceID, token := deviceCredential(r, body)
|
||||
if deviceID == "" || token == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorDeviceCredentialInvalid, "device credential is required", false, http.StatusUnauthorized))
|
||||
return
|
||||
}
|
||||
if _, cached := s.registry.ByDeviceID(deviceID); !cached {
|
||||
route, err := s.controlPlane.ResolveDeviceRoute(r.Context(), deviceID, controlplane.SHA256Hex(token))
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorDeviceCredentialInvalid, "device credential was rejected"))
|
||||
return
|
||||
}
|
||||
if !route.Local {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
s.forwardHTTP(w, r, route)
|
||||
return
|
||||
}
|
||||
}
|
||||
tenant, err := s.authorizeDevice(r.Context(), deviceID, token, true)
|
||||
if err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorDeviceCredentialInvalid, "device credential was rejected"))
|
||||
return
|
||||
}
|
||||
tenant.Handler().ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Service) handlePhoneRevoke(w http.ResponseWriter, r *http.Request, tenant *registry.Tenant, _ string) {
|
||||
body, err := readBounded(r.Body, 16<<10)
|
||||
if err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "invalid phone revoke request", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
PhoneID string `json:"phone_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &request); err != nil || strings.TrimSpace(request.PhoneID) == "" {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone_id is required", false, http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
payload := tenant.Payload()
|
||||
// The Cloud data plane has no admin bypass. A route/token pair resolves one
|
||||
// phone principal, and the control plane enforces ownership before mutation.
|
||||
routeHandle := strings.TrimSpace(r.Header.Get("X-Neko-Route-Handle"))
|
||||
token := requestPhoneToken(r, nil)
|
||||
auth, err := s.controlPlane.AuthorizePhone(r.Context(), routeHandle, controlplane.SHA256Hex(token))
|
||||
if err != nil || auth.Phone.PhoneID != request.PhoneID {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorPhoneCredentialInvalid, "phone may only revoke itself", false, http.StatusForbidden))
|
||||
return
|
||||
}
|
||||
if err := s.controlPlane.RevokePhone(r.Context(), payload.TenantID, request.PhoneID, "phone self-revocation"); err != nil {
|
||||
writeHTTPError(w, publicError(err, protocol.ServiceErrorRouteUnavailable, "phone revocation is indeterminate"))
|
||||
return
|
||||
}
|
||||
if err := tenant.RevokePhone(request.PhoneID); err != nil {
|
||||
writeHTTPError(w, apiError(protocol.ServiceErrorRouteUnavailable, "phone revocation is indeterminate", false, http.StatusConflict))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "revoked", "phone_id": request.PhoneID})
|
||||
}
|
||||
|
||||
func deviceCredential(r *http.Request, body []byte) (string, string) {
|
||||
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
|
||||
token := strings.TrimSpace(r.Header.Get("X-Neko-Device-Token"))
|
||||
if token == "" {
|
||||
token = bearerToken(r.Header.Get("Authorization"))
|
||||
}
|
||||
if len(body) > 0 && json.Valid(body) {
|
||||
var payload struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if json.Unmarshal(body, &payload) == nil {
|
||||
if deviceID == "" {
|
||||
deviceID = strings.TrimSpace(payload.DeviceID)
|
||||
}
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(payload.Token)
|
||||
}
|
||||
}
|
||||
}
|
||||
return deviceID, token
|
||||
}
|
||||
|
||||
func requestPhoneToken(r *http.Request, first *protocol.NekoMessage) string {
|
||||
for _, value := range []string{
|
||||
strings.TrimSpace(r.Header.Get("X-Neko-Phone-Token")),
|
||||
bearerToken(r.Header.Get("Authorization")),
|
||||
} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if first != nil && first.Payload != nil {
|
||||
if value, ok := first.Payload["phone_token"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requestRouteHandle(r *http.Request, first *protocol.NekoMessage) string {
|
||||
if value := strings.TrimSpace(r.Header.Get("X-Neko-Route-Handle")); value != "" {
|
||||
return value
|
||||
}
|
||||
if first != nil && first.Payload != nil {
|
||||
if value, ok := first.Payload["route_handle"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func bearerToken(value string) string {
|
||||
if len(value) > len("Bearer ") && strings.EqualFold(value[:len("Bearer ")], "Bearer ") {
|
||||
return strings.TrimSpace(value[len("Bearer "):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) sourceHash(r *http.Request) string {
|
||||
address := sourceAddress(r, s.proxies)
|
||||
mac := hmac.New(sha256.New, s.sourceSecret)
|
||||
_, _ = mac.Write([]byte("nekonest-cloud/registration-source/v1\x00" + address))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func sourceAddress(r *http.Request, trusted []netip.Prefix) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err != nil {
|
||||
host = strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
remote, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return "invalid"
|
||||
}
|
||||
remote = remote.Unmap()
|
||||
trustedProxy := false
|
||||
for _, prefix := range trusted {
|
||||
if prefix.Contains(remote) {
|
||||
trustedProxy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if trustedProxy {
|
||||
candidate := strings.TrimSpace(r.Header.Get("CF-Connecting-IP"))
|
||||
if candidate == "" {
|
||||
candidate = strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
|
||||
}
|
||||
if forwarded, err := netip.ParseAddr(candidate); err == nil {
|
||||
return forwarded.Unmap().String()
|
||||
}
|
||||
}
|
||||
return remote.String()
|
||||
}
|
||||
|
||||
func deriveHandoffCredentials(secret []byte, handoffID, identityFingerprint string) (string, string, string) {
|
||||
derive := func(label string) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(label))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(handoffID))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(identityFingerprint))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
phoneDigest := derive("phone-id")
|
||||
return "phone_" + hex.EncodeToString(phoneDigest[:16]),
|
||||
hex.EncodeToString(derive("phone-token")),
|
||||
hex.EncodeToString(derive("route-handle"))
|
||||
}
|
||||
|
||||
func (s *Service) completePhoneHandoff(
|
||||
ctx context.Context, handoffID, phoneID, phoneTokenHash, routeHandleHash string,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt, delay := range []time.Duration{0, 100 * time.Millisecond, 300 * time.Millisecond} {
|
||||
if attempt > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return errors.Join(lastErr, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
lastErr = s.controlPlane.CompletePhoneHandoff(
|
||||
ctx, handoffID, phoneID, phoneTokenHash, routeHandleHash,
|
||||
)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
var remote *controlplane.RemoteError
|
||||
if errors.As(lastErr, &remote) && remote.Status < http.StatusInternalServerError && !remote.Body.Retryable {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func constantTimeEqual(left, right string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
|
||||
func controlUnavailable(err error) bool {
|
||||
var remote *controlplane.RemoteError
|
||||
return !errors.As(err, &remote)
|
||||
}
|
||||
|
||||
type serviceError struct {
|
||||
protocol.ServiceErrorPayload
|
||||
status int
|
||||
}
|
||||
|
||||
func apiError(code protocol.ServiceErrorCode, message string, retryable bool, status int) serviceError {
|
||||
return serviceError{ServiceErrorPayload: protocol.ServiceErrorPayload{
|
||||
ErrorCode: code, Message: message, Retryable: retryable,
|
||||
}, status: status}
|
||||
}
|
||||
|
||||
func publicError(err error, fallback protocol.ServiceErrorCode, message string) serviceError {
|
||||
var remote *controlplane.RemoteError
|
||||
if errors.As(err, &remote) && remote.Body.ErrorCode != "" {
|
||||
body := remote.Body
|
||||
if body.ActionURL != "" {
|
||||
parsed, parseErr := url.Parse(body.ActionURL)
|
||||
if parseErr != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
body.ActionURL = ""
|
||||
}
|
||||
}
|
||||
return serviceError{ServiceErrorPayload: body, status: remote.Status}
|
||||
}
|
||||
return serviceError{ServiceErrorPayload: protocol.ServiceErrorPayload{
|
||||
ErrorCode: fallback, Message: message, Retryable: true, RetryAfterSeconds: 5,
|
||||
}, status: http.StatusServiceUnavailable}
|
||||
}
|
||||
|
||||
func writeHTTPError(w http.ResponseWriter, err serviceError) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
status := err.status
|
||||
if status < 400 || status > 599 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(err.ServiceErrorPayload)
|
||||
}
|
||||
|
||||
func writeWebSocketError(conn *websocket.Conn, err serviceError) {
|
||||
_ = conn.WriteJSON(protocol.NekoMessage{
|
||||
ProtocolVersion: protocol.CurrentProtocolVersion,
|
||||
TransportMode: protocol.TransportSealed,
|
||||
Type: protocol.MsgError,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Payload: map[string]any{
|
||||
"error_code": err.ErrorCode,
|
||||
"message": err.Message,
|
||||
"retryable": err.Retryable,
|
||||
"retry_after_seconds": err.RetryAfterSeconds,
|
||||
"action_url": err.ActionURL,
|
||||
},
|
||||
})
|
||||
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, string(err.ErrorCode)), time.Now().Add(time.Second))
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/controlplane"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/forwarder"
|
||||
"github.com/klarkxy/nekonest-cloud/relay/internal/registry"
|
||||
"github.com/klarkxy/nekonest/relaycore/protocol"
|
||||
)
|
||||
|
||||
type fakeControlPlane struct {
|
||||
snapshot controlplane.SnapshotResponse
|
||||
register func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error)
|
||||
route *controlplane.RouteResolution
|
||||
consume func(context.Context, any) (controlplane.ConsumedHandoff, error)
|
||||
complete func(context.Context, string, string, string, string) error
|
||||
}
|
||||
|
||||
func localRoute() controlplane.RouteResolution {
|
||||
return controlplane.RouteResolution{
|
||||
RelayNodeID: "node_test", PlacementGeneration: 1,
|
||||
HomeRegion: "test", Local: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) ResolveDeviceRoute(context.Context, string, string) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
func (f *fakeControlPlane) ResolvePhoneRoute(context.Context, string, string) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
func (f *fakeControlPlane) ResolveTenantRoute(context.Context, string, int64) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
func (f *fakeControlPlane) ResolveHandoffRoute(context.Context, string, string) (controlplane.RouteResolution, error) {
|
||||
if f.route != nil {
|
||||
return *f.route, nil
|
||||
}
|
||||
return localRoute(), nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) AuthorizeDevice(_ context.Context, _ string, _ string) (controlplane.SnapshotResponse, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
func (f *fakeControlPlane) AuthorizationSnapshot(_ context.Context, _ string, _ int64) (controlplane.SnapshotResponse, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
func (f *fakeControlPlane) AuthorizationDelta(_ context.Context, _ string, _ int64) (controlplane.DeltaResponse, error) {
|
||||
return controlplane.DeltaResponse{}, nil
|
||||
}
|
||||
func (f *fakeControlPlane) AuthorizePhone(context.Context, string, string) (controlplane.PhoneAuthorization, error) {
|
||||
return controlplane.PhoneAuthorization{}, nil
|
||||
}
|
||||
func (f *fakeControlPlane) ConsumePhoneHandoff(ctx context.Context, request any) (controlplane.ConsumedHandoff, error) {
|
||||
if f.consume != nil {
|
||||
return f.consume(ctx, request)
|
||||
}
|
||||
return controlplane.ConsumedHandoff{}, nil
|
||||
}
|
||||
func (f *fakeControlPlane) CompletePhoneHandoff(ctx context.Context, handoffID, phoneID, phoneTokenHash, routeHandleHash string) error {
|
||||
if f.complete != nil {
|
||||
return f.complete(ctx, handoffID, phoneID, phoneTokenHash, routeHandleHash)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeControlPlane) RevokePhone(context.Context, string, string, string) error { return nil }
|
||||
func (f *fakeControlPlane) RegisterDevice(_ context.Context, bootstrap, source string, request json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return f.register(bootstrap, source, request)
|
||||
}
|
||||
|
||||
func signedFixture(t *testing.T, token string) (controlplane.SnapshotResponse, authsnapshot.Keyring) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
keyString := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
payload := authsnapshot.Payload{
|
||||
SnapshotVersion: 1,
|
||||
TenantID: "tenant_0123456789abcdef0123456789abcdef",
|
||||
TenantStatus: "active",
|
||||
HomeRegion: "test",
|
||||
RelayNodeID: "node_test",
|
||||
PlacementGeneration: 1,
|
||||
AuthorizationRevision: 1,
|
||||
Devices: []authsnapshot.Device{{
|
||||
DeviceID: "host_0123456789abcdef0123456789abcdef",
|
||||
CredentialHash: controlplane.SHA256Hex(token),
|
||||
IdentityFingerprint: strings.Repeat("a", 64),
|
||||
Ed25519Public: keyString,
|
||||
X25519Public: keyString,
|
||||
Name: "Test host",
|
||||
OS: "windows",
|
||||
}},
|
||||
IssuedAt: now.Add(-time.Second).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(4 * time.Minute).Format(time.RFC3339Nano),
|
||||
}
|
||||
message, err := authsnapshot.SigningBytes(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed := authsnapshot.Signed{
|
||||
Algorithm: "Ed25519",
|
||||
KID: "test-key",
|
||||
Payload: payload,
|
||||
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, message)),
|
||||
}
|
||||
jwk, _ := json.Marshal(map[string]string{
|
||||
"kty": "OKP", "crv": "Ed25519", "kid": "test-key",
|
||||
"x": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
})
|
||||
return controlplane.SnapshotResponse{Snapshot: signed, PublicKeyJWK: jwk}, authsnapshot.Keyring{
|
||||
"test-key": {PublicKey: publicKey, NotBefore: now.Add(-time.Hour), RetainUntil: now.Add(time.Hour)},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, control *fakeControlPlane, keyring authsnapshot.Keyring) *Service {
|
||||
return newTestServiceWithNode(t, control, keyring, "node_test", nil)
|
||||
}
|
||||
|
||||
func newTestServiceWithNode(
|
||||
t *testing.T, control *fakeControlPlane, keyring authsnapshot.Keyring,
|
||||
nodeID string, relayForwarder *forwarder.Forwarder,
|
||||
) *Service {
|
||||
t.Helper()
|
||||
registryValue, err := registry.New(registry.Config{
|
||||
DataRoot: t.TempDir(),
|
||||
NodeID: nodeID,
|
||||
AllowedOrigins: []string{"https://pwa.example"},
|
||||
RouteSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Keyring: keyring,
|
||||
ControlPlane: control,
|
||||
RefreshInterval: time.Hour,
|
||||
DeltaInterval: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = registryValue.Close() })
|
||||
service, err := New(Config{
|
||||
ControlPlane: control,
|
||||
Registry: registryValue,
|
||||
AllowedPWAOrigins: []string{"https://pwa.example"},
|
||||
SourceHashSecret: []byte("abcdef0123456789abcdef0123456789"),
|
||||
NodeID: nodeID,
|
||||
ForwardSecret: []byte("fedcba9876543210fedcba9876543210"),
|
||||
HandoffSecret: []byte("handoff0123456789handoff0123456789"),
|
||||
Forwarder: relayForwarder,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestRegistrationHashesTrustedSourceAndReturnsStableContract(t *testing.T) {
|
||||
snapshot, keys := signedFixture(t, "device-token")
|
||||
control := &fakeControlPlane{snapshot: snapshot}
|
||||
control.register = func(bootstrap, source string, body json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
if bootstrap != "pair_secret" || len(source) != 64 {
|
||||
t.Fatalf("bootstrap=%q source=%q", bootstrap, source)
|
||||
}
|
||||
if strings.Contains(string(body), "pair_secret") {
|
||||
t.Fatal("relay injected bootstrap into the client body before the trusted control call")
|
||||
}
|
||||
return protocol.DeviceRegistrationResponse{
|
||||
DeviceID: "host_0123456789abcdef0123456789abcdef",
|
||||
Token: "device-token",
|
||||
Name: "Host",
|
||||
TransportMode: protocol.TransportSealed,
|
||||
ConnectionState: protocol.ConnectionReady,
|
||||
}, nil
|
||||
}
|
||||
service := newTestService(t, control, keys)
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/devices/register", strings.NewReader(`{"os":"windows"}`))
|
||||
request.RemoteAddr = "203.0.113.5:12345"
|
||||
request.Header.Set("X-Neko-Bootstrap", "pair_secret")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"connection_state":"ready"`) {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceAddressOnlyTrustsForwardedIPFromConfiguredProxy(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/devices/register", nil)
|
||||
request.RemoteAddr = "192.0.2.10:443"
|
||||
request.Header.Set("CF-Connecting-IP", "203.0.113.9")
|
||||
if got := sourceAddress(request, nil); got != "192.0.2.10" {
|
||||
t.Fatalf("untrusted source = %q", got)
|
||||
}
|
||||
prefix := netip.MustParsePrefix("192.0.2.0/24")
|
||||
if got := sourceAddress(request, []netip.Prefix{prefix}); got != "203.0.113.9" {
|
||||
t.Fatalf("trusted source = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneHandoffRejectsTrailingJSON(t *testing.T) {
|
||||
snapshot, keys := signedFixture(t, "device-token")
|
||||
control := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
service := newTestService(t, control, keys)
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/pwa/handoff/exchange", strings.NewReader(
|
||||
`{"ticket":"ticket","pwa_origin":"https://pwa.example"} {}`,
|
||||
))
|
||||
request.Header.Set("Origin", "https://pwa.example")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneHandoffRetriesAmbiguousCompletionWithStableCredentials(t *testing.T) {
|
||||
snapshot, keys := signedFixture(t, "device-token")
|
||||
key := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
control := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
control.consume = func(context.Context, any) (controlplane.ConsumedHandoff, error) {
|
||||
return controlplane.ConsumedHandoff{
|
||||
HandoffID: "handoff_0123456789abcdef0123456789abcdef",
|
||||
TenantID: snapshot.Snapshot.Payload.TenantID,
|
||||
Name: "Cloud PWA",
|
||||
PhoneEd25519Public: key,
|
||||
PhoneX25519Public: key,
|
||||
IdentityFingerprint: strings.Repeat("f", 64),
|
||||
PlacementGeneration: snapshot.Snapshot.Payload.PlacementGeneration,
|
||||
}, nil
|
||||
}
|
||||
type completion struct{ handoff, phone, tokenHash, routeHash string }
|
||||
var completions []completion
|
||||
control.complete = func(_ context.Context, handoff, phone, tokenHash, routeHash string) error {
|
||||
completions = append(completions, completion{handoff, phone, tokenHash, routeHash})
|
||||
if len(completions) == 1 {
|
||||
return errors.New("control-plane response lost after commit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
service := newTestService(t, control, keys)
|
||||
body := `{"ticket":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","pwa_origin":"https://pwa.example","name":"Cloud PWA","phone_ed25519_public":"` + key + `","phone_x25519_public":"` + key + `","identity_fingerprint":"` + strings.Repeat("f", 64) + `"}`
|
||||
exchange := func() map[string]string {
|
||||
request := httptest.NewRequest(http.MethodPost, "https://connect.example/api/pwa/handoff/exchange", strings.NewReader(body))
|
||||
request.Header.Set("Origin", "https://pwa.example")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
first := exchange()
|
||||
second := exchange()
|
||||
if len(completions) != 3 || completions[0] != completions[1] || completions[1] != completions[2] {
|
||||
t.Fatalf("completion credentials changed across retry: %#v", completions)
|
||||
}
|
||||
if first["phone_id"] != second["phone_id"] || first["phone_token"] != second["phone_token"] || first["route_handle"] != second["route_handle"] {
|
||||
t.Fatalf("handoff response changed across retry: first=%#v second=%#v", first, second)
|
||||
}
|
||||
if _, cached := service.registry.ByPhoneRoute(first["route_handle"]); cached {
|
||||
t.Fatal("pending handoff route became usable before first possession proof")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondLiveDaemonWithSameIdentityIsRejected(t *testing.T) {
|
||||
const token = "device-token"
|
||||
snapshot, keys := signedFixture(t, token)
|
||||
control := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
service := newTestService(t, control, keys)
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
|
||||
dial := func() *websocket.Conn {
|
||||
connection, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"/ws/daemon", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.WriteJSON(protocol.NekoMessage{
|
||||
ProtocolVersion: "1.3",
|
||||
TransportMode: protocol.TransportSealed,
|
||||
Type: protocol.MsgRegisterDevice,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Payload: map[string]any{
|
||||
"device_id": "host_0123456789abcdef0123456789abcdef",
|
||||
"token": token,
|
||||
"daemon_version": "0.2.6",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return connection
|
||||
}
|
||||
first := dial()
|
||||
defer first.Close()
|
||||
var authenticated protocol.NekoMessage
|
||||
if err := first.ReadJSON(&authenticated); err != nil || authenticated.Type != protocol.MsgAuthResponse {
|
||||
t.Fatalf("first authentication err=%v message=%+v", err, authenticated)
|
||||
}
|
||||
second := dial()
|
||||
defer second.Close()
|
||||
var rejected protocol.NekoMessage
|
||||
if err := second.ReadJSON(&rejected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rejected.Type != protocol.MsgError || rejected.Payload["error_code"] != string(protocol.ServiceErrorDeviceAlreadyConnected) {
|
||||
t.Fatalf("rejected = %+v", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteIngressTunnelsDaemonWithoutRedirectingClient(t *testing.T) {
|
||||
const token = "device-token"
|
||||
snapshot, keys := signedFixture(t, token)
|
||||
targetControl := &fakeControlPlane{snapshot: snapshot, register: func(string, string, json.RawMessage) (protocol.DeviceRegistrationResponse, error) {
|
||||
return protocol.DeviceRegistrationResponse{}, nil
|
||||
}}
|
||||
targetService := newTestServiceWithNode(t, targetControl, keys, "node_test", nil)
|
||||
targetServer := httptest.NewServer(targetService.Handler())
|
||||
defer targetServer.Close()
|
||||
|
||||
relayForwarder, err := forwarder.New(forwarder.Config{
|
||||
NodeID: "node_source", Secret: []byte("fedcba9876543210fedcba9876543210"),
|
||||
Endpoints: map[string]string{"target-ref": targetServer.URL},
|
||||
HTTPClient: targetServer.Client(), WebSocketDialer: websocket.DefaultDialer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteRoute := controlplane.RouteResolution{
|
||||
RelayNodeID: "node_test", PlacementGeneration: 1, HomeRegion: "test",
|
||||
Local: false, EndpointRef: "target-ref",
|
||||
}
|
||||
sourceControl := &fakeControlPlane{snapshot: snapshot, route: &remoteRoute, register: targetControl.register}
|
||||
sourceService := newTestServiceWithNode(t, sourceControl, keys, "node_source", relayForwarder)
|
||||
sourceServer := httptest.NewServer(sourceService.Handler())
|
||||
defer sourceServer.Close()
|
||||
|
||||
connection, response, err := websocket.DefaultDialer.Dial(
|
||||
"ws"+strings.TrimPrefix(sourceServer.URL, "http")+"/ws/daemon", nil,
|
||||
)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := connection.WriteJSON(protocol.NekoMessage{
|
||||
ProtocolVersion: "1.3", TransportMode: protocol.TransportSealed,
|
||||
Type: protocol.MsgRegisterDevice, Timestamp: time.Now().Unix(),
|
||||
Payload: map[string]any{
|
||||
"device_id": "host_0123456789abcdef0123456789abcdef",
|
||||
"token": token, "daemon_version": "0.2.6",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var authenticated protocol.NekoMessage
|
||||
if err := connection.ReadJSON(&authenticated); err != nil || authenticated.Type != protocol.MsgAuthResponse {
|
||||
t.Fatalf("forwarded authentication err=%v message=%+v", err, authenticated)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user