feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs

This commit is contained in:
Jason Staack
2026-03-14 09:05:14 -05:00
parent 7af08276ea
commit 970501e453
86 changed files with 3440 additions and 3764 deletions

2
winbox-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Compiled binary
/worker

77
winbox-worker/Dockerfile Normal file
View File

@@ -0,0 +1,77 @@
# Stage 1: Build Go session manager
FROM golang:1.22-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /winbox-worker ./cmd/worker/
# Stage 2: Runtime with Xpra + WinBox
FROM ubuntu:24.04 AS runtime
ARG WINBOX_VERSION=4.0.1
ARG WINBOX_SHA256=8ec2d08929fd434c4b88881f3354bdf60b057ecd2fb54961dd912df57e326a70
# Install Xpra + X11 deps
# Use distro xpra (works on all architectures including arm64 via emulation)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
unzip \
xvfb \
xpra \
libjs-jquery \
libjs-jquery-ui \
libxcb1 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-render-util0 \
libxcb-cursor0 \
libxcb-shape0 \
libx11-6 \
libx11-xcb1 \
libxkbcommon0 \
libxkbcommon-x11-0 \
libgl1 \
libgl1-mesa-dri \
libegl1 \
libegl-mesa0 \
libfontconfig1 \
libdbus-1-3 \
xauth \
python3-pil \
&& rm -rf /var/lib/apt/lists/*
# Download and verify WinBox binary
RUN curl -fsSL -o /tmp/WinBox_Linux.zip \
"https://download.mikrotik.com/routeros/winbox/${WINBOX_VERSION}/WinBox_Linux.zip" \
&& echo "${WINBOX_SHA256} /tmp/WinBox_Linux.zip" | sha256sum -c - \
&& mkdir -p /opt/winbox \
&& unzip /tmp/WinBox_Linux.zip -d /opt/winbox \
&& chmod +x /opt/winbox/WinBox \
&& rm /tmp/WinBox_Linux.zip
# Patch Xpra HTML5 client: _poll_clipboard is called on every mouse click
# but never checks clipboard_enabled, causing clipboard permission prompts
RUN sed -i 's/XpraClient.prototype._poll_clipboard = function(e) {/XpraClient.prototype._poll_clipboard = function(e) {\n\tif (!this.clipboard_enabled) { return; }/' \
/usr/share/xpra/www/js/Client.js
# Create non-root user
RUN groupadd --gid 1001 worker && \
useradd --uid 1001 --gid worker --create-home worker
# Create session directory and XDG runtime dir
RUN mkdir -p /tmp/winbox-sessions && chown worker:worker /tmp/winbox-sessions && \
mkdir -p /run/user/1001/xpra && chown -R worker:worker /run/user/1001
# Copy Go binary
COPY --from=builder /winbox-worker /usr/local/bin/winbox-worker
USER worker
EXPOSE 9090
ENTRYPOINT ["/usr/local/bin/winbox-worker"]

View File

@@ -0,0 +1,174 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/the-other-dude/winbox-worker/internal/session"
)
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg := session.Config{
MaxSessions: envInt("MAX_CONCURRENT_SESSIONS", 10),
DisplayMin: 100,
DisplayMax: 119,
WSPortMin: 10100,
WSPortMax: 10119,
IdleTimeout: envInt("IDLE_TIMEOUT_SECONDS", 600),
MaxLifetime: envInt("MAX_LIFETIME_SECONDS", 7200),
WinBoxPath: envStr("WINBOX_PATH", "/opt/winbox/WinBox"),
BindAddr: envStr("BIND_ADDR", "0.0.0.0"),
}
mgr := session.NewManager(cfg)
mgr.CleanupOrphans()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go mgr.RunCleanupLoop(ctx)
mux := http.NewServeMux()
mux.HandleFunc("POST /sessions", func(w http.ResponseWriter, r *http.Request) {
var req session.CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, session.ErrorResponse{Error: "invalid request body"})
return
}
if !mgr.HasCapacity() {
writeJSON(w, http.StatusServiceUnavailable, session.ErrorResponse{
Error: "capacity",
MaxSessions: cfg.MaxSessions,
})
return
}
resp, err := mgr.CreateSession(req)
req.Username = ""
req.Password = ""
if err != nil {
slog.Error("create session failed", "err", err)
if strings.Contains(err.Error(), "capacity") {
writeJSON(w, http.StatusServiceUnavailable, session.ErrorResponse{
Error: "capacity",
MaxSessions: cfg.MaxSessions,
})
return
}
writeJSON(w, http.StatusInternalServerError, session.ErrorResponse{Error: "launch failed"})
return
}
writeJSON(w, http.StatusCreated, resp)
})
mux.HandleFunc("DELETE /sessions/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := mgr.TerminateSession(id); err != nil {
writeJSON(w, http.StatusInternalServerError, session.ErrorResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "terminated"})
})
mux.HandleFunc("GET /sessions/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
resp, err := mgr.GetSession(id)
if err != nil {
writeJSON(w, http.StatusNotFound, session.ErrorResponse{Error: "not found"})
return
}
writeJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("GET /sessions", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, mgr.ListSessions())
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"sessions": mgr.SessionCount(),
"capacity": cfg.MaxSessions,
"available": cfg.MaxSessions - mgr.SessionCount(),
})
})
handler := provenanceMiddleware(mux)
listenAddr := envStr("LISTEN_ADDR", ":9090")
srv := &http.Server{
Addr: listenAddr,
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
slog.Info("shutting down worker")
cancel()
for _, s := range mgr.ListSessions() {
mgr.TerminateSession(s.WorkerSessionID)
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
}()
slog.Info("winbox-worker starting", "addr", listenAddr, "max_sessions", cfg.MaxSessions)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func provenanceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
svc := r.Header.Get("X-Internal-Service")
if svc == "" && !strings.HasPrefix(r.URL.Path, "/healthz") {
slog.Warn("request missing X-Internal-Service header", "path", r.URL.Path, "remote", r.RemoteAddr)
}
next.ServeHTTP(w, r)
})
}

5
winbox-worker/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/the-other-dude/winbox-worker
go 1.22
require github.com/google/uuid v1.6.0

2
winbox-worker/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View File

@@ -0,0 +1,375 @@
package session
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/google/uuid"
)
type Config struct {
MaxSessions int
DisplayMin int
DisplayMax int
WSPortMin int
WSPortMax int
IdleTimeout int // seconds
MaxLifetime int // seconds
WinBoxPath string
BindAddr string
}
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
displays *Pool
wsPorts *Pool
cfg Config
}
func NewManager(cfg Config) *Manager {
return &Manager{
sessions: make(map[string]*Session),
displays: NewPool(cfg.DisplayMin, cfg.DisplayMax),
wsPorts: NewPool(cfg.WSPortMin, cfg.WSPortMax),
cfg: cfg,
}
}
func (m *Manager) HasCapacity() bool {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sessions) < m.cfg.MaxSessions
}
func (m *Manager) SessionCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sessions)
}
func (m *Manager) CreateSession(req CreateRequest) (*CreateResponse, error) {
m.mu.Lock()
if len(m.sessions) >= m.cfg.MaxSessions {
m.mu.Unlock()
return nil, fmt.Errorf("capacity")
}
display, err := m.displays.Allocate()
if err != nil {
m.mu.Unlock()
return nil, fmt.Errorf("no displays available: %w", err)
}
wsPort, err := m.wsPorts.Allocate()
if err != nil {
m.displays.Release(display)
m.mu.Unlock()
return nil, fmt.Errorf("no ws ports available: %w", err)
}
workerID := req.SessionID
if workerID == "" {
workerID = uuid.New().String()
}
idleTimeout := time.Duration(req.IdleTimeoutSec) * time.Second
if idleTimeout == 0 {
idleTimeout = time.Duration(m.cfg.IdleTimeout) * time.Second
}
maxLifetime := time.Duration(req.MaxLifetimeSec) * time.Second
if maxLifetime == 0 {
maxLifetime = time.Duration(m.cfg.MaxLifetime) * time.Second
}
sess := &Session{
ID: workerID,
TunnelHost: req.TunnelHost,
TunnelPort: req.TunnelPort,
Display: display,
WSPort: wsPort,
State: StateCreating,
CreatedAt: time.Now(),
IdleTimeout: idleTimeout,
MaxLifetime: maxLifetime,
}
m.sessions[workerID] = sess
m.mu.Unlock()
tmpDir, err := CreateSessionTmpDir(workerID)
if err != nil {
m.terminateSession(workerID, "tmpdir creation failed")
return nil, fmt.Errorf("create tmpdir: %w", err)
}
sess.mu.Lock()
sess.TmpDir = tmpDir
sess.mu.Unlock()
xpraCfg := XpraConfig{
Display: display,
WSPort: wsPort,
BindAddr: m.cfg.BindAddr,
TunnelHost: req.TunnelHost,
TunnelPort: req.TunnelPort,
Username: req.Username,
Password: req.Password,
TmpDir: tmpDir,
WinBoxPath: m.cfg.WinBoxPath,
}
proc, err := StartXpra(xpraCfg)
// Zero credential copies (Go-side only; /proc and exec args are a known v1 limitation)
xpraCfg.Username = ""
xpraCfg.Password = ""
req.Username = ""
req.Password = ""
if err != nil {
m.terminateSession(workerID, "xpra start failed")
return nil, fmt.Errorf("xpra start: %w", err)
}
sess.mu.Lock()
sess.XpraPID = proc.Pid
sess.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := WaitForXpraReady(ctx, m.cfg.BindAddr, wsPort, 10*time.Second); err != nil {
m.terminateSession(workerID, "xpra not ready")
return nil, fmt.Errorf("xpra ready: %w", err)
}
sess.mu.Lock()
sess.State = StateActive
createdAt := sess.CreatedAt
sess.mu.Unlock()
return &CreateResponse{
WorkerSessionID: workerID,
Status: StateActive,
XpraWSPort: wsPort,
ExpiresAt: createdAt.Add(idleTimeout),
MaxExpiresAt: createdAt.Add(maxLifetime),
}, nil
}
func (m *Manager) TerminateSession(workerID string) error {
return m.terminateSession(workerID, "requested")
}
func (m *Manager) terminateSession(workerID string, reason string) error {
m.mu.Lock()
sess, ok := m.sessions[workerID]
if !ok {
m.mu.Unlock()
return nil
}
m.mu.Unlock()
sess.mu.Lock()
if sess.State == StateTerminating || sess.State == StateTerminated {
sess.mu.Unlock()
return nil
}
sess.State = StateTerminating
pid := sess.XpraPID
tmpDir := sess.TmpDir
display := sess.Display
wsPort := sess.WSPort
sess.mu.Unlock()
slog.Info("terminating session", "id", workerID, "reason", reason)
if pid > 0 {
KillXpraSession(pid)
}
if tmpDir != "" {
if err := CleanupTmpDir(tmpDir); err != nil {
slog.Warn("tmpdir cleanup failed", "id", workerID, "err", err)
}
}
m.displays.Release(display)
m.wsPorts.Release(wsPort)
sess.mu.Lock()
sess.State = StateTerminated
sess.mu.Unlock()
m.mu.Lock()
delete(m.sessions, workerID)
m.mu.Unlock()
return nil
}
func (m *Manager) GetSession(workerID string) (*StatusResponse, error) {
m.mu.Lock()
sess, ok := m.sessions[workerID]
m.mu.Unlock()
if !ok {
return nil, fmt.Errorf("not found")
}
sess.mu.Lock()
id := sess.ID
state := sess.State
display := sess.Display
wsPort := sess.WSPort
createdAt := sess.CreatedAt
sess.mu.Unlock()
idleSec := QueryIdleTime(display)
return &StatusResponse{
WorkerSessionID: id,
Status: state,
Display: display,
WSPort: wsPort,
CreatedAt: createdAt,
IdleSeconds: idleSec,
}, nil
}
func (m *Manager) ListSessions() []StatusResponse {
m.mu.Lock()
type sessInfo struct {
id string
state State
display int
wsPort int
createdAt time.Time
}
infos := make([]sessInfo, 0, len(m.sessions))
for _, sess := range m.sessions {
sess.mu.Lock()
infos = append(infos, sessInfo{
id: sess.ID,
state: sess.State,
display: sess.Display,
wsPort: sess.WSPort,
createdAt: sess.CreatedAt,
})
sess.mu.Unlock()
}
m.mu.Unlock()
result := make([]StatusResponse, 0, len(infos))
for _, info := range infos {
result = append(result, StatusResponse{
WorkerSessionID: info.id,
Status: info.state,
Display: info.display,
WSPort: info.wsPort,
CreatedAt: info.createdAt,
IdleSeconds: QueryIdleTime(info.display),
})
}
return result
}
func (m *Manager) RunCleanupLoop(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.checkTimeouts()
}
}
}
func (m *Manager) checkTimeouts() {
m.mu.Lock()
ids := make([]string, 0, len(m.sessions))
for id := range m.sessions {
ids = append(ids, id)
}
m.mu.Unlock()
now := time.Now()
for _, id := range ids {
m.mu.Lock()
sess, ok := m.sessions[id]
m.mu.Unlock()
if !ok {
continue
}
sess.mu.Lock()
state := sess.State
createdAt := sess.CreatedAt
maxLifetime := sess.MaxLifetime
idleTimeout := sess.IdleTimeout
display := sess.Display
pid := sess.XpraPID
sess.mu.Unlock()
if state != StateActive && state != StateGrace {
continue
}
if now.Sub(createdAt) > maxLifetime {
slog.Info("session max lifetime exceeded", "id", id)
m.terminateSession(id, "max_lifetime")
continue
}
if pid > 0 {
proc, err := os.FindProcess(pid)
if err != nil || proc.Signal(syscall.Signal(0)) != nil {
slog.Info("xpra process dead", "id", id)
m.terminateSession(id, "worker_failure")
continue
}
}
idleSec := QueryIdleTime(display)
if idleSec >= 0 && time.Duration(idleSec)*time.Second > idleTimeout {
slog.Info("session idle timeout", "id", id, "idle_seconds", idleSec)
m.terminateSession(id, "idle_timeout")
}
}
}
func (m *Manager) CleanupOrphans() {
baseDir := "/tmp/winbox-sessions"
entries, err := os.ReadDir(baseDir)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("orphan scan: cannot read dir", "err", err)
}
return
}
count := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(baseDir, entry.Name())
slog.Info("cleaning orphan session dir", "path", path)
os.RemoveAll(path)
count++
}
exec.Command("xpra", "stop", "--all").Run()
m.displays.ResetAll()
m.wsPorts.ResetAll()
if count > 0 {
slog.Info("orphan cleanup complete", "cleaned", count)
}
}

View File

@@ -0,0 +1,107 @@
package session
import "testing"
func TestManagerCapacityCheck(t *testing.T) {
m := NewManager(Config{
MaxSessions: 2,
DisplayMin: 100,
DisplayMax: 105,
WSPortMin: 10100,
WSPortMax: 10105,
IdleTimeout: 600,
MaxLifetime: 7200,
WinBoxPath: "/usr/bin/winbox4",
BindAddr: "0.0.0.0",
})
if m.SessionCount() != 0 {
t.Fatal("expected 0 sessions")
}
if !m.HasCapacity() {
t.Fatal("expected capacity")
}
}
func TestManagerListEmpty(t *testing.T) {
m := NewManager(Config{
MaxSessions: 5,
DisplayMin: 100,
DisplayMax: 105,
WSPortMin: 10100,
WSPortMax: 10105,
IdleTimeout: 600,
MaxLifetime: 7200,
WinBoxPath: "/usr/bin/winbox4",
BindAddr: "0.0.0.0",
})
sessions := m.ListSessions()
if len(sessions) != 0 {
t.Fatalf("expected 0 sessions, got %d", len(sessions))
}
}
func TestTerminateNonExistentIsIdempotent(t *testing.T) {
m := NewManager(Config{
MaxSessions: 2,
DisplayMin: 100,
DisplayMax: 105,
WSPortMin: 10100,
WSPortMax: 10105,
IdleTimeout: 600,
MaxLifetime: 7200,
WinBoxPath: "/usr/bin/winbox4",
BindAddr: "0.0.0.0",
})
// Terminating a non-existent session should return nil (no error)
err := m.TerminateSession("does-not-exist")
if err != nil {
t.Fatalf("expected nil error for non-existent session, got: %v", err)
}
}
func TestGetNonExistentSessionReturnsError(t *testing.T) {
m := NewManager(Config{
MaxSessions: 2,
DisplayMin: 100,
DisplayMax: 105,
WSPortMin: 10100,
WSPortMax: 10105,
IdleTimeout: 600,
MaxLifetime: 7200,
WinBoxPath: "/usr/bin/winbox4",
BindAddr: "0.0.0.0",
})
_, err := m.GetSession("does-not-exist")
if err == nil {
t.Fatal("expected error for non-existent session, got nil")
}
}
func TestCleanupOrphansRunsWithoutError(t *testing.T) {
m := NewManager(Config{
MaxSessions: 2,
DisplayMin: 100,
DisplayMax: 105,
WSPortMin: 10100,
WSPortMax: 10105,
IdleTimeout: 600,
MaxLifetime: 7200,
WinBoxPath: "/usr/bin/winbox4",
BindAddr: "0.0.0.0",
})
// CleanupOrphans should not panic on a fresh manager with no sessions
m.CleanupOrphans()
// After cleanup, manager should still be functional
if !m.HasCapacity() {
t.Fatal("expected capacity after cleanup")
}
if m.SessionCount() != 0 {
t.Fatal("expected 0 sessions after cleanup")
}
sessions := m.ListSessions()
if len(sessions) != 0 {
t.Fatalf("expected empty session list after cleanup, got %d", len(sessions))
}
}

View File

@@ -0,0 +1,60 @@
package session
import (
"fmt"
"sync"
)
type Pool struct {
mu sync.Mutex
available []int
inUse map[int]bool
}
func NewPool(min, max int) *Pool {
available := make([]int, 0, max-min+1)
for i := min; i <= max; i++ {
available = append(available, i)
}
return &Pool{
available: available,
inUse: make(map[int]bool),
}
}
func (p *Pool) Allocate() (int, error) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.available) == 0 {
return 0, fmt.Errorf("pool exhausted")
}
id := p.available[0]
p.available = p.available[1:]
p.inUse[id] = true
return id, nil
}
func (p *Pool) Release(id int) {
p.mu.Lock()
defer p.mu.Unlock()
if !p.inUse[id] {
return
}
delete(p.inUse, id)
p.available = append(p.available, id)
}
func (p *Pool) Available() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.available)
}
func (p *Pool) ResetAll() {
p.mu.Lock()
defer p.mu.Unlock()
for id := range p.inUse {
p.available = append(p.available, id)
}
p.inUse = make(map[int]bool)
}

View File

@@ -0,0 +1,48 @@
package session
import "testing"
func TestPoolAllocateAndRelease(t *testing.T) {
p := NewPool(100, 105)
allocated := make([]int, 0, 6)
for i := 0; i < 6; i++ {
n, err := p.Allocate()
if err != nil {
t.Fatalf("allocate %d: %v", i, err)
}
allocated = append(allocated, n)
}
_, err := p.Allocate()
if err == nil {
t.Fatal("expected error on exhausted pool")
}
p.Release(allocated[0])
n, err := p.Allocate()
if err != nil {
t.Fatalf("re-allocate: %v", err)
}
if n != allocated[0] {
t.Fatalf("expected %d, got %d", allocated[0], n)
}
}
func TestPoolAvailable(t *testing.T) {
p := NewPool(100, 102)
if p.Available() != 3 {
t.Fatalf("expected 3 available, got %d", p.Available())
}
p.Allocate()
if p.Available() != 2 {
t.Fatalf("expected 2 available, got %d", p.Available())
}
}
func TestPoolResetAll(t *testing.T) {
p := NewPool(100, 102)
p.Allocate()
p.Allocate()
p.ResetAll()
if p.Available() != 3 {
t.Fatalf("expected 3 after reset, got %d", p.Available())
}
}

View File

@@ -0,0 +1,67 @@
package session
import (
"sync"
"time"
)
type State string
const (
StateCreating State = "creating"
StateActive State = "active"
StateGrace State = "grace"
StateTerminating State = "terminating"
StateTerminated State = "terminated"
StateFailed State = "failed"
)
type Session struct {
mu sync.Mutex
ID string `json:"id"`
TunnelHost string `json:"-"`
TunnelPort int `json:"-"`
Display int `json:"display"`
WSPort int `json:"ws_port"`
State State `json:"state"`
XpraPID int `json:"-"`
WinBoxPID int `json:"-"`
TmpDir string `json:"-"`
CreatedAt time.Time `json:"created_at"`
IdleTimeout time.Duration `json:"-"`
MaxLifetime time.Duration `json:"-"`
}
type CreateRequest struct {
SessionID string `json:"session_id"`
TunnelHost string `json:"tunnel_host"`
TunnelPort int `json:"tunnel_port"`
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
IdleTimeoutSec int `json:"idle_timeout_seconds"`
MaxLifetimeSec int `json:"max_lifetime_seconds"`
}
type CreateResponse struct {
WorkerSessionID string `json:"worker_session_id"`
Status State `json:"status"`
XpraWSPort int `json:"xpra_ws_port"`
ExpiresAt time.Time `json:"expires_at"`
MaxExpiresAt time.Time `json:"max_expires_at"`
}
type StatusResponse struct {
WorkerSessionID string `json:"worker_session_id"`
Status State `json:"status"`
Display int `json:"display"`
WSPort int `json:"ws_port"`
CreatedAt time.Time `json:"created_at"`
IdleSeconds int `json:"idle_seconds"`
}
type ErrorResponse struct {
Error string `json:"error"`
MaxSessions int `json:"max_sessions,omitempty"`
}

View File

@@ -0,0 +1,161 @@
package session
import (
"context"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
type XpraConfig struct {
Display int
WSPort int
BindAddr string
TunnelHost string
TunnelPort int
Username string
Password string
TmpDir string
WinBoxPath string
}
func StartXpra(cfg XpraConfig) (*os.Process, error) {
display := fmt.Sprintf(":%d", cfg.Display)
bindWS := fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.WSPort)
winboxCmd := fmt.Sprintf("%s %s:%d %s %s",
cfg.WinBoxPath, cfg.TunnelHost, cfg.TunnelPort, cfg.Username, cfg.Password)
args := []string{
"start", display,
"--bind-ws=" + bindWS,
"--html=on",
"--daemon=no",
"--start-new-commands=no",
"--no-clipboard",
"--no-printing",
"--no-file-transfer",
"--no-notifications",
"--no-webcam",
"--no-speaker",
"--no-microphone",
"--sharing=no",
"--opengl=off",
"--env=XPRA_CLIENT_CAN_SHUTDOWN=0",
"--xvfb=Xvfb +extension GLX +extension Composite -screen 0 1280x800x24+32 -dpi 96 -nolisten tcp -noreset -auth /home/worker/.Xauthority",
"--start-child=" + winboxCmd,
}
logFile := filepath.Join(cfg.TmpDir, "xpra.log")
cmd := exec.Command("xpra", args...)
cmd.Dir = cfg.TmpDir
f, err := os.Create(logFile)
if err != nil {
return nil, fmt.Errorf("create xpra log: %w", err)
}
cmd.Stdout = f
cmd.Stderr = f
cmd.Env = append(os.Environ(),
"HOME="+cfg.TmpDir,
"DISPLAY="+display,
"XPRA_CLIENT_CAN_SHUTDOWN=0",
"LIBGL_ALWAYS_SOFTWARE=1",
"GALLIUM_DRIVER=llvmpipe",
)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("xpra start failed: %w", err)
}
return cmd.Process, nil
}
func WaitForXpraReady(ctx context.Context, bindAddr string, wsPort int, timeout time.Duration) error {
addr := fmt.Sprintf("%s:%d", bindAddr, wsPort)
deadline := time.After(timeout)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
return fmt.Errorf("xpra not ready after %s", timeout)
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
conn, err := (&net.Dialer{Timeout: 200 * time.Millisecond}).DialContext(ctx, "tcp", addr)
if err == nil {
conn.Close()
return nil
}
}
}
}
func QueryIdleTime(display int) int {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "xpra", "info", fmt.Sprintf(":%d", display))
out, err := cmd.Output()
if err != nil {
return -1
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "idle_time=") {
val := strings.TrimPrefix(line, "idle_time=")
if n, err := strconv.Atoi(val); err == nil {
return n
}
}
}
return -1
}
func KillXpraSession(pid int) error {
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
slog.Warn("SIGTERM to xpra process group failed", "pid", pid, "err", err)
}
done := make(chan struct{})
go func() {
proc, err := os.FindProcess(pid)
if err == nil {
proc.Wait()
}
close(done)
}()
select {
case <-done:
return nil
case <-time.After(5 * time.Second):
slog.Warn("SIGKILL to xpra process group", "pid", pid)
return syscall.Kill(-pid, syscall.SIGKILL)
}
}
func CleanupTmpDir(dir string) error {
if dir == "" || !strings.HasPrefix(dir, "/tmp/winbox-sessions/") {
return fmt.Errorf("refusing to remove suspicious path: %s", dir)
}
return os.RemoveAll(dir)
}
func CreateSessionTmpDir(sessionID string) (string, error) {
dir := filepath.Join("/tmp/winbox-sessions", sessionID)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create tmpdir: %w", err)
}
return dir, nil
}