Files
Piik/internal/client/loopback/server.go
T
AAA da47189fc3 refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2026-09-06 23:26:01 +08:00

541 lines
14 KiB
Go

package loopback
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
const (
defaultBindAddress = "127.0.0.1"
instanceTokenBytes = 32
helloTimeout = 5 * time.Second
shutdownTimeout = 2 * time.Second
controlQueueSize = 64 // MaxControlMessageBytes * 64 = 16 MiB worst-case.
)
type Options struct {
PortStart int
PortEnd int
AllowedOrigins []string
NativeMedia NativeMediaCapabilities
NewControl func() ControlSession
Presentation func(language string)
}
type ControlSession interface {
Handle(context.Context, []byte) (any, error)
Events() <-chan any
Close() error
}
type Endpoint struct {
URL string `json:"url"`
Host string `json:"host"`
Port int `json:"port"`
InstanceToken string `json:"instanceToken"`
}
type Health struct {
Protocol int `json:"protocol"`
Service string `json:"service"`
Port int `json:"port"`
InstanceToken string `json:"instanceToken"`
NativeMedia NativeMediaCapabilities `json:"nativeMedia"`
}
type NativeMediaCapabilities struct {
Video bool `json:"video"`
ProcessAudio bool `json:"processAudio"`
SystemAudio bool `json:"systemAudio"`
HardwareH264 bool `json:"hardwareH264"`
SoftwareVP8 bool `json:"softwareVP8"`
}
type Server struct {
ctx context.Context
cancel context.CancelFunc
listener net.Listener
httpServer *http.Server
endpoint Endpoint
allowedOrigins []string
nativeMedia NativeMediaCapabilities
newControl func() ControlSession
presentation func(string)
done chan error
mu sync.Mutex
activeConn *websocket.Conn
claimed bool
closed bool
}
func Start(parent context.Context, options Options) (*Server, error) {
if parent == nil {
parent = context.Background()
}
portStart, portEnd := normalizePortRange(options.PortStart, options.PortEnd)
listener, err := listenInRange(defaultBindAddress, portStart, portEnd)
if err != nil {
return nil, err
}
address, ok := listener.Addr().(*net.TCPAddr)
if !ok {
_ = listener.Close()
return nil, errors.New("loopback listener returned an unexpected address")
}
instanceToken, err := newInstanceToken()
if err != nil {
_ = listener.Close()
return nil, fmt.Errorf("create helper instance token: %w", err)
}
ctx, cancel := context.WithCancel(parent)
server := &Server{
ctx: ctx, cancel: cancel, listener: listener,
endpoint: Endpoint{
URL: "http://" + listener.Addr().String(),
Host: listener.Addr().String(),
Port: address.Port,
InstanceToken: instanceToken,
},
allowedOrigins: normalizedOrigins(options.AllowedOrigins),
nativeMedia: options.NativeMedia,
newControl: options.NewControl,
presentation: options.Presentation,
done: make(chan error, 1),
}
server.httpServer = &http.Server{
Handler: server,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 30 * time.Second,
}
go server.serve()
go func() {
<-ctx.Done()
_ = server.Close()
}()
return server, nil
}
func (server *Server) Endpoint() Endpoint {
return server.endpoint
}
func (server *Server) Done() <-chan error {
return server.done
}
func (server *Server) SetAllowedOrigins(origins []string) {
server.mu.Lock()
server.allowedOrigins = normalizedOrigins(origins)
server.mu.Unlock()
}
func (server *Server) Close() error {
server.mu.Lock()
if server.closed {
server.mu.Unlock()
return nil
}
server.closed = true
connection := server.activeConn
server.activeConn = nil
server.claimed = false
server.mu.Unlock()
server.cancel()
if connection != nil {
_ = connection.Close(websocket.StatusNormalClosure, "helper stopped")
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return server.httpServer.Shutdown(ctx)
}
func (server *Server) serve() {
err := server.httpServer.Serve(server.listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
if err != nil {
server.cancel()
}
server.done <- err
close(server.done)
}
func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Request) {
if !server.requestAllowed(response, request) {
return
}
switch request.URL.Path {
case "/health":
server.handleHealth(response, request)
case "/control":
server.handleControl(response, request)
case "/presentation":
server.handlePresentation(response, request)
default:
http.NotFound(response, request)
}
}
func (server *Server) handlePresentation(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodOptions {
response.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
response.Header().Set("Access-Control-Allow-Headers", "Content-Type")
response.WriteHeader(http.StatusNoContent)
return
}
if request.Method != http.MethodPost {
response.Header().Set("Allow", "POST, OPTIONS")
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
request.Body = http.MaxBytesReader(response, request.Body, 256)
var presentation struct {
Language string `json:"language"`
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if decoder.Decode(&presentation) != nil || decoder.Decode(&struct{}{}) != io.EOF ||
(presentation.Language != "zh" && presentation.Language != "en" && presentation.Language != "vis") {
http.Error(response, "invalid presentation", http.StatusBadRequest)
return
}
if server.presentation != nil {
server.presentation(presentation.Language)
}
response.WriteHeader(http.StatusNoContent)
}
func (server *Server) handleHealth(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
response.Header().Set("Allow", http.MethodGet)
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
response.Header().Set("Cache-Control", "no-store")
writeJSON(response, http.StatusOK, Health{
Protocol: ProtocolVersion,
Service: ServiceName,
Port: server.endpoint.Port,
InstanceToken: server.endpoint.InstanceToken,
NativeMedia: server.nativeMedia,
})
}
func (server *Server) handleControl(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
response.Header().Set("Allow", http.MethodGet)
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !server.claimConnection() {
http.Error(response, "control session already active", http.StatusConflict)
return
}
expectedSubprotocol := ControlSubprotocol + "." + server.endpoint.InstanceToken
connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{
Subprotocols: []string{expectedSubprotocol},
OriginPatterns: server.originPatterns(),
})
if err != nil {
server.releaseConnection(nil)
return
}
if connection.Subprotocol() != expectedSubprotocol {
closeControl(connection, "unsupported control protocol")
server.releaseConnection(connection)
return
}
server.setConnection(connection)
defer server.releaseConnection(connection)
defer connection.CloseNow()
connection.SetReadLimit(MaxControlMessageBytes)
helloContext, cancelHello := context.WithTimeout(server.ctx, helloTimeout)
messageType, payload, err := connection.Read(helloContext)
cancelHello()
if err != nil || messageType != websocket.MessageText {
closeControl(connection, "hello frame required")
return
}
message, err := decodeRequest(payload)
if err != nil {
closeControl(connection, "invalid control message")
return
}
if validateHello(message) != nil {
closeControl(connection, "hello required")
return
}
var writeMutex sync.Mutex
write := func(ctx context.Context, value any) error {
encoded, encodeErr := encodeMessage(value)
if encodeErr != nil {
return encodeErr
}
writeMutex.Lock()
defer writeMutex.Unlock()
return connection.Write(ctx, websocket.MessageText, encoded)
}
if err = write(server.ctx, controlMessage{
Version: ProtocolVersion, ID: message.ID, Type: "ready",
}); err != nil {
return
}
controlContext, cancelControl := context.WithCancel(server.ctx)
defer cancelControl()
var extension ControlSession
if server.newControl != nil {
extension = server.newControl()
}
if extension != nil {
defer extension.Close()
go func() {
for {
select {
case event, open := <-extension.Events():
if !open {
return
}
if write(controlContext, event) != nil {
cancelControl()
return
}
case <-controlContext.Done():
return
}
}
}()
}
reads := make(chan []byte, controlQueueSize)
go func() {
for {
messageType, payload, readErr := connection.Read(controlContext)
if readErr != nil || messageType != websocket.MessageText {
cancelControl()
return
}
select {
case reads <- payload:
case <-controlContext.Done():
return
default:
cancelControl()
return
}
}
}()
for {
select {
case <-controlContext.Done():
return
case payload = <-reads:
messageType = websocket.MessageText
}
message, err = decodeEnvelope(payload)
if err != nil {
closeControl(connection, "invalid control message")
return
}
if message.Type == "ping" {
strict, strictErr := decodeRequest(payload)
if strictErr != nil || validatePing(strict) != nil ||
write(controlContext, controlMessage{
Version: ProtocolVersion, ID: message.ID, Type: "pong",
}) != nil {
closeControl(connection, "invalid control message")
return
}
continue
}
if extension == nil {
closeControl(connection, "unsupported control message")
return
}
value, handleErr := extension.Handle(controlContext, payload)
if handleErr != nil || value == nil {
closeControl(connection, "invalid control message")
return
}
if err = write(controlContext, value); err != nil {
return
}
}
}
func (server *Server) originPatterns() []string {
server.mu.Lock()
origins := append([]string(nil), server.allowedOrigins...)
server.mu.Unlock()
patterns := make([]string, 0, len(origins)+2)
for _, origin := range origins {
if parsed, err := url.Parse(origin); err == nil && parsed.Host != "" {
patterns = append(patterns, parsed.Scheme+"://"+parsed.Host)
}
}
port := strconv.Itoa(server.endpoint.Port)
patterns = append(patterns, "http://localhost:"+port, "http://127.0.0.1:"+port)
return patterns
}
func closeControl(connection *websocket.Conn, reason string) {
_ = connection.Close(websocket.StatusPolicyViolation, reason)
}
func (server *Server) requestAllowed(response http.ResponseWriter, request *http.Request) bool {
origin := strings.TrimSpace(request.Header.Get("Origin"))
if origin != "" && !server.allowedOrigin(origin) && !server.localOrigin(origin) {
http.Error(response, "forbidden", http.StatusForbidden)
return false
}
if !server.hostAllowed(request.Host) {
http.NotFound(response, request)
return false
}
if origin != "" {
response.Header().Set("Access-Control-Allow-Origin", origin)
response.Header().Add("Vary", "Origin")
}
return true
}
func (server *Server) allowedOrigin(origin string) bool {
server.mu.Lock()
defer server.mu.Unlock()
for _, allowed := range server.allowedOrigins {
if sameOrigin(origin, allowed) {
return true
}
}
return false
}
func (server *Server) hostAllowed(host string) bool {
if host == server.endpoint.Host {
return true
}
return host == net.JoinHostPort("localhost", strconv.Itoa(server.endpoint.Port))
}
func (server *Server) localOrigin(origin string) bool {
parsed, err := url.Parse(origin)
return err == nil && parsed.Scheme == "http" &&
parsed.Port() == strconv.Itoa(server.endpoint.Port) &&
(strings.EqualFold(parsed.Hostname(), "localhost") ||
strings.EqualFold(parsed.Hostname(), "127.0.0.1"))
}
func (server *Server) claimConnection() bool {
server.mu.Lock()
defer server.mu.Unlock()
if server.closed || server.claimed {
return false
}
server.claimed = true
return true
}
func (server *Server) setConnection(connection *websocket.Conn) {
server.mu.Lock()
server.activeConn = connection
server.mu.Unlock()
}
func (server *Server) releaseConnection(connection *websocket.Conn) {
server.mu.Lock()
if server.activeConn == connection {
server.activeConn = nil
}
server.claimed = false
server.mu.Unlock()
}
func listenInRange(bindAddress string, start, end int) (net.Listener, error) {
var lastErr error
for port := start; port <= end; port++ {
listener, err := net.Listen("tcp4", net.JoinHostPort(bindAddress, strconv.Itoa(port)))
if err == nil {
return listener, nil
}
lastErr = err
}
return nil, fmt.Errorf("helper could not bind port range %d-%d: %w", start, end, lastErr)
}
func normalizePortRange(start, end int) (int, int) {
if start <= 0 && end <= 0 {
return DefaultPortStart, DefaultPortEnd
}
if start <= 0 {
start = end
}
if start < 1 {
start = 1
}
if start > 65535 {
start = 65535
}
if end < start {
end = start
}
if end > 65535 {
end = 65535
}
return start, end
}
func sameOrigin(actual, expected string) bool {
return strings.EqualFold(strings.TrimRight(actual, "/"), strings.TrimRight(expected, "/"))
}
func normalizedOrigins(origins []string) []string {
result := make([]string, 0, len(origins))
seen := make(map[string]struct{}, len(origins))
for _, origin := range origins {
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
if origin == "" {
continue
}
if _, found := seen[strings.ToLower(origin)]; found {
continue
}
seen[strings.ToLower(origin)] = struct{}{}
result = append(result, origin)
}
return result
}
func newInstanceToken() (string, error) {
buffer := make([]byte, instanceTokenBytes)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func writeJSON(response http.ResponseWriter, status int, value any) {
payload, err := encodeMessage(value)
if err != nil {
http.Error(response, "response unavailable", http.StatusInternalServerError)
return
}
response.Header().Set("Content-Type", "application/json; charset=utf-8")
response.WriteHeader(status)
_, _ = response.Write(payload)
}