Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkgs/bigfred/server/cli/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type File struct {
NoSupervisor *bool
MicroinitSocket string
MicroinitBin string
MicrodnsBin string
LogLevel string
RedisBin string
RedisBindAddr string
Expand All @@ -59,6 +60,7 @@ func DefaultFile() File {
CorsOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
LogLevel: "info",
RedisBin: "valkey-server",
MicrodnsBin: "microdns",
RedisBindAddr: "127.0.0.1",
RedisPort: &port,
TelemetryConfig: service.DefaultTelemetryConfigPath(),
Expand Down Expand Up @@ -134,6 +136,8 @@ func Parse(text string) File {
f.MicroinitSocket = value
case "MICROINIT_BIN", "MICROINITBIN":
f.MicroinitBin = value
case "MICRODNS_BIN", "MICRODNSBIN":
f.MicrodnsBin = value
case "LOG_LEVEL", "LOGLEVEL":
f.LogLevel = value
case "REDIS_BIN", "REDISBIN":
Expand Down
3 changes: 3 additions & 0 deletions pkgs/bigfred/server/cli/config_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func applyConfig(f *Flags, cfg *config.File, changed func(string) bool) {
if !changed("microinit-bin") && cfg.MicroinitBin != "" {
f.MicroinitBin = cfg.MicroinitBin
}
if !changed("microdns-bin") && cfg.MicrodnsBin != "" {
f.MicrodnsBin = cfg.MicrodnsBin
}
if !changed("log-level") && cfg.LogLevel != "" {
f.LogLevel = cfg.LogLevel
}
Expand Down
16 changes: 8 additions & 8 deletions pkgs/bigfred/server/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Flags struct {

MicroinitSocket string
MicroinitBin string
MicrodnsBin string

// Redis. By default loco-server spawns its own redis-server via
// supervisord on RedisBindAddr:RedisPort; pass --redis-external
Expand Down Expand Up @@ -127,6 +128,8 @@ real-time throttle commands.`,
"microinit IPC socket path (default $BIGFRED_DATA_DIR/run/microinit.sock)")
cmd.Flags().StringVar(&f.MicroinitBin, "microinit-bin", "microinit",
"microinit binary path (PATH-relative or absolute)")
cmd.Flags().StringVar(&f.MicrodnsBin, "microdns-bin", "microdns",
"microdns binary path (PATH-relative or absolute) used by the managed daemon")
cmd.Flags().StringVar(&f.LogLevel, "log-level", "info",
"logrus level (debug, info, warn, error). BIGFRED_LOG_LEVEL env overrides this flag.")

Expand Down Expand Up @@ -319,17 +322,13 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error {
Disable: !redisMgmt.Managed,
},
Telemetry: telemetryCfg,
Microdns: microinit.MicrodnsConfig{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: The refactoring to replace the direct microinit.EnsureMicrodnsConfig() call with a structured microinit.MicrodnsConfig within InfraConfig is a good architectural improvement. It centralizes configuration management and makes the system more modular and testable.

Bin: f.MicrodnsBin,
Disable: !platform.SupportsMicrodns(),
},
}); err != nil {
return fmt.Errorf("ensure microinit infrastructure: %w", err)
}
// microdns.json is best-effort: seed a default only when absent so
// operator edits are preserved; never block startup on write failure.
// Android phone builds do not ship microdns — skip seeding entirely.
if platform.SupportsMicrodns() {
if err := microinit.EnsureMicrodnsConfig(); err != nil {
log.WithError(err).Warn("microdns config seed failed; continuing without default template")
}
}
if f.EnableTelemetry {
log.WithFields(logrus.Fields{
"config": f.TelemetryConfig,
Expand Down Expand Up @@ -673,6 +672,7 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error {
CommandStations: commandStationSvc,
Diagnostics: diagSvc,
System: service.NewSystemControl(supSvc),
Microinit: service.NewMicroinitControl(supSvc),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: Adding Microinit: service.NewMicroinitControl(supSvc) to the server initialization is a key change that enables the new admin UI features by exposing microinit control functionality through a dedicated service layer.

Hub: hub,
DccBus: dccBusSvc,
Radio: radioSvc,
Expand Down
255 changes: 255 additions & 0 deletions pkgs/bigfred/server/http/microinit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package httpapi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: The new MicroinitHandler and its WebSocket-based StreamLogs endpoint provide a powerful real-time diagnostic tool. The in-handler authentication check for WebSockets is a practical solution to a known limitation of chi.RequireRole for upgrade requests.


import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strings"

"github.com/coder/websocket"
miclient "github.com/dcc-bigfred/microinit/go/client"
"github.com/go-chi/chi/v5"

"github.com/keskad/loco/pkgs/bigfred/server/cmd"
"github.com/keskad/loco/pkgs/bigfred/server/domain"
"github.com/keskad/loco/pkgs/bigfred/server/service"
)

const microinitLogHistoryLines = 300

type microinitWSMessage struct {
Type string `json:"type"`
Lines []string `json:"lines,omitempty"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
}

// MicroinitHandler serves admin microinit service listing, info, and log WS.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: The implementation of the MicroinitHandler for live log streaming via WebSocket is robust. The in-handler authentication for WebSocket connections is a good practice, and the inclusion of log history and a keepalive mechanism ensures a reliable and user-friendly experience.

type MicroinitHandler struct {
svc *service.MicroinitControl
auth *cmd.Auth
}

// NewMicroinitHandler returns a MicroinitHandler. svc/auth may be nil (503/401).
func NewMicroinitHandler(svc *service.MicroinitControl, auth *cmd.Auth) *MicroinitHandler {
return &MicroinitHandler{svc: svc, auth: auth}
}

// ListServices handles GET /api/v1/admin/microinit/services.
func (h *MicroinitHandler) ListServices(w http.ResponseWriter, _ *http.Request) {
if h.svc == nil || !h.svc.Available() {
writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
return
}
services, err := h.svc.ListServices()
if err != nil {
if errors.Is(err, service.ErrSystemUnavailable) {
writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
return
}
writeJSONError(w, http.StatusInternalServerError, "internal_error")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"services": services})
}

// Info handles GET /api/v1/admin/microinit/info.
func (h *MicroinitHandler) Info(w http.ResponseWriter, _ *http.Request) {
if h.svc == nil || !h.svc.Available() {
writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
return
}
info, err := h.svc.Info()
if err != nil {
if errors.Is(err, service.ErrSystemUnavailable) {
writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
return
}
writeJSONError(w, http.StatusInternalServerError, "internal_error")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(info)
}

// StreamLogs handles GET /api/v1/admin/microinit/services/{id}/logs/stream (WebSocket).
// Auth is verified in-handler (cookie / ?token=) like ScanWS — chi
// RequireRole does not reliably gate the WS upgrade itself. Uses
// auth.Effective so sudo-elevated admins match the rest of the admin UI.
func (h *MicroinitHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
if h.auth == nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
token := readSessionToken(r)
if token == "" {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := h.auth.VerifyToken(r.Context(), token)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
eff, err := h.auth.Effective(r.Context(), id.User, id.Layout.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal_error")
return
}
if !eff.Has(domain.RoleAdmin) {
writeJSONError(w, http.StatusForbidden, "forbidden")
return
}

serviceID := chi.URLParam(r, "id")
if err := miclient.ValidateName(serviceID); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_request")
return
}
if h.svc == nil || !h.svc.Available() {
writeJSONError(w, http.StatusServiceUnavailable, "service_unavailable")
return
}

wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] PRIORITY:LOW: The InsecureSkipVerify: true option for websocket.Accept is used here. While this might be acceptable for internal communication with microinit where TLS might not be configured or necessary, it's generally a security-sensitive flag. Consider adding a comment to explain the rationale behind this choice, e.g., "Used for internal microinit connections where TLS is not enforced or managed by BigFred."

})
if err != nil {
return
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")

ctx := r.Context()

history, err := h.fetchLogHistory(serviceID, microinitLogHistoryLines)
if err != nil {
_ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: err.Error()})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] PRIORITY:LOW: The _ = writeMicroinitWS(...) calls ignore potential errors when writing to the WebSocket. While defer wsConn.Close() and the read goroutine provide some cleanup, explicitly checking and logging errors from writeMicroinitWS could provide better insight into WebSocket write failures, especially if the connection is already in a half-open state.

return
}
if err := writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "history", Lines: history}); err != nil {
return
}

unix, err := h.svc.FollowLogs(serviceID, 0, true)
if err != nil {
_ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: err.Error()})
return
}
defer unix.Close()

// Keepalive + disconnect detection: answer client pings, and when the
// WebSocket read fails (client gone), close the microinit follow conn so
// the blocking ReadFrame loop below unblocks instead of leaking.
go func() {
defer unix.Close()
for {
_, data, err := wsConn.Read(ctx)
if err != nil {
return
}
var msg struct {
Type string `json:"type"`
}
if json.Unmarshal(data, &msg) != nil {
continue
}
if msg.Type == "ping" {
_ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "pong"})
}
}
}()

for {
resp, err := h.svc.ReadFrame(unix)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || isClosedConn(err) {
return
}
// Client disconnect closes unix from the ping goroutine; treat
// resulting read errors as a clean end of stream.
if ctx.Err() != nil {
return
}
_ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: "stream_failed"})
return
}

switch resp.Type {
case "log":
if resp.Line == nil {
continue
}
text := miclient.FormatLogLine(*resp.Line)
if err := writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "line", Text: text}); err != nil {
return
}
case "error":
msg := resp.Message
if msg == "" {
msg = "stream_failed"
}
_ = writeMicroinitWS(wsConn, ctx, microinitWSMessage{Type: "error", Error: msg})
return
case "ok":
return
}
}
}

func (h *MicroinitHandler) fetchLogHistory(name string, lines int) ([]string, error) {
unix, err := h.svc.FollowLogs(name, lines, false)
if err != nil {
return nil, err
}
defer unix.Close()

out := make([]string, 0, 64)
for {
resp, err := h.svc.ReadFrame(unix)
if err != nil {
if errors.Is(err, io.EOF) {
return out, nil
}
return nil, err
}
switch resp.Type {
case "log":
if resp.Line != nil {
out = append(out, miclient.FormatLogLine(*resp.Line))
}
case "ok":
return out, nil
case "error":
msg := resp.Message
if msg == "" {
msg = "read_failed"
}
return nil, errors.New(msg)
}
}
}

func writeMicroinitWS(conn *websocket.Conn, ctx context.Context, msg microinitWSMessage) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
return conn.Write(ctx, websocket.MessageText, data)
}

func isClosedConn(err error) bool {
if err == nil {
return false
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}
msg := err.Error()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] PRIORITY:LOW: The isClosedConn function relies on string matching for error messages (strings.Contains(msg, "closed")). This can be brittle as error messages might change across Go versions or different network implementations. Relying more on errors.Is(err, net.ErrClosed) and specific error types (like net.OpError) is generally more robust.

return strings.Contains(msg, "use of closed network connection") ||
strings.Contains(msg, "closed")
}
7 changes: 7 additions & 0 deletions pkgs/bigfred/server/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type RouterConfig struct {
CommandStations *cmd.CommandStation
Diagnostics *service.DiagnosticsService
System *service.SystemControl
Microinit *service.MicroinitControl
Hub *ws.Hub
DccBus *service.DccBusService
Radio *service.RadioService
Expand Down Expand Up @@ -103,6 +104,7 @@ func NewRouter(cfg RouterConfig) http.Handler {
}
diagnosticsH := NewDiagnosticsHandler(cfg.Diagnostics)
systemH := NewSystemHandler(cfg.System)
microinitH := NewMicroinitHandler(cfg.Microinit, cfg.Auth)
radioH := NewRadioHandler(cfg.Radio)
auditH := NewAuditHandler(cfg.Audit)
leaseH := NewLeaseHandler(cfg.Leases, cfg.Auth)
Expand Down Expand Up @@ -270,6 +272,11 @@ func NewRouter(cfg RouterConfig) http.Handler {

r.Get("/admin/system", systemH.Get)
r.Post("/admin/system/shutdown", systemH.Shutdown)
r.Get("/admin/system/ports", systemH.Ports)

r.Get("/admin/microinit/services", microinitH.ListServices)
r.Get("/admin/microinit/info", microinitH.Info)
r.Get("/admin/microinit/services/{id}/logs/stream", microinitH.StreamLogs)

if cfg.DccBus != nil {
slotsProxy := NewDccBusSlotsProxy(cfg.Auth, cfg.DccBus)
Expand Down
11 changes: 11 additions & 0 deletions pkgs/bigfred/server/http/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,14 @@ func (h *SystemHandler) Shutdown(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}

// Ports handles GET /api/v1/admin/system/ports.
func (h *SystemHandler) Ports(w http.ResponseWriter, _ *http.Request) {
if h.svc == nil {
writeJSONError(w, http.StatusServiceUnavailable, "system_unavailable")
return
}
ports := h.svc.Ports()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ports)
}
Loading
Loading