main
Raw Download raw file
  1package web
  2
  3import (
  4	"context"
  5	"embed"
  6	"fmt"
  7	"io/fs"
  8	"log/slog"
  9	"net/http"
 10	"strings"
 11
 12	"goVNC/pkg/proxy"
 13	"goVNC/pkg/rfb"
 14)
 15
 16// Server is the HTTP/WebSocket server for VNC proxy.
 17type Server struct {
 18	httpServer *http.Server
 19	mux        *proxy.Multiplexer
 20	api        *API
 21	config     *ServerConfig
 22}
 23
 24// ServerConfig configures the web server.
 25type ServerConfig struct {
 26	// ListenAddr is the address to listen on (e.g., ":8080").
 27	ListenAddr string
 28
 29	// APIPrefix is the prefix for API routes (default: "/api").
 30	APIPrefix string
 31
 32	// NoVNCPath is the path to noVNC static files.
 33	// If empty or "embedded", uses embedded noVNC files.
 34	NoVNCPath string
 35
 36	// CORSOrigin is the allowed CORS origin (* for any).
 37	CORSOrigin string
 38
 39	// ProxyMode determines how clients interact with the session.
 40	ProxyMode proxy.SessionMode
 41
 42	// MaxClients is the maximum number of concurrent clients.
 43	MaxClients int
 44}
 45
 46// DefaultServerConfig returns default server configuration.
 47func DefaultServerConfig() *ServerConfig {
 48	return &ServerConfig{
 49		ListenAddr: ":8080",
 50		APIPrefix:  "/api",
 51		CORSOrigin: "*",
 52		ProxyMode:  proxy.SharedMode,
 53		MaxClients: 10,
 54	}
 55}
 56
 57// NewServer creates a new web server.
 58func NewServer(vnc *rfb.Client, cfg *ServerConfig) *Server {
 59	if cfg == nil {
 60		cfg = DefaultServerConfig()
 61	}
 62
 63	mux := proxy.NewMultiplexer(vnc, cfg.ProxyMode)
 64	api := NewAPI(vnc)
 65
 66	return &Server{
 67		mux:    mux,
 68		api:    api,
 69		config: cfg,
 70	}
 71}
 72
 73// ListenAndServe starts the HTTP server.
 74func (s *Server) ListenAndServe(ctx context.Context) error {
 75	mux := http.NewServeMux()
 76
 77	// API routes
 78	apiPrefix := s.config.APIPrefix
 79	if !strings.HasSuffix(apiPrefix, "/") {
 80		apiPrefix += "/"
 81	}
 82
 83	mux.HandleFunc(apiPrefix+"clipboard", s.corsMiddleware(s.api.HandleClipboard))
 84	mux.HandleFunc(apiPrefix+"keys", s.corsMiddleware(s.api.HandleKeys))
 85	mux.HandleFunc(apiPrefix+"session", s.corsMiddleware(s.api.HandleSession))
 86
 87	// WebSocket proxy endpoint
 88	wsHandler := NewWebsockifyHandler(s.mux)
 89	wsHandler.SetMaxClients(s.config.MaxClients)
 90	mux.Handle("/websockify", wsHandler)
 91
 92	// noVNC static files
 93	err := s.setupNoVNC(mux)
 94	if err != nil {
 95		return fmt.Errorf("setting up noVNC: %w", err)
 96	}
 97
 98	// Root redirect to noVNC
 99	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
100		if r.URL.Path == "/" {
101			http.Redirect(w, r, "/noVNC/vnc.html", http.StatusFound)
102			return
103		}
104		http.NotFound(w, r)
105	})
106
107	s.httpServer = &http.Server{
108		Addr:    s.config.ListenAddr,
109		Handler: mux,
110	}
111
112	slog.Info("starting web server",
113		slog.String("addr", s.config.ListenAddr),
114		slog.String("api", s.config.APIPrefix))
115
116	// Start server in goroutine
117	errCh := make(chan error, 1)
118	go func() {
119		errCh <- s.httpServer.ListenAndServe()
120	}()
121
122	// Wait for context or error
123	select {
124	case <-ctx.Done():
125		s.httpServer.Shutdown(context.Background())
126		return ctx.Err()
127	case err := <-errCh:
128		return err
129	}
130}
131
132// corsMiddleware adds CORS headers to responses.
133func (s *Server) corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
134	return func(w http.ResponseWriter, r *http.Request) {
135		if s.config.CORSOrigin != "" {
136			w.Header().Set("Access-Control-Allow-Origin", s.config.CORSOrigin)
137			w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS")
138			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
139		}
140
141		if r.Method == http.MethodOptions {
142			w.WriteHeader(http.StatusOK)
143			return
144		}
145
146		next(w, r)
147	}
148}
149
150// setupNoVNC sets up the noVNC static file handler.
151func (s *Server) setupNoVNC(mux *http.ServeMux) error {
152	if s.config.NoVNCPath != "" && s.config.NoVNCPath != "embedded" {
153		// Serve from filesystem path
154		fileServer := http.FileServer(http.Dir(s.config.NoVNCPath))
155		mux.Handle("/noVNC/", http.StripPrefix("/noVNC/", fileServer))
156		slog.Info("serving noVNC from filesystem",
157			slog.String("path", s.config.NoVNCPath))
158		return nil
159	}
160
161	// Try embedded files
162	if hasEmbeddedNoVNC() {
163		subFS, err := fs.Sub(embeddedNoVNC, "noVNC")
164		if err != nil {
165			return fmt.Errorf("creating sub filesystem: %w", err)
166		}
167		fileServer := http.FileServer(http.FS(subFS))
168		mux.Handle("/noVNC/", http.StripPrefix("/noVNC/", fileServer))
169		slog.Info("serving noVNC from embedded files")
170		return nil
171	}
172
173	// No noVNC available - serve placeholder
174	mux.HandleFunc("/noVNC/", func(w http.ResponseWriter, _ *http.Request) {
175		w.Header().Set("Content-Type", "text/html")
176		w.Write([]byte(noVNCPlaceholder))
177	})
178	slog.Warn("noVNC not available - serving placeholder")
179	return nil
180}
181
182// Close shuts down the server.
183func (s *Server) Close() error {
184	if s.httpServer != nil {
185		s.httpServer.Close()
186	}
187	return s.mux.Close()
188}
189
190// Multiplexer returns the proxy multiplexer for registering message handlers.
191func (s *Server) Multiplexer() *proxy.Multiplexer {
192	return s.mux
193}
194
195// embeddedNoVNC holds embedded noVNC files.
196// The "all:" prefix includes files starting with . or _
197//
198//go:embed all:noVNC
199var embeddedNoVNC embed.FS
200
201func hasEmbeddedNoVNC() bool {
202	entries, err := embeddedNoVNC.ReadDir("noVNC")
203	return err == nil && len(entries) > 0
204}
205
206const noVNCPlaceholder = `<!DOCTYPE html>
207<html>
208<head>
209    <title>noVNC Not Available</title>
210    <style>
211        body { font-family: sans-serif; padding: 20px; max-width: 600px; margin: 0 auto; }
212        h1 { color: #333; }
213        code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
214        pre { background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }
215    </style>
216</head>
217<body>
218    <h1>noVNC Not Available</h1>
219    <p>The noVNC web client is not embedded in this build.</p>
220    <p>You can:</p>
221    <ul>
222        <li>Download noVNC and specify the path with <code>--novnc-path /path/to/noVNC</code></li>
223        <li>Use the REST API directly:
224            <pre>
225# Send keys
226curl -X PUT localhost:8080/api/keys -d '{"text":"hello\n"}'
227
228# Send clipboard
229curl -X PUT localhost:8080/api/clipboard -d '{"text":"copied"}'
230
231# Get session info
232curl localhost:8080/api/session
233            </pre>
234        </li>
235        <li>Connect with any noVNC-compatible client to <code>ws://localhost:8080/websockify</code></li>
236    </ul>
237</body>
238</html>`