1package webterm
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/subtle"
7 "encoding/base64"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net"
12 "net/http"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/bnema/vev-vt/core"
18 htmlrenderer "github.com/bnema/vev-vt/html"
19 "github.com/bnema/vev-vt/html/browser"
20 "github.com/bnema/vev/internal/domain"
21 "github.com/coder/websocket"
22)
23
24const (
25 Address = "127.0.0.1:8778"
26 Origin = "http://" + Address
27 cookieName = "vev-web-session"
28 maxConnections = 8
29 maxEventBytes = 8 << 20
30 writeTimeout = 5 * time.Second
31)
32
33// RunTerminal composes a normal client attachment. Returning ends its socket.
34type RunTerminal func(context.Context, *Terminal) error
35
36type Server struct {
37 settings Settings
38 ctx context.Context
39 token string
40 authCtx context.Context
41 authCancel context.CancelFunc
42 run RunTerminal
43 slots chan struct{}
44 workers sync.WaitGroup
45 mu sync.Mutex
46 stopping bool
47 activeViews int
48}
49
50func NewServer(ctx context.Context, settings Settings, token string, run RunTerminal) (*Server, error) {
51 settings, err := ParseSettings(settings.Listen, settings.Origin)
52 if err != nil {
53 return nil, err
54 }
55 if len(token) < 32 || run == nil {
56 return nil, errors.New("webterm: invalid server configuration")
57 }
58 authCtx, authCancel := context.WithCancel(ctx)
59 return &Server{settings: settings, ctx: ctx, token: token, authCtx: authCtx, authCancel: authCancel, run: run, slots: make(chan struct{}, maxConnections)}, nil
60}
61
62// Wait closes admission before draining all handlers, including handshakes.
63// The owner cancels the server context before calling Wait.
64func (s *Server) Wait() {
65 s.mu.Lock()
66 s.stopping = true
67 s.mu.Unlock()
68 s.workers.Wait()
69}
70
71func (s *Server) admit() bool {
72 s.mu.Lock()
73 defer s.mu.Unlock()
74 if s.stopping || s.ctx.Err() != nil {
75 return false
76 }
77 s.workers.Add(1)
78 return true
79}
80
81func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82 w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
83 w.Header().Set("X-Content-Type-Options", "nosniff")
84 w.Header().Set("Referrer-Policy", "no-referrer")
85 w.Header().Set("Cache-Control", "no-store")
86 if !strings.EqualFold(normalizeHost(r.Host), normalizeHost(s.settings.Host())) || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
87 http.Error(w, "Forbidden", http.StatusForbidden)
88 return
89 }
90 if origin := r.Header.Get("Origin"); origin != "" && origin != s.settings.Origin {
91 http.Error(w, "Forbidden", http.StatusForbidden)
92 return
93 }
94 switch r.URL.Path {
95 case "/":
96 if r.Method != http.MethodGet {
97 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
98 return
99 }
100 w.Header().Set("Content-Type", "text/html; charset=utf-8")
101 _, _ = w.Write([]byte(indexHTML))
102 return
103 case "/icons-license":
104 if r.Method != http.MethodGet {
105 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
106 return
107 }
108 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
109 _, _ = w.Write([]byte(iconsLicense))
110 return
111 case "/app.js":
112 if r.Method != http.MethodGet {
113 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
114 return
115 }
116 w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
117 _, _ = w.Write([]byte(appJS))
118 return
119 case "/terminal.js":
120 if r.Method != http.MethodGet {
121 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
122 return
123 }
124 w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
125 _, _ = w.Write([]byte(browser.JavaScript()))
126 return
127 case "/terminal.css":
128 if r.Method != http.MethodGet {
129 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
130 return
131 }
132 w.Header().Set("Content-Type", "text/css; charset=utf-8")
133 _, _ = w.Write([]byte(htmlrenderer.Stylesheet() + appCSS))
134 return
135 case "/login":
136 if r.Method != http.MethodPost || r.Header.Get("Origin") != s.settings.Origin {
137 http.Error(w, "Forbidden", http.StatusForbidden)
138 return
139 }
140 r.Body = http.MaxBytesReader(w, r.Body, 1024)
141 if err := r.ParseForm(); err != nil || !s.valid(r.PostForm.Get("token")) {
142 http.Error(w, "Forbidden", http.StatusForbidden)
143 return
144 }
145 http.SetCookie(w, &http.Cookie{Name: cookieName, Value: r.PostForm.Get("token"), Path: "/", HttpOnly: true, Secure: strings.HasPrefix(s.settings.Origin, "https://"), SameSite: http.SameSiteStrictMode})
146 w.WriteHeader(http.StatusNoContent)
147 return
148 case "/health", "/ws", "/views":
149 cookie, err := r.Cookie(cookieName)
150 if err != nil || !s.valid(cookie.Value) {
151 http.Error(w, "Unauthorized", http.StatusUnauthorized)
152 return
153 }
154 if r.Method != http.MethodGet {
155 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
156 return
157 }
158 if r.URL.Path == "/views" {
159 s.mu.Lock()
160 count := s.activeViews
161 s.mu.Unlock()
162 w.Header().Set("Content-Type", "application/json")
163 _ = json.NewEncoder(w).Encode(struct {
164 Active int `json:"active"`
165 }{count})
166 return
167 }
168 if r.URL.Path == "/health" {
169 w.WriteHeader(http.StatusNoContent)
170 return
171 }
172 if r.Header.Get("Origin") != s.settings.Origin {
173 http.Error(w, "Forbidden", http.StatusForbidden)
174 return
175 }
176 s.serveSocket(w, r)
177 default:
178 http.NotFound(w, r)
179 }
180}
181
182// normalizeHost trims one trailing dot from the hostname portion of a Host
183// value so the legal FQDN form ("example.internal.:8778") matches the
184// normalized origin host.
185func normalizeHost(host string) string {
186 hostname, port, err := net.SplitHostPort(host)
187 if err != nil {
188 return strings.TrimSuffix(host, ".")
189 }
190 return net.JoinHostPort(strings.TrimSuffix(hostname, "."), port)
191}
192
193func (s *Server) valid(token string) bool {
194 s.mu.Lock()
195 defer s.mu.Unlock()
196 return subtle.ConstantTimeCompare([]byte(token), []byte(s.token)) == 1
197}
198
199// Token returns the current credential to the trusted local control boundary.
200func (s *Server) Token() string {
201 s.mu.Lock()
202 defer s.mu.Unlock()
203 return s.token
204}
205
206// RenewToken revokes existing cookies and attachments without stopping shells.
207func (s *Server) RenewToken() (string, error) {
208 token, err := NewToken()
209 if err != nil {
210 return "", err
211 }
212 s.mu.Lock()
213 defer s.mu.Unlock()
214 s.authCancel()
215 s.authCtx, s.authCancel = context.WithCancel(s.ctx)
216 s.token = token
217 return token, nil
218}
219
220func NewToken() (string, error) {
221 var data [32]byte
222 if _, err := rand.Read(data[:]); err != nil {
223 return "", err
224 }
225 return base64.RawURLEncoding.EncodeToString(data[:]), nil
226}
227
228func (s *Server) serveSocket(w http.ResponseWriter, r *http.Request) {
229 if !s.admit() {
230 http.Error(w, "Server stopping", http.StatusServiceUnavailable)
231 return
232 }
233 defer s.workers.Done()
234 select {
235 case s.slots <- struct{}{}:
236 default:
237 http.Error(w, "Too many terminals", http.StatusServiceUnavailable)
238 return
239 }
240 defer func() { <-s.slots }()
241 // Bind this handshake to the authenticated generation under the same lock
242 // as rotation. A rotated credential cannot open a new-generation socket.
243 s.mu.Lock()
244 cookie, cookieErr := r.Cookie(cookieName)
245 if cookieErr != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(s.token)) != 1 {
246 s.mu.Unlock()
247 http.Error(w, "Unauthorized", http.StatusUnauthorized)
248 return
249 }
250 ctx, cancel := context.WithCancel(s.authCtx)
251 s.mu.Unlock()
252 defer cancel()
253 conn, err := websocket.Accept(w, r, nil)
254 if err != nil {
255 return
256 }
257 defer conn.CloseNow()
258 s.mu.Lock()
259 s.activeViews++
260 s.mu.Unlock()
261 defer func() { s.mu.Lock(); s.activeViews--; s.mu.Unlock() }()
262 conn.SetReadLimit(maxEventBytes)
263 stop := context.AfterFunc(ctx, func() { _ = conn.CloseNow() })
264 defer stop()
265 terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
266 if err != nil {
267 return
268 }
269 defer terminal.Close()
270 done := make(chan error, 2)
271 go func() { done <- s.run(ctx, terminal) }()
272 go func() { done <- readEvents(ctx, conn, terminal) }()
273 err = writeUpdates(ctx, conn, terminal, done)
274 cancel()
275 terminal.Close()
276 // writeUpdates consumes at most one worker result.
277 remaining := 2
278 var ended *workerEnded
279 if errors.As(err, &ended) {
280 remaining--
281 }
282 for range remaining {
283 <-done
284 }
285}
286
287type workerEnded struct{ err error }
288
289func (e *workerEnded) Error() string { return "webterm: attachment ended" }
290
291func readEvents(ctx context.Context, conn *websocket.Conn, terminal *Terminal) error {
292 for {
293 kind, data, err := conn.Read(ctx)
294 if err != nil {
295 return err
296 }
297 if kind != websocket.MessageText {
298 return errors.New("webterm: expected text event")
299 }
300 event, err := browser.DecodeEvent(data, browser.EventLimits{MaxColumns: MaxColumns, MaxRows: MaxRows})
301 if err != nil {
302 return err
303 }
304 if err := terminal.Handle(ctx, event); err != nil {
305 return err
306 }
307 }
308}
309
310func writeUpdates(ctx context.Context, conn *websocket.Conn, terminal *Terminal, done <-chan error) error {
311 renderer, err := htmlrenderer.New(htmlrenderer.Options{})
312 if err != nil {
313 return err
314 }
315 for {
316 select {
317 case <-ctx.Done():
318 return ctx.Err()
319 case err := <-done:
320 return &workerEnded{err: err}
321 case <-terminal.Changes():
322 }
323 snapshot := terminal.Snapshot()
324 frame := core.NewFrame(snapshot.Columns(), snapshot.Rows())
325 for y := range snapshot.Rows() {
326 frame.WriteRow(y, 0, snapshot.Row(y))
327 }
328 cursor := snapshot.Cursor()
329 prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: htmlrenderer.CursorStyle(cursor.Style), StyleSet: cursor.StyleSet})
330 if err != nil {
331 return fmt.Errorf("webterm: prepare: %w", err)
332 }
333 payload, err := json.Marshal(struct {
334 Update json.RawMessage `json:"update"`
335 Mouse bool `json:"mouse"`
336 }{Update: prepared.JSON(), Mouse: snapshot.Modes().MouseTracking != 0})
337 if err != nil {
338 _ = prepared.Abort()
339 return err
340 }
341 writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
342 err = conn.Write(writeCtx, websocket.MessageText, payload)
343 cancel()
344 if err != nil {
345 _ = prepared.Abort()
346 return err
347 }
348 if err := prepared.Commit(); err != nil {
349 return err
350 }
351 }
352}