feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs
This commit is contained in:
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