feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
+847
View File
@@ -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))
}