package networking import ( "context" "errors" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" ) // registerWrites adds all write endpoints for the networking module. Every // handler checks m.be != nil first and returns 501 when no backend was detected. type ApplyInput struct { Name string `path:"name" example:"eth0" doc:"Interface name"` Body IfaceConfig `doc:"Desired interface configuration"` } type ApplyOutput struct { Body struct { Status string `json:"status" example:"pending" doc:"Always \"pending\" — confirm to make permanent"` Interface string `json:"interface" example:"eth0"` Backend string `json:"backend" example:"nmcli" doc:"Network backend that applied the change"` RollbackSeconds int `json:"rollback_seconds" example:"60" doc:"Seconds until auto-rollback unless confirmed"` } } type IfacePathInput struct { Name string `path:"name" example:"eth0" doc:"Interface name"` } type PendingOutput struct { Body PendingInfo } func registerWrites(api huma.API, m *Module) { huma.Register(api, huma.Operation{ OperationID: "networking-apply-config", Method: "PUT", Path: "/api/networking/interfaces/{name}", Summary: "Apply interface configuration", Description: "Replaces the interface's IPv4 configuration. The change is applied " + "immediately but starts a rollback timer — if not confirmed within the timeout " + "(default 60s), the prior configuration is automatically restored. This prevents " + "lock-yourself-out mistakes on remote hosts.", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *ApplyInput) (*ApplyOutput, error) { if m.be == nil { return nil, huma.Error501NotImplemented("", errNoBackend) } if err := validateIface(in.Name); err != nil { return nil, err } if err := in.Body.validate(); err != nil { return nil, err } seconds, err := m.startRollback(ctx, in.Name, in.Body) if err != nil { if errors.Is(err, errAlreadyPending) { return nil, huma.Error409Conflict(err.Error()) } return nil, huma.Error500InternalServerError("apply failed", err) } out := &ApplyOutput{} out.Body.Status = "pending" out.Body.Interface = in.Name out.Body.Backend = m.be.Name() out.Body.RollbackSeconds = seconds return out, nil }) huma.Register(api, huma.Operation{ OperationID: "networking-confirm-change", Method: "POST", Path: "/api/networking/interfaces/{name}/confirm", Summary: "Confirm a pending change", Description: "Cancels the rollback timer, making the applied configuration permanent.", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) { if m.be == nil { return nil, huma.Error501NotImplemented("", errNoBackend) } if err := validateIface(in.Name); err != nil { return nil, err } if err := m.confirm(in.Name); err != nil { return nil, huma.Error409Conflict(err.Error()) } return oscmd.OK(), nil }) huma.Register(api, huma.Operation{ OperationID: "networking-rollback-change", Method: "POST", Path: "/api/networking/interfaces/{name}/rollback", Summary: "Immediately revert a pending change", Description: "Reverts the interface to its prior configuration and clears the pending change.", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) { if m.be == nil { return nil, huma.Error501NotImplemented("", errNoBackend) } if err := validateIface(in.Name); err != nil { return nil, err } if err := m.rollbackNow(in.Name); err != nil { return nil, huma.Error500InternalServerError("rollback failed", err) } return oscmd.OK(), nil }) huma.Register(api, huma.Operation{ OperationID: "networking-link-up", Method: "POST", Path: "/api/networking/interfaces/{name}/up", Summary: "Bring an interface up", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) { if m.be == nil { return nil, huma.Error501NotImplemented("", errNoBackend) } if err := validateIface(in.Name); err != nil { return nil, err } if err := m.be.SetLinkUp(ctx, in.Name); err != nil { return nil, huma.Error500InternalServerError("link up failed", err) } return oscmd.OK(), nil }) huma.Register(api, huma.Operation{ OperationID: "networking-link-down", Method: "POST", Path: "/api/networking/interfaces/{name}/down", Summary: "Take an interface down", Description: "Brings the interface down behind the rollback safety net: it is brought " + "back up automatically if not confirmed within the timeout (default 60s). This " + "prevents taking down the link you're managing the host over and losing access.", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *IfacePathInput) (*ApplyOutput, error) { if m.be == nil { return nil, huma.Error501NotImplemented("", errNoBackend) } if err := validateIface(in.Name); err != nil { return nil, err } seconds, err := m.startLinkDown(ctx, in.Name) if err != nil { if errors.Is(err, errAlreadyPending) { return nil, huma.Error409Conflict(err.Error()) } return nil, huma.Error500InternalServerError("link down failed", err) } out := &ApplyOutput{} out.Body.Status = "pending" out.Body.Interface = in.Name out.Body.Backend = m.be.Name() out.Body.RollbackSeconds = seconds return out, nil }) huma.Register(api, huma.Operation{ OperationID: "networking-get-pending", Method: "GET", Path: "/api/networking/pending", Summary: "Get pending change status", Description: "Returns the currently pending change (interface name and seconds until " + "auto-rollback), or 404 if there is no pending change.", Tags: []string{tagNetworking}, Metadata: op("read"), Errors: []int{401, 403, 404, 500}, }, func(ctx context.Context, _ *struct{}) (*PendingOutput, error) { info := m.pendingInfo() if info == nil { return nil, huma.Error404NotFound("no pending change") } out := &PendingOutput{} out.Body = *info return out, nil }) }