feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs
This commit is contained in:
2
winbox-worker/.gitignore
vendored
Normal file
2
winbox-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Compiled binary
|
||||
/worker
|
||||
77
winbox-worker/Dockerfile
Normal file
77
winbox-worker/Dockerfile
Normal 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"]
|
||||
174
winbox-worker/cmd/worker/main.go
Normal file
174
winbox-worker/cmd/worker/main.go
Normal 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
5
winbox-worker/go.mod
Normal 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
2
winbox-worker/go.sum
Normal 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=
|
||||
375
winbox-worker/internal/session/manager.go
Normal file
375
winbox-worker/internal/session/manager.go
Normal 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)
|
||||
}
|
||||
}
|
||||
107
winbox-worker/internal/session/manager_test.go
Normal file
107
winbox-worker/internal/session/manager_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
60
winbox-worker/internal/session/pool.go
Normal file
60
winbox-worker/internal/session/pool.go
Normal 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)
|
||||
}
|
||||
48
winbox-worker/internal/session/pool_test.go
Normal file
48
winbox-worker/internal/session/pool_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
67
winbox-worker/internal/session/types.go
Normal file
67
winbox-worker/internal/session/types.go
Normal 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"`
|
||||
}
|
||||
161
winbox-worker/internal/session/xpra.go
Normal file
161
winbox-worker/internal/session/xpra.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user