You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

754 lines
22 KiB

package serial
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
DefaultStreamSocketPath = "/run/tera-seriald/serial-stream.sock"
MaxCapturedPayloadBytes = 4 << 10
CRProbeClientID = "tera-serial-link-probe"
)
var (
ErrStreamLeaseBusy = errors.New("serial stream lease is already active")
streamClientPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
)
// StreamTarget is a short-lived reservation of one validated mapping entry.
// Release is idempotent and must be called if opening the device fails.
type StreamTarget struct {
Channel string
Device string
Group string
releaseOnce sync.Once
release func()
}
func newStreamTarget(channel, device, group string, release func()) *StreamTarget {
return &StreamTarget{Channel: channel, Device: device, Group: group, release: release}
}
func (t *StreamTarget) Release() {
if t == nil {
return
}
t.releaseOnce.Do(func() {
if t.release != nil {
t.release()
}
})
}
// StreamResolver prevents a stream lease from bypassing the board-validated
// CH mapping. MappingController is the production implementation.
type StreamResolver interface {
ReserveStream(context.Context, string) (*StreamTarget, error)
}
type StreamOpener interface {
OpenStream(path string) (io.ReadWriteCloser, error)
}
type platformStreamOpener struct{}
func (platformStreamOpener) OpenStream(path string) (io.ReadWriteCloser, error) {
return openSerialDevice(path)
}
type StreamEvent struct {
Timestamp time.Time `json:"timestamp"`
Channel string `json:"channel"`
LeaseID string `json:"lease_id"`
ClientID string `json:"client_id"`
Direction string `json:"direction"`
Bytes uint64 `json:"bytes,omitempty"`
Payload []byte `json:"payload,omitempty"`
LogFormat string `json:"log_format,omitempty"`
ShowTimestamp bool `json:"show_timestamp"`
ShowDirection bool `json:"show_direction"`
ShowUsername bool `json:"show_username"`
ShowIPAddress bool `json:"show_ip_address"`
ShowPort bool `json:"show_port"`
}
// StreamEventSink is deliberately non-blocking. Payload ownership transfers
// to the sink; BoundedStreamEventSink copies it before queueing so caller
// buffers can be reused immediately.
type StreamEventSink interface {
TryRecord(StreamEvent) bool
}
type BoundedStreamEventSink struct {
events chan StreamEvent
dropped atomic.Uint64
}
func NewBoundedStreamEventSink(capacity int) *BoundedStreamEventSink {
if capacity < 1 {
capacity = 1
}
if capacity > 4096 {
capacity = 4096
}
return &BoundedStreamEventSink{events: make(chan StreamEvent, capacity)}
}
func (s *BoundedStreamEventSink) TryRecord(event StreamEvent) bool {
if s == nil {
return true
}
if len(event.Payload) > 0 {
event.Payload = append([]byte(nil), event.Payload...)
}
select {
case s.events <- event:
return true
default:
s.dropped.Add(1)
return false
}
}
func (s *BoundedStreamEventSink) Events() <-chan StreamEvent { return s.events }
func (s *BoundedStreamEventSink) Dropped() uint64 { return s.dropped.Load() }
type StreamLeaseOptions struct {
Opener StreamOpener
Events StreamEventSink
}
type StreamSnapshot struct {
ActiveLeases int `json:"active_leases"`
RXBytes uint64 `json:"rx_bytes"`
TXBytes uint64 `json:"tx_bytes"`
DroppedEvents uint64 `json:"dropped_events"`
Channels []StreamChannelSnapshot `json:"channels,omitempty"`
}
type StreamChannelSnapshot struct {
Channel string `json:"channel"`
Active bool `json:"active"`
RXBytes uint64 `json:"rx_bytes"`
TXBytes uint64 `json:"tx_bytes"`
LastRXAt *time.Time `json:"last_rx_at,omitempty"`
}
type streamChannelCounters struct {
rx atomic.Uint64
tx atomic.Uint64
lastRXAt atomic.Int64
lastExternalActivity atomic.Int64
}
type StreamLeaseManager struct {
resolver StreamResolver
opener StreamOpener
events StreamEventSink
capture map[string]CaptureConfig
buffers map[string]*captureAccumulator
mu sync.Mutex
captureMu sync.Mutex
openMu sync.Mutex
openLocks map[string]*sync.Mutex
active map[string]*StreamLease
counts map[string]*streamChannelCounters
rx atomic.Uint64
tx atomic.Uint64
drops atomic.Uint64
}
type captureAccumulator struct {
channel string
leaseID string
clientID string
direction string
config CaptureConfig
payload []byte
timer *time.Timer
}
func NewStreamLeaseManager(resolver StreamResolver, options StreamLeaseOptions) (*StreamLeaseManager, error) {
if resolver == nil {
return nil, errors.New("serial stream resolver is required")
}
opener := options.Opener
if opener == nil {
opener = platformStreamOpener{}
}
manager := &StreamLeaseManager{
resolver: resolver,
opener: opener,
events: options.Events,
active: make(map[string]*StreamLease),
capture: defaultCaptureModes(),
buffers: make(map[string]*captureAccumulator),
counts: make(map[string]*streamChannelCounters, ChannelCount),
openLocks: make(map[string]*sync.Mutex),
}
for index := 1; index <= ChannelCount; index++ {
manager.counts[fmt.Sprintf("CH%02d", index)] = &streamChannelCounters{}
}
return manager, nil
}
// ApplyCapture atomically changes payload logging without touching an active
// UART lease. Unspecified channels keep their current setting.
func (m *StreamLeaseManager) ApplyCapture(ctx context.Context, configs []CaptureConfig) error {
if err := ctx.Err(); err != nil {
return err
}
normalized, err := normalizeCaptureConfigs(configs)
if err != nil {
return err
}
m.mu.Lock()
for channel, config := range normalized {
m.capture[channel] = config
}
m.mu.Unlock()
m.captureMu.Lock()
for key, accumulator := range m.buffers {
if _, changed := normalized[accumulator.channel]; !changed {
continue
}
if accumulator.timer != nil {
accumulator.timer.Stop()
}
delete(m.buffers, key)
}
m.captureMu.Unlock()
return nil
}
func (m *StreamLeaseManager) Acquire(ctx context.Context, channel, clientID string) (*StreamLease, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
canonical, err := normalizeChannel(channel)
if err != nil || canonical != channel {
return nil, errors.New("stream channel must be canonical CH01 through CH48")
}
if !streamClientPattern.MatchString(clientID) {
return nil, errors.New("stream client_id is invalid")
}
m.mu.Lock()
if _, exists := m.active[channel]; exists {
m.mu.Unlock()
return nil, ErrStreamLeaseBusy
}
// A nil entry reserves the channel while mapping validation and open run.
m.active[channel] = nil
m.mu.Unlock()
failed := true
defer func() {
if failed {
m.mu.Lock()
if current, exists := m.active[channel]; exists && current == nil {
delete(m.active, channel)
}
m.mu.Unlock()
}
}()
target, err := m.resolver.ReserveStream(ctx, channel)
if err != nil {
return nil, err
}
if target == nil || target.Channel != channel || target.Device == "" {
if target != nil {
target.Release()
}
return nil, &UnavailableError{Reason: "serial stream resolver returned an invalid target"}
}
if err := ctx.Err(); err != nil {
target.Release()
return nil, err
}
openLock := m.openLock(target.Group, target.Device)
openLock.Lock()
device, err := m.opener.OpenStream(target.Device)
openLock.Unlock()
if err != nil {
target.Release()
return nil, &UnavailableError{Reason: fmt.Sprintf("%s serial stream device cannot be opened", channel)}
}
leaseID, err := newStreamLeaseID()
if err != nil {
_ = device.Close()
target.Release()
return nil, err
}
lease := &StreamLease{
manager: m, target: target, device: device,
channel: channel, clientID: clientID, leaseID: leaseID,
}
m.mu.Lock()
m.active[channel] = lease
m.mu.Unlock()
failed = false
if clientID != CRProbeClientID {
m.emit(StreamEvent{Channel: channel, LeaseID: leaseID, ClientID: clientID, Direction: "open"})
}
return lease, nil
}
// openLock serializes the short open(2) path for UARTs sharing one PCI
// function/IRQ. Active leases remain independent after the device is open.
func (m *StreamLeaseManager) openLock(group, device string) *sync.Mutex {
key := strings.TrimSpace(group)
if key == "" {
key = device
}
m.openMu.Lock()
defer m.openMu.Unlock()
lock := m.openLocks[key]
if lock == nil {
lock = &sync.Mutex{}
m.openLocks[key] = lock
}
return lock
}
func (m *StreamLeaseManager) Snapshot() StreamSnapshot {
m.mu.Lock()
active := 0
activeChannels := make(map[string]bool, len(m.active))
for channel, lease := range m.active {
if lease != nil && lease.clientID == CRProbeClientID {
continue
}
active++
activeChannels[channel] = true
}
m.mu.Unlock()
channels := make([]StreamChannelSnapshot, 0, len(m.counts))
for index := 1; index <= ChannelCount; index++ {
channel := fmt.Sprintf("CH%02d", index)
counters := m.counts[channel]
rxBytes := counters.rx.Load()
txBytes := counters.tx.Load()
lastRXNanoseconds := counters.lastRXAt.Load()
if !activeChannels[channel] && rxBytes == 0 && txBytes == 0 {
continue
}
var lastRXAt *time.Time
if lastRXNanoseconds > 0 {
value := time.Unix(0, lastRXNanoseconds).UTC()
lastRXAt = &value
}
channels = append(channels, StreamChannelSnapshot{
Channel: channel, Active: activeChannels[channel], RXBytes: rxBytes, TXBytes: txBytes, LastRXAt: lastRXAt,
})
}
return StreamSnapshot{
ActiveLeases: active,
RXBytes: m.rx.Load(), TXBytes: m.tx.Load(), DroppedEvents: m.drops.Load(),
Channels: channels,
}
}
// RecentlyActive reports user/session traffic only. Internal CR probes do not
// extend the quiet period and are not exposed as active user sessions.
func (m *StreamLeaseManager) RecentlyActive(channel string, quietPeriod time.Duration, now time.Time) bool {
if m == nil || quietPeriod <= 0 {
return false
}
counters, ok := m.counts[channel]
if !ok {
return false
}
nanoseconds := counters.lastExternalActivity.Load()
if nanoseconds <= 0 {
return false
}
return now.Sub(time.Unix(0, nanoseconds)) < quietPeriod
}
func (m *StreamLeaseManager) Close() error {
m.mu.Lock()
leases := make([]*StreamLease, 0, len(m.active))
for _, lease := range m.active {
if lease != nil {
leases = append(leases, lease)
}
}
m.mu.Unlock()
var closeErr error
for _, lease := range leases {
if err := lease.Close(); err != nil {
closeErr = errors.Join(closeErr, err)
}
}
return closeErr
}
func (m *StreamLeaseManager) release(lease *StreamLease) {
m.flushCaptureLease(lease.leaseID)
m.mu.Lock()
if current, exists := m.active[lease.channel]; exists && current == lease {
delete(m.active, lease.channel)
}
m.mu.Unlock()
}
func (m *StreamLeaseManager) emit(event StreamEvent) {
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
if m.events != nil && !m.events.TryRecord(event) {
m.drops.Add(1)
}
}
func (m *StreamLeaseManager) emitPayload(channel, leaseID, clientID, direction string, payload []byte) {
config, enabled := m.captureConfig(channel, direction)
if len(payload) == 0 || !enabled {
return
}
trigger := config.DataLog.ReadTrigger
if direction == CaptureModeTX {
trigger = config.DataLog.SendTrigger
}
if config.DataPackage.Mode == "disabled" {
for len(payload) > 0 {
size := min(len(payload), MaxCapturedPayloadBytes)
m.emitCaptured(channel, leaseID, clientID, direction, config, trigger, payload[:size])
payload = payload[size:]
}
return
}
key := leaseID + "\x00" + direction
var ready [][]byte
m.captureMu.Lock()
accumulator := m.buffers[key]
if accumulator == nil {
accumulator = &captureAccumulator{channel: channel, leaseID: leaseID, clientID: clientID, direction: direction, config: config}
m.buffers[key] = accumulator
}
if accumulator.timer != nil {
accumulator.timer.Stop()
accumulator.timer = nil
}
accumulator.config = config
accumulator.payload = append(accumulator.payload, payload...)
for len(accumulator.payload) > 0 {
boundary := captureFrameBoundary(accumulator.payload, config, trigger)
if boundary == 0 {
break
}
ready = append(ready, append([]byte(nil), accumulator.payload[:boundary]...))
accumulator.payload = accumulator.payload[boundary:]
}
if len(accumulator.payload) > 0 {
if timeout := captureFlushTimeout(config, trigger); timeout > 0 {
accumulator.timer = time.AfterFunc(timeout, func() { m.flushCaptureKey(key) })
} else if captureFrameBoundary(accumulator.payload, config, trigger) == 0 {
ready = append(ready, append([]byte(nil), accumulator.payload...))
accumulator.payload = nil
}
}
if len(accumulator.payload) == 0 {
delete(m.buffers, key)
}
m.captureMu.Unlock()
for _, frame := range ready {
m.emitCaptured(channel, leaseID, clientID, direction, config, trigger, frame)
}
}
func (m *StreamLeaseManager) emitCaptured(channel, leaseID, clientID, direction string, config CaptureConfig, trigger CaptureTriggerConfig, payload []byte) {
chunk := append([]byte(nil), payload...)
chunk = processCaptureDelimiter(chunk, trigger.Process, trigger.Character1, trigger.Character2)
chunk = processCaptureDelimiter(chunk, config.DataPackage.DelimiterProcess, config.DataPackage.Delimiter1, config.DataPackage.Delimiter2)
if len(chunk) == 0 {
return
}
m.emit(StreamEvent{
Channel: channel, LeaseID: leaseID, ClientID: clientID,
Direction: direction, Bytes: uint64(len(chunk)), Payload: chunk,
LogFormat: config.DataLog.Format, ShowTimestamp: config.DataLog.ShowTimestamp,
ShowDirection: config.DataLog.ShowDirection, ShowUsername: config.DataLog.ShowUsername,
ShowIPAddress: config.DataLog.ShowIPAddress, ShowPort: config.DataLog.ShowPort,
})
}
func (m *StreamLeaseManager) flushCaptureKey(key string) {
m.captureMu.Lock()
accumulator := m.buffers[key]
if accumulator == nil {
m.captureMu.Unlock()
return
}
delete(m.buffers, key)
if accumulator.timer != nil {
accumulator.timer.Stop()
}
payload := append([]byte(nil), accumulator.payload...)
m.captureMu.Unlock()
trigger := accumulator.config.DataLog.ReadTrigger
if accumulator.direction == CaptureModeTX {
trigger = accumulator.config.DataLog.SendTrigger
}
m.emitCaptured(accumulator.channel, accumulator.leaseID, accumulator.clientID, accumulator.direction, accumulator.config, trigger, payload)
}
func (m *StreamLeaseManager) flushCaptureLease(leaseID string) {
prefix := leaseID + "\x00"
m.captureMu.Lock()
keys := make([]string, 0, 2)
for key := range m.buffers {
if strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
m.captureMu.Unlock()
for _, key := range keys {
m.flushCaptureKey(key)
}
}
func captureFrameBoundary(payload []byte, config CaptureConfig, trigger CaptureTriggerConfig) int {
boundary := 0
consider := func(candidate int) {
if candidate > 0 && (boundary == 0 || candidate < boundary) {
boundary = candidate
}
}
consider(min(len(payload), MaxCapturedPayloadBytes))
if len(payload) < MaxCapturedPayloadBytes {
boundary = 0
}
if (config.DataPackage.Mode == "length" || config.DataPackage.Mode == "interval") && config.DataPackage.Length > 0 && len(payload) >= config.DataPackage.Length {
consider(config.DataPackage.Length)
}
if config.DataPackage.Mode == "delimiter" {
consider(captureDelimiterBoundary(payload, config.DataPackage.Delimiter1, config.DataPackage.Delimiter2))
}
if trigger.Length > 0 && len(payload) >= trigger.Length {
consider(trigger.Length)
}
consider(captureDelimiterBoundary(payload, trigger.Character1, trigger.Character2))
return boundary
}
func captureDelimiterBoundary(payload []byte, first, second int) int {
if first < 0 && second < 0 {
return 0
}
if first >= 0 && second >= 0 {
for index := 0; index+1 < len(payload); index++ {
if int(payload[index]) == first && int(payload[index+1]) == second {
return index + 2
}
}
return 0
}
expected := first
if expected < 0 {
expected = second
}
for index, value := range payload {
if int(value) == expected {
return index + 1
}
}
return 0
}
func captureFlushTimeout(config CaptureConfig, trigger CaptureTriggerConfig) time.Duration {
milliseconds := 0
consider := func(value int) {
if value > 0 && (milliseconds == 0 || value < milliseconds) {
milliseconds = value
}
}
if config.DataPackage.Mode == "interval" || config.DataPackage.Mode == "timeout" || config.DataPackage.Mode == "delimiter" {
consider(config.DataPackage.TimeoutMillis)
}
consider(trigger.TimeoutMillis)
return time.Duration(milliseconds) * time.Millisecond
}
func (m *StreamLeaseManager) captureConfig(channel, direction string) (CaptureConfig, bool) {
m.mu.Lock()
config := m.capture[channel]
m.mu.Unlock()
enabled := config.Mode == CaptureModeRXTX || config.Mode == direction
if direction == CaptureModeRX {
enabled = enabled && config.DataLog.Read
} else if direction == CaptureModeTX {
enabled = enabled && config.DataLog.Send
}
return config, enabled
}
func defaultCaptureModes() map[string]CaptureConfig {
modes := make(map[string]CaptureConfig, ChannelCount)
for index := 1; index <= ChannelCount; index++ {
channel := fmt.Sprintf("CH%02d", index)
modes[channel] = CaptureConfig{
Channel: channel, Mode: CaptureModeRXTX,
DataPackage: CapturePackageConfig{Mode: "interval", Length: MaxCapturedPayloadBytes, DelimiterProcess: "keep", Delimiter1: -1, Delimiter2: -1},
DataLog: CaptureLogConfig{
Read: true, Send: true, ShowTimestamp: true, ShowDirection: true, Format: "ascii",
ReadTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1},
SendTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1},
},
}
}
return modes
}
func processCaptureDelimiter(payload []byte, process string, first, second int) []byte {
if strings.ToLower(strings.TrimSpace(process)) != "remove" {
return payload
}
result := payload[:0]
for _, value := range payload {
if (first >= 0 && int(value) == first) || (second >= 0 && int(value) == second) {
continue
}
result = append(result, value)
}
return result
}
type StreamLease struct {
manager *StreamLeaseManager
target *StreamTarget
device io.ReadWriteCloser
channel string
clientID string
leaseID string
writeMu sync.Mutex
closeOnce sync.Once
closeErr error
}
func (l *StreamLease) LeaseID() string { return l.leaseID }
func (l *StreamLease) Read(buffer []byte) (int, error) {
n, err := l.device.Read(buffer)
if n > 0 && l.clientID != CRProbeClientID {
now := time.Now()
l.manager.rx.Add(uint64(n))
l.manager.counts[l.channel].rx.Add(uint64(n))
l.manager.counts[l.channel].lastRXAt.Store(now.UnixNano())
l.manager.counts[l.channel].lastExternalActivity.Store(now.UnixNano())
l.manager.emitPayload(l.channel, l.leaseID, l.clientID, CaptureModeRX, buffer[:n])
}
return n, err
}
func (l *StreamLease) Write(buffer []byte) (int, error) {
l.writeMu.Lock()
defer l.writeMu.Unlock()
n, err := l.device.Write(buffer)
if n > 0 && l.clientID != CRProbeClientID {
l.manager.tx.Add(uint64(n))
l.manager.counts[l.channel].tx.Add(uint64(n))
l.manager.counts[l.channel].lastExternalActivity.Store(time.Now().UnixNano())
l.manager.emitPayload(l.channel, l.leaseID, l.clientID, CaptureModeTX, buffer[:n])
}
return n, err
}
// SendBreak emits a real UART break condition through tera-seriald. It is
// serialized with ordinary TX so terminal data cannot overlap the ioctl.
func (l *StreamLease) SendBreak(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
l.writeMu.Lock()
defer l.writeMu.Unlock()
if sender, ok := l.device.(interface{ SendBreak() error }); ok {
return sender.SendBreak()
}
return platformSendSerialBreak(l.device)
}
func (m *StreamLeaseManager) SendBreak(ctx context.Context, channel, leaseID, clientID string) error {
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
lease := m.active[channel]
m.mu.Unlock()
if lease == nil || lease.leaseID != leaseID || lease.clientID != clientID {
return errors.New("serial stream break lease is not active")
}
return lease.SendBreak(ctx)
}
func (l *StreamLease) Close() error {
l.closeOnce.Do(func() {
l.closeErr = l.device.Close()
l.target.Release()
l.manager.release(l)
if l.clientID != CRProbeClientID {
l.manager.emit(StreamEvent{
Channel: l.channel, LeaseID: l.leaseID, ClientID: l.clientID, Direction: "close",
})
}
})
return l.closeErr
}
func newStreamLeaseID() (string, error) {
buffer := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, buffer); err != nil {
return "", fmt.Errorf("generate serial stream lease id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
// ReserveStream is the only production path from a canonical CH label to a
// UART stream device. It also blocks line-setting mutation for the lease.
func (c *MappingController) ReserveStream(ctx context.Context, channel string) (*StreamTarget, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
canonical, err := normalizeChannel(channel)
if err != nil || canonical != channel {
return nil, errors.New("stream channel must be canonical CH01 through CH48")
}
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.activeStreams[channel]; exists {
return nil, ErrStreamLeaseBusy
}
entry, ok := c.byChannel[channel]
if !ok {
return nil, &UnavailableError{Reason: "channel is not in the validated serial mapping"}
}
state := c.inspectState(entry)
if !state.Available {
return nil, &UnavailableError{Reason: channel + " " + state.Message}
}
var target *StreamTarget
target = newStreamTarget(channel, entry.Device, entry.PCIBDF, func() {
c.mu.Lock()
defer c.mu.Unlock()
if current, exists := c.activeStreams[channel]; exists && current == target {
delete(c.activeStreams, channel)
}
})
c.activeStreams[channel] = target
return target, nil
}