27 changed files with 949 additions and 66 deletions
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -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 검증은 미실시입니다. |
|||
@ -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 |
|||
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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…
Reference in new issue