diff --git a/backend/cmd/tera-api/main.go b/backend/cmd/tera-api/main.go index 40c8b3a..dfb9282 100644 --- a/backend/cmd/tera-api/main.go +++ b/backend/cmd/tera-api/main.go @@ -333,6 +333,7 @@ func main() { } streamFactory := serial.NewStreamSocketClient(getenv("TERA_API_SERIAL_STREAM_SOCKET", serial.DefaultStreamSocketPath), "tera-port-service") gateway, gatewayErr := portservice.New(portservice.Options{ + AttachShared: (directstream.SharedSocketClient{Path: getenv("TERA_SESSIOND_SHARED_SOCKET", directstream.DefaultSharedSocketPath)}).Attach, ListenInterface: portservice.ListenOnInterface, TransportFactory: streamFactory, ListenHost: getenv("TERA_API_PORT_LISTEN_HOST", "0.0.0.0"), diff --git a/backend/cmd/tera-sessiond/main.go b/backend/cmd/tera-sessiond/main.go index 54aba20..965cbac 100644 --- a/backend/cmd/tera-sessiond/main.go +++ b/backend/cmd/tera-sessiond/main.go @@ -26,11 +26,21 @@ func main() { serialSocket := sessiondGetenv("TERA_SESSIOND_SERIAL_STREAM_SOCKET", serial.DefaultStreamSocketPath) ticketClient := directstream.NewTicketSocketPool(ticketSockets) transportFactory := serial.NewStreamSocketClient(serialSocket, "tera-sessiond") - broker, err := directstream.NewBroker(transportFactory, directstream.BrokerOptions{}) + broker, err := directstream.NewBroker(transportFactory, directstream.BrokerOptions{SharedPortSessions: true}) if err != nil { log.Fatalf("tera-sessiond broker setup failed: %v", err) } defer broker.Close() + sharedListener, err := directstream.ListenTicketSocket(sessiondGetenv("TERA_SESSIOND_SHARED_SOCKET", directstream.DefaultSharedSocketPath)) + if err != nil { + log.Fatalf("shared stream socket setup failed: %v", err) + } + defer sharedListener.Close() + go func() { + if err := directstream.ServeSharedSocket(sharedListener, broker); err != nil { + log.Printf("shared stream socket stopped: %v", err) + } + }() webSocketMetrics := &directstream.WebSocketMetrics{} streamHandler := directstream.NewWebSocketHandler(broker, ticketClient, ticketClient, directstream.WebSocketOptions{ diff --git a/backend/internal/adapters/mock/direct_ticket_authority.go b/backend/internal/adapters/mock/direct_ticket_authority.go index 59d0cc0..68a82d1 100644 --- a/backend/internal/adapters/mock/direct_ticket_authority.go +++ b/backend/internal/adapters/mock/direct_ticket_authority.go @@ -25,6 +25,7 @@ func (a *DirectTicketAuthority) ConsumeTicket(_ context.Context, value string, b result := "rejected" message := "Direct Mode stream ticket rejected" if err == nil { + claims.MaxAttachments = a.store.running[claims.Channel].Operation1.MaxClients ctx = domain.WithActor(ctx, domain.Actor{Name: claims.Actor}) result = "succeeded" message = "Direct Mode stream ticket consumed by tera-sessiond for session " + claims.SessionID @@ -41,7 +42,7 @@ func (a *DirectTicketAuthority) CloseConsumedStream(ctx context.Context, claims func (a *DirectTicketAuthority) ReleaseConsumedWriter(ctx context.Context, claims directstream.TicketClaims) error { ctx = domain.WithActor(ctx, domain.Actor{Name: claims.Actor}) - _, err := a.store.ReleaseDirectWriter(ctx, claims.Channel) + _, err := a.store.releaseDirectSessionWriter(ctx, claims.Channel, claims.SessionID) return err } diff --git a/backend/internal/adapters/mock/direct_ticket_authority_test.go b/backend/internal/adapters/mock/direct_ticket_authority_test.go index 49ab638..f05d1d5 100644 --- a/backend/internal/adapters/mock/direct_ticket_authority_test.go +++ b/backend/internal/adapters/mock/direct_ticket_authority_test.go @@ -10,6 +10,36 @@ import ( "tera-clone/services/tera-api/internal/domain" ) +func TestSharedDirectWriterReleaseTargetsOnlyItsOwnSession(t *testing.T) { + store := NewStore(time.Now) + authority := NewDirectTicketAuthority(store) + var claims []directstream.TicketClaims + for _, actor := range []string{"first", "second"} { + ctx := domain.WithActor(context.Background(), domain.Actor{Name: actor}) + session, err := store.OpenDirectSession(ctx, "CH20", domain.DirectSessionRequest{Access: directstream.AccessWriter}) + if err != nil { + t.Fatal(err) + } + claim, err := authority.ConsumeTicket(ctx, session.StreamTicket, directstream.TicketBinding{Channel: "CH20", SessionID: session.SessionID}) + if err != nil || claim.MaxAttachments != 4 { + t.Fatalf("claim: %+v, %v", claim, err) + } + claims = append(claims, claim) + } + if err := authority.ReleaseConsumedWriter(context.Background(), claims[0]); err != nil { + t.Fatal(err) + } + if store.directSessions[claims[0].SessionID].Access != directstream.AccessViewer || store.directSessions[claims[1].SessionID].Access != directstream.AccessWriter { + t.Fatal("release affected another writer") + } + if err := authority.CloseConsumedStream(context.Background(), claims[1]); err != nil { + t.Fatal(err) + } + if state := store.direct["CH20"]; state.Writer != "" || state.ViewerCount != 1 { + t.Fatalf("state: %+v", state) + } +} + func TestDirectTicketAuthorityConsumesAuditsAndAppliesVerifiedLifecycle(t *testing.T) { store := NewStore(time.Now) ctx := domain.WithActor(context.Background(), domain.Actor{Name: "admin"}) diff --git a/backend/internal/adapters/mock/store.go b/backend/internal/adapters/mock/store.go index f172567..c12bc7d 100644 --- a/backend/internal/adapters/mock/store.go +++ b/backend/internal/adapters/mock/store.go @@ -4676,17 +4676,14 @@ func (s *Store) OpenDirectSession(ctx context.Context, channel string, request d defer s.mu.Unlock() state := s.direct[normalized] - if state.MaxViewers == 0 { + state.MaxViewers = s.running[normalized].Operation1.MaxClients + if state.MaxViewers < 1 { state.MaxViewers = 4 } if state.ViewerCount >= state.MaxViewers { auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "rejected", "Direct Mode viewer limit reached") return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct viewer limit reached") } - if access == directstream.AccessWriter && state.Writer != "" && state.Writer != domain.ActorName(ctx) { - auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "rejected", "Direct Mode writer is already owned") - return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct writer already owned") - } state.ViewerCount++ state.Sequence++ @@ -4739,6 +4736,7 @@ func (s *Store) CloseDirectSession(ctx context.Context, channel string, sessionI state.WriterSessionID = "" } delete(s.directSessions, sessionID) + s.refreshDirectWriterLocked(normalized, &state) s.direct[normalized] = state message := fmt.Sprintf("Direct Mode session %s closed", sessionID) auditID := s.recordAuditLocked(ctx, "direct.session.close", normalized, "succeeded", message) @@ -4757,13 +4755,10 @@ func (s *Store) ClaimDirectWriter(ctx context.Context, channel string) (domain.D defer s.mu.Unlock() state := s.direct[normalized] - if state.MaxViewers == 0 { + state.MaxViewers = s.running[normalized].Operation1.MaxClients + if state.MaxViewers < 1 { state.MaxViewers = 4 } - if state.Writer != "" && state.Writer != domain.ActorName(ctx) { - auditID := s.recordAuditLocked(ctx, "direct.writer.claim", normalized, "rejected", "Direct Mode writer is already owned") - return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct writer already owned") - } record, found := s.firstDirectSessionLocked(normalized, domain.ActorName(ctx)) if !found { if state.ViewerCount >= state.MaxViewers { @@ -4791,6 +4786,10 @@ func (s *Store) ClaimDirectWriter(ctx context.Context, channel string) (domain.D } func (s *Store) ReleaseDirectWriter(ctx context.Context, channel string) (domain.DirectSession, error) { + return s.releaseDirectSessionWriter(ctx, channel, "") +} + +func (s *Store) releaseDirectSessionWriter(ctx context.Context, channel, sessionID string) (domain.DirectSession, error) { index, err := parseChannel(channel) if err != nil { return domain.DirectSession{}, err @@ -4804,21 +4803,37 @@ func (s *Store) ReleaseDirectWriter(ctx context.Context, channel string) (domain auditID := s.recordAuditLocked(ctx, "direct.writer.release", normalized, "rejected", "Direct Mode writer not found") return domain.DirectSession{AuditID: auditID}, errors.New("direct writer not found") } - if state.Writer != domain.ActorName(ctx) { + var record directSessionRecord + for _, candidate := range s.directSessions { + if candidate.Channel == normalized && candidate.Actor == domain.ActorName(ctx) && candidate.Access == directstream.AccessWriter && (sessionID == "" || candidate.SessionID == sessionID) { + if record.SessionID == "" || candidate.SessionID < record.SessionID { + record = candidate + } + } + } + if record.SessionID == "" { auditID := s.recordAuditLocked(ctx, "direct.writer.release", normalized, "authorization_denied", "Direct Mode writer is owned by another actor") return domain.DirectSession{AuditID: auditID}, errors.New("direct writer is owned by another actor") } - record := s.directSessions[state.WriterSessionID] record.Access = directstream.AccessViewer s.directSessions[record.SessionID] = record - state.Writer = "" - state.WriterSessionID = "" + s.refreshDirectWriterLocked(normalized, &state) s.direct[normalized] = state message := fmt.Sprintf("Direct Mode writer session %s ownership released", record.SessionID) auditID := s.recordAuditLocked(ctx, "direct.writer.release", normalized, "succeeded", message) return directLifecycleSession(record.SessionID, normalized, record.Actor, directstream.AccessViewer, state, "open", message, auditID), nil } +// Legacy summary fields name one writer; ownership lives in each session record. +func (s *Store) refreshDirectWriterLocked(channel string, state *directState) { + state.Writer, state.WriterSessionID = "", "" + for _, record := range s.directSessions { + if record.Channel == channel && record.Access == directstream.AccessWriter && (state.WriterSessionID == "" || record.SessionID < state.WriterSessionID) { + state.Writer, state.WriterSessionID = record.Actor, record.SessionID + } + } +} + func (s *Store) UpdatePort(ctx context.Context, channel string, update domain.PortUpdate) (domain.CommandResult, error) { index, err := parseChannel(channel) if err != nil { diff --git a/backend/internal/api/serial_runtime.go b/backend/internal/api/serial_runtime.go index 179ad90..bd7f879 100644 --- a/backend/internal/api/serial_runtime.go +++ b/backend/internal/api/serial_runtime.go @@ -258,6 +258,9 @@ func (r *Router) overlayPortRuntime(ports []domain.Port, runtime serialRuntimeSn } } port.ConnectedUsers = channel.broker.attachments + operationClients + if runtime.session.Broker.SharedPortSessions { + port.ConnectedUsers = channel.broker.attachments + } if channel.broker.maxAttachments > 0 { port.MaxViewers = channel.broker.maxAttachments } diff --git a/backend/internal/api/serial_runtime_test.go b/backend/internal/api/serial_runtime_test.go index 1b96b30..1e536ce 100644 --- a/backend/internal/api/serial_runtime_test.go +++ b/backend/internal/api/serial_runtime_test.go @@ -228,6 +228,17 @@ func TestReverseSSHClientMarksPortAsSession(t *testing.T) { } } +func TestSharedBrokerDoesNotCountOperationClientsTwice(t *testing.T) { + router := &Router{portServices: runtimePortServiceController{}} + runtime := serialRuntimeSnapshot{} + runtime.session.Broker.SharedPortSessions = true + runtime.session.Broker.Channels = []directstream.ChannelSnapshot{{Channel: "CH20", AttachmentCount: 3, MaxAttachments: 4}} + ports := router.overlayPortRuntime([]domain.Port{{Channel: "CH20"}}, runtime) + if ports[0].ConnectedUsers != 3 { + t.Fatalf("connected users = %d", ports[0].ConnectedUsers) + } +} + func TestPortRuntimeStatusEndpointReturnsCompactLiveState(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ SerialController: runtimeSerialController{}, SessionController: runtimeSessionController{}, diff --git a/backend/internal/directstream/broker.go b/backend/internal/directstream/broker.go index 6f52f6a..67e1dba 100644 --- a/backend/internal/directstream/broker.go +++ b/backend/internal/directstream/broker.go @@ -34,6 +34,7 @@ type breakTransport interface { type BrokerOptions struct { MaxAttachmentsPerChannel int AttachmentQueueBytes int + SharedPortSessions bool } type AttachRequest struct { @@ -41,11 +42,11 @@ type AttachRequest struct { Channel string Actor string Access string - // SharedWrite is reserved for authenticated reverse SSH/Telnet services. + // SharedWrite lets authenticated browser and SSH/Telnet sessions share input. // It lets every accepted terminal session write to the same UART while the // channel write mutex preserves each frame as one serialized operation. - // Browser direct-mode sessions keep the existing single-writer FIFO policy. - SharedWrite bool + SharedWrite bool + MaxAttachments int } type SessionSnapshot struct { @@ -67,6 +68,7 @@ type ChannelSnapshot struct { } type BrokerSnapshot struct { + SharedPortSessions bool `json:"shared_port_sessions"` TotalAttachments int `json:"total_attachments"` ActiveChannels int `json:"active_channels"` AttachAttempts uint64 `json:"attach_attempts"` @@ -84,6 +86,7 @@ type BrokerSnapshot struct { } type Broker struct { + sharedPortSessions bool mu sync.Mutex factory TransportFactory maxAttachments int @@ -154,6 +157,7 @@ func NewBroker(factory TransportFactory, options BrokerOptions) (*Broker, error) } lifetime, cancel := context.WithCancel(context.Background()) return &Broker{ + sharedPortSessions: options.SharedPortSessions, factory: factory, maxAttachments: maxAttachments, attachmentQueueSize: queueBytes, @@ -193,7 +197,16 @@ func (b *Broker) Attach(ctx context.Context, request AttachRequest) (*Attachment return nil, ErrSessionExists } if state := b.channels[request.Channel]; state != nil { - if len(state.sessions) >= b.maxAttachments { + maximum := b.maxAttachments + if request.MaxAttachments > 0 && request.MaxAttachments < maximum { + maximum = request.MaxAttachments + } + for _, active := range state.sessions { + if limit := active.request.MaxAttachments; limit > 0 && limit < maximum { + maximum = limit + } + } + if len(state.sessions) >= maximum { b.mu.Unlock() b.metrics.rejectedAttachments.Add(1) return nil, ErrAttachmentLimit @@ -281,7 +294,7 @@ func (b *Broker) RequestWriter(sessionID string) (int, error) { if session == nil { return 0, ErrSessionNotFound } - if state.writerID == sessionID { + if session.request.SharedWrite || state.writerID == sessionID { return 0, nil } if position := queuePosition(state.writerQueue, sessionID); position > 0 { @@ -302,6 +315,10 @@ func (b *Broker) ReleaseWriter(sessionID string) error { if session == nil { return ErrSessionNotFound } + if session.request.SharedWrite { + session.request.SharedWrite = false + return nil + } if state.writerID == sessionID { state.writerID = "" b.promoteWriterLocked(state) @@ -412,22 +429,31 @@ func (b *Broker) Snapshot() BrokerSnapshot { total := 0 for _, state := range b.channels { queuedBytes := 0 + maximum := b.maxAttachments + writerID := state.writerID for _, session := range state.sessions { queuedBytes += session.outbound.size() + if limit := session.request.MaxAttachments; limit > 0 && limit < maximum { + maximum = limit + } + if session.request.SharedWrite && (writerID == "" || session.request.SessionID < writerID) { + writerID = session.request.SessionID + } } total += len(state.sessions) channels = append(channels, ChannelSnapshot{ Channel: state.channel, AttachmentCount: len(state.sessions), - MaxAttachments: b.maxAttachments, - WriterSessionID: state.writerID, + MaxAttachments: maximum, + WriterSessionID: writerID, PendingWriterIDs: append([]string(nil), state.writerQueue...), QueuedBytes: queuedBytes, }) } sort.Slice(channels, func(left, right int) bool { return channels[left].Channel < channels[right].Channel }) return BrokerSnapshot{ - TotalAttachments: total, ActiveChannels: len(channels), Channels: channels, + SharedPortSessions: b.sharedPortSessions, + TotalAttachments: total, ActiveChannels: len(channels), Channels: channels, AttachAttempts: b.metrics.attachAttempts.Load(), SuccessfulAttachments: b.metrics.successfulAttachments.Load(), RejectedAttachments: b.metrics.rejectedAttachments.Load(), diff --git a/backend/internal/directstream/shared_socket.go b/backend/internal/directstream/shared_socket.go new file mode 100644 index 0000000..e4ad734 --- /dev/null +++ b/backend/internal/directstream/shared_socket.go @@ -0,0 +1,226 @@ +package directstream + +import ( + "context" + "errors" + "net" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const DefaultSharedSocketPath = "/run/tera-api/shared-stream.sock" + +// SharedAttachment is one authenticated terminal, not an aggregate per service. +type SharedAttachment interface { + Receive(context.Context) ([]byte, error) + Write(context.Context, []byte) error + SendBreak(context.Context) error + Close() error +} + +type sharedFrame struct { + Type string `json:"type"` + Data []byte `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// ServeSharedSocket must only be exposed on a service-group-owned Unix socket. +// The gateway has authenticated the user before it sends an attachment request. +func ServeSharedSocket(listener net.Listener, broker *Broker) error { + server := &http.Server{ReadHeaderTimeout: 5 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + connection, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer connection.Close() + connection.SetReadLimit(96 * 1024) + _ = connection.SetReadDeadline(time.Now().Add(10 * time.Second)) + var request AttachRequest + if connection.ReadJSON(&request) != nil { + return + } + request.SharedWrite = request.Access == AccessWriter + attachment, err := broker.Attach(r.Context(), request) + if err != nil { + _ = connection.WriteJSON(sharedFrame{Type: "attached", Error: err.Error()}) + return + } + defer attachment.Close() + _ = connection.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if connection.WriteJSON(sharedFrame{Type: "attached"}) != nil { + return + } + _ = connection.SetReadDeadline(time.Time{}) + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + var writeMu sync.Mutex + write := func(frame sharedFrame) error { + writeMu.Lock() + defer writeMu.Unlock() + _ = connection.SetWriteDeadline(time.Now().Add(10 * time.Second)) + return connection.WriteJSON(frame) + } + go func() { + defer connection.Close() + for { + payload, err := attachment.Receive(ctx) + if err != nil || write(sharedFrame{Type: "data", Data: payload}) != nil { + return + } + } + }() + for { + var frame sharedFrame + if connection.ReadJSON(&frame) != nil { + return + } + switch frame.Type { + case "write": + err = attachment.Write(ctx, frame.Data) + case "break": + err = attachment.SendBreak(ctx) + default: + return + } + ack := sharedFrame{Type: "ack"} + if err != nil { + ack.Error = err.Error() + } + if write(ack) != nil { + return + } + } + })} + return server.Serve(listener) +} + +type SharedSocketClient struct{ Path string } + +func (c SharedSocketClient) Attach(ctx context.Context, request AttachRequest) (SharedAttachment, error) { + path := c.Path + if path == "" { + path = DefaultSharedSocketPath + } + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second, NetDialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, "unix", path) + }} + connection, _, err := dialer.DialContext(ctx, "ws://localhost/stream", nil) + if err != nil { + return nil, err + } + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + connection.SetReadLimit(96 * 1024) + _ = connection.SetWriteDeadline(time.Now().Add(10 * time.Second)) + _ = connection.SetReadDeadline(time.Now().Add(10 * time.Second)) + if err = connection.WriteJSON(request); err != nil { + connection.Close() + return nil, err + } + var response sharedFrame + if err = connection.ReadJSON(&response); err != nil { + connection.Close() + return nil, err + } + if response.Error != "" || response.Type != "attached" { + connection.Close() + if response.Error == ErrAttachmentLimit.Error() { + return nil, ErrAttachmentLimit + } + return nil, errors.New("shared stream attachment rejected: " + response.Error) + } + _ = connection.SetReadDeadline(time.Time{}) + attachment := &remoteAttachment{connection: connection, queue: newByteQueue(defaultAttachmentQueueBytes), acknowledgements: make(chan error, 1), done: make(chan struct{})} + go attachment.read() + return attachment, nil +} + +type remoteAttachment struct { + connection *websocket.Conn + queue *byteQueue + requestMu sync.Mutex + closeOnce sync.Once + acknowledgements chan error + done chan struct{} +} + +func (a *remoteAttachment) read() { + defer a.Close() + for { + var frame sharedFrame + if a.connection.ReadJSON(&frame) != nil { + return + } + switch frame.Type { + case "data": + if !a.queue.push(frame.Data) { + return + } + case "ack": + var err error + if frame.Error != "" { + err = errors.New(frame.Error) + } + select { + case a.acknowledgements <- err: + default: + return + } + default: + return + } + } +} + +func (a *remoteAttachment) Receive(ctx context.Context) ([]byte, error) { return a.queue.pop(ctx) } +func (a *remoteAttachment) Write(ctx context.Context, payload []byte) error { + if len(payload) == 0 || len(payload) > MaxDataFrameBytes { + return ErrInvalidDataFrame + } + return a.command(ctx, sharedFrame{Type: "write", Data: payload}) +} +func (a *remoteAttachment) SendBreak(ctx context.Context) error { + return a.command(ctx, sharedFrame{Type: "break"}) +} +func (a *remoteAttachment) command(ctx context.Context, frame sharedFrame) error { + a.requestMu.Lock() + defer a.requestMu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + deadline := time.Now().Add(10 * time.Second) + if value, ok := ctx.Deadline(); ok && value.Before(deadline) { + deadline = value + } + _ = a.connection.SetWriteDeadline(deadline) + if err := a.connection.WriteJSON(frame); err != nil { + a.Close() + return err + } + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + select { + case err := <-a.acknowledgements: + return err + case <-a.done: + return ErrChannelClosed + case <-ctx.Done(): + a.Close() + return ctx.Err() + case <-timer.C: + a.Close() + return context.DeadlineExceeded + } +} +func (a *remoteAttachment) Close() error { + a.closeOnce.Do(func() { + close(a.done) + _ = a.connection.Close() + a.queue.close(ErrChannelClosed) + }) + return nil +} diff --git a/backend/internal/directstream/shared_socket_test.go b/backend/internal/directstream/shared_socket_test.go new file mode 100644 index 0000000..2e15152 --- /dev/null +++ b/backend/internal/directstream/shared_socket_test.go @@ -0,0 +1,151 @@ +package directstream + +import ( + "bytes" + "context" + "errors" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func sharedTestSocket(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "shared-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "s.sock") +} + +func TestBrowserAndTerminalSessionsShareOneUARTAndCombinedLimit(t *testing.T) { + factory := newFakeTransportFactory() + broker, _ := NewBroker(factory, BrokerOptions{}) + defer broker.Close() + path := sharedTestSocket(t) + listener, err := ListenTicketSocket(path) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go ServeSharedSocket(listener, broker) + client := SharedSocketClient{Path: path} + authority := &websocketAuthority{store: NewTicketStore(TicketStoreOptions{})} + server := httptest.NewServer(NewWebSocketHandler(broker, authority, authority, WebSocketOptions{})) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var browsers []*websocket.Conn + for _, id := range []string{"browser-1", "browser-2"} { + claims := TicketClaims{SessionID: id, Channel: "CH20", Actor: id, Access: AccessWriter, MaxAttachments: 4} + connection := dialDirectWebSocket(t, server.URL, claims, server.URL, true) + defer connection.Close() + authenticateWebSocket(t, connection, issueWebSocketTicket(t, authority.store, claims)) + status := readStatusFrame(t, connection) + if !status.Writer || status.QueuePosition != 0 { + t.Fatalf("browser waiting: %+v", status) + } + _ = readWelcomeFrame(t, connection) + browsers = append(browsers, connection) + } + var terminals []SharedAttachment + for _, id := range []string{"ssh-operation-1", "telnet-operation-2"} { + attachment, err := client.Attach(ctx, AttachRequest{SessionID: id, Channel: "CH20", Actor: id, Access: AccessWriter, MaxAttachments: 4}) + if err != nil { + t.Fatal(err) + } + defer attachment.Close() + terminals = append(terminals, attachment) + } + if _, err := client.Attach(ctx, AttachRequest{SessionID: "fifth", Channel: "CH20", Actor: "fifth", Access: AccessWriter, MaxAttachments: 4}); !errors.Is(err, ErrAttachmentLimit) { + t.Fatalf("fifth terminal: %v", err) + } + claims := TicketClaims{SessionID: "fifth-browser", Channel: "CH20", Actor: "fifth", Access: AccessWriter, MaxAttachments: 4} + fifth := dialDirectWebSocket(t, server.URL, claims, server.URL, true) + defer fifth.Close() + authenticateWebSocket(t, fifth, issueWebSocketTicket(t, authority.store, claims)) + _ = fifth.SetReadDeadline(time.Now().Add(time.Second)) + if _, _, err := fifth.ReadMessage(); err == nil { + t.Fatal("fifth browser accepted") + } + transport := factory.transport("CH20") + for _, browser := range browsers { + if err := browser.WriteMessage(websocket.BinaryMessage, []byte("web\r")); err != nil { + t.Fatal(err) + } + } + for _, terminal := range terminals { + if err := terminal.Write(ctx, []byte("terminal\r")); err != nil { + t.Fatal(err) + } + } + if !waitFor(time.Second, func() bool { return len(transport.written()) == 26 }) { + t.Fatalf("UART writes: %q", transport.written()) + } + payload := []byte{0, 0xff, 'o', 'k', '\r', '\n'} + transport.emit(payload) + for _, browser := range browsers { + _ = browser.SetReadDeadline(time.Now().Add(time.Second)) + kind, actual, err := browser.ReadMessage() + if err != nil || kind != websocket.BinaryMessage || !bytes.Equal(actual, payload) { + t.Fatalf("browser RX: %v %v", actual, err) + } + } + for _, terminal := range terminals { + actual, err := terminal.Receive(ctx) + if err != nil || !bytes.Equal(actual, payload) { + t.Fatalf("terminal RX: %v %v", actual, err) + } + } + if err := terminals[0].SendBreak(ctx); err != nil || transport.breakCount() != 1 { + t.Fatalf("break: %v", err) + } + _ = terminals[0].Close() + if !waitFor(time.Second, func() bool { return broker.Snapshot().TotalAttachments == 3 }) { + t.Fatal("closed terminal slot leaked") + } + replacement, err := client.Attach(ctx, AttachRequest{SessionID: "replacement", Channel: "CH20", Actor: "replacement", Access: AccessWriter, MaxAttachments: 4}) + if err != nil { + t.Fatal(err) + } + defer replacement.Close() + if err := terminals[1].Write(ctx, []byte("still-connected\r")); err != nil { + t.Fatal(err) + } + if snapshot := broker.Snapshot(); snapshot.TransportOpens != 1 || snapshot.TotalAttachments != 4 { + t.Fatalf("snapshot: %+v", snapshot) + } +} + +func TestSharedSocketHonorsLowerConfiguredLimitAndViewerAccess(t *testing.T) { + factory := newFakeTransportFactory() + broker, _ := NewBroker(factory, BrokerOptions{}) + defer broker.Close() + path := sharedTestSocket(t) + listener, err := ListenTicketSocket(path) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go ServeSharedSocket(listener, broker) + client := SharedSocketClient{Path: path} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + viewer, err := client.Attach(ctx, AttachRequest{SessionID: "viewer", Channel: "CH01", Actor: "viewer", Access: AccessViewer, SharedWrite: true, MaxAttachments: 1}) + if err != nil { + t.Fatal(err) + } + defer viewer.Close() + if err := viewer.Write(ctx, []byte("denied")); err == nil { + t.Fatal("viewer wrote data") + } + if _, err := client.Attach(ctx, AttachRequest{SessionID: "writer", Channel: "CH01", Actor: "writer", Access: AccessWriter, MaxAttachments: 4}); !errors.Is(err, ErrAttachmentLimit) { + t.Fatalf("configured limit bypassed: %v", err) + } +} diff --git a/backend/internal/directstream/ticket.go b/backend/internal/directstream/ticket.go index 2cad4ad..e514b8a 100644 --- a/backend/internal/directstream/ticket.go +++ b/backend/internal/directstream/ticket.go @@ -24,10 +24,11 @@ var ( ) type TicketClaims struct { - SessionID string - Channel string - Actor string - Access string + SessionID string + Channel string + Actor string + Access string + MaxAttachments int } type TicketBinding struct { diff --git a/backend/internal/directstream/websocket.go b/backend/internal/directstream/websocket.go index 8619e74..8654bca 100644 --- a/backend/internal/directstream/websocket.go +++ b/backend/internal/directstream/websocket.go @@ -202,13 +202,18 @@ func (h *webSocketHandler) serveConnection(connection *websocket.Conn, binding T } attachment, err := h.broker.Attach(context.Background(), AttachRequest{ SessionID: claims.SessionID, Channel: claims.Channel, Actor: claims.Actor, Access: claims.Access, + SharedWrite: claims.Access == AccessWriter, MaxAttachments: claims.MaxAttachments, }) if err != nil { h.metrics.attachmentFailures.Add(1) if h.closeLifecycle(binding) != nil { h.metrics.lifecycleFailures.Add(1) } - h.closeConnection(connection, websocket.CloseTryAgainLater, "stream attachment unavailable") + reason := "stream attachment unavailable" + if errors.Is(err, ErrAttachmentLimit) { + reason = "channel session limit reached" + } + h.closeConnection(connection, websocket.CloseTryAgainLater, reason) return } h.metrics.acceptedConnections.Add(1) diff --git a/backend/internal/portservice/gateway.go b/backend/internal/portservice/gateway.go index 6284aa5..f7a9fdc 100644 --- a/backend/internal/portservice/gateway.go +++ b/backend/internal/portservice/gateway.go @@ -34,6 +34,7 @@ type DialFunc func(context.Context, string, string) (net.Conn, error) type Options struct { TransportFactory directstream.TransportFactory + AttachShared func(context.Context, directstream.AttachRequest) (directstream.SharedAttachment, error) Authenticate AuthenticateFunc AuthenticateMethod AuthenticateMethodFunc Record RecordFunc @@ -768,7 +769,7 @@ func (g *Gateway) forwardRedirect(ctx context.Context, config serviceConfig) err sequence := g.sequence.Add(1) targetChannel := config.channel targetSession := fmt.Sprintf("redirect-to-%s-%d", strings.ToLower(targetChannel), sequence) - target, err := g.broker.Attach(ctx, directstream.AttachRequest{ + target, err := g.attach(ctx, directstream.AttachRequest{ SessionID: targetSession, Channel: targetChannel, Actor: "tera-redirect", Access: directstream.AccessWriter, }) if err != nil { @@ -777,7 +778,7 @@ func (g *Gateway) forwardRedirect(ctx context.Context, config serviceConfig) err defer target.Close() type sourceAttachment struct { channel string - attachment *directstream.Attachment + attachment directstream.SharedAttachment } sources := make([]sourceAttachment, 0, len(config.sources)) defer func() { @@ -786,7 +787,7 @@ func (g *Gateway) forwardRedirect(ctx context.Context, config serviceConfig) err } }() for _, channel := range config.sources { - source, attachErr := g.broker.Attach(ctx, directstream.AttachRequest{ + source, attachErr := g.attach(ctx, directstream.AttachRequest{ SessionID: fmt.Sprintf("redirect-%s-to-%s-%d", strings.ToLower(channel), strings.ToLower(targetChannel), sequence), Channel: channel, Actor: "tera-redirect", Access: directstream.AccessViewer, }) @@ -828,16 +829,25 @@ type readWriteCloser interface { io.Closer } +func (g *Gateway) attach(ctx context.Context, request directstream.AttachRequest) (directstream.SharedAttachment, error) { + if g.options.AttachShared != nil { + return g.options.AttachShared(ctx, request) + } + return g.broker.Attach(ctx, request) +} + func (g *Gateway) bridge(ctx context.Context, connection readWriteCloser, reader io.Reader, config serviceConfig, actor string, remote endpoint, welcome bool) { if strings.TrimSpace(actor) == "" { actor = "anonymous" } sessionID := fmt.Sprintf("operation-%s-%d-%d", strings.ToLower(config.channel), config.number, g.sequence.Add(1)) - attachment, err := g.broker.Attach(ctx, directstream.AttachRequest{ + attachment, err := g.attach(ctx, directstream.AttachRequest{ SessionID: sessionID, Channel: config.channel, Actor: actor, Access: directstream.AccessWriter, SharedWrite: true, + MaxAttachments: config.operation.MaxClients, }) if err != nil { + _, _ = connection.Write([]byte("\r\n[TERA] " + err.Error() + "\r\n")) g.recordFailure(config, err) return } diff --git a/backend/internal/portservice/shared_gateway_test.go b/backend/internal/portservice/shared_gateway_test.go new file mode 100644 index 0000000..bdef150 --- /dev/null +++ b/backend/internal/portservice/shared_gateway_test.go @@ -0,0 +1,132 @@ +package portservice + +import ( + "bufio" + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/crypto/ssh" + "tera-clone/services/tera-api/internal/directstream" + "tera-clone/services/tera-api/internal/domain" +) + +func TestBothOperationsShareCentralStreamWithDirectSession(t *testing.T) { + for _, modes := range [][2]string{{"reverse_ssh", "reverse_telnet"}, {"reverse_ssh", "reverse_ssh"}, {"reverse_telnet", "reverse_telnet"}} { + t.Run(modes[0]+"-"+modes[1], func(t *testing.T) { + factory := &pipeFactory{peers: make(chan net.Conn, 1)} + broker, _ := directstream.NewBroker(factory, directstream.BrokerOptions{SharedPortSessions: true}) + defer broker.Close() + dir, err := os.MkdirTemp("", "gateway-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "s.sock") + listener, err := directstream.ListenTicketSocket(path) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go directstream.ServeSharedSocket(listener, broker) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + direct, err := broker.Attach(ctx, directstream.AttachRequest{SessionID: "web", Channel: "CH20", Actor: "web", Access: directstream.AccessWriter, SharedWrite: true, MaxAttachments: 4}) + if err != nil { + t.Fatal(err) + } + defer direct.Close() + peer := <-factory.peers + defer peer.Close() + _ = peer.SetDeadline(time.Now().Add(8 * time.Second)) + _, key, _ := ed25519.GenerateKey(rand.Reader) + signer, _ := ssh.NewSignerFromKey(key) + listeners := map[string]net.Listener{} + gateway, err := New(Options{TransportFactory: factory, AttachShared: (directstream.SharedSocketClient{Path: path}).Attach, SSHSigner: signer, + Authenticate: func(context.Context, string, string, string) (string, error) { return "tester", nil }, + ListenInterface: func(device, address string) (net.Listener, error) { + if address != "0.0.0.0:10020" { + t.Errorf("address = %s", address) + } + value, err := net.Listen("tcp", "127.0.0.1:0") + listeners[device] = value + return value, err + }, + }) + if err != nil { + t.Fatal(err) + } + defer gateway.Close() + port := domain.Port{Channel: "CH20", Operation1: domain.PortOperation{Mode: modes[0], Port: 10020, MaxClients: 4, Authentication: "none", Newline: "cr"}, Operation2: domain.PortOperation{Mode: modes[1], Port: 10020, MaxClients: 4, Authentication: "none", Newline: "cr"}} + if err := gateway.Apply(ctx, []domain.Port{port}); err != nil { + t.Fatal(err) + } + var readers []*bufio.Reader + for index, mode := range modes { + address := listeners[fmt.Sprintf("eth%d", index)].Addr().String() + var input io.Writer + var output io.Reader + if mode == "reverse_ssh" { + client, err := ssh.Dial("tcp", address, &ssh.ClientConfig{User: "tester", Auth: []ssh.AuthMethod{ssh.Password("test")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + t.Fatal(err) + } + defer session.Close() + input, _ = session.StdinPipe() + output, _ = session.StdoutPipe() + if err := session.Shell(); err != nil { + t.Fatal(err) + } + } else { + connection, err := net.DialTimeout("tcp", address, time.Second) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(8 * time.Second)) + input, output = connection, connection + } + reader := bufio.NewReader(output) + if _, err := readUntilContains(reader, "Connected at:"); err != nil { + t.Fatal(err) + } + if _, err := reader.ReadString('\n'); err != nil { + t.Fatal(err) + } + readers = append(readers, reader) + want := fmt.Sprintf("op%d\r", index+1) + go input.Write([]byte(want)) + actual := make([]byte, len(want)) + if _, err := io.ReadFull(peer, actual); err != nil || string(actual) != want { + t.Fatalf("operation TX %q: %v", actual, err) + } + } + go peer.Write([]byte("shared-output\r\n")) + for _, reader := range readers { + line, err := reader.ReadString('\n') + if err != nil || line != "shared-output\r\n" { + t.Fatalf("operation RX %q: %v", line, err) + } + } + payload, err := direct.Receive(ctx) + if err != nil || string(payload) != "shared-output\r\n" { + t.Fatalf("web RX %q: %v", payload, err) + } + if snapshot := broker.Snapshot(); snapshot.TotalAttachments != 3 || snapshot.TransportOpens != 1 { + t.Fatalf("snapshot: %+v", snapshot) + } + }) + } +} diff --git a/demo-guide/08-shared-port-sessions-20260916.md b/demo-guide/08-shared-port-sessions-20260916.md new file mode 100644 index 0000000..5223bc3 --- /dev/null +++ b/demo-guide/08-shared-port-sessions-20260916.md @@ -0,0 +1,23 @@ +# 직접 연결과 SSH/Telnet 동시 접속 + +웹 직접 연결, 동작 1, 동작 2는 같은 시리얼 포트를 공유합니다. 최대 세션 4는 셋을 합친 수입니다. 웹 1개 + 동작 1 SSH 2개 + 동작 2 Telnet 1개가 한 예입니다. + +| 동작 1 (eth0) | 동작 2 (eth1) | 같은 Listen Port 번호 | +|---|---|---| +| Reverse SSH | Reverse SSH | 가능 | +| Reverse SSH | Reverse Telnet | 가능 | +| Reverse Telnet | Reverse Telnet | 가능 | + +각 Ethernet IP로 접속해야 합니다. 동일 IP·동일 TCP 포트에 서로 다른 프로토콜을 동시에 바인딩하는 기능은 아닙니다. 일반 SSH/Telnet 모드는 장비가 원격 서버로 나가는 연결이며, PuTTY로 접속을 받으려면 Reverse 모드를 사용합니다. + +## 확인 순서 + +1. 페이지를 새로고침하고 UP 채널에 웹 직접 연결을 엽니다. +2. PuTTY에서 해당 채널의 동작 1 IP·Listen Port·프로토콜로 접속합니다. +3. 동작 2도 해당 Ethernet IP와 프로토콜로 접속합니다. +4. 합계 4개까지 접속하고 각 화면에서 출력 수신과 입력을 확인합니다. +5. 5번째 접속이 거절되는지 확인합니다. 기존 연결 하나를 종료하면 새 연결이 가능해야 합니다. + +모든 세션은 동일 장비 CLI 상태와 출력을 공유합니다. 입력은 전송 단위로 직렬화하지만, 서로 다른 사용자가 동시에 타이핑하면 명령이 섞일 수 있습니다. + +검증 완료: Windows 관련 Go 테스트, 장비 ARM64에서 모의 UART와 루프백 SSH/Telnet을 이용한 같은 모드·다른 모드 동시 송수신, 웹/터미널 합계 제한과 재접속 테스트. 실제 두 Ethernet 경로를 통한 PuTTY GUI 검증은 미실시입니다. diff --git a/final-build/README.md b/final-build/README.md index 04db0c9..b4f1c4d 100644 --- a/final-build/README.md +++ b/final-build/README.md @@ -1,6 +1,12 @@ # 최종 장비 빌드 -**릴리스 20260914-direct-mode-title-7 · Linux ARM64 · 192.168.0.223 배포 완료** +**릴리스 20260916-shared-port-sessions-1 · Linux ARM64 · 192.168.0.223 배포 완료** + +2026-09-16: 웹 직접 연결과 동작 1·2의 SSH/Telnet 접속이 tera-sessiond의 동일 시리얼 연결을 공유합니다. 최대 세션이 4이면 웹과 두 동작의 합계 4개까지 접속하며, 모든 쓰기 권한 세션에서 입력할 수 있습니다. 5번째 접속은 거절하고 하나가 종료되면 다시 접속할 수 있습니다. 동작 1(eth0)과 동작 2(eth1)는 같은 포트 번호로 SSH/SSH, SSH/Telnet, Telnet/Telnet 조합을 사용할 수 있습니다. 출력과 장비 CLI 상태를 공유하므로 여러 사람이 동시에 입력하면 명령이 섞일 수 있습니다. + +API·sessiond·웹을 백업 후 배포하고 해시, 공유 소켓, 준비 상태 12/12를 확인했습니다. 백업: `/userdata/tera-backups/shared-sessions-20260916-104536`. 관련 Go 테스트와 JavaScript 13개 테스트를 통과했고, 장비에서도 모의 UART·루프백 SSH/Telnet 통합 테스트를 통과했습니다. 실제 두 Ethernet 경로의 PuTTY 동시 접속은 별도 검증하지 않았습니다. 포트 선택 축소도 포함합니다. + +아래는 이전 릴리스의 변경·검증 기록입니다. 동작 설정 1·2의 줄바꿈 표시를 CR·LF·CRLF 대문자로 바꾸고, 직접 연결 모드 제목의 `/ Port 1` 부분을 제거했습니다. 저장 값과 채널 선택 기능은 유지합니다. 렌더링 확인 후 웹을 배포했으며 최종 웹 백업은 `/userdata/tera-backups/direct-mode-title-20260914`입니다. diff --git a/final-build/SHA256SUMS b/final-build/SHA256SUMS index 84ebff2..ee6cea5 100644 --- a/final-build/SHA256SUMS +++ b/final-build/SHA256SUMS @@ -1,8 +1,8 @@ -51bc18f451533bf0e1b1b446458f099b4a13e87db2c15d3d5dd2328acf8b95bc frontend/tera-clone-demo-web -112bcdadf04155209b1a361200163c57232427dd628fdd31d8e060b1ab49461f backend/tera-clone-demo-api +724cf8a004dbbb7b8217ad70b9268fc6b0a6149a75f6ce80380e31693b6b4d8b frontend/tera-clone-demo-web +5553b5610e12f3da8bb4ac363abdff54108649e32a1652fbac18cc52e2f29caf backend/tera-clone-demo-api b5856899698bfafb1f382a3dd45db02b034ba785d56a075ea964c937b57a48b3 backend/tera-logd ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd 2864dbde4fcde16898a3b187716b325883365a1fd39f16264a5186877f94c50c backend/tera-netd 1263fe6823fb07704ecbd0bc5be18db56f9f2b60ebafc1692e873c40bac8f01f backend/tera-seriald -a1d868818fa2bbb57996e5fe5786fb1838d337e223a23729cd243762c751aadf backend/tera-sessiond +b652d13da2e8b26d6912f2330f86cbd7b68e839e4f39767a9e5700e6242dd3bd backend/tera-sessiond c0cc73a1d390ef9964adc3a1623b90b8c7950a798af71a7201c93b1b58f12b12 backend/tera-timed diff --git a/final-build/backend/tera-clone-demo-api b/final-build/backend/tera-clone-demo-api index 06aa267..b7a3127 100644 Binary files a/final-build/backend/tera-clone-demo-api and b/final-build/backend/tera-clone-demo-api differ diff --git a/final-build/backend/tera-sessiond b/final-build/backend/tera-sessiond index bfe5aac..4ceed3d 100644 Binary files a/final-build/backend/tera-sessiond and b/final-build/backend/tera-sessiond differ diff --git a/final-build/frontend/tera-clone-demo-web b/final-build/frontend/tera-clone-demo-web index b52417a..a7a8f2d 100644 Binary files a/final-build/frontend/tera-clone-demo-web and b/final-build/frontend/tera-clone-demo-web differ diff --git a/final-build/release-manifest.json b/final-build/release-manifest.json index 9c0f7b6..e3dfd3f 100644 --- a/final-build/release-manifest.json +++ b/final-build/release-manifest.json @@ -1,20 +1,20 @@ { - "release_id": "20260914-direct-mode-title-7", + "release_id": "20260916-shared-port-sessions-1", "platform": "linux-arm64", "scope": "application-and-daemon-binaries-not-full-firmware", - "generated_at": "2026-09-14T07:40:16.0139133+00:00", + "generated_at": "2026-09-16T10:46:13+09:00", "files": [ { "path": "frontend/tera-clone-demo-web", - "bytes": 13238434, - "sha256": "51bc18f451533bf0e1b1b446458f099b4a13e87db2c15d3d5dd2328acf8b95bc", + "bytes": 19065005, + "sha256": "724cf8a004dbbb7b8217ad70b9268fc6b0a6149a75f6ce80380e31693b6b4d8b", "install_path": "/usr/bin/tera-clone-demo-web", "provenance": "built-and-deployed" }, { "path": "backend/tera-clone-demo-api", - "bytes": 10027170, - "sha256": "112bcdadf04155209b1a361200163c57232427dd628fdd31d8e060b1ab49461f", + "bytes": 14459728, + "sha256": "5553b5610e12f3da8bb4ac363abdff54108649e32a1652fbac18cc52e2f29caf", "install_path": "/usr/bin/tera-api", "provenance": "built-and-deployed" }, @@ -48,10 +48,10 @@ }, { "path": "backend/tera-sessiond", - "bytes": 9089559, - "sha256": "a1d868818fa2bbb57996e5fe5786fb1838d337e223a23729cd243762c751aadf", + "bytes": 9099838, + "sha256": "b652d13da2e8b26d6912f2330f86cbd7b68e839e4f39767a9e5700e6242dd3bd", "install_path": "/usr/bin/tera-sessiond", - "provenance": "copied-from-device" + "provenance": "built-and-deployed" }, { "path": "backend/tera-timed", @@ -62,29 +62,32 @@ } ], "source": { - "base_commit": "d790e0673775ef296ddd4093300ccd3c3c4fc952", - "changes": "Release source is included with this manifest in the Git commit; baseline daemons have individual provenance above." + "base_commit": "838b5ebb82b5b43fda10f4a044d8d083ce3aeeba", + "changes": "Built from the current working tree with shared-session and compact-selector changes; baseline daemons retain their individual provenance." }, "deployment": { "device": "192.168.0.223", - "date": "2026-09-14", + "date": "2026-09-16", "ready": true, "completed": 12, "total": 12, - "backup": "/userdata/tera-backups/operation2-telnet-20260914", - "verified": "Eight binary SHA256 values match installed files; HTTP/HTTPS listeners and served workspace-tabs.js verified.", + "backup": "/userdata/tera-backups/shared-sessions-20260916-104536", + "verified": "API, sessiond and web installed SHA256 values match; readiness 12/12, shared broker readyz and exact integration.js content verified. Other daemon binaries unchanged.", "web_alias_backup": "/userdata/tera-backups/port-selector-alias-20260914", "web_title_backup": "/userdata/tera-backups/direct-mode-title-20260914" }, "validation": { - "backend": "mock store, API and portservice suites passed; CH01 SSH plus Telnet stage/apply/save/reload regression and same-interface conflict guards passed.", + "backend": "directstream, portservice, mock store, API, tera-sessiond and tera-api suites passed. Mixed browser/terminal four-session limit, fifth-session rejection, fanout, shared writes, slot reuse, per-session release and combined count covered.", "frontend": "Rendered operation 1/2 pages verified uppercase CR/LF/CRLF labels with lowercase values; direct mode page heading has no Port suffix. Relevant page rendering checks passed. Installed web SHA256 and backend readiness verified.", - "javascript": "19 workspace, navigation and dashboard tests passed.", + "javascript": "13 workspace and navigation tests passed; integration.js syntax check passed.", "browser": "Local Edge: port selector aliases, truncation/hover/accessibility, one-tab navigation, checkboxes and responsive layout; ASCII/HEX display with lowercase values passed.", - "live": "Authenticated live API: CH01 operation 2 Telnet applied and saved (running/persistent revision 29). eth0 SSH and eth1 Telnet both listening on 10001 with shared max_clients=4. Live HTML ASCII/HEX labels confirmed. Final web hash and readiness 12/12 verified.", - "limitations": "No cable-unplug, destructive action, power-cycle or SSH/Telnet clients across both physical networks acceptance test." + "live": "ARM64 tests executed on the target: WebSocket plus Unix gateway attachments, same-number operation 1/2 SSH+Telnet, SSH+SSH and Telnet+Telnet over test listeners with a simulated UART all passed. Deployed API, sessiond and web hashes, shared socket and readiness verified.", + "limitations": "No PuTTY GUI or simultaneous clients through both physical Ethernet networks acceptance test; integration tests use loopback listeners and a simulated UART. No cable-unplug or power-cycle test." }, "changes": [ + "Share one UART broker across browser Direct Mode and operation 1/2 terminal sessions; enforce their combined per-channel maximum", + "Give every authorized writer session shared input and report the combined session limit on rejection", + "Reduce port selector cards to approximately 95px high and panel maximum width to 550px", "Keep channel positions fixed from 1 to 48", "Replace port settings in one tab", "Larger cards with target checkboxes", diff --git a/frontend/web/static/css/style.css b/frontend/web/static/css/style.css index 56f31b2..a954b22 100644 --- a/frontend/web/static/css/style.css +++ b/frontend/web/static/css/style.css @@ -573,7 +573,7 @@ body[data-current-path="/"] .page-header h1 { font-size: 25px; letter-spacing: - .port-link-apply span { white-space: nowrap; } body[data-current-path^="/ports/"] .blocks { - grid-template-columns: clamp(510px, 34vw, 600px) minmax(0, 1fr); + grid-template-columns: clamp(510px, 31vw, 550px) minmax(0, 1fr); column-gap: 18px; align-items: stretch; min-height: calc(100vh - 205px); @@ -620,16 +620,16 @@ body[data-current-path^="/ports/"] .port-selector-panel .panel-header { } body[data-current-path^="/ports/"] .port-selector-panel .port-link-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 7px; + gap: 6px; } body[data-current-path^="/ports/"] .port-selector-panel .port-link-card { border-radius: 5px; } body[data-current-path^="/ports/"] .port-selector-panel .port-link-view { - min-height: 72px; + min-height: 62px; grid-template-columns: minmax(0, 1fr); - padding: 8px 4px; - gap: 3px; + padding: 5px 4px; + gap: 2px; font-size: 12px; } body[data-current-path^="/ports/"] .port-selector-panel .port-link-number { @@ -653,8 +653,8 @@ body[data-current-path^="/ports/"] .port-link-alias { color: #60738d; } body[data-current-path^="/ports/"] .port-selector-panel .port-link-apply { - min-height: 34px; - padding: 7px 3px; + min-height: 30px; + padding: 5px 3px; font-size: 11px; } body[data-current-path^="/ports/"] .form-panel .form-grid { diff --git a/frontend/web/static/js/integration.js b/frontend/web/static/js/integration.js index 0a24409..2da4e67 100644 --- a/frontend/web/static/js/integration.js +++ b/frontend/web/static/js/integration.js @@ -3877,8 +3877,9 @@ if (!wasAuthenticated && closingSessionID) { api("/direct/" + selectedChannel + "/sessions/" + encodeURIComponent(closingSessionID), { method: "DELETE" }).catch(function () {}); } - resetControls(wasOperatorClose ? "disconnected" : (wasAuthenticated ? "disconnected" : "error"), wasOperatorClose ? "연결 종료" : "연결 끊김"); - appendSystem(wasOperatorClose ? "운영자가 연결을 종료했습니다." : ("연결이 종료되었습니다. (code " + event.code + ")")); + const sessionLimitReached = event.reason === "channel session limit reached"; + resetControls(wasOperatorClose ? "disconnected" : (wasAuthenticated ? "disconnected" : "error"), sessionLimitReached ? "최대 세션 도달" : (wasOperatorClose ? "연결 종료" : "연결 끊김")); + appendSystem(sessionLimitReached ? "이 포트의 최대 세션 수에 도달했습니다. 웹 직접 연결과 동작 1·2의 접속 수를 합산합니다." : (wasOperatorClose ? "운영자가 연결을 종료했습니다." : ("연결이 종료되었습니다. (code " + event.code + ")"))); refreshPortAvailability(true); }); } catch (error) { diff --git a/frontend/web/templates/layout.html b/frontend/web/templates/layout.html index 6b06d34..3dc1a20 100644 --- a/frontend/web/templates/layout.html +++ b/frontend/web/templates/layout.html @@ -5,7 +5,7 @@ {{.Title}} - {{.AppName}} - +
@@ -188,7 +188,7 @@ - + {{end}} diff --git a/scripts/deploy-compact-port-selector.ps1 b/scripts/deploy-compact-port-selector.ps1 new file mode 100644 index 0000000..86ec6d9 --- /dev/null +++ b/scripts/deploy-compact-port-selector.ps1 @@ -0,0 +1,97 @@ +$ErrorActionPreference = 'Stop' +$project = Split-Path -Parent $PSScriptRoot +$binary = Join-Path $project 'build/linux-arm64/tera-clone-demo-web' +$hash = (Get-FileHash -LiteralPath $binary -Algorithm SHA256).Hash.ToLowerInvariant() +$script = @' +#!/bin/sh +set -eu +stage=$(mktemp -d /tmp/tera-compact-ports.XXXXXX) +wget -q -T 30 -O "$stage/web" http://192.168.0.100:9001/web +echo '__HASH__ '"$stage/web" | sha256sum -c - +backup=/userdata/tera-backups/compact-port-selector-$(date +%Y%m%d-%H%M%S) +mkdir "$backup" +cp -p /usr/bin/tera-clone-demo-web "$backup/web" +echo "BACKUP=$backup" +rollback() { + trap - EXIT + /etc/init.d/S61teraweb stop || true + cp -p "$backup/web" /usr/bin/tera-clone-demo-web.restore + mv /usr/bin/tera-clone-demo-web.restore /usr/bin/tera-clone-demo-web + /etc/init.d/S61teraweb start || true + echo COMPACT_PORT_ROLLED_BACK +} +trap rollback EXIT +cp "$stage/web" /usr/bin/tera-clone-demo-web.next +chmod 755 /usr/bin/tera-clone-demo-web.next +chown root:root /usr/bin/tera-clone-demo-web.next +/etc/init.d/S61teraweb stop +mv /usr/bin/tera-clone-demo-web.next /usr/bin/tera-clone-demo-web +/etc/init.d/S61teraweb start +ready=0 +for n in $(seq 1 30); do + if wget -q -T 2 -O "$stage/status" http://127.0.0.1/login/backend-status && grep -q '"ready":true' "$stage/status"; then ready=1; break; fi + sleep 1 +done +[ "$ready" = 1 ] +wget -q -T 5 -O "$stage/style" http://127.0.0.1/static/css/style.css +grep -q 'clamp(510px, 31vw, 550px)' "$stage/style" +grep -q 'min-height: 62px;' "$stage/style" +cat "$stage/status" +sha256sum /usr/bin/tera-clone-demo-web +sync +trap - EXIT +echo COMPACT_PORT_DEPLOY_OK +'@ +$script = $script.Replace('__HASH__', $hash).Replace("`r`n", "`n") + "`n" +$rule = 'TERA-compact-ports-' + [guid]::NewGuid().ToString('N') +$listener = New-Object System.Net.HttpListener +$listener.Prefixes.Add('http://192.168.0.100:9001/') +$serial = New-Object System.IO.Ports.SerialPort 'COM4',115200,'None',8,'One' +$serial.Encoding = [Text.Encoding]::UTF8 +Start-Transcript -Path (Join-Path $project 'runtime/compact-port-selector-transfer.log') -Force | Out-Null +try { + New-NetFirewallRule -DisplayName $rule -Direction Inbound -Action Allow -Protocol TCP -LocalPort 9001 -RemoteAddress 192.168.0.223 | Out-Null + $listener.Start() + $serial.Open() + $serial.DiscardInBuffer() + $serial.WriteLine('wget -q -T 15 -O /tmp/deploy-compact-port-selector.sh http://192.168.0.100:9001/deploy.sh && sh /tmp/deploy-compact-port-selector.sh') + $deadline = [DateTime]::UtcNow.AddSeconds(150) + $output = '' + $pending = $listener.GetContextAsync() + while ([DateTime]::UtcNow -lt $deadline) { + if ($pending.IsCompleted) { + $context = $pending.GetAwaiter().GetResult() + $bytes = $null + if ($context.Request.RemoteEndPoint.Address.ToString() -ne '192.168.0.223') { $context.Response.StatusCode = 403 } + else { + switch ($context.Request.Url.AbsolutePath) { + '/deploy.sh' { $bytes = [Text.Encoding]::UTF8.GetBytes($script) } + '/web' { $bytes = [IO.File]::ReadAllBytes($binary) } + default { $context.Response.StatusCode = 404 } + } + } + if ($null -ne $bytes) { + $context.Response.ContentLength64 = $bytes.Length + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + $context.Response.Close() + $pending = $listener.GetContextAsync() + } + $chunk = $serial.ReadExisting() + if ($chunk) { $output += $chunk; Write-Host -NoNewline $chunk } + if ($output.Contains('COMPACT_PORT_ROLLED_BACK')) { throw 'Deployment rolled back.' } + if ($output.Contains('COMPACT_PORT_DEPLOY_OK')) { break } + Start-Sleep -Milliseconds 100 + } + if (-not $output.Contains('COMPACT_PORT_DEPLOY_OK') -or -not $output.Contains($hash)) { throw 'Deployment verification incomplete; inspect console.' } + $response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 'http://192.168.0.223/static/css/style.css?v=20260916-compact-port-selector-1' + $localCSS = [IO.File]::ReadAllText((Join-Path $project 'frontend/web/static/css/style.css')) + if ($response.Content -cne $localCSS) { throw 'Served CSS differs from local CSS.' } + Write-Output "VERIFIED deployed SHA256=$hash and exact CSS content" +} finally { + if ($serial.IsOpen) { $serial.Close() } + $serial.Dispose() + $listener.Close() + Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue + Stop-Transcript | Out-Null +} diff --git a/scripts/deploy-shared-sessions.ps1 b/scripts/deploy-shared-sessions.ps1 new file mode 100644 index 0000000..065dcdc --- /dev/null +++ b/scripts/deploy-shared-sessions.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = 'Stop' +$project = Split-Path -Parent $PSScriptRoot +$assets = @{ + '/api' = Join-Path $project 'build/linux-arm64/tera-clone-demo-api' + '/sessiond' = Join-Path $project 'build/linux-arm64/tera-sessiond' + '/web' = Join-Path $project 'build/linux-arm64/tera-clone-demo-web' + '/directstream.test' = Join-Path $project 'build/linux-arm64/directstream.test' + '/portservice.test' = Join-Path $project 'build/linux-arm64/portservice.test' +} +$hashes = @{} +foreach ($key in $assets.Keys) { $hashes[$key] = (Get-FileHash -LiteralPath $assets[$key] -Algorithm SHA256).Hash.ToLowerInvariant() } +$manifest = (($assets.Keys | Sort-Object | ForEach-Object { $hashes[$_] + ' ' + $_.Substring(1) }) -join "`n") + "`n" +$script = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'deploy-shared-sessions.sh')).Replace("`r`n", "`n") +$rule = 'TERA-shared-sessions-' + [guid]::NewGuid().ToString('N') +$listener = New-Object System.Net.HttpListener +$listener.Prefixes.Add('http://192.168.0.100:9001/') +$serial = New-Object System.IO.Ports.SerialPort 'COM4',115200,'None',8,'One' +$serial.Encoding = [Text.Encoding]::UTF8 +Start-Transcript -Path (Join-Path $project 'runtime/shared-sessions-transfer.log') -Force | Out-Null +try { + New-NetFirewallRule -DisplayName $rule -Direction Inbound -Action Allow -Protocol TCP -LocalPort 9001 -RemoteAddress 192.168.0.223 | Out-Null + $listener.Start() + $serial.Open() + $serial.DiscardInBuffer() + $serial.WriteLine('wget -q -T 15 -O /tmp/deploy-shared-sessions.sh http://192.168.0.100:9001/deploy.sh && sh /tmp/deploy-shared-sessions.sh') + $deadline = [DateTime]::UtcNow.AddSeconds(240) + $output = '' + $pending = $listener.GetContextAsync() + while ([DateTime]::UtcNow -lt $deadline) { + if ($pending.IsCompleted) { + $context = $pending.GetAwaiter().GetResult() + $bytes = $null + $path = $context.Request.Url.AbsolutePath + if ($context.Request.RemoteEndPoint.Address.ToString() -ne '192.168.0.223') { $context.Response.StatusCode = 403 } + elseif ($assets.ContainsKey($path)) { $bytes = [IO.File]::ReadAllBytes($assets[$path]) } + elseif ($path -eq '/deploy.sh') { $bytes = [Text.Encoding]::UTF8.GetBytes($script) } + elseif ($path -eq '/SHA256SUMS') { $bytes = [Text.Encoding]::UTF8.GetBytes($manifest) } + else { $context.Response.StatusCode = 404 } + if ($null -ne $bytes) { + $context.Response.ContentLength64 = $bytes.Length + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + $context.Response.Close() + $pending = $listener.GetContextAsync() + } + $chunk = $serial.ReadExisting() + if ($chunk) { $output += $chunk; Write-Host -NoNewline $chunk } + if ($output.Contains('SHARED_SESSIONS_ROLLED_BACK')) { throw 'Deployment rolled back.' } + if ($output.Contains('SHARED_SESSIONS_DEPLOY_OK')) { break } + Start-Sleep -Milliseconds 100 + } + if (-not $output.Contains('SHARED_SESSIONS_DEPLOY_OK')) { throw 'Deployment verification incomplete; inspect console.' } + foreach ($key in @('/api','/sessiond','/web')) { if (-not $output.Contains($hashes[$key])) { throw "Missing installed hash for $key" } } + $response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 'http://192.168.0.223/static/js/integration.js?v=20260916-shared-port-sessions-1' + if ($response.Content -cne [IO.File]::ReadAllText((Join-Path $project 'frontend/web/static/js/integration.js'))) { throw 'Served integration.js differs from local file.' } + Write-Output 'VERIFIED API, sessiond, web hashes and exact browser script' +} finally { + if ($serial.IsOpen) { $serial.Close() } + $serial.Dispose() + $listener.Close() + Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue + Stop-Transcript | Out-Null +} diff --git a/scripts/deploy-shared-sessions.sh b/scripts/deploy-shared-sessions.sh new file mode 100644 index 0000000..bc09604 --- /dev/null +++ b/scripts/deploy-shared-sessions.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu +stage=$(mktemp -d /tmp/tera-shared-sessions.XXXXXX) +for name in api sessiond web directstream.test portservice.test SHA256SUMS; do + wget -q -T 30 -O "$stage/$name" "http://192.168.0.100:9001/$name" +done +cd "$stage" +sha256sum -c SHA256SUMS +chmod 755 directstream.test portservice.test +./directstream.test -test.run 'TestBrowserAndTerminal|TestSharedSocket' -test.v -test.timeout 30s +./portservice.test -test.run TestBothOperations -test.v -test.timeout 30s +backup=/userdata/tera-backups/shared-sessions-$(date +%Y%m%d-%H%M%S) +mkdir "$backup" +cp -p /usr/bin/tera-api "$backup/api" +cp -p /usr/bin/tera-sessiond "$backup/sessiond" +cp -p /usr/bin/tera-clone-demo-web "$backup/web" +echo "BACKUP=$backup" +rollback() { + trap - EXIT + set +e + /etc/init.d/S61teraweb stop + /etc/init.d/S55tera-sessiond stop + /etc/init.d/S50tera-api stop + cp -p "$backup/api" /usr/bin/tera-api.restore + cp -p "$backup/sessiond" /usr/bin/tera-sessiond.restore + cp -p "$backup/web" /usr/bin/tera-clone-demo-web.restore + mv /usr/bin/tera-api.restore /usr/bin/tera-api + mv /usr/bin/tera-sessiond.restore /usr/bin/tera-sessiond + mv /usr/bin/tera-clone-demo-web.restore /usr/bin/tera-clone-demo-web + /etc/init.d/S50tera-api start + /etc/init.d/S55tera-sessiond start + /etc/init.d/S61teraweb start + echo SHARED_SESSIONS_ROLLED_BACK + exit 1 +} +trap rollback EXIT +cp api /usr/bin/tera-api.next +cp sessiond /usr/bin/tera-sessiond.next +cp web /usr/bin/tera-clone-demo-web.next +chmod 755 /usr/bin/tera-api.next /usr/bin/tera-sessiond.next /usr/bin/tera-clone-demo-web.next +chown root:root /usr/bin/tera-api.next /usr/bin/tera-sessiond.next /usr/bin/tera-clone-demo-web.next +/etc/init.d/S61teraweb stop +/etc/init.d/S55tera-sessiond stop +/etc/init.d/S50tera-api stop +sleep 2 +mv /usr/bin/tera-api.next /usr/bin/tera-api +mv /usr/bin/tera-sessiond.next /usr/bin/tera-sessiond +mv /usr/bin/tera-clone-demo-web.next /usr/bin/tera-clone-demo-web +/etc/init.d/S50tera-api start +/etc/init.d/S55tera-sessiond start +/etc/init.d/S61teraweb start +ready=0 +for n in $(seq 1 90); do + if wget -q -T 2 -O "$stage/status" http://127.0.0.1/login/backend-status && grep -q '"ready":true' "$stage/status"; then ready=1; break; fi + sleep 1 +done +[ "$ready" = 1 ] +[ -S /run/tera-api/shared-stream.sock ] +wget -q -T 5 -O "$stage/session-status" http://127.0.0.1:8081/readyz +grep -q '"shared_port_sessions":true' "$stage/session-status" +wget -q -T 5 -O "$stage/integration.js" http://127.0.0.1/static/js/integration.js +grep -q 'channel session limit reached' "$stage/integration.js" +cat "$stage/status" +cat "$stage/session-status" +sha256sum /usr/bin/tera-api /usr/bin/tera-sessiond /usr/bin/tera-clone-demo-web +sync +trap - EXIT +echo SHARED_SESSIONS_DEPLOY_OK