main
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>`