v0.5.0 c37833b

server.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close