package httpserver import ( "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "github.com/toki/oto/services/core/internal/runnerregistry" otopb "github.com/toki/oto/services/core/oto" ) func handleHealthz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } func handleReadyz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } func handleRunnerRegister(registry *runnerregistry.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } var request otopb.RegisterRunnerRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { writeRunnerRegisterResponse(w, http.StatusBadRequest, &otopb.RegisterRunnerResponse{ Accepted: false, RejectReason: "invalid registration request", Error: &otopb.ProtocolError{ Code: "invalid_registration_request", Message: "invalid registration request", }, }) return } response := registry.Register(&request) writeRunnerRegisterResponse(w, http.StatusOK, response) } } func handleRunnerHeartbeat(registry *runnerregistry.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } runnerID := r.PathValue("id") if runnerID == "" { parts := strings.Split(r.URL.Path, "/") if len(parts) >= 5 { runnerID = parts[4] } } if runnerID == "" { writeHeartbeatResponse(w, http.StatusBadRequest, &otopb.HeartbeatResponse{ Success: false, ErrorMessage: "missing runner id", }) return } var req otopb.HeartbeatRequest if r.ContentLength > 0 || r.Body != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { writeHeartbeatResponse(w, http.StatusBadRequest, &otopb.HeartbeatResponse{ Success: false, ErrorMessage: "invalid heartbeat request", }) return } } if req.RunnerId == "" { req.RunnerId = runnerID } else if req.RunnerId != runnerID { writeHeartbeatResponse(w, http.StatusBadRequest, &otopb.HeartbeatResponse{ Success: false, ErrorMessage: "runner id mismatch between path and body", }) return } response := registry.Heartbeat(req.RunnerId, req.Status) if !response.Success { status := http.StatusNotFound if response.ErrorMessage != "unknown runner" { status = http.StatusBadRequest } writeHeartbeatResponse(w, status, response) return } writeHeartbeatResponse(w, http.StatusOK, response) } } func handleRunnerDisconnect(registry *runnerregistry.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } runnerID := r.PathValue("id") if runnerID == "" { parts := strings.Split(r.URL.Path, "/") if len(parts) >= 5 { runnerID = parts[4] } } if runnerID == "" { writeResponse(w, http.StatusBadRequest, runnerDisconnectToJSON(false, "missing runner id")) return } success := registry.Disconnect(runnerID) if !success { writeResponse(w, http.StatusNotFound, runnerDisconnectToJSON(false, "runner not found")) return } writeResponse(w, http.StatusOK, runnerDisconnectToJSON(true, "")) } } func handleGetRunner(registry *runnerregistry.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } runnerID := r.PathValue("id") if runnerID == "" { parts := strings.Split(r.URL.Path, "/") if len(parts) >= 5 { runnerID = parts[4] } } if runnerID == "" { http.Error(w, "missing runner id", http.StatusBadRequest) return } record, ok := registry.Snapshot(runnerID) if !ok { http.Error(w, "runner not found", http.StatusNotFound) return } writeResponse(w, http.StatusOK, runnerRecordToJSON(record)) } } func shellEscape(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } func powershellEscape(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } var request otopb.BootstrapCommandRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { writeResponse(w, http.StatusBadRequest, errorToJSON("invalid bootstrap command request")) return } runnerID := strings.TrimSpace(request.GetRunnerId()) token := strings.TrimSpace(request.GetEnrollmentToken()) if runnerID == "" || token == "" { writeResponse(w, http.StatusBadRequest, errorToJSON("missing runner id or enrollment token")) return } target := strings.ToLower(strings.TrimSpace(request.GetTarget())) if target != "" && target != "linux" && target != "macos" && target != "windows" { writeResponse(w, http.StatusBadRequest, errorToJSON(fmt.Sprintf("unsupported bootstrap target: %s", target))) return } scheme := "http" if r.TLS != nil { scheme = "https" } serverURL := scheme + "://" + r.Host u, err := url.Parse(serverURL) if err != nil || u.Host == "" || u.Scheme == "" { writeResponse(w, http.StatusBadRequest, errorToJSON("invalid server URL")) return } // Host validation to prevent command injection and host header spoofing for _, char := range u.Host { if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '.' || char == '-' || char == ':' || char == '[' || char == ']') { writeResponse(w, http.StatusBadRequest, errorToJSON("invalid characters in server Host")) return } } releaseBaseURL := os.Getenv("OTO_RUNNER_RELEASE_BASE_URL") if releaseBaseURL == "" { if scheme == "https" { releaseBaseURL = serverURL + "/releases" } else { writeResponse(w, http.StatusBadRequest, errorToJSON("release base URL must use HTTPS (set OTO_RUNNER_RELEASE_BASE_URL environment variable)")) return } } else { if !strings.HasPrefix(releaseBaseURL, "https://") { writeResponse(w, http.StatusBadRequest, errorToJSON("OTO_RUNNER_RELEASE_BASE_URL must use https:// scheme")) return } } var bootstrapCmd string if target == "windows" { escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1") escapedServerURL := powershellEscape(serverURL) escapedRunnerID := powershellEscape(runnerID) escapedToken := powershellEscape(token) escapedReleaseURL := powershellEscape(releaseBaseURL) bootstrapCmd = fmt.Sprintf( "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --server-url %s --agent-id %s --enrollment-token %s --release-base-url %s\"", escapedScriptURL, escapedServerURL, escapedRunnerID, escapedToken, escapedReleaseURL, ) } else { escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh") escapedServerURL := shellEscape(serverURL) escapedRunnerID := shellEscape(runnerID) escapedToken := shellEscape(token) escapedReleaseURL := shellEscape(releaseBaseURL) bootstrapCmd = fmt.Sprintf( "curl -fsSL %s | bash -s -- --server-url %s --agent-id %s --enrollment-token %s --release-base-url %s", escapedScriptURL, escapedServerURL, escapedRunnerID, escapedToken, escapedReleaseURL, ) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(&otopb.BootstrapCommandResponse{ BootstrapCommand: bootstrapCmd, }) } } func handleServeBootstrapScript(provider bootstrapScriptProvider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } content, err := provider.bootstrapScript() if err != nil { http.Error(w, "Bootstrap script not available: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-sh") w.WriteHeader(http.StatusOK) _, _ = w.Write(content) } } func handleServeBootstrapPs1(provider bootstrapPs1Provider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } content, err := provider.bootstrapPs1() if err != nil { http.Error(w, "Bootstrap PS1 script not available: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-powershell") w.WriteHeader(http.StatusOK) _, _ = w.Write(content) } } func writeRunnerRegisterResponse(w http.ResponseWriter, status int, response *otopb.RegisterRunnerResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(response) } func writeHeartbeatResponse(w http.ResponseWriter, status int, response *otopb.HeartbeatResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(response) }