diff --git a/pkgs/bigfred/dcc-bus/cli/cli.go b/pkgs/bigfred/dcc-bus/cli/cli.go index 2a08872..676b4a3 100644 --- a/pkgs/bigfred/dcc-bus/cli/cli.go +++ b/pkgs/bigfred/dcc-bus/cli/cli.go @@ -60,6 +60,9 @@ type Flags struct { SingleVehicleControl bool AllocatePhysicalSlots bool + EnableProgramming bool + DefaultProgrammingTrack string + AllowedOrigins []string } @@ -90,6 +93,10 @@ should rarely be invoked manually.`, if err != nil { return fmt.Errorf("dcc-bus station config: %w", err) } + progTrack, err := ProgrammingTrackFromFlag(f.DefaultProgrammingTrack) + if err != nil { + return fmt.Errorf("dcc-bus programming config: %w", err) + } cfg := dccbus.Config{ LayoutID: f.LayoutID, CommandStationID: f.CommandStationID, @@ -119,6 +126,8 @@ should rarely be invoked manually.`, BootStopEnabled: f.BootStopEnabled, SingleVehicleControl: f.SingleVehicleControl, AllocatePhysicalSlots: f.AllocatePhysicalSlots, + EnableProgramming: f.EnableProgramming, + DefaultProgrammingTrack: progTrack, } d, err := dccbus.New(c.Context(), log, cfg) if err != nil { @@ -161,6 +170,8 @@ should rarely be invoked manually.`, cmd.Flags().BoolVar(&f.BootStopEnabled, FlagBootStopEnabled, false, "emergency-stop all roster locomotives once after daemon start") cmd.Flags().BoolVar(&f.SingleVehicleControl, FlagSingleVehicleControl, false, "stop the user's other moving vehicles when driving a different one") cmd.Flags().BoolVar(&f.AllocatePhysicalSlots, FlagAllocatePhysicalSlots, true, "allocate LocoNet slots like a physical FRED (exclusive IN_USE; disable to piggyback)") + cmd.Flags().BoolVar(&f.EnableProgramming, FlagEnableProgramming, false, "accept decoder CV and address programming frames (loco.cvRead/cvWrite/addrGet/addrSet)") + cmd.Flags().StringVar(&f.DefaultProgrammingTrack, FlagDefaultProgrammingTrack, DefaultProgrammingTrack, "programming track used when a frame omits `mode`: pom (main track) or prog (programming track)") cmd.AddCommand(newScanCommand(log)) diff --git a/pkgs/bigfred/dcc-bus/cli/station.go b/pkgs/bigfred/dcc-bus/cli/station.go index 5568d31..c0c24f5 100644 --- a/pkgs/bigfred/dcc-bus/cli/station.go +++ b/pkgs/bigfred/dcc-bus/cli/station.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" "github.com/keskad/loco/pkgs/bigfred/server/domain" ) @@ -25,8 +26,29 @@ const ( FlagBootStopEnabled = "enable-boot-stop" FlagSingleVehicleControl = "enable-single-vehicle-control" FlagAllocatePhysicalSlots = "allocate-physical-slots" + FlagEnableProgramming = "enable-programming" + FlagDefaultProgrammingTrack = "default-programming-track" ) +// DefaultProgrammingTrack is the track CV / address frames land on when +// they omit `mode`. It mirrors domain.DefaultCommandStationProgrammingTrackOutput: +// the isolated programming output cannot disturb locos on the main track. +const DefaultProgrammingTrack = protocol.ProgrammingModeProg + +// ProgrammingTrackFromFlag normalises --default-programming-track. An +// empty value falls back to DefaultProgrammingTrack. +func ProgrammingTrackFromFlag(track string) (string, error) { + switch t := strings.ToLower(strings.TrimSpace(track)); t { + case "": + return DefaultProgrammingTrack, nil + case protocol.ProgrammingModePOM, protocol.ProgrammingModeProg: + return t, nil + default: + return "", fmt.Errorf("unsupported %s %q (want %q or %q)", + FlagDefaultProgrammingTrack, track, protocol.ProgrammingModePOM, protocol.ProgrammingModeProg) + } +} + // AppendStationFlags appends command-station connection flags for cs. func AppendStationFlags(args []string, cs domain.CommandStation) []string { return append(args, diff --git a/pkgs/bigfred/dcc-bus/cli/station_test.go b/pkgs/bigfred/dcc-bus/cli/station_test.go index 930127d..1bd13f9 100644 --- a/pkgs/bigfred/dcc-bus/cli/station_test.go +++ b/pkgs/bigfred/dcc-bus/cli/station_test.go @@ -51,6 +51,33 @@ func TestAppendSingleVehicleControlFlag(t *testing.T) { } } +func TestProgrammingTrackFromFlag(t *testing.T) { + for _, tc := range []struct { + in string + want string + wantErr bool + }{ + {in: "", want: DefaultProgrammingTrack}, + {in: "pom", want: "pom"}, + {in: " PROG ", want: "prog"}, + {in: "service", wantErr: true}, + } { + got, err := ProgrammingTrackFromFlag(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("%q: expected an error, got %q", tc.in, got) + } + continue + } + if err != nil { + t.Fatalf("%q: %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("%q = %q, want %q", tc.in, got, tc.want) + } + } +} + func stringsJoin(ss []string) string { out := "" for i, s := range ss { diff --git a/pkgs/bigfred/dcc-bus/cmd/control_redis.go b/pkgs/bigfred/dcc-bus/cmd/control_redis.go index 9a0c59b..4d61707 100644 --- a/pkgs/bigfred/dcc-bus/cmd/control_redis.go +++ b/pkgs/bigfred/dcc-bus/cmd/control_redis.go @@ -5,6 +5,8 @@ import ( "encoding/json" stderrors "errors" + "github.com/sirupsen/logrus" + "github.com/keskad/loco/pkgs/bigfred/contract" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/service" @@ -48,6 +50,63 @@ func (r *Router) HandleControlCommand(ctx context.Context, raw []byte) { return } r.applyEStopTarget(ctx, p.Addresses) + + case protocol.TypeLocoCVWrite: + var p protocol.LocoCVWritePayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return + } + r.logControlProgramming(env.Type, r.HandleLocoCVWrite(ctx, controlActor, noopResponder{}, p, "")) + + case protocol.TypeLocoCVRead: + var p protocol.LocoCVReadPayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return + } + r.logControlProgramming(env.Type, r.HandleLocoCVRead(ctx, controlActor, noopResponder{}, p, "")) + + case protocol.TypeLocoAddrSet: + var p protocol.LocoAddrSetPayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return + } + r.logControlProgramming(env.Type, r.HandleLocoAddrSet(ctx, controlActor, noopResponder{}, p, "")) + + case protocol.TypeLocoAddrGet: + var p protocol.LocoAddrGetPayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return + } + r.logControlProgramming(env.Type, r.HandleLocoAddrGet(ctx, controlActor, noopResponder{}, p, "")) + } +} + +// controlActor labels commands that arrive on the Redis control channel +// rather than from a browser session. +var controlActor = Actor{Source: "server"} + +// logControlProgramming reports the outcome of a control-channel +// programming command. The channel is fire-and-forget, so on rejection +// the daemon publishes a control.programming.rejected event on its +// event channel — that is the server's only signal that the command +// did not run (loco-server can log it, surface it to an admin HUD, or +// retry against a different station). On success the daemon log is +// enough; no event is emitted. +func (r *Router) logControlProgramming(frameType string, res Result) { + if res.OK { + return + } + r.log.WithFields(logrus.Fields{ + "type": frameType, + "code": res.Code, + }).Warn("dcc-bus control programming command rejected") + if r.redis != nil { + _ = r.redis.Publish(context.Background(), protocol.TypeControlProgrammingRejected, + protocol.ControlProgrammingRejectedPayload{ + FrameType: frameType, + Code: res.Code, + Address: res.LocoAddress, + }) } } diff --git a/pkgs/bigfred/dcc-bus/cmd/control_redis_test.go b/pkgs/bigfred/dcc-bus/cmd/control_redis_test.go new file mode 100644 index 0000000..6e0b9c3 --- /dev/null +++ b/pkgs/bigfred/dcc-bus/cmd/control_redis_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + + "github.com/keskad/loco/pkgs/bigfred/contract" + buserrors "github.com/keskad/loco/pkgs/bigfred/dcc-bus/errors" + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/state" +) + +func TestLogControlProgramming_publishesRejectionEvent(t *testing.T) { + t.Parallel() + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + defer mr.Close() + + rs := state.NewRedis(redis.NewClient(&redis.Options{Addr: mr.Addr()}), 2, 1) + log := logrus.New() + log.SetOutput(io.Discard) + r := &Router{redis: rs, log: log} + + // Subscribe to the daemon's event channel before publishing so the + // message is not lost to fire-and-forget timing. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sub := rs.Client().Subscribe(ctx, contract.DccBusEventChannel(2, 1)) + defer func() { _ = sub.Close() }() + if _, err := sub.Receive(ctx); err != nil { + t.Fatalf("subscribe: %v", err) + } + msgCh := sub.Channel() + + r.logControlProgramming(protocol.TypeLocoCVWrite, Result{ + OK: false, + Code: buserrors.CodeProgrammingDisabled, + LocoAddress: 47, + }) + + select { + case msg := <-msgCh: + var env contract.EnvelopeWire + if err := json.Unmarshal([]byte(msg.Payload), &env); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if env.Type != protocol.TypeControlProgrammingRejected { + t.Fatalf("event type = %q, want %q", env.Type, protocol.TypeControlProgrammingRejected) + } + var p protocol.ControlProgrammingRejectedPayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if p.FrameType != protocol.TypeLocoCVWrite { + t.Errorf("frameType = %q, want %q", p.FrameType, protocol.TypeLocoCVWrite) + } + if p.Code != buserrors.CodeProgrammingDisabled { + t.Errorf("code = %q, want %q", p.Code, buserrors.CodeProgrammingDisabled) + } + if p.Address != 47 { + t.Errorf("address = %d, want 47", p.Address) + } + case <-time.After(time.Second): + t.Fatal("control.programming.rejected event was not published") + } +} + +func TestLogControlProgramming_silentOnSuccess(t *testing.T) { + t.Parallel() + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + defer mr.Close() + + rs := state.NewRedis(redis.NewClient(&redis.Options{Addr: mr.Addr()}), 2, 1) + log := logrus.New() + log.SetOutput(io.Discard) + r := &Router{redis: rs, log: log} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sub := rs.Client().Subscribe(ctx, contract.DccBusEventChannel(2, 1)) + defer func() { _ = sub.Close() }() + if _, err := sub.Receive(ctx); err != nil { + t.Fatalf("subscribe: %v", err) + } + msgCh := sub.Channel() + + r.logControlProgramming(protocol.TypeLocoCVWrite, OKResult()) + + select { + case msg := <-msgCh: + t.Fatalf("expected no event on success, got %s", msg.Payload) + case <-time.After(150 * time.Millisecond): + // ok — no event published. + } +} diff --git a/pkgs/bigfred/dcc-bus/cmd/port.go b/pkgs/bigfred/dcc-bus/cmd/port.go index 0d7b8c4..402ded4 100644 --- a/pkgs/bigfred/dcc-bus/cmd/port.go +++ b/pkgs/bigfred/dcc-bus/cmd/port.go @@ -44,6 +44,25 @@ type Responder interface { SendAck(ctx context.Context, requestID string, payload protocol.AckPayload) error } +// noopResponder satisfies Responder for commands that arrive without a +// client to answer — the Redis control channel is fire-and-forget. +type noopResponder struct{} + +func (noopResponder) Subscribe(...uint16) {} +func (noopResponder) Unsubscribe(...uint16) {} +func (noopResponder) SubscribedAddrs() []uint16 { return nil } +func (noopResponder) OldestSubscribed() (uint16, bool) { return 0, false } +func (noopResponder) SelectedAddr() uint16 { return 0 } +func (noopResponder) SetSelected(uint16) {} +func (noopResponder) ClearSelected() {} + +func (noopResponder) SendLocoState(context.Context, contract.LocoStateWire) error { return nil } +func (noopResponder) SendLocoError(context.Context, uint16, string, string) error { return nil } +func (noopResponder) SendLocoErrorPayload(context.Context, protocol.LocoErrorPayload) error { + return nil +} +func (noopResponder) SendAck(context.Context, string, protocol.AckPayload) error { return nil } + // SessionView is a snapshot of one live browser session used for fan-out // and dead-man bookkeeping without importing the ws package. type SessionView struct { diff --git a/pkgs/bigfred/dcc-bus/cmd/programming.go b/pkgs/bigfred/dcc-bus/cmd/programming.go new file mode 100644 index 0000000..5fc199a --- /dev/null +++ b/pkgs/bigfred/dcc-bus/cmd/programming.go @@ -0,0 +1,256 @@ +package cmd + +import ( + "context" + stderrors "errors" + "fmt" + "time" + + "github.com/sirupsen/logrus" + + buserrors "github.com/keskad/loco/pkgs/bigfred/dcc-bus/errors" + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" + "github.com/keskad/loco/pkgs/loco/commandstation" +) + +const ( + // programmingTimeout bounds one CV read or write round-trip. Service + // mode acks are slow (a decoder may take several packet periods to + // answer) so this is far above the throttle-path budget. + programmingTimeout = 15 * time.Second + // programmingReadRetries re-issues a read when the decoder stays + // silent; writes are not retried because they are not idempotent + // from the decoder's point of view. + programmingReadRetries = 1 + // programmingSettle lets a decoder finish its internal write cycle + // before the next CV is pushed (matches the loco CLI default). + programmingSettle = 300 * time.Millisecond +) + +var errNoStation = stderrors.New("dcc-bus: no command station") + +// normalizeProgrammingTrack coerces a configured track name onto one of +// the two driver modes. Anything unrecognised falls back to the +// programming track, which cannot disturb locos on the main track. +func normalizeProgrammingTrack(track string) string { + if track == protocol.ProgrammingModePOM { + return protocol.ProgrammingModePOM + } + return protocol.ProgrammingModeProg +} + +// resolveProgrammingTarget maps a frame's `mode` (or the daemon default +// when it is empty) onto the driver's mode / address pair. Service-mode +// programming addresses the single decoder sitting on the programming +// track, so the loco id is always 0 there; POM needs the real address +// because the packet travels over the shared main track. +func (r *Router) resolveProgrammingTarget(mode string, addr uint16) (commandstation.Mode, commandstation.LocoAddr, error) { + if mode == "" { + mode = r.defaultProgrammingTrack + } + switch mode { + case protocol.ProgrammingModeProg: + return commandstation.ProgrammingTrackMode, 0, nil + case protocol.ProgrammingModePOM: + if addr == 0 { + return "", 0, fmt.Errorf("pom programming requires a locomotive address") + } + return commandstation.MainTrackMode, commandstation.LocoAddr(addr), nil + default: + return "", 0, fmt.Errorf("unsupported programming mode %q", mode) + } +} + +// programmingGate rejects every CV / address use case when the daemon +// was started without --enable-programming, or when no driver is bound. +func (r *Router) programmingGate() (Result, bool) { + if r == nil || !r.programmingEnabled { + return FailResult(buserrors.CodeProgrammingDisabled), false + } + if r.station == nil { + return FailResult(buserrors.CodeCommandStationError), false + } + return Result{}, true +} + +// HandleLocoCVWrite programs the requested CVs on one decoder. Writes +// are applied in the order the client sent them with a settle pause in +// between; the first failure aborts the batch so a half-applied address +// change is reported instead of silently continued. +func (r *Router) HandleLocoCVWrite(_ context.Context, actor Actor, _ Responder, p protocol.LocoCVWritePayload, _ string) Result { + if res, ok := r.programmingGate(); !ok { + return res + } + mode, locoID, err := r.resolveProgrammingTarget(p.Mode, p.Address) + if err != nil { + return r.programmingFailure(actor, "loco.cvWrite", p.Address, err, buserrors.WsCodeBadPayload) + } + + r.progMu.Lock() + defer r.progMu.Unlock() + + written := make([]protocol.CVEntry, 0, len(p.CVs)) + for i, entry := range p.CVs { + if i > 0 { + time.Sleep(programmingSettle) + } + if err := r.writeCV(mode, locoID, entry.CV, int(entry.Value), false); err != nil { + return r.programmingFailure(actor, "loco.cvWrite", p.Address, err, buserrors.CodeProgrammingFailed) + } + written = append(written, entry) + } + + res := OKResult() + res.CVs = written + return res +} + +// HandleLocoCVRead reads the requested CVs back from one decoder. A POM +// read needs a RailCom-capable command station; on the programming +// track any decoder answers. +func (r *Router) HandleLocoCVRead(_ context.Context, actor Actor, _ Responder, p protocol.LocoCVReadPayload, _ string) Result { + if res, ok := r.programmingGate(); !ok { + return res + } + mode, locoID, err := r.resolveProgrammingTarget(p.Mode, p.Address) + if err != nil { + return r.programmingFailure(actor, "loco.cvRead", p.Address, err, buserrors.WsCodeBadPayload) + } + + r.progMu.Lock() + defer r.progMu.Unlock() + + out := make([]protocol.CVEntry, 0, len(p.CVs)) + for _, num := range p.CVs { + value, err := r.readCV(mode, locoID, num) + if err != nil { + return r.programmingFailure(actor, "loco.cvRead", p.Address, err, buserrors.CodeProgrammingFailed) + } + out = append(out, protocol.CVEntry{CV: num, Value: uint8(value)}) + } + + res := OKResult() + res.CVs = out + return res +} + +// HandleLocoAddrGet decodes the decoder's programmed address from +// CV1 / CV17 / CV18 / CV29 and returns both the raw CVs and the decoded +// address so a UI can show the long/short format it is in. +func (r *Router) HandleLocoAddrGet(_ context.Context, actor Actor, _ Responder, p protocol.LocoAddrGetPayload, _ string) Result { + if res, ok := r.programmingGate(); !ok { + return res + } + mode, locoID, err := r.resolveProgrammingTarget(p.Mode, p.Address) + if err != nil { + return r.programmingFailure(actor, "loco.addrGet", p.Address, err, buserrors.WsCodeBadPayload) + } + + r.progMu.Lock() + defer r.progMu.Unlock() + + values := make(map[uint16]int, len(addressCVNums)) + cvs := make([]protocol.CVEntry, 0, len(addressCVNums)) + for _, num := range addressCVNums { + value, err := r.readCV(mode, locoID, num) + if err != nil { + return r.programmingFailure(actor, "loco.addrGet", p.Address, fmt.Errorf("read CV%d: %w", num, err), buserrors.CodeProgrammingFailed) + } + values[num] = value + cvs = append(cvs, protocol.CVEntry{CV: num, Value: uint8(value)}) + } + + addr, long, err := addressFromCVs(values[1], values[17], values[18], values[29]) + if err != nil { + return r.programmingFailure(actor, "loco.addrGet", p.Address, err, buserrors.CodeProgrammingFailed) + } + + res := OKResult() + res.CVs = cvs + res.LocoAddress = addr + res.LongAddress = long + return res +} + +// HandleLocoAddrSet rewrites a decoder's DCC address. CV29 is read +// first so only its long-address bit is touched and the operator's +// other decoder settings survive the change. +func (r *Router) HandleLocoAddrSet(_ context.Context, actor Actor, _ Responder, p protocol.LocoAddrSetPayload, _ string) Result { + if res, ok := r.programmingGate(); !ok { + return res + } + // On the main track a decoder can only be reached at the address it + // already answers to, so a POM addrSet re-encodes that same address + // (e.g. short → long format); moving a decoder to a different + // address needs the programming track. + mode, locoID, err := r.resolveProgrammingTarget(p.Mode, p.Address) + if err != nil { + return r.programmingFailure(actor, "loco.addrSet", p.Address, err, buserrors.WsCodeBadPayload) + } + + r.progMu.Lock() + defer r.progMu.Unlock() + + cv29, err := r.readCV(mode, locoID, 29) + if err != nil { + return r.programmingFailure(actor, "loco.addrSet", p.Address, fmt.Errorf("read CV29: %w", err), buserrors.CodeProgrammingFailed) + } + + writes, long, err := addressCVWrites(p.Address, cv29) + if err != nil { + return r.programmingFailure(actor, "loco.addrSet", p.Address, err, buserrors.WsCodeBadPayload) + } + + for i, entry := range writes { + if i > 0 { + time.Sleep(programmingSettle) + } + if err := r.writeCV(mode, locoID, entry.CV, int(entry.Value), p.Verify); err != nil { + return r.programmingFailure(actor, "loco.addrSet", p.Address, fmt.Errorf("write CV%d: %w", entry.CV, err), buserrors.CodeProgrammingFailed) + } + } + + r.log.WithFields(logrus.Fields{ + "sessionId": actor.SessionID, + "userId": actor.UserID, + "addr": p.Address, + "mode": mode, + "long": long, + }).Info("dcc-bus decoder address programmed") + + res := OKResult() + res.CVs = writes + res.LocoAddress = p.Address + res.LongAddress = long + return res +} + +func (r *Router) readCV(mode commandstation.Mode, locoID commandstation.LocoAddr, num uint16) (int, error) { + if r.station == nil { + return 0, errNoStation + } + return r.station.ReadCV(mode, commandstation.LocoCV{ + LocoId: locoID, + Cv: commandstation.CV{Num: commandstation.CVNum(num)}, + }, commandstation.Timeout(programmingTimeout), commandstation.Retries(programmingReadRetries)) +} + +func (r *Router) writeCV(mode commandstation.Mode, locoID commandstation.LocoAddr, num uint16, value int, verify bool) error { + if r.station == nil { + return errNoStation + } + return r.station.WriteCV(mode, commandstation.LocoCV{ + LocoId: locoID, + Cv: commandstation.CV{Num: commandstation.CVNum(num), Value: value}, + }, commandstation.Verify(verify), commandstation.Timeout(programmingTimeout)) +} + +func (r *Router) programmingFailure(actor Actor, frameType string, addr uint16, err error, code string) Result { + r.log.WithError(err).WithFields(logrus.Fields{ + "sessionId": actor.SessionID, + "userId": actor.UserID, + "type": frameType, + "addr": addr, + }).Warn("dcc-bus programming request failed") + return FailResult(code) +} diff --git a/pkgs/bigfred/dcc-bus/cmd/programming_addr.go b/pkgs/bigfred/dcc-bus/cmd/programming_addr.go new file mode 100644 index 0000000..f23c22e --- /dev/null +++ b/pkgs/bigfred/dcc-bus/cmd/programming_addr.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" +) + +// Address encoding per NMRA S-9.2.2. Mirrors pkgs/loco/app/addr.go, +// duplicated here because those helpers are unexported and tied to the +// CLI's LocoApp lifecycle (InitializeCommandStation / CleanUp), which +// the long-lived daemon must not run per request. +const ( + cv29LongAddressBit = 32 // bit 5: address is taken from CV17/CV18 + + shortAddressMin = 1 + shortAddressMax = 127 + longAddressMax = 10239 +) + +// addressCVNums are the CVs that together encode a decoder address. +var addressCVNums = []uint16{1, 17, 18, 29} + +// addressFromCVs decodes a decoder address out of its four address CVs. +func addressFromCVs(cv1, cv17, cv18, cv29 int) (addr uint16, long bool, err error) { + if cv29&cv29LongAddressBit != 0 { + if cv17 < 192 { + return 0, false, fmt.Errorf("invalid long address: CV17=%d (expected >= 192)", cv17) + } + return uint16((cv17-192)*256 + cv18), true, nil + } + if cv1 < shortAddressMin || cv1 > shortAddressMax { + return 0, false, fmt.Errorf("invalid short address: CV1=%d (expected %d-%d)", cv1, shortAddressMin, shortAddressMax) + } + return uint16(cv1), false, nil +} + +// addressCVWrites builds the CV writes that program addr, preserving +// every CV29 bit other than the long-address bit. +func addressCVWrites(addr uint16, cv29 int) ([]protocol.CVEntry, bool, error) { + if addr > longAddressMax { + return nil, false, fmt.Errorf("address %d out of range (0-%d)", addr, longAddressMax) + } + if addr >= shortAddressMin && addr <= shortAddressMax { + return []protocol.CVEntry{ + {CV: 1, Value: uint8(addr)}, + {CV: 17, Value: 0}, + {CV: 18, Value: 0}, + {CV: 29, Value: uint8(cv29 &^ cv29LongAddressBit)}, + }, false, nil + } + return []protocol.CVEntry{ + {CV: 17, Value: uint8(192 + addr/256)}, + {CV: 18, Value: uint8(addr % 256)}, + {CV: 29, Value: uint8(cv29 | cv29LongAddressBit)}, + }, true, nil +} diff --git a/pkgs/bigfred/dcc-bus/cmd/programming_test.go b/pkgs/bigfred/dcc-bus/cmd/programming_test.go new file mode 100644 index 0000000..aabd6f7 --- /dev/null +++ b/pkgs/bigfred/dcc-bus/cmd/programming_test.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/sirupsen/logrus" + + buserrors "github.com/keskad/loco/pkgs/bigfred/dcc-bus/errors" + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" + "github.com/keskad/loco/pkgs/loco/commandstation" +) + +type cvCall struct { + mode commandstation.Mode + locoID commandstation.LocoAddr + cv uint16 + value int +} + +// cvStubStation records CV traffic and answers reads from a fixed map. +type cvStubStation struct { + commandstation.StubStation + mu sync.Mutex + values map[uint16]int + reads []cvCall + writes []cvCall +} + +func (s *cvStubStation) ReadCV(mode commandstation.Mode, lcv commandstation.LocoCV, _ ...commandstation.Option) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.reads = append(s.reads, cvCall{mode: mode, locoID: lcv.LocoId, cv: uint16(lcv.Cv.Num)}) + return s.values[uint16(lcv.Cv.Num)], nil +} + +func (s *cvStubStation) WriteCV(mode commandstation.Mode, lcv commandstation.LocoCV, _ ...commandstation.Option) error { + s.mu.Lock() + defer s.mu.Unlock() + s.writes = append(s.writes, cvCall{mode: mode, locoID: lcv.LocoId, cv: uint16(lcv.Cv.Num), value: lcv.Cv.Value}) + return nil +} + +func newProgrammingRouter(st commandstation.Station, enabled bool, track string) *Router { + log := logrus.New() + log.SetOutput(io.Discard) + return &Router{ + station: st, + log: log, + programmingEnabled: enabled, + defaultProgrammingTrack: normalizeProgrammingTrack(track), + } +} + +func TestProgramming_disabledRejectsEveryFrame(t *testing.T) { + t.Parallel() + st := &cvStubStation{values: map[uint16]int{}} + r := newProgrammingRouter(st, false, protocol.ProgrammingModeProg) + ctx := context.Background() + actor := Actor{UserID: 1, SessionID: "s1"} + + results := []Result{ + r.HandleLocoCVWrite(ctx, actor, noopResponder{}, protocol.LocoCVWritePayload{CVs: []protocol.CVEntry{{CV: 1, Value: 3}}}, ""), + r.HandleLocoCVRead(ctx, actor, noopResponder{}, protocol.LocoCVReadPayload{CVs: []uint16{1}}, ""), + r.HandleLocoAddrSet(ctx, actor, noopResponder{}, protocol.LocoAddrSetPayload{Address: 3}, ""), + r.HandleLocoAddrGet(ctx, actor, noopResponder{}, protocol.LocoAddrGetPayload{}, ""), + } + for i, res := range results { + if res.OK || res.Code != buserrors.CodeProgrammingDisabled { + t.Fatalf("result %d = %+v, want %s", i, res, buserrors.CodeProgrammingDisabled) + } + } + if len(st.reads) != 0 || len(st.writes) != 0 { + t.Fatalf("station touched while programming disabled: reads=%v writes=%v", st.reads, st.writes) + } +} + +func TestProgramming_modeFallsBackToDefaultTrack(t *testing.T) { + t.Parallel() + st := &cvStubStation{values: map[uint16]int{8: 151}} + r := newProgrammingRouter(st, true, protocol.ProgrammingModeProg) + + res := r.HandleLocoCVRead(context.Background(), Actor{}, noopResponder{}, protocol.LocoCVReadPayload{ + Address: 42, + CVs: []uint16{8}, + }, "") + if !res.OK { + t.Fatalf("read failed: %s", res.Code) + } + if len(st.reads) != 1 { + t.Fatalf("reads = %v, want one", st.reads) + } + // Service mode addresses the single decoder on the programming + // track, so the loco id must be dropped even though one was sent. + if got := st.reads[0]; got.mode != commandstation.ProgrammingTrackMode || got.locoID != 0 { + t.Fatalf("read call = %+v, want prog mode with loco 0", got) + } + if len(res.CVs) != 1 || res.CVs[0].CV != 8 || res.CVs[0].Value != 151 { + t.Fatalf("cvs = %+v, want CV8=151", res.CVs) + } +} + +func TestProgramming_payloadModeOverridesDefaultTrack(t *testing.T) { + t.Parallel() + st := &cvStubStation{values: map[uint16]int{}} + r := newProgrammingRouter(st, true, protocol.ProgrammingModeProg) + + res := r.HandleLocoCVWrite(context.Background(), Actor{}, noopResponder{}, protocol.LocoCVWritePayload{ + Address: 42, + Mode: protocol.ProgrammingModePOM, + CVs: []protocol.CVEntry{{CV: 3, Value: 7}}, + }, "") + if !res.OK { + t.Fatalf("write failed: %s", res.Code) + } + if got := st.writes[0]; got.mode != commandstation.MainTrackMode || got.locoID != 42 || got.value != 7 { + t.Fatalf("write call = %+v, want pom mode addressed to 42", got) + } +} + +func TestProgramming_pomWithoutAddressIsRejected(t *testing.T) { + t.Parallel() + st := &cvStubStation{values: map[uint16]int{}} + r := newProgrammingRouter(st, true, protocol.ProgrammingModePOM) + + res := r.HandleLocoCVRead(context.Background(), Actor{}, noopResponder{}, protocol.LocoCVReadPayload{CVs: []uint16{1}}, "") + if res.OK || res.Code != buserrors.WsCodeBadPayload { + t.Fatalf("result = %+v, want %s", res, buserrors.WsCodeBadPayload) + } +} + +func TestHandleLocoAddrGet_decodesLongAddress(t *testing.T) { + t.Parallel() + // CV29 bit 5 set → address lives in CV17/CV18: (196-192)*256 + 210. + st := &cvStubStation{values: map[uint16]int{1: 3, 17: 196, 18: 210, 29: 34}} + r := newProgrammingRouter(st, true, protocol.ProgrammingModeProg) + + res := r.HandleLocoAddrGet(context.Background(), Actor{}, noopResponder{}, protocol.LocoAddrGetPayload{}, "") + if !res.OK { + t.Fatalf("addrGet failed: %s", res.Code) + } + if res.LocoAddress != 1234 || !res.LongAddress { + t.Fatalf("addr = %d long = %v, want 1234 long", res.LocoAddress, res.LongAddress) + } + if len(res.CVs) != 4 { + t.Fatalf("cvs = %+v, want CV1/17/18/29", res.CVs) + } +} + +func TestHandleLocoAddrSet_preservesOtherCV29Bits(t *testing.T) { + t.Parallel() + // CV29 = 6: speed table + 28/128 steps, short address. + st := &cvStubStation{values: map[uint16]int{29: 6}} + r := newProgrammingRouter(st, true, protocol.ProgrammingModeProg) + + res := r.HandleLocoAddrSet(context.Background(), Actor{}, noopResponder{}, protocol.LocoAddrSetPayload{Address: 1234}, "") + if !res.OK { + t.Fatalf("addrSet failed: %s", res.Code) + } + if !res.LongAddress || res.LocoAddress != 1234 { + t.Fatalf("result = %+v, want long 1234", res) + } + want := []cvCall{ + {mode: commandstation.ProgrammingTrackMode, cv: 17, value: 196}, + {mode: commandstation.ProgrammingTrackMode, cv: 18, value: 210}, + {mode: commandstation.ProgrammingTrackMode, cv: 29, value: 6 | 32}, + } + if len(st.writes) != len(want) { + t.Fatalf("writes = %+v, want %+v", st.writes, want) + } + for i, w := range want { + if st.writes[i] != w { + t.Fatalf("write %d = %+v, want %+v", i, st.writes[i], w) + } + } +} + +func TestHandleLocoAddrSet_shortAddressClearsLongBit(t *testing.T) { + t.Parallel() + // CV29 = 38: long-address bit set on top of the same config bits. + st := &cvStubStation{values: map[uint16]int{29: 38}} + r := newProgrammingRouter(st, true, protocol.ProgrammingModeProg) + + res := r.HandleLocoAddrSet(context.Background(), Actor{}, noopResponder{}, protocol.LocoAddrSetPayload{Address: 7}, "") + if !res.OK { + t.Fatalf("addrSet failed: %s", res.Code) + } + if res.LongAddress { + t.Fatalf("address 7 must be programmed as a short address") + } + last := st.writes[len(st.writes)-1] + if last.cv != 29 || last.value != 6 { + t.Fatalf("CV29 write = %+v, want 6 (long bit cleared)", last) + } +} diff --git a/pkgs/bigfred/dcc-bus/cmd/result.go b/pkgs/bigfred/dcc-bus/cmd/result.go index b64c88d..fccbab6 100644 --- a/pkgs/bigfred/dcc-bus/cmd/result.go +++ b/pkgs/bigfred/dcc-bus/cmd/result.go @@ -10,6 +10,11 @@ type Result struct { Members []protocol.TrainSetSpeedMemberAck EvictedAddr uint16 DrivenAddrs []uint16 + // CVs, LocoAddress and LongAddress are populated by the decoder + // programming use cases (loco.cvRead / cvWrite / addrGet / addrSet). + CVs []protocol.CVEntry + LocoAddress uint16 + LongAddress bool } // OKResult returns a successful action result. diff --git a/pkgs/bigfred/dcc-bus/cmd/router.go b/pkgs/bigfred/dcc-bus/cmd/router.go index c5a1319..c5b501e 100644 --- a/pkgs/bigfred/dcc-bus/cmd/router.go +++ b/pkgs/bigfred/dcc-bus/cmd/router.go @@ -75,6 +75,13 @@ type Router struct { bootStopDone bool bootStopEnabled bool singleVehicleControl bool + + programmingEnabled bool + defaultProgrammingTrack string + // progMu serialises decoder programming sequences: CV batches and + // address rewrites are multi-round-trip and must not interleave on + // a single command station. + progMu sync.Mutex } // Config carries the inputs Router needs at construction time. @@ -100,6 +107,11 @@ type Config struct { BootStopEnabled bool SingleVehicleControl bool SlotMetrics slotlease.Recorder + // ProgrammingEnabled opens the CV / address use cases. When false + // every programming frame is rejected with programming_disabled. + ProgrammingEnabled bool + // DefaultProgrammingTrack is "pom" or "prog"; empty means "prog". + DefaultProgrammingTrack string } // NewRouter assembles the router and seeds roster caches from Redis. @@ -136,6 +148,8 @@ func NewRouter(_ context.Context, cfg Config) (*Router, error) { slotMetrics: slotlease.RecorderOrNoop(cfg.SlotMetrics), bootStopEnabled: cfg.BootStopEnabled, singleVehicleControl: cfg.SingleVehicleControl, + programmingEnabled: cfg.ProgrammingEnabled, + defaultProgrammingTrack: normalizeProgrammingTrack(cfg.DefaultProgrammingTrack), } r.dcc.LogFields = r.stationLogFields r.reconcileBootSlots(cfg) diff --git a/pkgs/bigfred/dcc-bus/daemon.go b/pkgs/bigfred/dcc-bus/daemon.go index fdfe6bc..c157f5e 100644 --- a/pkgs/bigfred/dcc-bus/daemon.go +++ b/pkgs/bigfred/dcc-bus/daemon.go @@ -22,6 +22,7 @@ import ( "github.com/keskad/loco/pkgs/bigfred/contract" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/auth" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/cmd" + "github.com/keskad/loco/pkgs/bigfred/dcc-bus/protocol" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/service/station" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/slotlease" "github.com/keskad/loco/pkgs/bigfred/dcc-bus/state" @@ -89,6 +90,14 @@ type Config struct { // AllocatePhysicalSlots enables PE 1.0 exclusive LocoNet slot allocation // (default true). When false, BigFred may piggyback on IN_USE slots. AllocatePhysicalSlots bool + + // EnableProgramming opens the decoder CV / address frames. Off by + // default: writing CVs on a live layout is destructive, so an + // operator has to opt the daemon in explicitly. + EnableProgramming bool + // DefaultProgrammingTrack is "pom" or "prog" and applies to frames + // that omit `mode`. Empty means "prog". + DefaultProgrammingTrack string } // Daemon is the assembled dcc-bus instance. @@ -145,6 +154,9 @@ func New(ctx context.Context, log *logrus.Logger, cfg Config) (*Daemon, error) { if cfg.DeadmanSecs <= 0 { cfg.DeadmanSecs = 6 } + if cfg.DefaultProgrammingTrack == "" { + cfg.DefaultProgrammingTrack = protocol.ProgrammingModeProg + } cs := cfg.CommandStation @@ -314,6 +326,8 @@ func New(ctx context.Context, log *logrus.Logger, cfg Config) (*Daemon, error) { BootStopEnabled: cfg.BootStopEnabled, SingleVehicleControl: cfg.SingleVehicleControl, SlotMetrics: slotMetrics, + ProgrammingEnabled: cfg.EnableProgramming, + DefaultProgrammingTrack: cfg.DefaultProgrammingTrack, }) if err != nil { _ = st.CleanUp() @@ -364,6 +378,7 @@ func New(ctx context.Context, log *logrus.Logger, cfg Config) (*Daemon, error) { DeadmanSecs: cfg.DeadmanSecs, AllowedOrigins: cfg.AllowedOrigins, Metrics: wsMetrics, + ProgrammingEnabled: cfg.EnableProgramming, SlotsDiag: ws.NewSlotsDiagHandler(ws.SlotsDiagConfig{ Leaser: router.SlotLeaser(), Metrics: slotMetrics, diff --git a/pkgs/bigfred/dcc-bus/errors/codes.go b/pkgs/bigfred/dcc-bus/errors/codes.go index 752a0ce..5670909 100644 --- a/pkgs/bigfred/dcc-bus/errors/codes.go +++ b/pkgs/bigfred/dcc-bus/errors/codes.go @@ -48,4 +48,12 @@ const ( // CodePartialFailure is returned when train.setSpeed succeeds for // some members but not all. CodePartialFailure = "partial_failure" + + // CodeProgrammingDisabled is returned for every CV / address frame + // when the daemon was started without --enable-programming. + CodeProgrammingDisabled = "programming_disabled" + + // CodeProgrammingFailed is returned when the decoder or command + // station rejects a CV read/write (no ack, RailCom silence, ...). + CodeProgrammingFailed = "programming_failed" ) diff --git a/pkgs/bigfred/dcc-bus/protocol/protocol.go b/pkgs/bigfred/dcc-bus/protocol/protocol.go index 44f28a6..69c4cff 100644 --- a/pkgs/bigfred/dcc-bus/protocol/protocol.go +++ b/pkgs/bigfred/dcc-bus/protocol/protocol.go @@ -96,6 +96,48 @@ type SystemEStopPayload struct { Reason string `json:"reason,omitempty"` } +// CVEntry is one configuration-variable slot: the CV number and its +// value. Used both as a write instruction and as a read result. +type CVEntry struct { + CV uint16 `json:"cv"` + Value uint8 `json:"value"` +} + +// LocoCVWritePayload programs one or more CVs on a decoder. `Mode` +// selects the programming track ("prog") or programming-on-main +// ("pom"); empty falls back to the daemon's --default-programming-track. +type LocoCVWritePayload struct { + Address uint16 `json:"address"` + CVs []CVEntry `json:"cvs"` + Mode string `json:"mode,omitempty"` // "pom"|"prog" +} + +// LocoCVReadPayload reads the listed CVs back from a decoder. POM +// reads need a RailCom-capable command station; programming-track +// reads do not. +type LocoCVReadPayload struct { + Address uint16 `json:"address"` + CVs []uint16 `json:"cvs"` + Mode string `json:"mode,omitempty"` +} + +// LocoAddrSetPayload rewrites a decoder's DCC address. The daemon +// derives the CV1 / CV17 / CV18 / CV29 writes from the requested +// address, preserving every CV29 bit other than the long-address bit. +type LocoAddrSetPayload struct { + Address uint16 `json:"address"` + Mode string `json:"mode,omitempty"` + Verify bool `json:"verify,omitempty"` +} + +// LocoAddrGetPayload reads a decoder's currently programmed address. +// `Address` is only meaningful in "pom" mode, where it addresses the +// decoder being interrogated. +type LocoAddrGetPayload struct { + Address uint16 `json:"address,omitempty"` + Mode string `json:"mode,omitempty"` +} + // -------- Server → Client frames -------- // DccBusOpenedPayload is the welcome frame the daemon sends right @@ -128,6 +170,23 @@ type LocoErrorPayload struct { DrivenAddrs []uint16 `json:"drivenAddrs,omitempty"` } +// ControlProgrammingRejectedPayload is published on the dcc-bus event +// channel when a programming command (loco.cvWrite / cvRead / addrSet / +// addrGet) arriving on the Redis control channel is rejected by the +// router. The control channel is fire-and-forget, so this event is the +// server's only signal that the command did not run — loco-server can +// log it, surface it to an admin HUD, or retry against a different +// station. +type ControlProgrammingRejectedPayload struct { + // FrameType is the original control frame type (loco.cvWrite, …). + FrameType string `json:"frameType"` + // Code is the machine-readable rejection code + // (errors.CodeProgrammingDisabled / CodeProgrammingFailed / …). + Code string `json:"code"` + // Address is the decoder address the command targeted, when known. + Address uint16 `json:"address,omitempty"` +} + // TrainSetSpeedMemberAck is one member result inside a train.setSpeed ack. type TrainSetSpeedMemberAck struct { Addr uint16 `json:"addr"` @@ -144,6 +203,13 @@ type AckPayload struct { Members []TrainSetSpeedMemberAck `json:"members,omitempty"` EvictedAddr uint16 `json:"evictedAddr,omitempty"` DrivenAddrs []uint16 `json:"drivenAddrs,omitempty"` + // CVs carries the CVs read back (loco.cvRead) or the CVs the + // daemon actually wrote (loco.cvWrite, loco.addrSet). + CVs []CVEntry `json:"cvs,omitempty"` + // LocoAddress and LongAddress report the decoder address decoded + // from CV1/CV17/CV18/CV29 on loco.addrGet and loco.addrSet. + LocoAddress uint16 `json:"locoAddress,omitempty"` + LongAddress bool `json:"longAddress,omitempty"` } // -------- Frame type catalogue -------- @@ -164,9 +230,30 @@ const ( TypeSystemEStop = "system.estop" TypeSystemRadioStop = "system.radioStop" TypeSystemEStopTarget = "system.estopTarget" + TypeLocoCVWrite = "loco.cvWrite" + TypeLocoCVRead = "loco.cvRead" + TypeLocoAddrSet = "loco.addrSet" + TypeLocoAddrGet = "loco.addrGet" + + // TypeControlProgrammingRejected is published on the dcc-bus event + // channel when a programming command arriving on the Redis control + // channel (dcc-bus:cmd) is rejected by the router (e.g. programming + // disabled, no command station, decoder error). The control channel + // is fire-and-forget, so this event is the only feedback the server + // gets that the command did not run. + TypeControlProgrammingRejected = "control.programming.rejected" TypeDccBusOpened = "dcc-bus.opened" TypeLocoState = "loco.state" TypeLocoError = "loco.error" TypeAck = "ack" ) + +// Programming track selectors accepted in the `mode` field of the +// CV / address frames. They mirror commandstation.Mode on the wire: +// POM programs a decoder on the main track while it is running, +// Prog uses the command station's isolated programming output. +const ( + ProgrammingModePOM = "pom" + ProgrammingModeProg = "prog" +) diff --git a/pkgs/bigfred/dcc-bus/validation/ws.go b/pkgs/bigfred/dcc-bus/validation/ws.go index ddb3b83..2c00aec 100644 --- a/pkgs/bigfred/dcc-bus/validation/ws.go +++ b/pkgs/bigfred/dcc-bus/validation/ws.go @@ -54,6 +54,71 @@ func (LocoSubscribe) Valid(p protocol.LocoSubscribePayload) bool { return true } +// maxCVNum is the highest CV number addressable by NMRA S-9.2.2 +// indexed/paged addressing. +const maxCVNum = 1024 + +// validProgrammingMode reports whether mode is empty (use the daemon +// default) or one of the two track selectors. +func validProgrammingMode(mode string) bool { + switch mode { + case "", protocol.ProgrammingModePOM, protocol.ProgrammingModeProg: + return true + default: + return false + } +} + +// LocoCVWrite validates loco.cvWrite payloads. +type LocoCVWrite struct{} + +// Valid reports whether the mode is known and every CV number is in range. +func (LocoCVWrite) Valid(p protocol.LocoCVWritePayload) bool { + if !validProgrammingMode(p.Mode) || len(p.CVs) == 0 { + return false + } + for _, entry := range p.CVs { + if entry.CV == 0 || entry.CV > maxCVNum { + return false + } + } + return true +} + +// LocoCVRead validates loco.cvRead payloads. +type LocoCVRead struct{} + +// Valid reports whether the mode is known and every CV number is in range. +func (LocoCVRead) Valid(p protocol.LocoCVReadPayload) bool { + if !validProgrammingMode(p.Mode) || len(p.CVs) == 0 { + return false + } + for _, cv := range p.CVs { + if cv == 0 || cv > maxCVNum { + return false + } + } + return true +} + +// LocoAddrSet validates loco.addrSet payloads. +type LocoAddrSet struct{} + +// Valid reports whether the mode is known and the address is a legal +// DCC address (NMRA S-9.2.2 long-address ceiling). +func (LocoAddrSet) Valid(p protocol.LocoAddrSetPayload) bool { + return validProgrammingMode(p.Mode) && p.Address >= 1 && p.Address <= 10239 +} + +// LocoAddrGet validates loco.addrGet payloads. +type LocoAddrGet struct{} + +// Valid reports whether the mode is known. The address is optional — +// it is only needed for POM reads, which the router enforces. +func (LocoAddrGet) Valid(p protocol.LocoAddrGetPayload) bool { + return validProgrammingMode(p.Mode) +} + // SetSpeed validates loco.setSpeed wire payloads against speedSteps. type SetSpeed struct { SpeedSteps uint diff --git a/pkgs/bigfred/dcc-bus/ws/adapter.go b/pkgs/bigfred/dcc-bus/ws/adapter.go index d9b60f5..1dbe50f 100644 --- a/pkgs/bigfred/dcc-bus/ws/adapter.go +++ b/pkgs/bigfred/dcc-bus/ws/adapter.go @@ -55,6 +55,22 @@ func (a *RouterAdapter) HandleEStop(ctx context.Context, sess *Session, payload return deliver(ctx, sess, requestID, a.inner.HandleEStop(ctx, actor(sess), NewResponder(sess), payload, requestID)) } +func (a *RouterAdapter) HandleLocoCVWrite(ctx context.Context, sess *Session, payload protocol.LocoCVWritePayload, requestID string) Outcome { + return deliver(ctx, sess, requestID, a.inner.HandleLocoCVWrite(ctx, actor(sess), NewResponder(sess), payload, requestID)) +} + +func (a *RouterAdapter) HandleLocoCVRead(ctx context.Context, sess *Session, payload protocol.LocoCVReadPayload, requestID string) Outcome { + return deliver(ctx, sess, requestID, a.inner.HandleLocoCVRead(ctx, actor(sess), NewResponder(sess), payload, requestID)) +} + +func (a *RouterAdapter) HandleLocoAddrSet(ctx context.Context, sess *Session, payload protocol.LocoAddrSetPayload, requestID string) Outcome { + return deliver(ctx, sess, requestID, a.inner.HandleLocoAddrSet(ctx, actor(sess), NewResponder(sess), payload, requestID)) +} + +func (a *RouterAdapter) HandleLocoAddrGet(ctx context.Context, sess *Session, payload protocol.LocoAddrGetPayload, requestID string) Outcome { + return deliver(ctx, sess, requestID, a.inner.HandleLocoAddrGet(ctx, actor(sess), NewResponder(sess), payload, requestID)) +} + func (a *RouterAdapter) HandleSessionClose(ctx context.Context, sess *Session, reason string) { a.inner.HandleSessionClose(ctx, cmd.Actor{ UserID: sess.UserID, @@ -75,6 +91,9 @@ func deliver(ctx context.Context, sess *Session, requestID string, res cmd.Resul Members: res.Members, EvictedAddr: res.EvictedAddr, DrivenAddrs: res.DrivenAddrs, + CVs: res.CVs, + LocoAddress: res.LocoAddress, + LongAddress: res.LongAddress, } if err := NewResponder(sess).SendAck(ctx, requestID, ack); err != nil { return Fail(errors.WsCodeSendFailed) diff --git a/pkgs/bigfred/dcc-bus/ws/handler.go b/pkgs/bigfred/dcc-bus/ws/handler.go index 3122f6d..593e0d5 100644 --- a/pkgs/bigfred/dcc-bus/ws/handler.go +++ b/pkgs/bigfred/dcc-bus/ws/handler.go @@ -43,6 +43,14 @@ type Router interface { // HandleEStop fires the data-plane emergency stop scoped to // this daemon's command station. HandleEStop(ctx context.Context, sess *Session, payload protocol.SystemEStopPayload, requestID string) Outcome + // HandleLocoCVWrite programs decoder CVs on the resolved track. + HandleLocoCVWrite(ctx context.Context, sess *Session, payload protocol.LocoCVWritePayload, requestID string) Outcome + // HandleLocoCVRead reads decoder CVs back from the resolved track. + HandleLocoCVRead(ctx context.Context, sess *Session, payload protocol.LocoCVReadPayload, requestID string) Outcome + // HandleLocoAddrSet rewrites the decoder's DCC address. + HandleLocoAddrSet(ctx context.Context, sess *Session, payload protocol.LocoAddrSetPayload, requestID string) Outcome + // HandleLocoAddrGet decodes the decoder's programmed DCC address. + HandleLocoAddrGet(ctx context.Context, sess *Session, payload protocol.LocoAddrGetPayload, requestID string) Outcome // HandleSessionClose is called once when the session goes away // (any reason: WS close, error, ctx cancellation). It is the // router's chance to fire the dead-man's plan and drop user- @@ -65,6 +73,9 @@ type Server struct { csID uint metrics *Metrics slotsDiag *SlotsDiagHandler + // programmingEnabled mirrors --enable-programming. When false the + // CV / address frames are rejected here, before any driver call. + programmingEnabled bool // AllowedOrigins is forwarded verbatim to websocket.AcceptOptions. // Empty slice means InsecureSkipVerify = true (acceptable when @@ -87,6 +98,9 @@ type ServerConfig struct { AllowedOrigins []string Metrics *Metrics SlotsDiag *SlotsDiagHandler + // ProgrammingEnabled opens the loco.cvRead / cvWrite / addrGet / + // addrSet frames. Off by default. + ProgrammingEnabled bool } // NewServer returns a ready-to-mount Server. Heartbeat and dead-man @@ -122,6 +136,7 @@ func NewServer(cfg ServerConfig) *Server { AllowedOrigins: cfg.AllowedOrigins, metrics: cfg.Metrics, slotsDiag: cfg.SlotsDiag, + programmingEnabled: cfg.ProgrammingEnabled, } } @@ -406,6 +421,84 @@ func (s *Server) dispatch(ctx context.Context, sess *Session, env contract.Envel } out = s.router.HandleEStop(ctx, sess, p, env.ID) + case protocol.TypeLocoCVWrite: + var p protocol.LocoCVWritePayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !(validation.LocoCVWrite{}).Valid(p) { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !s.programmingEnabled { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.CodeProgrammingDisabled) + break + } + s.dispatchAsync(ctx, sess, env, func() Outcome { + return s.router.HandleLocoCVWrite(ctx, sess, p, env.ID) + }) + return + + case protocol.TypeLocoCVRead: + var p protocol.LocoCVReadPayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !(validation.LocoCVRead{}).Valid(p) { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !s.programmingEnabled { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.CodeProgrammingDisabled) + break + } + s.dispatchAsync(ctx, sess, env, func() Outcome { + return s.router.HandleLocoCVRead(ctx, sess, p, env.ID) + }) + return + + case protocol.TypeLocoAddrSet: + var p protocol.LocoAddrSetPayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !(validation.LocoAddrSet{}).Valid(p) { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !s.programmingEnabled { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.CodeProgrammingDisabled) + break + } + s.dispatchAsync(ctx, sess, env, func() Outcome { + return s.router.HandleLocoAddrSet(ctx, sess, p, env.ID) + }) + return + + case protocol.TypeLocoAddrGet: + var p protocol.LocoAddrGetPayload + if env.Payload != nil { + if err := json.Unmarshal(env.Payload, &p); err != nil { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + } + if !(validation.LocoAddrGet{}).Valid(p) { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.WsCodeBadPayload) + break + } + if !s.programmingEnabled { + out = s.ackOrFail(ctx, sess, env.ID, false, errors.CodeProgrammingDisabled) + break + } + s.dispatchAsync(ctx, sess, env, func() Outcome { + return s.router.HandleLocoAddrGet(ctx, sess, p, env.ID) + }) + return + default: out = s.handleUnknown(ctx, sess, env.Type) } diff --git a/pkgs/bigfred/server/cli/root.go b/pkgs/bigfred/server/cli/root.go index 56e48c2..23d1927 100644 --- a/pkgs/bigfred/server/cli/root.go +++ b/pkgs/bigfred/server/cli/root.go @@ -390,6 +390,21 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error { authSvc := cmd.NewAuth(users, layoutSvc, layoutSignalmen, sudoElevations, cmd.AuthConfig{JWTSecret: secret}) + oauthClients, err := cmd.NewOAuthClientsRegistry(cmd.DefaultOAuthClientsDir(), log) + if err != nil { + log.WithError(err).Warn("oauth-clients: registry unavailable") + oauthClients = nil + } + var oauthSvc *cmd.OAuth + if oauthClients != nil && redisReady { + oauthStop := make(chan struct{}) + defer close(oauthStop) + if err := oauthClients.StartWatch(oauthStop); err != nil { + log.WithError(err).Warn("oauth-clients: watch unavailable") + } + oauthSvc = cmd.NewOAuth(authSvc, oauthClients, redisSvc.Client()) + } + hub := ws.NewHub() hub.SetMetrics(serverMetrics) if serverMetrics != nil { @@ -686,6 +701,8 @@ func run(ctx context.Context, log *logrus.Logger, f Flags) error { Leases: leaseSvc, Remote: remoteSvc, AllowedOrigins: f.AllowedOrigins, + OAuthClients: oauthClients, + OAuth: oauthSvc, SecureCookie: f.SecureCookie, StaticFS: staticFS, Metrics: serverMetrics, diff --git a/pkgs/bigfred/server/cmd/auth.go b/pkgs/bigfred/server/cmd/auth.go index bdeb8b6..98cafb5 100644 --- a/pkgs/bigfred/server/cmd/auth.go +++ b/pkgs/bigfred/server/cmd/auth.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -221,6 +222,44 @@ func (a *Auth) IssueToken(id Identity) (string, time.Time, error) { return signed, expiry, nil } +// IssueImpersonatedToken signs a session JWT for login when actor is an +// effective admin in the pinned layout (same gate as MaybeImpersonate). +// Layout stays the actor's — participants inherit the organizer makieta. +func (a *Auth) IssueImpersonatedToken(ctx context.Context, actor Identity, login string) (string, time.Time, error) { + login = strings.TrimSpace(login) + if login == "" { + return "", time.Time{}, svcerrors.ErrUserNotFound + } + eff, err := a.Effective(ctx, actor.User, actor.Layout.ID) + if err != nil { + return "", time.Time{}, err + } + if !eff.Has(domain.RoleAdmin) { + return "", time.Time{}, svcerrors.ErrImpersonationForbidden + } + subject, err := a.users.FindByLogin(ctx, login) + if err != nil { + if errors.Is(err, repo.ErrUserNotFound) { + return "", time.Time{}, svcerrors.ErrUserNotFound + } + return "", time.Time{}, err + } + if !subject.Active { + return "", time.Time{}, svcerrors.ErrAccountDeactivated + } + return a.IssueToken(Identity{User: subject, Layout: actor.Layout}) +} + +// IdentityForLayout returns a session identity for user pinned to layoutID +// (rejects locked layouts the same way as Login). +func (a *Auth) IdentityForLayout(ctx context.Context, user domain.User, layoutID uint) (Identity, error) { + layout, err := a.layouts.ValidateForLogin(ctx, layoutID) + if err != nil { + return Identity{}, err + } + return Identity{User: user, Layout: layout}, nil +} + // VerifyToken parses and verifies a previously issued session token. func (a *Auth) VerifyToken(ctx context.Context, raw string) (Identity, error) { parsed, err := jwt.ParseWithClaims(raw, &sessionClaims{}, func(t *jwt.Token) (interface{}, error) { diff --git a/pkgs/bigfred/server/cmd/command_station.go b/pkgs/bigfred/server/cmd/command_station.go index 8a36f92..7e8d231 100644 --- a/pkgs/bigfred/server/cmd/command_station.go +++ b/pkgs/bigfred/server/cmd/command_station.go @@ -79,7 +79,10 @@ type CommandStationCreateInput struct { BootStopEnabled bool SingleVehicleControl bool // AllocatePhysicalSlots nil means default true for LocoNet kinds. - AllocatePhysicalSlots *bool + AllocatePhysicalSlots *bool + Programming bool + HideInThrottle bool + DefaultProgrammingTrackOutput string } func (s *CommandStation) Create(ctx context.Context, eff domain.EffectiveRoles, in CommandStationCreateInput) (domain.CommandStation, error) { @@ -98,6 +101,10 @@ func (s *CommandStation) Create(ctx context.Context, eff domain.EffectiveRoles, if err != nil { return domain.CommandStation{}, err } + progTrack, err := validation.SanitiseCommandStationProgrammingTrackOutput(in.DefaultProgrammingTrackOutput) + if err != nil { + return domain.CommandStation{}, err + } if _, err := s.stations.FindByName(ctx, name); err == nil { return domain.CommandStation{}, svcerrors.ErrCommandStationNameTaken } else if !errors.Is(err, repo.ErrCommandStationNotFound) { @@ -118,6 +125,9 @@ func (s *CommandStation) Create(ctx context.Context, eff domain.EffectiveRoles, WithrottleServerEnabled: in.WithrottleServerEnabled, BootStopEnabled: in.BootStopEnabled, SingleVehicleControl: in.SingleVehicleControl, + Programming: in.Programming, + HideInThrottle: in.HideInThrottle, + DefaultProgrammingTrackOutput: progTrack, CreatedAt: now, UpdatedAt: now, } @@ -150,6 +160,9 @@ type CommandStationUpdateInput struct { BootStopEnabled *bool SingleVehicleControl *bool AllocatePhysicalSlots *bool + Programming *bool + HideInThrottle *bool + DefaultProgrammingTrackOutput *string } func (s *CommandStation) Update(ctx context.Context, eff domain.EffectiveRoles, id uint, in CommandStationUpdateInput) (domain.CommandStation, error) { @@ -231,6 +244,19 @@ func (s *CommandStation) Update(ctx context.Context, eff domain.EffectiveRoles, if in.SingleVehicleControl != nil { row.SingleVehicleControl = *in.SingleVehicleControl } + if in.Programming != nil { + row.Programming = *in.Programming + } + if in.HideInThrottle != nil { + row.HideInThrottle = *in.HideInThrottle + } + if in.DefaultProgrammingTrackOutput != nil { + progTrack, err := validation.SanitiseCommandStationProgrammingTrackOutput(*in.DefaultProgrammingTrackOutput) + if err != nil { + return domain.CommandStation{}, err + } + row.DefaultProgrammingTrackOutput = progTrack + } if in.AllocatePhysicalSlots != nil || in.Kind != nil { // When kind changes to LocoNet without an explicit value, default ON. s.applyAllocatePhysicalSlots(&row, in.AllocatePhysicalSlots, in.Kind != nil) diff --git a/pkgs/bigfred/server/cmd/dcc_pool.go b/pkgs/bigfred/server/cmd/dcc_pool.go index 0159c42..c27bb45 100644 --- a/pkgs/bigfred/server/cmd/dcc_pool.go +++ b/pkgs/bigfred/server/cmd/dcc_pool.go @@ -16,6 +16,10 @@ import ( const ( minDCCAddress = uint16(1) maxDCCAddress = uint16(9999) + // autoAllocateFirstDCCAddress is where automatic pool allocation starts + // scanning. Addresses below it are left free for ad-hoc / factory-default + // decoders (3 is the DCC factory address). + autoAllocateFirstDCCAddress = uint16(50) ) // DCCPool orchestrates per-user DCC address pools. @@ -161,3 +165,44 @@ func validatePoolRanges(userID uint, ranges []PoolRange, existing []domain.DCCAd func poolRangesOverlap(a, b PoolRange) bool { return a.From <= b.To && b.From <= a.To } + +// allocateFreeDCCAddresses picks count addresses that no user owns yet, +// scanning upwards from autoAllocateFirstDCCAddress, and returns them +// merged into as few contiguous ranges as possible. It returns +// ErrDCCPoolExhausted when fewer than count addresses remain free. +func allocateFreeDCCAddresses(count int, existing []domain.DCCAddressRange) ([]PoolRange, error) { + if count <= 0 { + return nil, nil + } + occupied := make(map[uint16]struct{}) + for _, r := range existing { + from, to := r.FromAddr, r.ToAddr + if from < minDCCAddress { + from = minDCCAddress + } + if to > maxDCCAddress { + to = maxDCCAddress + } + for addr := from; addr <= to; addr++ { + occupied[addr] = struct{}{} + } + } + + out := make([]PoolRange, 0, count) + remaining := count + for addr := autoAllocateFirstDCCAddress; addr <= maxDCCAddress && remaining > 0; addr++ { + if _, taken := occupied[addr]; taken { + continue + } + if n := len(out); n > 0 && out[n-1].To == addr-1 { + out[n-1].To = addr + } else { + out = append(out, PoolRange{From: addr, To: addr}) + } + remaining-- + } + if remaining > 0 { + return nil, svcerrors.ErrDCCPoolExhausted + } + return out, nil +} diff --git a/pkgs/bigfred/server/cmd/dcc_pool_alloc_test.go b/pkgs/bigfred/server/cmd/dcc_pool_alloc_test.go new file mode 100644 index 0000000..a62f0ff --- /dev/null +++ b/pkgs/bigfred/server/cmd/dcc_pool_alloc_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/keskad/loco/pkgs/bigfred/server/domain" + svcerrors "github.com/keskad/loco/pkgs/bigfred/server/errors" +) + +func TestAllocateFreeDCCAddresses(t *testing.T) { + cases := []struct { + name string + count int + existing []domain.DCCAddressRange + want []PoolRange + }{ + { + name: "empty pool starts at 50", + count: 3, + want: []PoolRange{{From: 50, To: 52}}, + }, + { + name: "skips occupied addresses and merges contiguous", + count: 4, + existing: []domain.DCCAddressRange{{FromAddr: 51, ToAddr: 52}}, + want: []PoolRange{{From: 50, To: 50}, {From: 53, To: 55}}, + }, + { + name: "splits around several occupied blocks", + count: 5, + existing: []domain.DCCAddressRange{ + {FromAddr: 50, ToAddr: 50}, + {FromAddr: 53, ToAddr: 53}, + {FromAddr: 56, ToAddr: 60}, + }, + want: []PoolRange{{From: 51, To: 52}, {From: 54, To: 55}, {From: 61, To: 61}}, + }, + { + name: "ignores addresses below the start address", + count: 2, + existing: []domain.DCCAddressRange{{FromAddr: 1, ToAddr: 49}}, + want: []PoolRange{{From: 50, To: 51}}, + }, + { + name: "zero count allocates nothing", + count: 0, + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := allocateFreeDCCAddresses(tc.count, tc.existing) + if err != nil { + t.Fatalf("allocateFreeDCCAddresses: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("ranges: got %+v want %+v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("range %d: got %+v want %+v", i, got[i], tc.want[i]) + } + } + var total int + for _, r := range got { + total += int(r.To-r.From) + 1 + } + if total != tc.count { + t.Fatalf("allocated %d addresses, want %d", total, tc.count) + } + }) + } +} + +func TestAllocateFreeDCCAddressesExhausted(t *testing.T) { + existing := []domain.DCCAddressRange{{FromAddr: 1, ToAddr: 9998}} + if _, err := allocateFreeDCCAddresses(2, existing); !errors.Is(err, svcerrors.ErrDCCPoolExhausted) { + t.Fatalf("expected ErrDCCPoolExhausted, got %v", err) + } + got, err := allocateFreeDCCAddresses(1, existing) + if err != nil { + t.Fatalf("allocate last free address: %v", err) + } + if len(got) != 1 || got[0] != (PoolRange{From: 9999, To: 9999}) { + t.Fatalf("unexpected allocation: %+v", got) + } +} diff --git a/pkgs/bigfred/server/cmd/dcc_pool_test.go b/pkgs/bigfred/server/cmd/dcc_pool_test.go index b1082e8..b5a5b8f 100644 --- a/pkgs/bigfred/server/cmd/dcc_pool_test.go +++ b/pkgs/bigfred/server/cmd/dcc_pool_test.go @@ -111,6 +111,62 @@ func TestUserCreateRequiresDCCPool(t *testing.T) { } } +func TestUserCreateAutoAllocatesDCCPool(t *testing.T) { + bundle, cleanup := freshRepo(t) + defer cleanup() + + ctx := context.Background() + pool := cmd.NewDCCPool(bundle.Repo, bundle.Pool) + userSvc := cmd.NewUser(bundle.Repo, bundle.Users, bundle.Vehicles, bundle.Trains, pool) + + if _, err := userSvc.Create(ctx, testAdminEff, cmd.UserCreateInput{ + Login: "alice", PIN: "123456", Role: domain.RoleDriver, + DCCPool: []cmd.PoolRange{{From: 51, To: 52}}, + }); err != nil { + t.Fatalf("create alice: %v", err) + } + + bob, err := userSvc.Create(ctx, testAdminEff, cmd.UserCreateInput{ + Login: "bob", PIN: "123456", Role: domain.RoleDriver, + AutoAllocateDccCount: 3, + }) + if err != nil { + t.Fatalf("create bob: %v", err) + } + + rows, err := userSvc.GetDCCPool(ctx, bob.ID) + if err != nil { + t.Fatalf("get bob pool: %v", err) + } + want := []cmd.PoolRange{{From: 50, To: 50}, {From: 53, To: 54}} + if len(rows) != len(want) { + t.Fatalf("bob pool: got %+v want %+v", rows, want) + } + for i, r := range rows { + if r.FromAddr != want[i].From || r.ToAddr != want[i].To { + t.Fatalf("bob pool range %d: got %d-%d want %d-%d", i, r.FromAddr, r.ToAddr, want[i].From, want[i].To) + } + } +} + +func TestUserCreateRejectsAutoAllocateWithExplicitPool(t *testing.T) { + bundle, cleanup := freshRepo(t) + defer cleanup() + + ctx := context.Background() + pool := cmd.NewDCCPool(bundle.Repo, bundle.Pool) + userSvc := cmd.NewUser(bundle.Repo, bundle.Users, bundle.Vehicles, bundle.Trains, pool) + + _, err := userSvc.Create(ctx, testAdminEff, cmd.UserCreateInput{ + Login: "alice", PIN: "123456", Role: domain.RoleDriver, + DCCPool: []cmd.PoolRange{{From: 100, To: 199}}, + AutoAllocateDccCount: 5, + }) + if !errors.Is(err, svcerrors.ErrDCCPoolAutoAllocateConflict) { + t.Fatalf("expected ErrDCCPoolAutoAllocateConflict, got %v", err) + } +} + func TestUserCreateDoesNotPersistWhenPoolOverlaps(t *testing.T) { bundle, cleanup := freshRepo(t) defer cleanup() diff --git a/pkgs/bigfred/server/cmd/oauth.go b/pkgs/bigfred/server/cmd/oauth.go new file mode 100644 index 0000000..6177724 --- /dev/null +++ b/pkgs/bigfred/server/cmd/oauth.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/redis/go-redis/v9" + + svcerrors "github.com/keskad/loco/pkgs/bigfred/server/errors" +) + +const oauthCodeTTL = 60 * time.Second + +// OAuth handles authorization-code issue and token exchange. +type OAuth struct { + auth *Auth + clients *OAuthClientsRegistry + redis *redis.Client +} + +// NewOAuth wires OAuth2 code flow helpers. +func NewOAuth(auth *Auth, clients *OAuthClientsRegistry, rdb *redis.Client) *OAuth { + return &OAuth{auth: auth, clients: clients, redis: rdb} +} + +type oauthCodePayload struct { + UserID uint `json:"uid"` + LayoutID uint `json:"lid"` + ClientID string `json:"clientId"` + RedirectURI string `json:"redirectUri"` +} + +// AuthorizeValidatedClient returns the client or ErrOAuthInvalidClient / +// redirect errors. +func (o *OAuth) AuthorizeValidatedClient(clientID, redirectURI string) (OAuthClient, error) { + c, ok := o.clients.Get(clientID) + if !ok { + return OAuthClient{}, svcerrors.ErrOAuthInvalidClient + } + if !c.RedirectURIAllowed(redirectURI) { + return OAuthClient{}, svcerrors.ErrOAuthInvalidRedirectURI + } + return c, nil +} + +// IssueCode stores a one-time code for the authenticated identity. +func (o *OAuth) IssueCode(ctx context.Context, id Identity, clientID, redirectURI string) (string, error) { + if _, err := o.AuthorizeValidatedClient(clientID, redirectURI); err != nil { + return "", err + } + if o.redis == nil { + return "", fmt.Errorf("oauth: redis unavailable") + } + code, err := randomHex(16) + if err != nil { + return "", err + } + payload, err := json.Marshal(oauthCodePayload{ + UserID: id.User.ID, + LayoutID: id.Layout.ID, + ClientID: clientID, + RedirectURI: redirectURI, + }) + if err != nil { + return "", err + } + key := oauthCodeKey(code) + if err := o.redis.Set(ctx, key, payload, oauthCodeTTL).Err(); err != nil { + return "", err + } + return code, nil +} + +// TokenExchangeInput is the body of POST /auth/oauth/token. +type TokenExchangeInput struct { + GrantType string + Code string + ClientID string + ClientSecret string + RedirectURI string +} + +// TokenExchangeResult is returned to the OAuth client. +type TokenExchangeResult struct { + AccessToken string + TokenType string + ExpiresAt time.Time +} + +// ExchangeCode validates client credentials and returns a session JWT. +func (o *OAuth) ExchangeCode(ctx context.Context, in TokenExchangeInput) (TokenExchangeResult, error) { + if strings.TrimSpace(in.GrantType) != "authorization_code" { + return TokenExchangeResult{}, svcerrors.ErrOAuthInvalidGrant + } + c, err := o.AuthorizeValidatedClient(in.ClientID, in.RedirectURI) + if err != nil { + return TokenExchangeResult{}, err + } + if c.ClientSecret == "" || c.ClientSecret != in.ClientSecret { + return TokenExchangeResult{}, svcerrors.ErrOAuthInvalidClientSecret + } + if o.redis == nil { + return TokenExchangeResult{}, fmt.Errorf("oauth: redis unavailable") + } + key := oauthCodeKey(in.Code) + raw, err := o.redis.GetDel(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return TokenExchangeResult{}, svcerrors.ErrOAuthInvalidGrant + } + return TokenExchangeResult{}, err + } + var p oauthCodePayload + if err := json.Unmarshal(raw, &p); err != nil { + return TokenExchangeResult{}, svcerrors.ErrOAuthInvalidGrant + } + if p.ClientID != in.ClientID || p.RedirectURI != in.RedirectURI { + return TokenExchangeResult{}, svcerrors.ErrOAuthInvalidGrant + } + user, err := o.auth.users.FindByID(ctx, p.UserID) + if err != nil { + return TokenExchangeResult{}, svcerrors.ErrOAuthInvalidGrant + } + if !user.Active { + return TokenExchangeResult{}, svcerrors.ErrAccountDeactivated + } + layout, err := o.auth.layouts.ValidateForLogin(ctx, p.LayoutID) + if err != nil { + return TokenExchangeResult{}, err + } + token, exp, err := o.auth.IssueToken(Identity{User: user, Layout: layout}) + if err != nil { + return TokenExchangeResult{}, err + } + return TokenExchangeResult{ + AccessToken: token, + TokenType: "Bearer", + ExpiresAt: exp, + }, nil +} + +func oauthCodeKey(code string) string { + return "oauth:code:" + code +} + +func randomHex(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/pkgs/bigfred/server/cmd/oauth_clients.go b/pkgs/bigfred/server/cmd/oauth_clients.go new file mode 100644 index 0000000..d75d334 --- /dev/null +++ b/pkgs/bigfred/server/cmd/oauth_clients.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" + + "github.com/keskad/loco/pkgs/bigfred/server/datadir" +) + +// OAuthClient is one drop-in OAuth client registration. +type OAuthClient struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + DisplayName string `json:"displayName"` + RedirectURIs []string `json:"redirectUris"` + CorsEnabled bool `json:"corsEnabled"` + CorsOrigins []string `json:"corsOrigins"` + Enabled bool `json:"enabled"` +} + +// OAuthClientsRegistry loads `$DATA_DIR/etc/bigfred/oauth-clients/*.json`. +type OAuthClientsRegistry struct { + dir string + log *logrus.Logger + mu sync.RWMutex + byID map[string]OAuthClient +} + +// DefaultOAuthClientsDir is the drop-in directory under DATA_DIR. +func DefaultOAuthClientsDir() string { + return datadir.Path("etc", "bigfred", "oauth-clients") +} + +// NewOAuthClientsRegistry loads clients from dir (created if missing). +func NewOAuthClientsRegistry(dir string, log *logrus.Logger) (*OAuthClientsRegistry, error) { + if dir == "" { + dir = DefaultOAuthClientsDir() + } + if log == nil { + log = logrus.New() + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, err + } + r := &OAuthClientsRegistry{dir: dir, log: log, byID: map[string]OAuthClient{}} + if err := r.reload(); err != nil { + return nil, err + } + return r, nil +} + +// StartWatch watches the drop-in directory and reloads on change. +func (r *OAuthClientsRegistry) StartWatch(stop <-chan struct{}) error { + w, err := fsnotify.NewWatcher() + if err != nil { + return err + } + if err := w.Add(r.dir); err != nil { + _ = w.Close() + return err + } + go func() { + defer w.Close() + debounce := time.NewTimer(0) + if !debounce.Stop() { + <-debounce.C + } + for { + select { + case <-stop: + return + case ev, ok := <-w.Events: + if !ok { + return + } + if !shouldReloadOnOAuthClientEvent(ev) { + continue + } + debounce.Reset(300 * time.Millisecond) + case <-debounce.C: + if err := r.reload(); err != nil { + r.log.WithError(err).Warn("oauth-clients: reload failed") + } + case err, ok := <-w.Errors: + if !ok { + return + } + r.log.WithError(err).Warn("oauth-clients: watch error") + } + } + }() + return nil +} + +// shouldReloadOnOAuthClientEvent reports whether a filesystem event from the +// OAuth clients drop-in directory should trigger a reload. Only write/create/ +// remove/rename events on .json files (or directory-level events with no +// file extension) reload the registry; unrelated files (editor swap files, +// READMEs, temp backups) are ignored. +func shouldReloadOnOAuthClientEvent(ev fsnotify.Event) bool { + if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 { + return false + } + // Directory-level events (empty Name or no extension) still trigger a + // reload because they may rename or remove JSON files inside the + // watched directory. + if filepath.Ext(ev.Name) != "" && !strings.HasSuffix(strings.ToLower(ev.Name), ".json") { + return false + } + return true +} + +func (r *OAuthClientsRegistry) reload() error { + entries, err := os.ReadDir(r.dir) + if err != nil { + return err + } + next := make(map[string]OAuthClient) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { + continue + } + path := filepath.Join(r.dir, e.Name()) + raw, err := os.ReadFile(path) + if err != nil { + r.log.WithError(err).WithField("path", path).Warn("oauth-clients: skip unreadable") + continue + } + var c OAuthClient + if err := json.Unmarshal(raw, &c); err != nil { + r.log.WithError(err).WithField("path", path).Warn("oauth-clients: skip invalid JSON") + continue + } + c.ClientID = strings.TrimSpace(c.ClientID) + if c.ClientID == "" { + r.log.WithField("path", path).Warn("oauth-clients: skip missing clientId") + continue + } + if !c.Enabled { + continue + } + next[c.ClientID] = c + } + r.mu.Lock() + r.byID = next + r.mu.Unlock() + r.log.WithField("count", len(next)).Debug("oauth-clients: reloaded") + return nil +} + +// Get returns an enabled client by id. +func (r *OAuthClientsRegistry) Get(clientID string) (OAuthClient, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + c, ok := r.byID[strings.TrimSpace(clientID)] + return c, ok +} + +// CorsOrigins returns origins from clients with corsEnabled=true. +func (r *OAuthClientsRegistry) CorsOrigins() []string { + r.mu.RLock() + defer r.mu.RUnlock() + var out []string + seen := map[string]struct{}{} + for _, c := range r.byID { + if !c.CorsEnabled { + continue + } + for _, o := range c.CorsOrigins { + o = strings.TrimSpace(o) + if o == "" { + continue + } + if _, ok := seen[o]; ok { + continue + } + seen[o] = struct{}{} + out = append(out, o) + } + } + return out +} + +// RedirectURIAllowed reports exact-match allowlist membership. +func (c OAuthClient) RedirectURIAllowed(uri string) bool { + uri = strings.TrimSpace(uri) + for _, u := range c.RedirectURIs { + if strings.TrimSpace(u) == uri { + return true + } + } + return false +} diff --git a/pkgs/bigfred/server/cmd/oauth_clients_test.go b/pkgs/bigfred/server/cmd/oauth_clients_test.go new file mode 100644 index 0000000..f8d2581 --- /dev/null +++ b/pkgs/bigfred/server/cmd/oauth_clients_test.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fsnotify/fsnotify" +) + +func TestOAuthClientsRegistryLoadAndCors(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o640); err != nil { + t.Fatal(err) + } + } + write("wizard.json", `{ + "clientId": "bigfred-wizard", + "clientSecret": "secret", + "redirectUris": ["http://localhost:8091/auth/callback"], + "corsEnabled": false, + "corsOrigins": ["http://localhost:8091"], + "enabled": true + }`) + write("other.json", `{ + "clientId": "other-app", + "clientSecret": "x", + "redirectUris": ["http://localhost:3000/cb"], + "corsEnabled": true, + "corsOrigins": ["http://localhost:3000"], + "enabled": true + }`) + write("disabled.json", `{ + "clientId": "disabled", + "clientSecret": "x", + "redirectUris": ["http://x/cb"], + "enabled": false + }`) + + reg, err := NewOAuthClientsRegistry(dir, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := reg.Get("disabled"); ok { + t.Fatal("disabled client must be absent") + } + c, ok := reg.Get("bigfred-wizard") + if !ok { + t.Fatal("wizard client missing") + } + if !c.RedirectURIAllowed("http://localhost:8091/auth/callback") { + t.Fatal("redirect URI should match") + } + if c.RedirectURIAllowed("http://evil/") { + t.Fatal("evil redirect must fail") + } + origins := reg.CorsOrigins() + if len(origins) != 1 || origins[0] != "http://localhost:3000" { + t.Fatalf("cors origins = %#v, want only other-app", origins) + } +} + +func TestShouldReloadOnOAuthClientEvent(t *testing.T) { + t.Parallel() + jsonPath := filepath.Join("tmp", "oauth-clients", "wizard.json") + cases := []struct { + name string + ev fsnotify.Event + want bool + }{ + {"json write reloads", fsnotify.Event{Name: jsonPath, Op: fsnotify.Write}, true}, + {"json create reloads", fsnotify.Event{Name: jsonPath, Op: fsnotify.Create}, true}, + {"json remove reloads", fsnotify.Event{Name: jsonPath, Op: fsnotify.Remove}, true}, + {"json rename reloads", fsnotify.Event{Name: jsonPath, Op: fsnotify.Rename}, true}, + {"txt write skipped", fsnotify.Event{Name: "README.txt", Op: fsnotify.Write}, false}, + {"swap file skipped", fsnotify.Event{Name: ".wizard.json.swp", Op: fsnotify.Write}, false}, + {"bak file skipped", fsnotify.Event{Name: "wizard.json.bak", Op: fsnotify.Write}, false}, + {"dir event reloads", fsnotify.Event{Name: "", Op: fsnotify.Remove}, true}, + {"chmod only skipped", fsnotify.Event{Name: jsonPath, Op: fsnotify.Chmod}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldReloadOnOAuthClientEvent(tc.ev); got != tc.want { + t.Fatalf("shouldReloadOnOAuthClientEvent(%+v) = %v, want %v", tc.ev, got, tc.want) + } + }) + } +} diff --git a/pkgs/bigfred/server/cmd/session_control.go b/pkgs/bigfred/server/cmd/session_control.go index 8121c3a..1dfb1b1 100644 --- a/pkgs/bigfred/server/cmd/session_control.go +++ b/pkgs/bigfred/server/cmd/session_control.go @@ -385,13 +385,20 @@ func (s *SessionControl) availableStationsForClient(ctx context.Context, c Contr return out } +// listAvailableStations returns the stations a throttle on layoutID may +// drive on. Stations flagged HideInThrottle (e.g. a dedicated programming +// station) are filtered out. func (s *SessionControl) listAvailableStations(ctx context.Context, layoutID uint) ([]domain.CommandStation, error) { layout, err := s.layoutRows.FindByID(ctx, layoutID) if err != nil { return nil, err } if layout.IsSystem { - return s.cs.ListAll(ctx) + all, err := s.cs.ListAll(ctx) + if err != nil { + return nil, err + } + return visibleInThrottle(all), nil } rows, err := s.layoutCS.ListByLayout(ctx, layoutID) if err != nil { @@ -404,7 +411,22 @@ func (s *SessionControl) listAvailableStations(ctx context.Context, layoutID uin for _, r := range rows { ids = append(ids, r.CommandStationID) } - return s.cs.ListByIDs(ctx, ids) + stations, err := s.cs.ListByIDs(ctx, ids) + if err != nil { + return nil, err + } + return visibleInThrottle(stations), nil +} + +func visibleInThrottle(rows []domain.CommandStation) []domain.CommandStation { + out := make([]domain.CommandStation, 0, len(rows)) + for _, row := range rows { + if row.HideInThrottle { + continue + } + out = append(out, row) + } + return out } func (s *SessionControl) commandStationAttached(ctx context.Context, layoutID, commandStationID uint) bool { diff --git a/pkgs/bigfred/server/cmd/user.go b/pkgs/bigfred/server/cmd/user.go index 45f56ba..ebfd0b0 100644 --- a/pkgs/bigfred/server/cmd/user.go +++ b/pkgs/bigfred/server/cmd/user.go @@ -37,6 +37,10 @@ type UserCreateInput struct { Organization string Role domain.Role DCCPool []PoolRange + // AutoAllocateDccCount asks the server to carve out that many free DCC + // addresses for the new user instead of declaring DCCPool by hand. + // Mutually exclusive with DCCPool. + AutoAllocateDccCount int } // UserUpdateInput is the validated payload of User.Update. @@ -68,6 +72,18 @@ func (u *User) List(ctx context.Context) ([]domain.User, error) { return u.users.ListAll(ctx) } +// FindByLogin resolves a catalogue user by login (impersonation). +func (u *User) FindByLogin(ctx context.Context, login string) (domain.User, error) { + user, err := u.users.FindByLogin(ctx, login) + if err != nil { + if errors.Is(err, repo.ErrUserNotFound) { + return domain.User{}, svcerrors.ErrUserNotFound + } + return domain.User{}, err + } + return user, nil +} + // ListWithDCCPools returns every user together with their DCC pool rows. func (u *User) ListWithDCCPools(ctx context.Context, eff domain.EffectiveRoles) ([]UserWithDCCPool, error) { if err := u.checkManageUsers(eff); err != nil { @@ -156,12 +172,28 @@ func (u *User) Create(ctx context.Context, eff domain.EffectiveRoles, in UserCre return domain.User{}, svcerrors.ErrUserRoleInvalid } + if in.AutoAllocateDccCount > 0 && len(in.DCCPool) > 0 { + return domain.User{}, svcerrors.ErrDCCPoolAutoAllocateConflict + } + if _, err := u.users.FindByLogin(ctx, login); err == nil { return domain.User{}, svcerrors.ErrUserLoginTaken } else if !errors.Is(err, repo.ErrUserNotFound) { return domain.User{}, err } - if err := u.dccPool.Validate(ctx, 0, in.DCCPool); err != nil { + + pool := in.DCCPool + if in.AutoAllocateDccCount > 0 { + existing, err := u.dccPool.ListAll(ctx) + if err != nil { + return domain.User{}, err + } + pool, err = allocateFreeDCCAddresses(in.AutoAllocateDccCount, existing) + if err != nil { + return domain.User{}, err + } + } + if err := u.dccPool.Validate(ctx, 0, pool); err != nil { return domain.User{}, err } @@ -184,7 +216,7 @@ func (u *User) Create(ctx context.Context, eff domain.EffectiveRoles, in UserCre if err := u.users.Insert(tctx, &row); err != nil { return err } - _, err := u.dccPool.Replace(tctx, eff, row.ID, in.DCCPool) + _, err := u.dccPool.Replace(tctx, eff, row.ID, pool) return err }) if err != nil { diff --git a/pkgs/bigfred/server/domain/command_station.go b/pkgs/bigfred/server/domain/command_station.go index 0899405..21f4a79 100644 --- a/pkgs/bigfred/server/domain/command_station.go +++ b/pkgs/bigfred/server/domain/command_station.go @@ -113,8 +113,21 @@ type CommandStation struct { // false, BigFred may piggyback on slots already IN_USE by another throttle. // Meaningful only for LocoNet kinds. AllocatePhysicalSlots bool `db:"allocate_physical_slots"` - CreatedAt time.Time - UpdatedAt time.Time + // Programming opens the decoder CV / address programming frames on the + // dcc-bus daemon. Off by default: writing CVs on a live layout is + // destructive, so the station has to be opted in explicitly. + Programming bool `db:"programming"` + // HideInThrottle keeps the station out of the throttle's available + // command-station list. Useful for a dedicated programming station that + // nobody should accidentally drive on. + HideInThrottle bool `db:"hide_in_throttle"` + // DefaultProgrammingTrackOutput selects the track that CV / address + // frames use when they omit `mode`: ProgrammingTrackPOM (main track) or + // ProgrammingTrackProg (isolated programming output). Empty means + // DefaultCommandStationProgrammingTrackOutput. + DefaultProgrammingTrackOutput string `db:"default_programming_track_output"` + CreatedAt time.Time + UpdatedAt time.Time } // Table tells REL which physical table backs this struct. @@ -134,6 +147,29 @@ const ( MaxLocoNetPhysicalSlots = 117 ) +// Programming track outputs a command station can default to for CV and +// address frames that do not carry an explicit mode. +const ( + // ProgrammingTrackPOM programs a decoder on the main track while it runs. + ProgrammingTrackPOM = "pom" + // ProgrammingTrackProg uses the isolated programming output. + ProgrammingTrackProg = "prog" + + // DefaultCommandStationProgrammingTrackOutput is the safe default: the + // isolated programming output cannot disturb locos on the main track. + DefaultCommandStationProgrammingTrackOutput = ProgrammingTrackProg +) + +// IsValidProgrammingTrackOutput reports whether v is a supported default +// programming track output. +func IsValidProgrammingTrackOutput(v string) bool { + switch v { + case ProgrammingTrackPOM, ProgrammingTrackProg: + return true + } + return false +} + // EffectiveSpeedSteps returns the catalogue DCC speed-step count, applying // the default when the stored value is zero. func (cs CommandStation) EffectiveSpeedSteps() uint { @@ -225,6 +261,15 @@ func (cs CommandStation) EffectiveAllocatePhysicalSlots() bool { return cs.AllocatePhysicalSlots } +// EffectiveDefaultProgrammingTrackOutput returns the configured default +// programming track, applying the product default for pre-migration rows. +func (cs CommandStation) EffectiveDefaultProgrammingTrackOutput() string { + if !IsValidProgrammingTrackOutput(cs.DefaultProgrammingTrackOutput) { + return DefaultCommandStationProgrammingTrackOutput + } + return cs.DefaultProgrammingTrackOutput +} + // LayoutCommandStation is the join row binding a CommandStation to a // Layout. The pair (LayoutID, CommandStationID) is unique on the // table; that pair is the daemon identity (`dcc-bus--` in diff --git a/pkgs/bigfred/server/errors/auth.go b/pkgs/bigfred/server/errors/auth.go index ef8bf0f..001d550 100644 --- a/pkgs/bigfred/server/errors/auth.go +++ b/pkgs/bigfred/server/errors/auth.go @@ -5,10 +5,26 @@ import "errors" const ( CodeInvalidCredentials = "invalid_credentials" CodeAccountDeactivated = "account_deactivated" + + CodeOAuthInvalidClient = "invalid_client" + CodeOAuthInvalidRedirectURI = "invalid_redirect_uri" + CodeOAuthInvalidGrant = "invalid_grant" + CodeOAuthExpiredCode = "expired_code" + CodeOAuthInvalidClientSecret = "invalid_client_secret" + CodeImpersonationForbidden = "impersonation_forbidden" + CodeDCCPoolExhausted = "dcc_pool_exhausted" ) var ( // ErrInvalidCredentials intentionally covers unknown login and wrong PIN. ErrInvalidCredentials = errors.New("invalid credentials") ErrAccountDeactivated = errors.New(CodeAccountDeactivated) + + ErrOAuthInvalidClient = errors.New(CodeOAuthInvalidClient) + ErrOAuthInvalidRedirectURI = errors.New(CodeOAuthInvalidRedirectURI) + ErrOAuthInvalidGrant = errors.New(CodeOAuthInvalidGrant) + ErrOAuthExpiredCode = errors.New(CodeOAuthExpiredCode) + ErrOAuthInvalidClientSecret = errors.New(CodeOAuthInvalidClientSecret) + ErrImpersonationForbidden = errors.New(CodeImpersonationForbidden) + ErrDCCPoolExhausted = errors.New(CodeDCCPoolExhausted) ) diff --git a/pkgs/bigfred/server/errors/command_station.go b/pkgs/bigfred/server/errors/command_station.go index 3a5c854..cfada4a 100644 --- a/pkgs/bigfred/server/errors/command_station.go +++ b/pkgs/bigfred/server/errors/command_station.go @@ -15,6 +15,7 @@ const ( CodeCommandStationInboundPortConflict = "command_station_inbound_port_conflict" CodeCommandStationMaxLoconetSlotsInvalid = "command_station_max_loconet_slots_invalid" CodeCommandStationIdleTimeoutInvalid = "command_station_idle_timeout_invalid" + CodeCommandStationProgrammingTrackInvalid = "command_station_programming_track_invalid" CodeCommandStationForbidden = "forbidden" CodeLayoutNeedsAtLeastOneCommandStation = "layout_needs_at_least_one_command_station" CodeSystemLayoutCommandStationsImmutable = "default_layout_command_stations_immutable" @@ -33,6 +34,7 @@ var ( ErrCommandStationInboundPortConflict = errors.New(CodeCommandStationInboundPortConflict) ErrCommandStationMaxLoconetSlotsInvalid = errors.New(CodeCommandStationMaxLoconetSlotsInvalid) ErrCommandStationIdleTimeoutInvalid = errors.New(CodeCommandStationIdleTimeoutInvalid) + ErrCommandStationProgrammingTrackInvalid = errors.New(CodeCommandStationProgrammingTrackInvalid) ErrCommandStationForbidden = errors.New(CodeCommandStationForbidden) ErrLayoutNeedsAtLeastOneCommandStation = errors.New(CodeLayoutNeedsAtLeastOneCommandStation) ErrSystemLayoutCommandStationsImmutable = errors.New(CodeSystemLayoutCommandStationsImmutable) diff --git a/pkgs/bigfred/server/errors/dcc.go b/pkgs/bigfred/server/errors/dcc.go index ef10fb8..49812aa 100644 --- a/pkgs/bigfred/server/errors/dcc.go +++ b/pkgs/bigfred/server/errors/dcc.go @@ -3,19 +3,23 @@ package errors import "errors" const ( - CodeDCCAddressOutsidePool = "dcc_address_outside_pool" - CodeDCCPoolEmpty = "dcc_pool_empty" - CodeDCCPoolRangeInvalid = "dcc_pool_range_invalid" - CodeDCCPoolOverlap = "dcc_pool_overlap" - CodeDCCPoolForbidden = "forbidden" - CodeNoDCCBusPortsAvailable = "no_dcc_bus_ports_available" + CodeDCCAddressOutsidePool = "dcc_address_outside_pool" + CodeDCCPoolEmpty = "dcc_pool_empty" + CodeDCCPoolRangeInvalid = "dcc_pool_range_invalid" + CodeDCCPoolOverlap = "dcc_pool_overlap" + CodeDCCPoolForbidden = "forbidden" + CodeDCCPoolAutoAllocateConflict = "dcc_pool_auto_allocate_conflict" + CodeNoDCCBusPortsAvailable = "no_dcc_bus_ports_available" ) var ( - ErrDCCAddressOutsidePool = errors.New(CodeDCCAddressOutsidePool) - ErrDCCPoolEmpty = errors.New(CodeDCCPoolEmpty) - ErrDCCPoolRangeInvalid = errors.New(CodeDCCPoolRangeInvalid) - ErrDCCPoolOverlap = errors.New(CodeDCCPoolOverlap) - ErrDCCPoolForbidden = errors.New(CodeDCCPoolForbidden) - ErrNoDCCBusPortsAvailable = errors.New(CodeNoDCCBusPortsAvailable) + ErrDCCAddressOutsidePool = errors.New(CodeDCCAddressOutsidePool) + ErrDCCPoolEmpty = errors.New(CodeDCCPoolEmpty) + ErrDCCPoolRangeInvalid = errors.New(CodeDCCPoolRangeInvalid) + ErrDCCPoolOverlap = errors.New(CodeDCCPoolOverlap) + ErrDCCPoolForbidden = errors.New(CodeDCCPoolForbidden) + // ErrDCCPoolAutoAllocateConflict is returned when a create request asks + // for automatic allocation and also declares explicit ranges. + ErrDCCPoolAutoAllocateConflict = errors.New(CodeDCCPoolAutoAllocateConflict) + ErrNoDCCBusPortsAvailable = errors.New(CodeNoDCCBusPortsAvailable) ) diff --git a/pkgs/bigfred/server/errors/http.go b/pkgs/bigfred/server/errors/http.go index 9bc74bc..c19224a 100644 --- a/pkgs/bigfred/server/errors/http.go +++ b/pkgs/bigfred/server/errors/http.go @@ -148,6 +148,10 @@ func UserHTTPStatus(err error) (status int, code string) { return http.StatusUnprocessableEntity, CodeDCCPoolRangeInvalid case stderrors.Is(err, ErrDCCPoolOverlap): return http.StatusConflict, CodeDCCPoolOverlap + case stderrors.Is(err, ErrDCCPoolAutoAllocateConflict): + return http.StatusConflict, CodeDCCPoolAutoAllocateConflict + case stderrors.Is(err, ErrDCCPoolExhausted): + return http.StatusConflict, CodeDCCPoolExhausted case stderrors.Is(err, ErrDCCPoolForbidden), stderrors.Is(err, ErrUserForbidden): return http.StatusForbidden, CodeUserForbidden default: @@ -324,6 +328,8 @@ func CommandStationHTTPStatus(err error) (status int, code string) { return http.StatusUnprocessableEntity, CodeCommandStationMaxLoconetSlotsInvalid case stderrors.Is(err, ErrCommandStationIdleTimeoutInvalid): return http.StatusUnprocessableEntity, CodeCommandStationIdleTimeoutInvalid + case stderrors.Is(err, ErrCommandStationProgrammingTrackInvalid): + return http.StatusUnprocessableEntity, CodeCommandStationProgrammingTrackInvalid case stderrors.Is(err, ErrLayoutNeedsAtLeastOneCommandStation): return http.StatusConflict, CodeLayoutNeedsAtLeastOneCommandStation default: diff --git a/pkgs/bigfred/server/http/ctx.go b/pkgs/bigfred/server/http/ctx.go index 58622c0..56c8650 100644 --- a/pkgs/bigfred/server/http/ctx.go +++ b/pkgs/bigfred/server/http/ctx.go @@ -1,6 +1,3 @@ -// Package httpapi implements the HTTP transport layer of the BigFred -// server. It is named `httpapi` (not `http`) so the import doesn't -// shadow the stdlib `net/http` import inside its own files. package httpapi import ( @@ -14,6 +11,8 @@ import ( // context. Handlers retrieve it via IdentityFromContext. type identityCtxKey struct{} +type actorCtxKey struct{} + // WithIdentity returns a derived context that carries id. Used by the // auth middleware exclusively. func WithIdentity(ctx context.Context, id cmd.Identity) context.Context { @@ -21,8 +20,20 @@ func WithIdentity(ctx context.Context, id cmd.Identity) context.Context { } // IdentityFromContext returns the authenticated user's identity, or -// (zero, false) if the request is anonymous. +// (zero, false) if the request is anonymous. Under impersonation this +// is the **subject**. func IdentityFromContext(ctx context.Context) (cmd.Identity, bool) { id, ok := ctx.Value(identityCtxKey{}).(cmd.Identity) return id, ok } + +// WithActor stores the real caller when impersonating. +func WithActor(ctx context.Context, id cmd.Identity) context.Context { + return context.WithValue(ctx, actorCtxKey{}, id) +} + +// ActorFromContext returns the real caller when impersonation is active. +func ActorFromContext(ctx context.Context) (cmd.Identity, bool) { + id, ok := ctx.Value(actorCtxKey{}).(cmd.Identity) + return id, ok +} diff --git a/pkgs/bigfred/server/http/dcc_bus_proxy.go b/pkgs/bigfred/server/http/dcc_bus_proxy.go index 56e7ecc..1352711 100644 --- a/pkgs/bigfred/server/http/dcc_bus_proxy.go +++ b/pkgs/bigfred/server/http/dcc_bus_proxy.go @@ -1,17 +1,19 @@ package httpapi import ( + "errors" "net/http" "net/http/httputil" "net/url" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" "github.com/keskad/loco/pkgs/bigfred/server/cmd" + svcerrors "github.com/keskad/loco/pkgs/bigfred/server/errors" "github.com/keskad/loco/pkgs/bigfred/server/metrics" - "github.com/keskad/loco/pkgs/bigfred/server/service" ) @@ -19,6 +21,10 @@ import ( // so the SPA only ever talks to loco-server. JWT is verified before // forwarding; the layout pinning makes sure a session for layout L // cannot reach a daemon serving layout L'. +// +// When an admin sends ImpersonateAsHeader, the proxy mints a subject +// JWT (same layout) for the daemon so drive commands run as the +// participant — matching MaybeImpersonate on REST. type DccBusProxy struct { auth *cmd.Auth dccBus *service.DccBusService @@ -63,6 +69,25 @@ func (p *DccBusProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } layoutID = id.Layout.ID + daemonToken := token + if login := strings.TrimSpace(r.Header.Get(ImpersonateAsHeader)); login != "" { + minted, _, err := p.auth.IssueImpersonatedToken(r.Context(), id, login) + if err != nil { + if errors.Is(err, svcerrors.ErrImpersonationForbidden) { + writeJSONError(w, http.StatusForbidden, svcerrors.CodeImpersonationForbidden) + return + } + if errors.Is(err, svcerrors.ErrAccountDeactivated) { + writeJSONError(w, http.StatusForbidden, svcerrors.CodeAccountDeactivated) + return + } + status, code := svcerrors.UserHTTPStatus(err) + writeJSONError(w, status, code) + return + } + daemonToken = minted + } + csIDStr := chi.URLParam(r, "commandStationId") csID64, err := strconv.ParseUint(csIDStr, 10, 64) if err != nil || csID64 == 0 { @@ -100,11 +125,10 @@ func (p *DccBusProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { req.URL.Scheme = target.Scheme req.URL.Host = target.Host req.URL.Path = "/ws" - // Forward the JWT as a query param so the daemon's - // authenticator (which doesn't share AuthService's cookie - // jar) can verify the same token. + // Forward the (possibly impersonated) JWT as a query param so + // the daemon's authenticator can verify the same token. q := req.URL.Query() - q.Set("token", token) + q.Set("token", daemonToken) req.URL.RawQuery = q.Encode() req.Host = target.Host } diff --git a/pkgs/bigfred/server/http/middleware.go b/pkgs/bigfred/server/http/middleware.go index 110807e..061f079 100644 --- a/pkgs/bigfred/server/http/middleware.go +++ b/pkgs/bigfred/server/http/middleware.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "github.com/keskad/loco/pkgs/bigfred/server/cmd" "github.com/keskad/loco/pkgs/bigfred/server/domain" @@ -16,11 +17,13 @@ import ( // handler and the auth middleware agree on it. const SessionCookieName = "bigfred_session" +// ImpersonateAsHeader is the admin-only subject switch for authenticated APIs. +const ImpersonateAsHeader = "X-BigFred-Impersonate-As" + // RequireAuth is the chi middleware that enforces an authenticated // session for the wrapped handler chain. It reads the JWT from the -// session cookie (falling back to a `?token=` query parameter to -// support WS upgrades per §7a.1), verifies it via AuthService and -// attaches the resulting Identity to the request context. +// session cookie (falling back to Bearer / `?token=`), verifies it via +// AuthService and attaches the resulting Identity to the request context. func RequireAuth(auth *cmd.Auth, m *metrics.Metrics) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -54,18 +57,21 @@ func RequireAuth(auth *cmd.Auth, m *metrics.Metrics) func(http.Handler) http.Han // RequireRole composes on top of RequireAuth: it returns 403 when the // authenticated user's effective role inside their active layout -// isn't in the allow-list. Per §7a.7 a sudo admin grants the same -// authority as a permanent admin everywhere, so the gate consults -// AuthService.Effective rather than the JWT-pinned permanent role. +// isn't in the allow-list. When impersonation is active, Effective is +// computed for the **actor** (real caller), not the subject. func RequireRole(auth *cmd.Auth, roles ...domain.Role) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id, ok := IdentityFromContext(r.Context()) + actor, ok := ActorFromContext(r.Context()) if !ok { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") - return + id, idOK := IdentityFromContext(r.Context()) + if !idOK { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } + actor = id } - eff, err := auth.Effective(r.Context(), id.User, id.Layout.ID) + eff, err := auth.Effective(r.Context(), actor.User, actor.Layout.ID) if err != nil { writeJSONError(w, http.StatusInternalServerError, "internal_error") return @@ -81,20 +87,60 @@ func RequireRole(auth *cmd.Auth, roles ...domain.Role) func(http.Handler) http.H } } +// MaybeImpersonate switches Identity to the named user when the admin +// sends ImpersonateAsHeader. Actor remains available via ActorFromContext. +func MaybeImpersonate(auth *cmd.Auth, users *cmd.User) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + login := strings.TrimSpace(r.Header.Get(ImpersonateAsHeader)) + if login == "" { + next.ServeHTTP(w, r) + return + } + id, ok := IdentityFromContext(r.Context()) + if !ok { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } + eff, err := 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, svcerrors.CodeImpersonationForbidden) + return + } + subject, err := users.FindByLogin(r.Context(), login) + if err != nil { + status, code := svcerrors.UserHTTPStatus(err) + writeJSONError(w, status, code) + return + } + if !subject.Active { + writeJSONError(w, http.StatusForbidden, svcerrors.CodeAccountDeactivated) + return + } + ctx := WithActor(r.Context(), id) + ctx = WithIdentity(ctx, cmd.Identity{User: subject, Layout: id.Layout}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + // readSessionToken extracts the session JWT from a request, preferring -// the cookie (set by /auth/login) and falling back to a `?token=` -// query parameter so a WebSocket upgrade can authenticate without -// custom headers. +// the cookie, then Authorization: Bearer, then `?token=`. func readSessionToken(r *http.Request) string { if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" { return c.Value } + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } return r.URL.Query().Get("token") } -// writeJSONError renders {"error": "..."} with the given status. The -// machine-readable code lets the frontend localise without parsing -// English prose. +// writeJSONError renders {"error": "..."} with the given status. func writeJSONError(w http.ResponseWriter, status int, code string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/pkgs/bigfred/server/http/oauth.go b/pkgs/bigfred/server/http/oauth.go new file mode 100644 index 0000000..cfa464c --- /dev/null +++ b/pkgs/bigfred/server/http/oauth.go @@ -0,0 +1,190 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/keskad/loco/pkgs/bigfred/server/cmd" + svcerrors "github.com/keskad/loco/pkgs/bigfred/server/errors" +) + +// OAuthHandler serves /api/v1/auth/oauth/*. +type OAuthHandler struct { + oauth *cmd.OAuth + auth *cmd.Auth +} + +// NewOAuthHandler constructs the OAuth HTTP handlers. +func NewOAuthHandler(oauth *cmd.OAuth, auth *cmd.Auth) *OAuthHandler { + return &OAuthHandler{oauth: oauth, auth: auth} +} + +// Authorize handles GET /api/v1/auth/oauth/authorize (browser redirect). +// Optional `layout_id` pins the issued code (and thus the wizard JWT) to +// that makieta — used by bigfred-wizard's pre-SSO layout picker. +func (h *OAuthHandler) Authorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + clientID := strings.TrimSpace(q.Get("client_id")) + redirectURI := strings.TrimSpace(q.Get("redirect_uri")) + state := q.Get("state") + responseType := strings.TrimSpace(q.Get("response_type")) + preferredLayoutID := parseOptionalUint(q.Get("layout_id")) + if responseType != "" && responseType != "code" { + writeJSONError(w, http.StatusBadRequest, "unsupported_response_type") + return + } + if _, err := h.oauth.AuthorizeValidatedClient(clientID, redirectURI); err != nil { + status, code := oauthHTTPStatus(err) + writeJSONError(w, status, code) + return + } + + token := readSessionToken(r) + if token == "" { + h.redirectToLogin(w, r, preferredLayoutID) + return + } + id, err := h.auth.VerifyToken(r.Context(), token) + if err != nil { + h.redirectToLogin(w, r, preferredLayoutID) + return + } + + if preferredLayoutID != 0 && preferredLayoutID != id.Layout.ID { + bound, err := h.auth.IdentityForLayout(r.Context(), id.User, preferredLayoutID) + if err != nil { + status, code := svcerrors.LayoutHTTPStatus(err) + writeJSONError(w, status, code) + return + } + id = bound + } + + code, err := h.oauth.IssueCode(r.Context(), id, clientID, redirectURI) + if err != nil { + status, code := oauthHTTPStatus(err) + writeJSONError(w, status, code) + return + } + u, err := url.Parse(redirectURI) + if err != nil { + writeJSONError(w, http.StatusBadRequest, svcerrors.CodeOAuthInvalidRedirectURI) + return + } + qq := u.Query() + qq.Set("code", code) + if state != "" { + qq.Set("state", state) + } + u.RawQuery = qq.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (h *OAuthHandler) redirectToLogin(w http.ResponseWriter, r *http.Request, layoutID uint) { + returnTo := r.URL.RequestURI() + if !strings.HasPrefix(returnTo, "/api/v1/auth/oauth/authorize") { + returnTo = "/api/v1/auth/oauth/authorize?" + r.URL.RawQuery + } + params := url.Values{} + params.Set("return_to", returnTo) + if layoutID != 0 { + params.Set("layout_id", strconv.FormatUint(uint64(layoutID), 10)) + } + http.Redirect(w, r, "/login?"+params.Encode(), http.StatusFound) +} + +type oauthTokenRequest struct { + GrantType string `json:"grantType"` + Code string `json:"code"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectUri"` +} + +type oauthTokenResponse struct { + AccessToken string `json:"accessToken"` + TokenType string `json:"tokenType"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// Token handles POST /api/v1/auth/oauth/token. +func (h *OAuthHandler) Token(w http.ResponseWriter, r *http.Request) { + var body oauthTokenRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + // Also accept form-urlencoded for compatibility. + if err := r.ParseForm(); err != nil { + writeJSONError(w, http.StatusBadRequest, "bad_request") + return + } + body = oauthTokenRequest{ + GrantType: firstNonEmpty(r.Form.Get("grant_type"), r.Form.Get("grantType")), + Code: r.Form.Get("code"), + ClientID: firstNonEmpty(r.Form.Get("client_id"), r.Form.Get("clientId")), + ClientSecret: firstNonEmpty(r.Form.Get("client_secret"), r.Form.Get("clientSecret")), + RedirectURI: firstNonEmpty(r.Form.Get("redirect_uri"), r.Form.Get("redirectUri")), + } + } else if body.GrantType == "" { + body.GrantType = "authorization_code" + } + res, err := h.oauth.ExchangeCode(r.Context(), cmd.TokenExchangeInput{ + GrantType: body.GrantType, + Code: body.Code, + ClientID: body.ClientID, + ClientSecret: body.ClientSecret, + RedirectURI: body.RedirectURI, + }) + if err != nil { + status, code := oauthHTTPStatus(err) + writeJSONError(w, status, code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(oauthTokenResponse{ + AccessToken: res.AccessToken, + TokenType: "Bearer", + ExpiresAt: res.ExpiresAt, + }) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func parseOptionalUint(raw string) uint { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + n, err := strconv.ParseUint(raw, 10, 64) + if err != nil || n == 0 { + return 0 + } + return uint(n) +} + +func oauthHTTPStatus(err error) (int, string) { + switch { + case errors.Is(err, svcerrors.ErrOAuthInvalidClient): + return http.StatusUnauthorized, svcerrors.CodeOAuthInvalidClient + case errors.Is(err, svcerrors.ErrOAuthInvalidRedirectURI): + return http.StatusBadRequest, svcerrors.CodeOAuthInvalidRedirectURI + case errors.Is(err, svcerrors.ErrOAuthInvalidGrant): + return http.StatusBadRequest, svcerrors.CodeOAuthInvalidGrant + case errors.Is(err, svcerrors.ErrOAuthExpiredCode): + return http.StatusBadRequest, svcerrors.CodeOAuthExpiredCode + case errors.Is(err, svcerrors.ErrOAuthInvalidClientSecret): + return http.StatusUnauthorized, svcerrors.CodeOAuthInvalidClientSecret + default: + return http.StatusInternalServerError, "internal_error" + } +} diff --git a/pkgs/bigfred/server/http/router.go b/pkgs/bigfred/server/http/router.go index 9e64a10..664c1f3 100644 --- a/pkgs/bigfred/server/http/router.go +++ b/pkgs/bigfred/server/http/router.go @@ -49,6 +49,13 @@ type RouterConfig struct { // (5173) than the API, so cookies must be allowed cross-origin. AllowedOrigins []string + // OAuthClients, when set, supplies drop-in OAuth clients and extra + // CORS origins (corsEnabled:true only). + OAuthClients *cmd.OAuthClientsRegistry + + // OAuth implements authorization-code SSO. + OAuth *cmd.OAuth + // SecureCookie controls the `Secure` flag on the session cookie. // Set to false ONLY when the server is reachable over http:// // (i.e. local development). @@ -77,9 +84,9 @@ func NewRouter(cfg RouterConfig) http.Handler { r.Use(chimiddleware.Recoverer) r.Use(cors.Handler(cors.Options{ - AllowedOrigins: cfg.AllowedOrigins, + AllowOriginFunc: corsAllowOriginFunc(cfg.AllowedOrigins, cfg.OAuthClients), AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowedHeaders: []string{"Content-Type", "Authorization", ImpersonateAsHeader}, ExposedHeaders: []string{"Content-Type"}, AllowCredentials: true, MaxAge: 300, @@ -130,6 +137,12 @@ func NewRouter(cfg RouterConfig) http.Handler { r.Post("/auth/login", authH.Login) r.Post("/auth/logout", authH.Logout) + if cfg.OAuth != nil { + oauthH := NewOAuthHandler(cfg.OAuth, cfg.Auth) + r.Get("/auth/oauth/authorize", oauthH.Authorize) + r.Post("/auth/oauth/token", oauthH.Token) + } + // Public layout dropdown for the login form (§7a.1). Lives // outside RequireAuth so an unauthenticated client can fetch // the list before submitting credentials. @@ -142,6 +155,7 @@ func NewRouter(cfg RouterConfig) http.Handler { // Authenticated routes share the RequireAuth middleware. r.Group(func(r chi.Router) { r.Use(RequireAuth(cfg.Auth, cfg.Metrics)) + r.Use(MaybeImpersonate(cfg.Auth, cfg.Users)) r.Get("/audit-log", auditH.List) @@ -308,3 +322,34 @@ func NewRouter(cfg RouterConfig) http.Handler { return r } + +func corsAllowOriginFunc(static []string, clients *cmd.OAuthClientsRegistry) func(r *http.Request, origin string) bool { + allowed := make(map[string]struct{}, len(static)) + for _, o := range static { + if o == "" { + continue + } + allowed[o] = struct{}{} + } + return func(_ *http.Request, origin string) bool { + if origin == "" { + return true + } + if _, ok := allowed[origin]; ok { + return true + } + if clients != nil { + for _, o := range clients.CorsOrigins() { + if o == origin { + return true + } + } + } + // Mirror previous empty-list behavior used in some local setups: + // if no static origins configured and no oauth cors, allow all. + if len(allowed) == 0 && (clients == nil || len(clients.CorsOrigins()) == 0) { + return true + } + return false + } +} diff --git a/pkgs/bigfred/server/protocol/command_station.go b/pkgs/bigfred/server/protocol/command_station.go index daaf741..cd64f80 100644 --- a/pkgs/bigfred/server/protocol/command_station.go +++ b/pkgs/bigfred/server/protocol/command_station.go @@ -22,6 +22,9 @@ type CommandStationResponse struct { BootStopEnabled bool `json:"bootStopEnabled"` SingleVehicleControl bool `json:"singleVehicleControl"` AllocatePhysicalSlots bool `json:"allocatePhysicalSlots"` + Programming bool `json:"programming"` + HideInThrottle bool `json:"hideInThrottle"` + DefaultProgrammingTrackOutput string `json:"defaultProgrammingTrackOutput"` } func ToCommandStationResponse(cs domain.CommandStation) CommandStationResponse { @@ -39,6 +42,9 @@ func ToCommandStationResponse(cs domain.CommandStation) CommandStationResponse { WithrottleServerEnabled: cs.WithrottleServerEnabled, BootStopEnabled: cs.BootStopEnabled, SingleVehicleControl: cs.SingleVehicleControl, + Programming: cs.Programming, + HideInThrottle: cs.HideInThrottle, + DefaultProgrammingTrackOutput: cs.EffectiveDefaultProgrammingTrackOutput(), } if cs.Kind.IsLocoNet() { resp.MaxLoconetSlots = cs.EffectiveMaxLoconetSlots() @@ -64,6 +70,9 @@ type CommandStationCreateRequest struct { BootStopEnabled bool `json:"bootStopEnabled"` SingleVehicleControl bool `json:"singleVehicleControl"` AllocatePhysicalSlots *bool `json:"allocatePhysicalSlots"` + Programming bool `json:"programming"` + HideInThrottle bool `json:"hideInThrottle"` + DefaultProgrammingTrackOutput string `json:"defaultProgrammingTrackOutput"` } func (r CommandStationCreateRequest) ToCreateInput() cmd.CommandStationCreateInput { @@ -83,6 +92,9 @@ func (r CommandStationCreateRequest) ToCreateInput() cmd.CommandStationCreateInp BootStopEnabled: r.BootStopEnabled, SingleVehicleControl: r.SingleVehicleControl, AllocatePhysicalSlots: r.AllocatePhysicalSlots, + Programming: r.Programming, + HideInThrottle: r.HideInThrottle, + DefaultProgrammingTrackOutput: r.DefaultProgrammingTrackOutput, } } @@ -102,6 +114,9 @@ type CommandStationUpdateRequest struct { BootStopEnabled *bool `json:"bootStopEnabled"` SingleVehicleControl *bool `json:"singleVehicleControl"` AllocatePhysicalSlots *bool `json:"allocatePhysicalSlots"` + Programming *bool `json:"programming"` + HideInThrottle *bool `json:"hideInThrottle"` + DefaultProgrammingTrackOutput *string `json:"defaultProgrammingTrackOutput"` } func (r CommandStationUpdateRequest) ToUpdateInput() cmd.CommandStationUpdateInput { @@ -121,5 +136,8 @@ func (r CommandStationUpdateRequest) ToUpdateInput() cmd.CommandStationUpdateInp BootStopEnabled: r.BootStopEnabled, SingleVehicleControl: r.SingleVehicleControl, AllocatePhysicalSlots: r.AllocatePhysicalSlots, + Programming: r.Programming, + HideInThrottle: r.HideInThrottle, + DefaultProgrammingTrackOutput: r.DefaultProgrammingTrackOutput, } } diff --git a/pkgs/bigfred/server/protocol/user.go b/pkgs/bigfred/server/protocol/user.go index 49e725b..101ca36 100644 --- a/pkgs/bigfred/server/protocol/user.go +++ b/pkgs/bigfred/server/protocol/user.go @@ -91,17 +91,24 @@ type UserCreateRequest struct { Organization string `json:"organization"` Role domain.Role `json:"role"` DCCPool []DCCPoolRangeRequest `json:"dccPool"` + // AutoAllocateDccCount asks the server to pick that many free DCC + // addresses instead of sending an explicit dccPool. + AutoAllocateDccCount *int `json:"autoAllocateDccCount,omitempty"` } // ToCreateInput maps the HTTP body to cmd input. func (r UserCreateRequest) ToCreateInput() cmd.UserCreateInput { - return cmd.UserCreateInput{ + in := cmd.UserCreateInput{ Login: r.Login, PIN: r.PIN, Organization: r.Organization, Role: r.Role, DCCPool: ToPoolRanges(r.DCCPool), } + if r.AutoAllocateDccCount != nil { + in.AutoAllocateDccCount = *r.AutoAllocateDccCount + } + return in } // UserUpdateRequest mirrors optional fields exposed by cmd.User.Update. diff --git a/pkgs/bigfred/server/repo/migrations/migrations.go b/pkgs/bigfred/server/repo/migrations/migrations.go index c5e01f6..c0f2056 100644 --- a/pkgs/bigfred/server/repo/migrations/migrations.go +++ b/pkgs/bigfred/server/repo/migrations/migrations.go @@ -124,6 +124,10 @@ func register(m *migrator.Migrator) { m.Register(migrationVersion(20260716, 1), addCommandStationAllocatePhysicalSlotsColumnUp, addCommandStationAllocatePhysicalSlotsColumnDown) m.Register(migrationVersion(20260720, 1), addVehicleCatalogMetaColumnsUp, addVehicleCatalogMetaColumnsDown) m.Register(migrationVersion(20260721, 1), replaceVehicleExternalIDUniqueIndexUp, replaceVehicleExternalIDUniqueIndexDown) + m.Register(migrationVersion(20260808, 1), addCommandStationProgrammingColumnUp, addCommandStationProgrammingColumnDown) + m.Register(migrationVersion(20260808, 2), addCommandStationHideInThrottleColumnUp, addCommandStationHideInThrottleColumnDown) + m.Register(migrationVersion(20260808, 3), addCommandStationDefaultProgrammingTrackOutputColumnUp, addCommandStationDefaultProgrammingTrackOutputColumnDown) + m.Register(migrationVersion(20260809, 1), seedBasicTemplateUp, seedBasicTemplateDown) } // createCommandStationsUp installs the `command_stations` catalogue @@ -891,6 +895,36 @@ func addCommandStationAllocatePhysicalSlotsColumnDown(s *rel.Schema) { // SQLite cannot DROP COLUMN in older schemas; leave columns in place. } +// addCommandStationProgrammingColumnUp opens decoder CV / address programming +// on the dcc-bus daemon spawned for this station (off by default). +func addCommandStationProgrammingColumnUp(s *rel.Schema) { + s.Exec(rel.Raw(`ALTER TABLE command_stations ADD COLUMN programming INTEGER NOT NULL DEFAULT 0`)) +} + +func addCommandStationProgrammingColumnDown(s *rel.Schema) { + // SQLite cannot DROP COLUMN in older schemas; leave columns in place. +} + +// addCommandStationHideInThrottleColumnUp hides the station from the throttle's +// available command-station list (e.g. a dedicated programming station). +func addCommandStationHideInThrottleColumnUp(s *rel.Schema) { + s.Exec(rel.Raw(`ALTER TABLE command_stations ADD COLUMN hide_in_throttle INTEGER NOT NULL DEFAULT 0`)) +} + +func addCommandStationHideInThrottleColumnDown(s *rel.Schema) { + // SQLite cannot DROP COLUMN in older schemas; leave columns in place. +} + +// addCommandStationDefaultProgrammingTrackOutputColumnUp records which track +// CV / address frames land on when they omit `mode` ("pom" or "prog"). +func addCommandStationDefaultProgrammingTrackOutputColumnUp(s *rel.Schema) { + s.Exec(rel.Raw(`ALTER TABLE command_stations ADD COLUMN default_programming_track_output TEXT NOT NULL DEFAULT 'prog'`)) +} + +func addCommandStationDefaultProgrammingTrackOutputColumnDown(s *rel.Schema) { + // SQLite cannot DROP COLUMN in older schemas; leave columns in place. +} + // updateWithrottlePairingAddrDefaultUp moves rows still on the old sentinel default (10239) to 3. func updateWithrottlePairingAddrDefaultUp(s *rel.Schema) { s.Exec(rel.Raw(`UPDATE command_stations SET withrottle_pairing_addr = 3 WHERE withrottle_pairing_addr = 10239`)) diff --git a/pkgs/bigfred/server/repo/migrations/seed_basic_template.go b/pkgs/bigfred/server/repo/migrations/seed_basic_template.go new file mode 100644 index 0000000..482ebea --- /dev/null +++ b/pkgs/bigfred/server/repo/migrations/seed_basic_template.go @@ -0,0 +1,36 @@ +package migrations + +import ( + "github.com/go-rel/rel" +) + +const basicTemplateName = "Basic" + +// basicFunctions is a generic F0–F10 catalogue for party/kiosk loco setup. +// F3 and F4 are forced momentary (1s) via seedTemplateFunctionsWithForce. +var basicFunctions = []templateFunctionSeed{ + {0, "F0", "light"}, + {1, "F1", "unspecified"}, + {2, "F2", "unspecified"}, + {3, "F3", "unspecified"}, + {4, "F4", "unspecified"}, + {5, "F5", "unspecified"}, + {6, "F6", "unspecified"}, + {7, "F7", "unspecified"}, + {8, "F8", "unspecified"}, + {9, "F9", "unspecified"}, + {10, "F10", "unspecified"}, +} + +var basicForceMomentary = forceMomentaryOverride{ + 3: 1000, + 4: 1000, +} + +func seedBasicTemplateUp(s *rel.Schema) { + seedTemplateFunctionsWithForce(s, basicTemplateName, basicFunctions, basicForceMomentary) +} + +func seedBasicTemplateDown(s *rel.Schema) { + deleteTemplateSeed(s, basicTemplateName) +} diff --git a/pkgs/bigfred/server/repo/migrations/seed_helpers.go b/pkgs/bigfred/server/repo/migrations/seed_helpers.go index 9e18a38..fdff1cc 100644 --- a/pkgs/bigfred/server/repo/migrations/seed_helpers.go +++ b/pkgs/bigfred/server/repo/migrations/seed_helpers.go @@ -10,12 +10,17 @@ import ( const defaultMomentaryDurationMs = 1000 // templateFunctionSeed is one F-slot inserted by a vehicle-template seed migration. +// Keep exactly three fields so existing positional composite literals keep compiling. type templateFunctionSeed struct { num uint8 name string icon string } +// forceMomentaryOverride maps function number → momentary duration (ms). +// Duration <= 0 means defaultMomentaryDurationMs. +type forceMomentaryOverride map[uint8]int + func sqlLiteral(s string) string { return strings.ReplaceAll(s, "'", "''") } @@ -31,7 +36,28 @@ func momentarySeedValues(icon string) (momentary int, durationMs int) { return 0, defaultMomentaryDurationMs } +func (fn templateFunctionSeed) momentaryValues(force forceMomentaryOverride) (momentary int, durationMs int) { + if force != nil { + if d, ok := force[fn.num]; ok { + if d <= 0 { + d = defaultMomentaryDurationMs + } + return 1, d + } + } + return momentarySeedValues(fn.icon) +} + func seedTemplateFunctions(s *rel.Schema, templateName string, functions []templateFunctionSeed) { + seedTemplateFunctionsWithForce(s, templateName, functions, nil) +} + +func seedTemplateFunctionsWithForce( + s *rel.Schema, + templateName string, + functions []templateFunctionSeed, + force forceMomentaryOverride, +) { name := sqlLiteral(templateName) s.Exec(rel.Raw(fmt.Sprintf(` INSERT INTO vehicle_templates (name, description, owner_user_id, version, created_at, updated_at) @@ -41,7 +67,7 @@ func seedTemplateFunctions(s *rel.Schema, templateName string, functions []templ var parts []string for _, fn := range functions { - momentary, durationMs := momentarySeedValues(fn.icon) + momentary, durationMs := fn.momentaryValues(force) parts = append(parts, fmt.Sprintf( `SELECT NULL, t.id, %d, '%s', '%s', %d, %d, %d, datetime('now'), datetime('now') FROM vehicle_templates t diff --git a/pkgs/bigfred/server/repo/migrations/seed_helpers_test.go b/pkgs/bigfred/server/repo/migrations/seed_helpers_test.go index 856f85a..58d8b4d 100644 --- a/pkgs/bigfred/server/repo/migrations/seed_helpers_test.go +++ b/pkgs/bigfred/server/repo/migrations/seed_helpers_test.go @@ -32,3 +32,18 @@ func TestMomentarySeedValues(t *testing.T) { t.Fatalf("light: got momentary=%d durationMs=%d", m, d) } } + +func TestForceMomentarySeed(t *testing.T) { + t.Parallel() + force := forceMomentaryOverride{3: 1000, 4: 1000} + fn := templateFunctionSeed{3, "F3", "unspecified"} + m, d := fn.momentaryValues(force) + if m != 1 || d != 1000 { + t.Fatalf("force momentary: got momentary=%d durationMs=%d", m, d) + } + fn = templateFunctionSeed{0, "F0", "light"} + m, d = fn.momentaryValues(force) + if m != 0 || d != defaultMomentaryDurationMs { + t.Fatalf("non-force: got momentary=%d durationMs=%d", m, d) + } +} diff --git a/pkgs/bigfred/server/service/dcc_bus.go b/pkgs/bigfred/server/service/dcc_bus.go index 6aca737..d8be035 100644 --- a/pkgs/bigfred/server/service/dcc_bus.go +++ b/pkgs/bigfred/server/service/dcc_bus.go @@ -611,6 +611,10 @@ func (d *DccBusService) buildServiceDef(ctx context.Context, name string, layout if cs.SingleVehicleControl { args = append(args, "--"+dccbuscli.FlagSingleVehicleControl) } + if cs.Programming { + args = append(args, "--"+dccbuscli.FlagEnableProgramming) + } + args = append(args, "--"+dccbuscli.FlagDefaultProgrammingTrack, cs.EffectiveDefaultProgrammingTrackOutput()) for _, origin := range d.cfg.AllowedOrigins { args = append(args, "--allowed-origin", origin) } diff --git a/pkgs/bigfred/server/validation/command_station.go b/pkgs/bigfred/server/validation/command_station.go index ace19f0..a15f608 100644 --- a/pkgs/bigfred/server/validation/command_station.go +++ b/pkgs/bigfred/server/validation/command_station.go @@ -125,3 +125,16 @@ func SanitiseCommandStationIdleTimeoutSecs(secs uint) error { } return nil } + +// SanitiseCommandStationProgrammingTrackOutput normalises the default +// programming track output. Empty selects the catalogue default ("prog"). +func SanitiseCommandStationProgrammingTrackOutput(track string) (string, error) { + track = strings.ToLower(strings.TrimSpace(track)) + if track == "" { + return domain.DefaultCommandStationProgrammingTrackOutput, nil + } + if !domain.IsValidProgrammingTrackOutput(track) { + return "", svcerrors.ErrCommandStationProgrammingTrackInvalid + } + return track, nil +} diff --git a/web/src/api/command_stations.ts b/web/src/api/command_stations.ts index 98ce013..2f42145 100644 --- a/web/src/api/command_stations.ts +++ b/web/src/api/command_stations.ts @@ -28,6 +28,15 @@ export const DEFAULT_COMMAND_STATION_MAX_LOCONET_SLOTS = 80; export const DEFAULT_COMMAND_STATION_IDLE_TIMEOUT_SECS = 60; export const DEFAULT_LAYOUT_MAX_VEHICLES_PER_USER = 8; +export type ProgrammingTrackOutput = "pom" | "prog"; + +export const PROGRAMMING_TRACK_OUTPUTS: ProgrammingTrackOutput[] = [ + "prog", + "pom", +]; + +export const DEFAULT_PROGRAMMING_TRACK_OUTPUT: ProgrammingTrackOutput = "prog"; + export interface CommandStation { id: number; name: string; @@ -45,6 +54,9 @@ export interface CommandStation { bootStopEnabled: boolean; singleVehicleControl: boolean; allocatePhysicalSlots?: boolean; + programming: boolean; + hideInThrottle: boolean; + defaultProgrammingTrackOutput: ProgrammingTrackOutput; } const commandStationsCatalogueQueryKey = [ @@ -80,6 +92,9 @@ export function useCreateCommandStation() { bootStopEnabled?: boolean; singleVehicleControl?: boolean; allocatePhysicalSlots?: boolean; + programming?: boolean; + hideInThrottle?: boolean; + defaultProgrammingTrackOutput?: ProgrammingTrackOutput; }) => apiFetch("/api/v1/command-stations", { method: "POST", @@ -111,6 +126,9 @@ export function useUpdateCommandStation() { bootStopEnabled?: boolean; singleVehicleControl?: boolean; allocatePhysicalSlots?: boolean; + programming?: boolean; + hideInThrottle?: boolean; + defaultProgrammingTrackOutput?: ProgrammingTrackOutput; }) => apiFetch(`/api/v1/command-stations/${args.id}`, { method: "PUT", @@ -130,6 +148,9 @@ export function useUpdateCommandStation() { bootStopEnabled: args.bootStopEnabled, singleVehicleControl: args.singleVehicleControl, allocatePhysicalSlots: args.allocatePhysicalSlots, + programming: args.programming, + hideInThrottle: args.hideInThrottle, + defaultProgrammingTrackOutput: args.defaultProgrammingTrackOutput, }), }), onSuccess: () => { diff --git a/web/src/i18n/locales/de/commandStation.json b/web/src/i18n/locales/de/commandStation.json index 7f8f0dc..23a1cc5 100644 --- a/web/src/i18n/locales/de/commandStation.json +++ b/web/src/i18n/locales/de/commandStation.json @@ -77,6 +77,12 @@ "instruction": "Boot-Stop ist ein einmaliger Notstopp aller Anlagenfahrzeuge direkt nach dem Start von dcc-bus. Nützlich, wenn Loks nach einem Neustart nicht mit der alten Geschwindigkeit weiterfahren sollen. Jede Änderung der Zentraleneinstellungen startet die Verbindung neu — das löst ebenfalls Boot-Stop aus.", "alert": "Mit aktivem Boot-Stop erhalten Fahrzeuge bei jedem Neustart von BigFred / dcc-bus einen Notstopp." }, + "programming": { + "label": "Programmierung", + "title": "Decoder-Programmierung", + "instruction": "Lege fest, ob diese Zentrale Decoder programmieren darf (CVs lesen und schreiben, Adresse ändern), und wähle das Gleis, das dafür standardmäßig genutzt wird. Du kannst die Zentrale außerdem im Reglerbild ausblenden, damit niemand versehentlich darauf fährt — praktisch für eine reine Programmierzentrale.", + "alert": "CVs auf einer laufenden Anlage zu schreiben ist destruktiv. Lass die Programmierung aus, wenn du diese Zentrale nicht wirklich dafür nutzt." + }, "slots": { "label": "Slots", "title": "LocoNet-Slot-Zuweisung", @@ -95,6 +101,10 @@ "loconet_serial": "LocoNet (seriell)", "loconet_tcp": "LocoNet (TCP)" }, + "programmingTrack": { + "prog": "Programmiergleis (prog)", + "pom": "Hauptgleis (POM)" + }, "dialogs": { "create": { "title": "Neue Zentrale" @@ -136,7 +146,13 @@ "bootStopEnabled": "Boot-Stop aller Fahrzeuge", "bootStopEnabledHelp": "Wenn aktiviert, sendet dcc-bus nach dem Start einmal einen Notstopp an jedes im System angelegte Fahrzeug.", "singleVehicleControl": "Nur ein Fahrzeug gleichzeitig steuerbar", - "singleVehicleControlHelp": "Wenn aktiviert, stoppt die Auswahl oder Fahrt eines anderen Fahrzeugs/Zugs die übrigen fahrenden Fahrzeuge des Benutzers (Geschwindigkeit > 1). Verliehene Fahrzeuge werden nicht gestoppt." + "singleVehicleControlHelp": "Wenn aktiviert, stoppt die Auswahl oder Fahrt eines anderen Fahrzeugs/Zugs die übrigen fahrenden Fahrzeuge des Benutzers (Geschwindigkeit > 1). Verliehene Fahrzeuge werden nicht gestoppt.", + "programming": "Decoder-Programmierung", + "programmingHelp": "Erlaubt das Lesen und Schreiben von Decoder-CVs sowie das Ändern der DCC-Adresse über diese Zentrale. Standardmäßig aus, weil CV-Schreibvorgänge auf einer laufenden Anlage destruktiv sind.", + "defaultProgrammingTrackOutput": "Standard-Programmiergleisausgang", + "defaultProgrammingTrackOutputHelp": "Gleis, das genutzt wird, wenn eine Programmieranfrage es nicht angibt: der separate Programmiergleisausgang (prog) oder das Hauptgleis (POM, Programmierung auf dem Hauptgleis).", + "hideInThrottle": "Im Reglerbild ausblenden", + "hideInThrottleHelp": "Die Zentrale erscheint nicht in der Zentralenauswahl des Reglers. Für eine reine Programmierzentrale, auf der niemand fahren soll." } }, "empty": "Keine Zentralen anzuzeigen.", diff --git a/web/src/i18n/locales/de/errors.json b/web/src/i18n/locales/de/errors.json index 82c66be..8f38857 100644 --- a/web/src/i18n/locales/de/errors.json +++ b/web/src/i18n/locales/de/errors.json @@ -32,6 +32,7 @@ "command_station_poll_interval_invalid": "Abfrageintervall muss zwischen 0 und 60000 ms liegen.", "command_station_max_loconet_slots_invalid": "LocoNet-Slot-Budget muss zwischen 1 und 116 liegen.", "command_station_idle_timeout_invalid": "Remote-Leerlaufzeit muss zwischen 0 und 3600 s liegen.", + "command_station_programming_track_invalid": "Standard-Programmiergleisausgang muss „prog“ oder „pom“ sein.", "command_station_inbound_port_conflict": "Eine andere Zentrale hat bereits den Z21- oder WiFred-Piloten-Server auf demselben Port aktiviert (Standard 21105 bzw. 12090). Pro Host darf nur eine Zentrale auf einem Port lauschen — Piloten auf einer Zentrale deaktivieren oder die Ports in den erweiterten Einstellungen ändern.", "layout_max_vehicles_invalid": "Fahrzeuglimit pro Benutzer muss zwischen 1 und 120 liegen.", "layout_max_vehicles_exceeds_slot_budget": "Das Fahrzeuglimit darf das LocoNet-Slot-Budget einer zugeordneten Zentrale nicht überschreiten.", @@ -56,6 +57,8 @@ "dcc_pool_range_invalid": "Ungültiger DCC-Pool-Bereich (1–9999, „von“ darf „bis“ nicht überschreiten).", "dcc_pool_empty": "Mindestens einen DCC-Adressbereich zuweisen.", "dcc_pool_overlap": "Der Bereich überschneidet sich mit dem DCC-Pool eines anderen Benutzers.", + "dcc_pool_exhausted": "Es sind nicht genug freie DCC-Adressen für die automatische Zuteilung übrig. Gib Bereiche frei oder weise sie von Hand zu.", + "dcc_pool_auto_allocate_conflict": "Wähle entweder die automatische Zuteilung oder eigene DCC-Adressbereiche, nicht beides.", "train_not_found": "Dieser Zug existiert nicht mehr.", "train_name_required": "Bitte einen Zugnamen eingeben.", "train_name_taken": "Sie haben bereits einen Zug mit diesem Namen.", diff --git a/web/src/i18n/locales/en/commandStation.json b/web/src/i18n/locales/en/commandStation.json index f3d2832..80e59cd 100644 --- a/web/src/i18n/locales/en/commandStation.json +++ b/web/src/i18n/locales/en/commandStation.json @@ -77,6 +77,12 @@ "instruction": "Boot-stop is a one-shot emergency stop of all layout vehicles right after dcc-bus starts. Useful when you do not want locomotives to resume their previous speed after a restart. Any change to command-station settings restarts the connection, which also triggers boot-stop.", "alert": "With boot-stop enabled, every BigFred / dcc-bus restart will emergency-stop vehicles." }, + "programming": { + "label": "Programming", + "title": "Decoder programming", + "instruction": "Decide whether this command station may program decoders (read and write CVs, change the address) and pick the track those operations use by default. You can also hide the station from the throttle so nobody drives on it by accident — handy for a dedicated programming station.", + "alert": "Writing CVs on a live layout is destructive. Leave programming off unless you actually use this station for it." + }, "slots": { "label": "Slots", "title": "LocoNet slot allocation", @@ -95,6 +101,10 @@ "loconet_serial": "LocoNet (serial)", "loconet_tcp": "LocoNet (TCP)" }, + "programmingTrack": { + "prog": "Programming track (prog)", + "pom": "Main track (POM)" + }, "dialogs": { "create": { "title": "New command station" @@ -136,7 +146,13 @@ "bootStopEnabled": "Boot-stop all vehicles", "bootStopEnabledHelp": "When enabled, dcc-bus sends emergency stop to every vehicle in the system once after start.", "singleVehicleControl": "Only one vehicle can be controlled at a time", - "singleVehicleControlHelp": "When enabled, selecting or driving a different vehicle or consist stops the user's other moving vehicles (speed > 1). Lent-out vehicles are not stopped." + "singleVehicleControlHelp": "When enabled, selecting or driving a different vehicle or consist stops the user's other moving vehicles (speed > 1). Lent-out vehicles are not stopped.", + "programming": "Decoder programming", + "programmingHelp": "Allows reading and writing decoder CVs and changing the DCC address through this command station. Off by default because writing CVs on a live layout is destructive.", + "defaultProgrammingTrackOutput": "Default programming track output", + "defaultProgrammingTrackOutputHelp": "Track used when a programming request does not say which one: the isolated programming output (prog) or the main track (POM, programming on the main).", + "hideInThrottle": "Hide in throttle view", + "hideInThrottleHelp": "Keeps the station out of the command-station picker in the throttle. Use it for a dedicated programming station nobody should drive on." } }, "empty": "No command stations to show.", diff --git a/web/src/i18n/locales/en/errors.json b/web/src/i18n/locales/en/errors.json index aaf05ae..e3cb131 100644 --- a/web/src/i18n/locales/en/errors.json +++ b/web/src/i18n/locales/en/errors.json @@ -32,6 +32,7 @@ "command_station_poll_interval_invalid": "Poll interval must be between 0 and 60000 ms.", "command_station_max_loconet_slots_invalid": "LocoNet slot budget must be between 1 and 116.", "command_station_idle_timeout_invalid": "Remote idle timeout must be between 0 and 3600 s.", + "command_station_programming_track_invalid": "Default programming track output must be “prog” or “pom”.", "command_station_inbound_port_conflict": "Another command station already has the Z21 or WiFred handset server enabled on the same port (default 21105 or 12090). Only one station per host can listen on a given port — disable handsets on one station or change the ports in advanced settings.", "layout_max_vehicles_invalid": "Vehicles-per-user limit must be between 1 and 120.", "layout_max_vehicles_exceeds_slot_budget": "Vehicles-per-user limit cannot exceed the LocoNet slot budget on an attached command station.", @@ -56,6 +57,8 @@ "dcc_pool_range_invalid": "Invalid DCC pool range (1–9999, “from” must not exceed “to”).", "dcc_pool_empty": "Assign at least one DCC address range.", "dcc_pool_overlap": "The range overlaps another user's DCC address pool.", + "dcc_pool_exhausted": "Not enough free DCC addresses left to allocate automatically. Free some ranges or assign them by hand.", + "dcc_pool_auto_allocate_conflict": "Choose either automatic allocation or explicit DCC address ranges, not both.", "train_not_found": "This train no longer exists.", "train_name_required": "Please enter a train name.", "train_name_taken": "You already have a train with this name.", diff --git a/web/src/i18n/locales/pl/commandStation.json b/web/src/i18n/locales/pl/commandStation.json index 3e1410b..510070a 100644 --- a/web/src/i18n/locales/pl/commandStation.json +++ b/web/src/i18n/locales/pl/commandStation.json @@ -77,6 +77,12 @@ "instruction": "Boot-stop to jednorazowe awaryjne zatrzymanie wszystkich pojazdów z makiety zaraz po starcie dcc-bus. Przydatne, gdy po restarcie nie chcesz, żeby lokomotywy ruszyły z poprzednią prędkością. Każda zmiana ustawień centralki powoduje restart połączenia, co również uruchamia boot-stop.", "alert": "Po włączeniu boot-stop przy każdym restarcie BigFred / dcc-bus pojazdy dostaną emergency stop." }, + "programming": { + "label": "Programowanie", + "title": "Programowanie dekoderów", + "instruction": "Zdecyduj, czy ta centralka może programować dekodery (odczyt i zapis CV, zmiana adresu) i wybierz tor, z którego te operacje mają domyślnie korzystać. Możesz też ukryć centralkę w widoku manipulatora, aby nikt przez przypadek nie jeździł na niej pociągami — przydatne dla centralki dedykowanej do programowania.", + "alert": "Zapis CV na działającej makiecie jest destrukcyjny. Zostaw programowanie wyłączone, jeśli faktycznie nie używasz tej centralki do tego celu." + }, "slots": { "label": "Sloty", "title": "Alokacja slotów LocoNet", @@ -95,6 +101,10 @@ "loconet_serial": "LocoNet (serial)", "loconet_tcp": "LocoNet (TCP)" }, + "programmingTrack": { + "prog": "Tor programujący (prog)", + "pom": "Tor główny (POM)" + }, "dialogs": { "create": { "title": "Nowa centralka" @@ -136,7 +146,13 @@ "bootStopEnabled": "Boot-stop wszystkich pojazdów", "bootStopEnabledHelp": "Po włączeniu dcc-bus raz po starcie wysyła emergency stop do każdej lokomotywy dodanej w systemie", "singleVehicleControl": "Tylko jeden pojazd może być sterowany w jednym czasie", - "singleVehicleControlHelp": "Gdy włączone, wybór lub jazda innym pojazdem/składem zatrzymuje pozostałe pojazdy użytkownika (prędkość > 1). Wypożyczone pojazdy nie są zatrzymywane." + "singleVehicleControlHelp": "Gdy włączone, wybór lub jazda innym pojazdem/składem zatrzymuje pozostałe pojazdy użytkownika (prędkość > 1). Wypożyczone pojazdy nie są zatrzymywane.", + "programming": "Programowanie dekoderów", + "programmingHelp": "Pozwala odczytywać i zapisywać CV dekodera oraz zmieniać adres DCC przez tę centralkę. Domyślnie wyłączone, bo zapis CV na działającej makiecie jest destrukcyjny.", + "defaultProgrammingTrackOutput": "Domyślne wyjście toru programującego", + "defaultProgrammingTrackOutputHelp": "Tor używany, gdy żądanie programowania nie wskazuje wprost: wydzielone wyjście toru programującego (prog) albo tor główny (POM, programowanie na torze głównym).", + "hideInThrottle": "Ukryj w widoku manipulatora", + "hideInThrottleHelp": "Centralka nie pojawi się na liście wyboru centralki w manipulatorze. Użyj dla centralki dedykowanej do programowania, na której nikt nie powinien jeździć." } }, "empty": "Brak centralki do wyświetlenia.", diff --git a/web/src/i18n/locales/pl/errors.json b/web/src/i18n/locales/pl/errors.json index cd782c8..5aa7738 100644 --- a/web/src/i18n/locales/pl/errors.json +++ b/web/src/i18n/locales/pl/errors.json @@ -33,6 +33,7 @@ "command_station_poll_interval_invalid": "Interwał odpytywania musi być między 0 a 60000 ms.", "command_station_max_loconet_slots_invalid": "Budżet slotów LocoNet musi być między 1 a 116.", "command_station_idle_timeout_invalid": "Limit bezczynności pilota musi być między 0 a 3600 s.", + "command_station_programming_track_invalid": "Domyślne wyjście toru programującego musi być „prog” albo „pom”.", "command_station_inbound_port_conflict": "Inna centralka ma już włączony serwer pilota Z21 lub WiFred na tym samym porcie (domyślnie 21105 lub 12090). Na jednym hoście tylko jedna centralka może nasłuchiwać na danym porcie — wyłącz piloty na jednej z centralek lub zmień porty w zaawansowanych ustawieniach.", "layout_max_vehicles_invalid": "Limit pojazdów na użytkownika musi być między 1 a 120.", "layout_max_vehicles_exceeds_slot_budget": "Limit pojazdów nie może przekraczać budżetu slotów LocoNet przypisanej centralce.", @@ -56,6 +57,8 @@ "dcc_pool_range_invalid": "Nieprawidłowy zakres puli adresów DCC (od 1 do 9999, wartość „od” nie może być większa niż „do”).", "dcc_pool_empty": "Przypisz co najmniej jeden zakres adresacji DCC.", "dcc_pool_overlap": "Zakres nakłada się na pulę adresacji innego użytkownika.", + "dcc_pool_exhausted": "Za mało wolnych adresów DCC, aby przydzielić je automatycznie. Zwolnij część zakresów lub przypisz je ręcznie.", + "dcc_pool_auto_allocate_conflict": "Wybierz albo automatyczne przydzielanie, albo własne zakresy adresów DCC — nie oba naraz.", "train_not_found": "Skład nie istnieje.", "train_name_required": "Wpisz nazwę składu.", "train_name_taken": "Masz już skład o tej nazwie.", diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index bbd5414..1515893 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -27,6 +27,17 @@ interface LocationState { from?: { pathname?: string }; } +/** Same-origin authorize path only — blocks open redirects. */ +function isSafeSSOReturnTo(returnTo: string): boolean { + if (!returnTo.startsWith("/api/v1/auth/oauth/authorize")) { + return false; + } + if (returnTo.includes("://") || returnTo.includes("//")) { + return false; + } + return true; +} + // renderLayoutLabel resolves the user-visible label of a layout row // from the dropdown payload (§7a.1). The bootstrap system row stores // a stable Name ("default") that is NEVER rendered directly — instead @@ -45,8 +56,8 @@ export default function LoginPage() { const [login, setLogin] = useState(""); const [pin, setPin] = useState(""); // `layoutId === 0` means "not yet picked"; the effect below selects - // the system layout as soon as the dropdown payload arrives, so the - // value is 0 only during the brief loading window. + // the system layout (or a layout_id from the SSO return URL) as soon + // as the dropdown payload arrives. const [layoutId, setLayoutId] = useState(0); const loginMut = useLogin(); @@ -61,16 +72,24 @@ export default function LoginPage() { const systemLabel = t("layout:system_default_label"); - // Pre-select the system layout on first paint, matching §7a.1: - // "It is also the dropdown's default pre-selected entry on first - // paint, so a user who never touches the selector simply lands in - // the system layout." + const preferredLayoutId = useMemo(() => { + const raw = new URLSearchParams(location.search).get("layout_id"); + const n = raw ? Number(raw) : 0; + return Number.isInteger(n) && n > 0 ? n : 0; + }, [location.search]); + + // Pre-select preferred layout_id from SSO (wizard) or the system + // layout on first paint (§7a.1). useEffect(() => { if (layoutId !== 0) return; if (!layouts.data || layouts.data.length === 0) return; + if (preferredLayoutId && layouts.data.some((l) => l.id === preferredLayoutId)) { + setLayoutId(preferredLayoutId); + return; + } const sys = layouts.data.find((l) => l.isSystem); setLayoutId(sys?.id ?? layouts.data[0].id); - }, [layouts.data, layoutId]); + }, [layouts.data, layoutId, preferredLayoutId]); // Memoise the dropdown options so MUI's Select doesn't re-render // every keystroke in the login/PIN fields. @@ -82,8 +101,14 @@ export default function LoginPage() { })); }, [layouts.data, systemLabel]); - // Already authenticated → straight to the protected app. + // Already authenticated → SSO return_to (silent) or protected app. if (me.data) { + const params = new URLSearchParams(location.search); + const returnTo = params.get("return_to"); + if (returnTo && isSafeSSOReturnTo(returnTo)) { + window.location.assign(returnTo); + return null; + } const dest = (location.state as LocationState | undefined)?.from?.pathname ?? "/"; return ; diff --git a/web/src/pages/admin/CommandStationsPage.tsx b/web/src/pages/admin/CommandStationsPage.tsx index 0376d85..820ac1b 100644 --- a/web/src/pages/admin/CommandStationsPage.tsx +++ b/web/src/pages/admin/CommandStationsPage.tsx @@ -57,9 +57,12 @@ import { DEFAULT_COMMAND_STATION_SPEED_STEPS, DEFAULT_COMMAND_STATION_MAX_LOCONET_SLOTS, DEFAULT_COMMAND_STATION_IDLE_TIMEOUT_SECS, + DEFAULT_PROGRAMMING_TRACK_OUTPUT, + PROGRAMMING_TRACK_OUTPUTS, type CommandStation, type CommandStationKind, type DccBusSupervisordAction, + type ProgrammingTrackOutput, } from "../../api/command_stations"; import DccBusProgramList from "../../components/dcc-bus/DccBusProgramList"; @@ -114,6 +117,12 @@ export default function CommandStationsPage() { const [singleVehicleControlInput, setSingleVehicleControlInput] = useState(false); const [allocatePhysicalSlotsInput, setAllocatePhysicalSlotsInput] = useState(true); + const [programmingInput, setProgrammingInput] = useState(false); + const [hideInThrottleInput, setHideInThrottleInput] = useState(false); + const [ + defaultProgrammingTrackOutputInput, + setDefaultProgrammingTrackOutputInput, + ] = useState(DEFAULT_PROGRAMMING_TRACK_OUTPUT); const [actionError, setActionError] = useState(null); const closeDialog = () => { @@ -133,6 +142,9 @@ export default function CommandStationsPage() { setBootStopEnabledInput(false); setSingleVehicleControlInput(false); setAllocatePhysicalSlotsInput(true); + setProgrammingInput(false); + setHideInThrottleInput(false); + setDefaultProgrammingTrackOutputInput(DEFAULT_PROGRAMMING_TRACK_OUTPUT); setActionError(null); create.reset(); update.reset(); @@ -165,6 +177,9 @@ export default function CommandStationsPage() { setBootStopEnabledInput(false); setSingleVehicleControlInput(false); setAllocatePhysicalSlotsInput(true); + setProgrammingInput(false); + setHideInThrottleInput(false); + setDefaultProgrammingTrackOutputInput(DEFAULT_PROGRAMMING_TRACK_OUTPUT); setActionError(null); }; @@ -189,6 +204,11 @@ export default function CommandStationsPage() { setBootStopEnabledInput(target.bootStopEnabled); setSingleVehicleControlInput(target.singleVehicleControl); setAllocatePhysicalSlotsInput(target.allocatePhysicalSlots ?? true); + setProgrammingInput(target.programming); + setHideInThrottleInput(target.hideInThrottle); + setDefaultProgrammingTrackOutputInput( + target.defaultProgrammingTrackOutput ?? DEFAULT_PROGRAMMING_TRACK_OUTPUT, + ); setActionError(null); }; @@ -214,6 +234,9 @@ export default function CommandStationsPage() { idleTimeoutSecs: idleTimeoutSecsInput, bootStopEnabled: bootStopEnabledInput, singleVehicleControl: singleVehicleControlInput, + programming: programmingInput, + hideInThrottle: hideInThrottleInput, + defaultProgrammingTrackOutput: defaultProgrammingTrackOutputInput, ...(isLoconetKind(kindInput) ? { maxLoconetSlots: maxLoconetSlotsInput, @@ -582,6 +605,63 @@ export default function CommandStationsPage() { {t("commandStation:admin.dialogs.fields.singleVehicleControlHelp")} + setProgrammingInput(e.target.checked)} + /> + } + label={t("commandStation:admin.dialogs.fields.programming")} + /> + + {t("commandStation:admin.dialogs.fields.programmingHelp")} + + {programmingInput && ( + + + {t( + "commandStation:admin.dialogs.fields.defaultProgrammingTrackOutput", + )} + + + + )} + {programmingInput && ( + + {t( + "commandStation:admin.dialogs.fields.defaultProgrammingTrackOutputHelp", + )} + + )} + setHideInThrottleInput(e.target.checked)} + /> + } + label={t("commandStation:admin.dialogs.fields.hideInThrottle")} + /> + + {t("commandStation:admin.dialogs.fields.hideInThrottleHelp")} + (DEFAULT_PROGRAMMING_TRACK_OUTPUT); const [allocatePhysicalSlots, setAllocatePhysicalSlots] = useState(true); const [attachToLayout, setAttachToLayout] = useState(true); const [actionError, setActionError] = useState(null); @@ -94,7 +111,12 @@ export default function ConnectionWizardPage() { const showSlotsStep = selectedKind != null && isLoconetKind(selectedKind); const steps = useMemo((): WizardStepId[] => { - const base: WizardStepId[] = ["select", "remotes", "bootstop"]; + const base: WizardStepId[] = [ + "select", + "remotes", + "bootstop", + "programming", + ]; if (showSlotsStep) base.push("slots"); base.push("layout"); return base; @@ -255,6 +277,9 @@ export default function ConnectionWizardPage() { idleTimeoutSecs: DEFAULT_COMMAND_STATION_IDLE_TIMEOUT_SECS, bootStopEnabled, singleVehicleControl: false, + programming, + hideInThrottle, + defaultProgrammingTrackOutput, ...(isLoconetKind(kind) ? { maxLoconetSlots: DEFAULT_COMMAND_STATION_MAX_LOCONET_SLOTS, @@ -534,6 +559,71 @@ export default function ConnectionWizardPage() { )} + {activeStep === "programming" && ( + <> + + {t("commandStation:admin.wizard.steps.programming.alert")} + + setProgramming(v)} + /> + } + label={t("commandStation:admin.dialogs.fields.programming")} + /> + + {t("commandStation:admin.dialogs.fields.programmingHelp")} + + {programming && ( + + + {t( + "commandStation:admin.dialogs.fields.defaultProgrammingTrackOutput", + )} + + + + {t( + "commandStation:admin.dialogs.fields.defaultProgrammingTrackOutputHelp", + )} + + + )} + setHideInThrottle(v)} + /> + } + label={t( + "commandStation:admin.dialogs.fields.hideInThrottle", + )} + /> + + {t("commandStation:admin.dialogs.fields.hideInThrottleHelp")} + + + )} + {activeStep === "slots" && (