Files

175 lines
4.5 KiB
Go

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)
})
}