Browse Source

Share direct and terminal sessions and update deployed release

main
gudae 2 months ago
parent
commit
6ed3580df7
  1. 1
      backend/cmd/tera-api/main.go
  2. 12
      backend/cmd/tera-sessiond/main.go
  3. 3
      backend/internal/adapters/mock/direct_ticket_authority.go
  4. 30
      backend/internal/adapters/mock/direct_ticket_authority_test.go
  5. 43
      backend/internal/adapters/mock/store.go
  6. 3
      backend/internal/api/serial_runtime.go
  7. 11
      backend/internal/api/serial_runtime_test.go
  8. 42
      backend/internal/directstream/broker.go
  9. 226
      backend/internal/directstream/shared_socket.go
  10. 151
      backend/internal/directstream/shared_socket_test.go
  11. 9
      backend/internal/directstream/ticket.go
  12. 7
      backend/internal/directstream/websocket.go
  13. 18
      backend/internal/portservice/gateway.go
  14. 132
      backend/internal/portservice/shared_gateway_test.go
  15. 23
      demo-guide/08-shared-port-sessions-20260916.md
  16. 8
      final-build/README.md
  17. 6
      final-build/SHA256SUMS
  18. BIN
      final-build/backend/tera-clone-demo-api
  19. BIN
      final-build/backend/tera-sessiond
  20. BIN
      final-build/frontend/tera-clone-demo-web
  21. 39
      final-build/release-manifest.json
  22. 14
      frontend/web/static/css/style.css
  23. 5
      frontend/web/static/js/integration.js
  24. 4
      frontend/web/templates/layout.html
  25. 97
      scripts/deploy-compact-port-selector.ps1
  26. 63
      scripts/deploy-shared-sessions.ps1
  27. 68
      scripts/deploy-shared-sessions.sh

1
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"),

12
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{

3
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
}

30
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"})

43
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 {

3
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
}

11
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{},

42
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(),

226
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
}

151
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)
}
}

9
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 {

7
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)

18
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
}

132
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)
}
})
}
}

23
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 검증은 미실시입니다.

8
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`입니다.

6
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

BIN
final-build/backend/tera-clone-demo-api

Binary file not shown.

BIN
final-build/backend/tera-sessiond

Binary file not shown.

BIN
final-build/frontend/tera-clone-demo-web

Binary file not shown.

39
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",

14
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 {

5
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) {

4
frontend/web/templates/layout.html

@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - {{.AppName}}</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260914-fixed-port-positions-2">
<link rel="stylesheet" href="/static/css/style.css?v=20260916-compact-port-selector-1">
</head>
<body data-ui-version="20260914-direct-mode-title-7" data-workspace-id="{{.WorkspaceID}}" data-current-user="{{.CurrentUser}}" data-current-path="{{.CurrentPath}}" data-current-role="{{.CurrentRole}}" data-current-tab-key="{{(index .WorkspaceTabs 0).Key}}" data-current-tab-label="{{(index .WorkspaceTabs 0).Label}}">
<div class="app-shell">
@ -188,7 +188,7 @@
<script src="/static/js/log-view.js?v=20260914-fixed-port-positions-2"></script>
<script src="/static/js/port-log-clear.js?v=20260914-fixed-port-positions-2"></script>
<script src="/static/js/firmware-progress.js?v=20260908-firmware-progress-1"></script>
<script src="/static/js/integration.js?v=20260914-fixed-port-positions-2"></script>
<script src="/static/js/integration.js?v=20260916-shared-port-sessions-1"></script>
</body>
</html>
{{end}}

97
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
}

63
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
}

68
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
Loading…
Cancel
Save