Browse Source

Improve port controls, session sharing and administrator log management

main
gudae 2 months ago
parent
commit
8c2fe0061d
  1. 1
      backend/cmd/tera-api/main.go
  2. 4
      backend/internal/adapters/mock/log_journal_test.go
  3. 57
      backend/internal/adapters/mock/reauthenticate.go
  4. 36
      backend/internal/adapters/mock/reauthenticate_test.go
  5. 25
      backend/internal/adapters/mock/store.go
  6. 2
      backend/internal/adapters/mock/store_test.go
  7. 4
      backend/internal/api/clear_all_logs_test.go
  8. 5
      backend/internal/api/clear_link_logs.go
  9. 9
      backend/internal/api/clear_port_logs.go
  10. 2
      backend/internal/api/clear_port_logs_test.go
  11. 5
      backend/internal/api/clear_system_logs.go
  12. 2
      backend/internal/api/clear_system_logs_test.go
  13. 33
      backend/internal/api/port_log_display.go
  14. 25
      backend/internal/api/port_log_display_test.go
  15. 19
      backend/internal/api/port_log_settings_auth.go
  16. 18
      backend/internal/api/reauthenticate.go
  17. 42
      backend/internal/api/reauthenticate_test.go
  18. 23
      backend/internal/api/router.go
  19. 6
      backend/internal/api/router_test.go
  20. 15
      backend/internal/api/serial_runtime.go
  21. 6
      backend/internal/api/serial_runtime_test.go
  22. 3
      backend/internal/domain/models.go
  23. 1
      backend/internal/domain/store.go
  24. 15
      backend/internal/logpolicy/policy.go
  25. 7
      backend/internal/logruntime/scheduler.go
  26. 12
      backend/internal/logruntime/scheduler_test.go
  27. 8
      backend/internal/logstorage/free_linux.go
  28. 2
      backend/internal/logstorage/free_other.go
  29. 13
      backend/internal/logstorage/free_windows.go
  30. 33
      backend/internal/logstorage/rotation_limit_test.go
  31. 71
      backend/internal/logstorage/store.go
  32. 95
      backend/internal/portservice/gateway.go
  33. 26
      backend/internal/portservice/listen_linux.go
  34. 12
      backend/internal/portservice/listen_other.go
  35. 52
      backend/internal/portservice/shared_sessions_test.go
  36. 23
      backend/internal/serial/connection_status.go
  37. 34
      backend/internal/serial/connection_status_test.go
  38. 40
      demo-guide/07-system-improvements-20260914.md
  39. 2
      demo-guide/README.md
  40. 65
      final-build/README.md
  41. 14
      final-build/SHA256SUMS
  42. BIN
      final-build/backend/tera-clone-demo-api
  43. BIN
      final-build/backend/tera-logd
  44. BIN
      final-build/backend/tera-netd
  45. BIN
      final-build/backend/tera-seriald
  46. BIN
      final-build/backend/tera-sessiond
  47. BIN
      final-build/backend/tera-timed
  48. BIN
      final-build/frontend/tera-clone-demo-web
  49. 106
      final-build/release-manifest.json
  50. 2
      frontend/internal/handlers/account_roles_test.go
  51. 2
      frontend/internal/handlers/admin_controls_test.go
  52. 2
      frontend/internal/handlers/dashboard_test.go
  53. 25
      frontend/internal/handlers/device_handler.go
  54. 57
      frontend/internal/handlers/device_handler_integration_test.go
  55. 2
      frontend/internal/handlers/log_view_test.go
  56. 2
      frontend/internal/handlers/proxy_metadata_test.go
  57. 64
      frontend/internal/handlers/system_improvements_test.go
  58. 78
      frontend/internal/mock/mock_data.go
  59. 84
      frontend/web/static/css/style.css
  60. 17
      frontend/web/static/js/app.js
  61. 9
      frontend/web/static/js/dashboard.js
  62. 10
      frontend/web/static/js/i18n.js
  63. 87
      frontend/web/static/js/integration.js
  64. 2
      frontend/web/static/js/log-view.js
  65. 40
      frontend/web/static/js/port-log-clear.js
  66. 4
      frontend/web/static/js/workspace-tabs.js
  67. 18
      frontend/web/templates/layout.html
  68. 7
      frontend/web/templates/partials/block_port-selector.html
  69. 1
      frontend/web/templates/partials/block_serial-terminal.html
  70. 4
      frontend/web/templates/partials/dashboard.html
  71. 18
      scripts/test-workspace-tabs.cjs

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{
ListenInterface: portservice.ListenOnInterface,
TransportFactory: streamFactory,
ListenHost: getenv("TERA_API_PORT_LISTEN_HOST", "0.0.0.0"),
SSHSigner: signer,

4
backend/internal/adapters/mock/log_journal_test.go

@ -61,7 +61,7 @@ func (j *memoryJournal) Snapshot() (logstorage.Snapshot, error) {
func TestOperationalStoragePressureAlarmIsEdgeTriggered(t *testing.T) {
journal := &memoryJournal{snapshot: logstorage.Snapshot{
UsageBytes: map[string]int64{"audit": 20, "system": 30, "port": 31},
UsageBytes: map[string]int64{"audit": 20, "system": 30, "port": 41},
CapBytes: map[string]int64{"audit": 20, "system": 30, "port": 50},
}}
controller := &recordingAlarmDeliveryController{}
@ -85,7 +85,7 @@ func TestOperationalStoragePressureAlarmIsEdgeTriggered(t *testing.T) {
t.Fatal(err)
}
journal.mu.Lock()
journal.snapshot.UsageBytes["port"] = 31
journal.snapshot.UsageBytes["port"] = 41
journal.mu.Unlock()
if err := store.EvaluateOperationalAlarms(context.Background()); err != nil {
t.Fatal(err)

57
backend/internal/adapters/mock/reauthenticate.go

@ -0,0 +1,57 @@
package mock
import (
"context"
"errors"
"strings"
"tera-clone/services/tera-api/internal/domain"
)
// VerifyCurrentPassword reauthenticates the session actor without creating or
// replacing a browser session. The caller cannot nominate another account.
func (s *Store) VerifyCurrentPassword(ctx context.Context, credential string) error {
actor, ok := domain.ActorFromContext(ctx)
if !ok || actor.Name == "" {
return errors.New("authentication required")
}
s.mu.Lock()
username := ""
var user authUserRecord
for name, candidate := range s.authUsers {
if candidate.Actor == actor.Name {
username, user = name, candidate
break
}
}
external := username == "" && strings.HasPrefix(actor.Name, "external-")
if external {
username = strings.TrimPrefix(actor.Name, "external-")
}
now := s.clock().UTC()
locked, _ := s.loginLockedLocked(username, now)
config := cloneExternalAuth(s.runningExternalAuth)
verified := false
if !locked && !external && username != "" && !user.Disabled && credential != "" {
verified, _ = s.passwordKDFs.Verify(user.Password, credential)
}
s.mu.Unlock()
if external && !locked && credential != "" {
authenticated, accepted, _ := s.authenticateExternal(ctx, config, username, credential)
verified = accepted && authenticated.Name == actor.Name
}
s.mu.Lock()
defer s.mu.Unlock()
if !verified {
if !locked {
s.recordLoginFailureLocked(username, now)
}
s.recordAuditLocked(ctx, "auth.reauthenticate", actor.Name, "failed", "current account password verification failed")
if err := s.persistLoginFailureStoreLocked(); err != nil {
return err
}
return errors.New("current account password is incorrect or account is locked")
}
delete(s.loginFailures, username)
return s.persistLoginFailureStoreLocked()
}

36
backend/internal/adapters/mock/reauthenticate_test.go

@ -0,0 +1,36 @@
package mock
import (
"context"
"testing"
"tera-clone/services/tera-api/internal/domain"
)
func TestReauthenticationUsesSessionAccountAndPreservesSession(t *testing.T) {
store := NewStore(fixedClock)
defer store.Close()
force := false
_, err := store.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{Username: "second-admin", Password: "second-admin-secret", Role: "administrator", ForceChange: &force})
if err != nil {
t.Fatal(err)
}
session, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "second-admin", Password: "second-admin-secret"})
if err != nil {
t.Fatal(err)
}
actor, err := store.ActorBySession(context.Background(), session.SessionToken)
if err != nil {
t.Fatal(err)
}
ctx := domain.WithActor(context.Background(), actor)
if err := store.VerifyCurrentPassword(ctx, "mock-admin-secret"); err == nil {
t.Fatal("another administrator's password accepted")
}
if err := store.VerifyCurrentPassword(ctx, "second-admin-secret"); err != nil {
t.Fatal(err)
}
if _, err := store.ActorBySession(context.Background(), session.SessionToken); err != nil {
t.Fatalf("reauthentication replaced active session: %v", err)
}
}

25
backend/internal/adapters/mock/store.go

@ -3433,7 +3433,7 @@ func (s *Store) UpdateLogRetention(ctx context.Context, update domain.LogRetenti
err := fmt.Errorf("protected_usage_warning_pct must be between 50 and 95")
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
}
next.ProtectedUsageWarningPct = *update.ProtectedUsageWarningPct
next.ProtectedUsageWarningPct = 90
}
if update.PreserveOnFactoryReset != nil {
if *update.PreserveOnFactoryReset {
@ -9949,7 +9949,7 @@ func baseLogRetention() logRetentionConfig {
SystemDays: 365,
AuditDays: 365,
PortDays: 365,
ProtectedUsageWarningPct: 80,
ProtectedUsageWarningPct: 90,
PreserveOnFactoryReset: false,
LocalSystemEnabled: true,
LocalConfigurationEnabled: true,
@ -10774,7 +10774,7 @@ func makeLogRetentionSettings(config logRetentionConfig) domain.LogRetentionSett
SystemDays: config.SystemDays,
AuditDays: config.AuditDays,
PortDays: config.PortDays,
ProtectedUsageWarningPct: config.ProtectedUsageWarningPct,
ProtectedUsageWarningPct: 90,
PreserveOnFactoryReset: false,
LocalSystemEnabled: config.LocalSystemEnabled,
LocalConfigurationEnabled: config.LocalConfigurationEnabled,
@ -11410,6 +11410,13 @@ func normalizePortConfigWithMigration(config portConfig, index int, allowDefault
return portConfig{}, err
}
config.DataLog = dataLog
if config.Operation1.Mode != "disabled" {
config.Operation1.Port = 10000 + index
}
if config.Operation2.Mode != "disabled" {
config.Operation2.Port = 10000 + index
}
config.Operation2.MaxClients = config.Operation1.MaxClients
operation1, err := normalizePortOperation(config.Operation1)
if err != nil {
return portConfig{}, fmt.Errorf("operation_1: %w", err)
@ -11680,9 +11687,9 @@ func listenerPortFor(index int, mode string) int {
switch mode {
case "disabled":
return 0
case "reverse_ssh":
case "reverse_ssh", "reverse_telnet":
return 10000 + index
case "telnet", "reverse_telnet":
case "telnet":
return 2300 + index
default:
return 2200 + index
@ -11933,7 +11940,7 @@ func logRetentionConfigFromExport(settings domain.LogRetentionSettings) (logRete
SystemDays: settings.SystemDays,
AuditDays: settings.AuditDays,
PortDays: settings.PortDays,
ProtectedUsageWarningPct: settings.ProtectedUsageWarningPct,
ProtectedUsageWarningPct: 90,
PreserveOnFactoryReset: false,
LocalSystemEnabled: settings.LocalSystemEnabled,
LocalConfigurationEnabled: settings.LocalConfigurationEnabled,
@ -11956,7 +11963,11 @@ func logRetentionConfigFromExport(settings domain.LogRetentionSettings) (logRete
}
func migrateLogEventDefaults(settings *logRetentionConfig) {
if settings == nil || settings.KeepaliveIntervalMinutes != 0 || settings.AllPortStatusIntervalMinutes != 0 || settings.SinglePortStatusIntervalMinutes != 0 {
if settings == nil {
return
}
settings.ProtectedUsageWarningPct = 90
if settings.KeepaliveIntervalMinutes != 0 || settings.AllPortStatusIntervalMinutes != 0 || settings.SinglePortStatusIntervalMinutes != 0 {
return
}
defaults := baseLogRetention()

2
backend/internal/adapters/mock/store_test.go

@ -1639,7 +1639,7 @@ func TestConfigStorePersistsLogRetentionTransactionState(t *testing.T) {
}
portDays := 180
warningPct := 85
warningPct := 90
result, err := first.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{
PortDays: &portDays,
ProtectedUsageWarningPct: &warningPct,

4
backend/internal/api/clear_all_logs_test.go

@ -38,9 +38,9 @@ func TestClearAllLogsRequiresExactConfirmationAndAdmin(t *testing.T) {
t.Fatal(err)
}
}
result := getJSONAs(t, h, http.MethodPost, "/api/v1/logs/port/clear", `{"channel":"ALL","confirmation":"ALL"}`, "mock-admin")
result := getJSONAs(t, h, http.MethodPost, "/api/v1/logs/port/clear", `{"channel":"ALL","password":"mock-admin-secret"}`, "mock-admin")
if result["deleted"] != float64(2) {
t.Fatal(result)
}
getJSONAs(t, h, http.MethodPost, "/api/v1/logs/links/clear", `{"confirmation":"LINKS"}`, "mock-admin")
getJSONAs(t, h, http.MethodPost, "/api/v1/logs/links/clear", `{"password":"mock-admin-secret"}`, "mock-admin")
}

5
backend/internal/api/clear_link_logs.go

@ -8,13 +8,12 @@ func (r *Router) handleClearLinkLogs(w http.ResponseWriter, req *http.Request) {
return
}
var body struct {
Confirmation string `json:"confirmation"`
Password string `json:"password"`
}
if ok, _ := decodeJSONBody(w, req, &body, false); !ok {
return
}
if body.Confirmation != "LINKS" {
writeError(w, http.StatusBadRequest, "validation_failed", "confirm LINKS exactly")
if !r.verifyCurrentPassword(w, ctx, body.Password) {
return
}
result, err := r.store.ClearLinkLogs(ctx)

9
backend/internal/api/clear_port_logs.go

@ -12,13 +12,16 @@ func (r *Router) handleClearPortLogs(w http.ResponseWriter, req *http.Request) {
}
var body struct {
Channel string `json:"channel"`
Confirmation string `json:"confirmation"`
Password string `json:"password"`
}
if ok, _ := decodeJSONBody(w, req, &body, false); !ok {
return
}
if (body.Channel != "ALL" && !regexp.MustCompile(`^CH(?:0[1-9]|[1-3][0-9]|4[0-8])$`).MatchString(body.Channel)) || body.Confirmation != body.Channel {
writeError(w, http.StatusBadRequest, "validation_failed", "select ALL or one port CH01 through CH48 and confirm its exact channel")
if body.Channel != "ALL" && !regexp.MustCompile(`^CH(?:0[1-9]|[1-3][0-9]|4[0-8])$`).MatchString(body.Channel) {
writeError(w, http.StatusBadRequest, "validation_failed", "select ALL or one port CH01 through CH48 ")
return
}
if !r.verifyCurrentPassword(w, ctx, body.Password) {
return
}
result, err := r.store.ClearPortLogs(ctx, body.Channel)

2
backend/internal/api/clear_port_logs_test.go

@ -38,7 +38,7 @@ func TestPortLogClearRequiresAdminExactTargetAndConfirmation(t *testing.T) {
t.Fatalf("clear %s = %d: %s", tc.actor, res.Code, res.Body.String())
}
}
result := getJSONAs(t, handler, http.MethodPost, "/api/v1/logs/port/clear", `{"channel":"CH01","confirmation":"CH01"}`, "mock-admin")
result := getJSONAs(t, handler, http.MethodPost, "/api/v1/logs/port/clear", `{"channel":"CH01","password":"mock-admin-secret"}`, "mock-admin")
if result["deleted"] != float64(1) || result["channel"] != "CH01" {
t.Fatalf("clear result = %+v", result)
}

5
backend/internal/api/clear_system_logs.go

@ -8,13 +8,12 @@ func (r *Router) handleClearSystemLogs(w http.ResponseWriter, req *http.Request)
return
}
var body struct {
Confirmation string `json:"confirmation"`
Password string `json:"password"`
}
if ok, _ := decodeJSONBody(w, req, &body, false); !ok {
return
}
if body.Confirmation != "SYSTEM" {
writeError(w, http.StatusBadRequest, "validation_failed", "confirm SYSTEM exactly")
if !r.verifyCurrentPassword(w, ctx, body.Password) {
return
}
result, err := r.store.ClearSystemLogs(ctx)

2
backend/internal/api/clear_system_logs_test.go

@ -36,7 +36,7 @@ func TestSystemClearRequiresConfirmationAndDeletesAuditProjection(t *testing.T)
t.Fatalf("clear %s: %d %s", tc.actor, w.Code, w.Body.String())
}
}
result := getJSONAs(t, h, http.MethodPost, "/api/v1/logs/system/clear", `{"confirmation":"SYSTEM"}`, "mock-admin")
result := getJSONAs(t, h, http.MethodPost, "/api/v1/logs/system/clear", `{"password":"mock-admin-secret"}`, "mock-admin")
if result["deleted"].(float64) < 2 {
t.Fatalf("deleted=%v", result)
}

33
backend/internal/api/port_log_display.go

@ -0,0 +1,33 @@
package api
import (
"fmt"
"strings"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/logpolicy"
)
func portLogDisplay(event domain.PortLogEvent) domain.PortLogEvent {
if len(event.Payload) == 0 {
return event
}
text, redacted := logpolicy.SanitizeMultiline(strings.ToValidUTF8(string(event.Payload), "�"), logpolicy.MaxSerialPayloadBytes)
event.Redacted = event.Redacted || redacted
bytes := event.Payload
if event.Redacted {
bytes = []byte(text)
}
ascii := strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
switch event.DisplayFormat {
case "hex":
event.DisplayText = fmt.Sprintf("% X", bytes)
case "ascii_hex":
event.DisplayText = "ASCII\n" + ascii + "\nHEX\n" + fmt.Sprintf("% X", bytes)
default:
event.DisplayText = ascii
}
// Raw bytes remain in the journal; API responses expose only the sanitized view.
event.Payload = nil
return event
}

25
backend/internal/api/port_log_display_test.go

@ -0,0 +1,25 @@
package api
import (
"strings"
"tera-clone/services/tera-api/internal/domain"
"testing"
)
func TestPortLogDisplayPreservesASCIIAndUppercasesHEX(t *testing.T) {
event := domain.PortLogEvent{Payload: []byte("MiXeD\r\nnext\rline"), DisplayFormat: "ascii"}
result := portLogDisplay(event)
if result.DisplayText != "MiXeD\nnext\nline" || result.Payload != nil {
t.Fatalf("display: %+v", result)
}
event.Payload = []byte{0xab, 0xcd, 0xef}
event.DisplayFormat = "hex"
if result = portLogDisplay(event); result.DisplayText != "AB CD EF" {
t.Fatal(result.DisplayText)
}
event.Payload = []byte("password=supersecret")
event.DisplayFormat = "ascii"
if result = portLogDisplay(event); strings.Contains(result.DisplayText, "supersecret") || !result.Redacted {
t.Fatalf("secret not redacted: %+v", result)
}
}

19
backend/internal/api/port_log_settings_auth.go

@ -0,0 +1,19 @@
package api
import (
"context"
"net/http"
"tera-clone/services/tera-api/internal/domain"
)
func (r *Router) authorizePortLogSettings(w http.ResponseWriter, ctx context.Context, update domain.PortUpdate) bool {
if update.DataLog == nil && update.DataLogMode == nil {
return true
}
actor, ok := domain.ActorFromContext(ctx)
if !ok || actor.Role != "administrator" {
writeError(w, http.StatusForbidden, "authorization_denied", "port log settings require administrator role")
return false
}
return true
}

18
backend/internal/api/reauthenticate.go

@ -0,0 +1,18 @@
package api
import (
"context"
"net/http"
)
func (r *Router) verifyCurrentPassword(w http.ResponseWriter, ctx context.Context, password string) bool {
if password == "" {
writeError(w, http.StatusBadRequest, "password_required", "enter the current account password")
return false
}
if err := r.store.VerifyCurrentPassword(ctx, password); err != nil {
writeError(w, http.StatusForbidden, "reauthentication_failed", "current account password is incorrect or account is locked")
return false
}
return true
}

42
backend/internal/api/reauthenticate_test.go

@ -0,0 +1,42 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"tera-clone/services/tera-api/internal/adapters/mock"
)
func TestDestructiveActionsRequireCurrentAccountPassword(t *testing.T) {
for _, path := range []string{"/api/v1/logs/system/clear", "/api/v1/logs/links/clear", "/api/v1/logs/port/clear", "/api/v1/system/factory-reset"} {
t.Run(path, func(t *testing.T) {
store := mock.NewStore(fixedClock)
defer store.Close()
handler := NewRouter(store)
body := `{"password":"wrong"}`
if strings.Contains(path, "/port/") {
body = `{"password":"wrong","channel":"ALL"}`
}
if strings.Contains(path, "factory-reset") {
body = `{"password":"wrong","confirmed":true}`
}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("X-Mock-Actor", "mock-admin")
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
if response.Code != http.StatusForbidden {
t.Fatalf("wrong password accepted: %d %s", response.Code, response.Body.String())
}
body = strings.Replace(body, "wrong", "mock-admin-secret", 1)
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("X-Mock-Actor", "mock-admin")
response = httptest.NewRecorder()
handler.ServeHTTP(response, req)
if response.Code != http.StatusOK {
t.Fatalf("correct password rejected: %d %s", response.Code, response.Body.String())
}
})
}
}

23
backend/internal/api/router.go

@ -688,6 +688,20 @@ func (r *Router) handleFactoryReset(w http.ResponseWriter, req *http.Request) {
if !ok {
return
}
var body struct {
Password string `json:"password"`
Confirmed bool `json:"confirmed"`
}
if ok, _ := decodeJSONBody(w, req, &body, false); !ok {
return
}
if !body.Confirmed {
writeError(w, http.StatusBadRequest, "confirmation_required", "confirm factory reset")
return
}
if !r.verifyCurrentPassword(w, ctx, body.Password) {
return
}
operation, err := r.store.FactoryReset(ctx)
writeResult(w, operation, err)
}
@ -1543,6 +1557,9 @@ func (r *Router) handlePorts(w http.ResponseWriter, req *http.Request) {
return
}
}
if !r.authorizePortLogSettings(w, ctx, update.Update) {
return
}
result, err := r.store.UpdatePorts(ctx, update.Channels, update.Update)
writeResult(w, result, err)
return
@ -1619,6 +1636,9 @@ func (r *Router) handlePortSubtree(w http.ResponseWriter, req *http.Request) {
if !r.decodeCommandJSON(w, req, ctx, spec, &update) {
return
}
if !r.authorizePortLogSettings(w, ctx, update) {
return
}
result, err := r.store.UpdatePort(ctx, channel, update)
writeResult(w, result, err)
case req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restart":
@ -2103,6 +2123,9 @@ func (r *Router) handlePortLogEvents(w http.ResponseWriter, req *http.Request) {
return
}
events, err := r.store.PortLogEvents(ctx, filter)
for index := range events {
events[index] = portLogDisplay(events[index])
}
writeResult(w, events, err)
}

6
backend/internal/api/router_test.go

@ -4030,7 +4030,7 @@ func TestFactoryResetContract(t *testing.T) {
apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin")
assertConfigSummary(t, apply, 0, 2, 1, true)
operation := getJSONAs(t, handler, http.MethodPost, "/api/v1/system/factory-reset", "", "mock-admin")
operation := getJSONAs(t, handler, http.MethodPost, "/api/v1/system/factory-reset", `{"password":"mock-admin-secret","confirmed":true}`, "mock-admin")
assertOperationPayload(t, operation)
port := getPort(t, handler, "CH01")
@ -5399,7 +5399,7 @@ func TestLogRetentionUpdateStagesPendingConfig(t *testing.T) {
before := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/retention", "", "mock-admin")
assertNumber(t, before, "port_days", 365)
assertNumber(t, before, "protected_usage_warning_pct", 80)
assertNumber(t, before, "protected_usage_warning_pct", 90)
assertNumber(t, before, "keepalive_interval_minutes", 3)
assertNumber(t, before, "all_port_status_interval_minutes", 30)
assertNumber(t, before, "single_port_status_interval_minutes", 1)
@ -5435,7 +5435,7 @@ func TestLogRetentionUpdateStagesPendingConfig(t *testing.T) {
afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/retention", "", "mock-admin")
assertNumber(t, afterApply, "port_days", 180)
assertNumber(t, afterApply, "protected_usage_warning_pct", 85)
assertNumber(t, afterApply, "protected_usage_warning_pct", 90)
assertNumber(t, afterApply, "keepalive_interval_minutes", 5)
assertNumber(t, afterApply, "all_port_status_interval_minutes", 60)
if afterApply["remote_system_enabled"] != true || afterApply["single_port_status_realtime"] != true {

15
backend/internal/api/serial_runtime.go

@ -233,6 +233,7 @@ func (r *Router) overlayPortRuntime(ports []domain.Port, runtime serialRuntimeSn
value := domain.PortOperationRuntime{
Operation: service.Operation, Mode: service.Mode, Host: service.Host, Port: service.Port,
State: service.State, ActiveClients: service.ActiveClients,
SessionIPs: append([]string(nil), service.SessionIPs...), Interface: service.Interface,
Sources: append([]string(nil), service.Sources...), Targets: append([]string(nil), service.Targets...),
}
if service.Mode == "redirect" {
@ -363,12 +364,6 @@ func runtimePortStatus(runtime *domain.PortRuntime) string {
if runtime.SessionActive && (runtime.CRProbeEverUp || runtime.LinkEverUp) {
return "session"
}
if runtime.CRProbeUp {
return "up"
}
if runtime.CRProbeEverUp && runtime.CRProbeState == serial.CRProbeStateNoResponse {
return "down"
}
if runtime.LinkAvailable {
if runtime.LinkState == "up" {
return "up"
@ -377,7 +372,13 @@ func runtimePortStatus(runtime *domain.PortRuntime) string {
return "down"
}
}
return "unknown"
if runtime.LastRXAt != nil && runtime.RXAgeMillis >= 0 && runtime.RXAgeMillis <= 10000 {
return "up"
}
return serial.CRConnectionStatus(serial.CRProbeState{
Enabled: runtime.CRProbeEnabled, Up: runtime.CRProbeUp,
EverUp: runtime.CRProbeEverUp, LastResponseAt: runtime.CRProbeResponseAt,
}, time.Now())
}
func (r *Router) withRuntimeHealth(ctx context.Context, health []domain.ServiceHealth) []domain.ServiceHealth {

6
backend/internal/api/serial_runtime_test.go

@ -272,8 +272,10 @@ func TestRuntimePortStatusRequiresUpBeforeDownAndPrioritizesSession(t *testing.T
}{
{name: "boot", runtime: &domain.PortRuntime{}, want: "unknown"},
{name: "unconfirmed miss", runtime: &domain.PortRuntime{CRProbeState: serial.CRProbeStateNoResponse}, want: "unknown"},
{name: "confirmed response", runtime: &domain.PortRuntime{CRProbeUp: true, CRProbeEverUp: true}, want: "up"},
{name: "lost after response", runtime: &domain.PortRuntime{CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "down"},
{name: "confirmed response", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeUp: true, CRProbeEverUp: true}, want: "up"},
{name: "no response is not cable down", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "unknown"},
{name: "no RX timestamp", runtime: &domain.PortRuntime{RXAgeMillis: 0}, want: "unknown"},
{name: "confirmed hardware down", runtime: &domain.PortRuntime{LinkAvailable: true, LinkEverUp: true, LinkState: "down", CRProbeEnabled: true, CRProbeUp: true}, want: "down"},
{name: "unknown session is not promoted", runtime: &domain.PortRuntime{SessionActive: true}, want: "unknown"},
{name: "active session", runtime: &domain.PortRuntime{SessionActive: true, CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "session"},
}

3
backend/internal/domain/models.go

@ -320,6 +320,8 @@ type PortRuntime struct {
}
type PortOperationRuntime struct {
SessionIPs []string `json:"session_ips"`
Interface string `json:"interface"`
Operation int `json:"operation"`
Mode string `json:"mode"`
Host string `json:"host,omitempty"`
@ -603,6 +605,7 @@ type PortLogEvent struct {
PayloadBytes int `json:"payload_bytes,omitempty"`
PayloadEncoding string `json:"payload_encoding,omitempty"`
DisplayFormat string `json:"display_format,omitempty"`
DisplayText string `json:"display_text,omitempty"`
ShowTimestamp bool `json:"show_timestamp"`
ShowDirection bool `json:"show_direction"`
ShowUsername bool `json:"show_username"`

1
backend/internal/domain/store.go

@ -3,6 +3,7 @@ package domain
import "context"
type Store interface {
VerifyCurrentPassword(ctx context.Context, password string) error
Login(ctx context.Context, request AuthLoginRequest) (AuthSession, error)
ActorBySession(ctx context.Context, sessionToken string) (Actor, error)
Logout(ctx context.Context, sessionToken string) error

15
backend/internal/logpolicy/policy.go

@ -32,6 +32,16 @@ var (
// Sanitize converts text to a single injection-safe log line, removes common
// credential forms, and enforces a Unicode-character bound.
func Sanitize(value string, maximum int) (string, bool) {
return sanitizeText(value, maximum, false)
}
// SanitizeMultiline preserves serial whitespace for a text-only UI while applying
// the same credential redaction as single-line operational logs.
func SanitizeMultiline(value string, maximum int) (string, bool) {
return sanitizeText(value, maximum, true)
}
func sanitizeText(value string, maximum int, multiline bool) (string, bool) {
redacted := false
if strings.Contains(strings.ToUpper(value), "PRIVATE KEY-----") {
value = "[REDACTED PRIVATE KEY MATERIAL]"
@ -58,12 +68,17 @@ func Sanitize(value string, maximum int) (string, bool) {
value = next
}
value = strings.Map(func(r rune) rune {
if multiline && (r == '\n' || r == '\r' || r == '\t') {
return r
}
if unicode.IsControl(r) {
return ' '
}
return r
}, value)
if !multiline {
value = strings.Join(strings.Fields(value), " ")
}
if maximum > 0 {
runes := []rune(value)
if len(runes) > maximum {

7
backend/internal/logruntime/scheduler.go

@ -269,12 +269,7 @@ func (o SerialObserver) Observe(ctx context.Context, now time.Time) ([]domain.Li
if !probe.Enabled {
continue
}
state := "unknown"
if probe.Up {
state = "up"
} else if probe.EverUp && probe.State == serial.CRProbeStateNoResponse {
state = "down"
}
state := serial.CRConnectionStatus(probe, now)
result = append(result, domain.LinkStateObservation{
Kind: "serial", Resource: probe.Channel, State: state,
Signal: "cr", ObservedAt: now,

12
backend/internal/logruntime/scheduler_test.go

@ -154,8 +154,16 @@ func TestSerialObserverUsesCRProbeForUnifiedConnectionTransitions(t *testing.T)
Channel: "CH06", Enabled: true, State: serial.CRProbeStateNoResponse, Up: false, EverUp: true,
}}}
values, err = observer.Observe(context.Background(), now.Add(time.Second))
if err != nil || len(values) != 1 || values[0].State != "down" || values[0].Signal != "cr" {
t.Fatalf("CR down observations = %+v, %v", values, err)
if err != nil || len(values) != 1 || values[0].State != "unknown" || values[0].Signal != "cr" {
t.Fatalf("CR no response must remain indeterminate = %+v, %v", values, err)
}
observer.Probes = fakeCRProbeController{values: []serial.CRProbeState{{
Channel: "CH06", Enabled: true, EverUp: true, LastResponseAt: &now,
State: serial.CRProbeStateNoResponse,
}}}
values, err = observer.Observe(context.Background(), now.Add(4*time.Second))
if err != nil || len(values) != 1 || values[0].State != "up" {
t.Fatalf("transient CR miss must use shared grace period = %+v, %v", values, err)
}
}

8
backend/internal/logstorage/free_linux.go

@ -4,6 +4,14 @@ package logstorage
import "golang.org/x/sys/unix"
func filesystemTotalBytes(path string) (int64, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return 0, err
}
return int64(stat.Blocks) * int64(stat.Bsize), nil
}
func filesystemFreeBytes(path string) (int64, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {

2
backend/internal/logstorage/free_other.go

@ -7,3 +7,5 @@ import "fmt"
func filesystemFreeBytes(string) (int64, error) {
return 0, fmt.Errorf("filesystem free-space inspection is unsupported on this platform")
}
func filesystemTotalBytes(path string) (int64, error) { return filesystemFreeBytes(path) }

13
backend/internal/logstorage/free_windows.go

@ -10,6 +10,19 @@ import (
var getLogDiskFreeSpaceEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GetDiskFreeSpaceExW")
func filesystemTotalBytes(path string) (int64, error) {
pathUTF16, err := syscall.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var total uint64
result, _, callErr := getLogDiskFreeSpaceEx.Call(uintptr(unsafe.Pointer(pathUTF16)), 0, uintptr(unsafe.Pointer(&total)), 0)
if result == 0 {
return 0, fmt.Errorf("GetDiskFreeSpaceExW: %w", callErr)
}
return int64(total), nil
}
func filesystemFreeBytes(path string) (int64, error) {
pathUTF16, err := syscall.UTF16PtrFromString(path)
if err != nil {

33
backend/internal/logstorage/rotation_limit_test.go

@ -0,0 +1,33 @@
package logstorage
import (
"strings"
"testing"
"time"
)
func TestAuditCapacityRotatesOldestActiveSegment(t *testing.T) {
store := openTestStore(t, Options{AuditCapBytes: 1400})
for i := 0; i < 5; i++ {
if _, err := store.Append(ClassAudit, time.Now().Add(time.Duration(i)*time.Second), map[string]string{"message": strings.Repeat(string(rune('a'+i)), 400)}); err != nil {
t.Fatal(err)
}
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatal(err)
}
if snapshot.UsageBytes["audit"] > 1400 {
t.Fatalf("capacity exceeded: %+v", snapshot)
}
var messages []string
store.mu.Lock()
err = store.walkClassLocked(ClassAudit, false, func(event Envelope) error { messages = append(messages, string(event.Payload)); return nil })
store.mu.Unlock()
if err != nil {
t.Fatal(err)
}
if len(messages) == 0 || strings.Contains(strings.Join(messages, ""), strings.Repeat("a", 100)) || !strings.Contains(messages[len(messages)-1], strings.Repeat("e", 100)) {
t.Fatalf("rotation retained wrong records: %v", messages)
}
}

71
backend/internal/logstorage/store.go

@ -159,6 +159,14 @@ func Open(options Options) (*Store, error) {
free := options.FreeBytes
if free == nil {
free = filesystemFreeBytes
total, err := filesystemTotalBytes(root)
if err != nil {
return nil, err
}
// Reserve at least 10% of the filesystem: log capacity is fixed at 90%.
if total/10 > options.GlobalFreeFloor {
options.GlobalFreeFloor = total / 10
}
}
store := &Store{
root: root, segment: segmentBytes, indexCap: options.IndexCapBytes,
@ -793,16 +801,18 @@ func (s *Store) ensureCapacityLocked(class string, incoming int64) error {
bucket := bucketForClass(class)
capBytes := s.caps[bucket]
if capBytes > 0 {
if incoming > capBytes {
return fmt.Errorf("%w: incoming record exceeds class capacity", ErrCapacityExhausted)
}
usage, err := directoryUsage(filepath.Join(s.root, bucket))
if err != nil {
return err
}
if usage+incoming > capBytes {
if class == ClassAudit {
if err := s.reclaimExpiredLocked(bucket, usage+incoming-capBytes); err != nil {
if err := s.sealBucketLocked(bucket); err != nil {
return err
}
} else if err := s.reclaimOldestLocked(bucket, usage+incoming-capBytes); err != nil {
if err := s.reclaimOldestLocked(bucket, usage+incoming-capBytes); err != nil {
return err
}
usage, err = directoryUsage(filepath.Join(s.root, bucket))
@ -821,19 +831,10 @@ func (s *Store) ensureCapacityLocked(class string, incoming int64) error {
}
if free-incoming < s.floor {
needed := s.floor - (free - incoming)
if err := s.reclaimOldestLocked("port", needed); err != nil && !errors.Is(err, ErrCapacityExhausted) {
return err
}
free, err = s.freeBytes(s.root)
if err != nil {
if err := s.reclaimGlobalOldestLocked(needed); err != nil {
return err
}
if free-incoming < s.floor {
needed = s.floor - (free - incoming)
if err := s.reclaimOldestLocked("system", needed); err != nil && !errors.Is(err, ErrCapacityExhausted) {
return err
}
}
free, err = s.freeBytes(s.root)
if err != nil || free-incoming < s.floor {
return fmt.Errorf("%w: free=%d incoming=%d floor=%d", ErrCapacityExhausted, free, incoming, s.floor)
@ -843,31 +844,45 @@ func (s *Store) ensureCapacityLocked(class string, incoming int64) error {
return nil
}
func (s *Store) reclaimExpiredLocked(bucket string, needed int64) error {
cutoff := s.clock().UTC().Add(-time.Duration(s.retention.AuditDays) * 24 * time.Hour)
candidates, err := s.bucketSegmentsLocked(bucket)
if err != nil {
func (s *Store) sealBucketLocked(bucket string) error {
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
if bucketForClass(class) == bucket && s.classes[class] != nil && s.classes[class].ActiveBytes > 0 {
if err := s.rotateLocked(class); err != nil {
return err
}
var reclaimed int64
for _, path := range candidates {
class := classFromSegment(path)
meta, err := inspectSegment(path, class, false, nil)
if err != nil {
}
}
return nil
}
func (s *Store) reclaimGlobalOldestLocked(needed int64) error {
var candidates []segmentMeta
for _, bucket := range []string{"audit", "system", "port"} {
if err := s.sealBucketLocked(bucket); err != nil {
return err
}
if meta.Newest.Before(cutoff) {
if err := os.Remove(path); err != nil {
}
for _, segments := range s.closed {
candidates = append(candidates, segments...)
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].Oldest.Equal(candidates[j].Oldest) {
return candidates[i].Path < candidates[j].Path
}
return candidates[i].Oldest.Before(candidates[j].Oldest)
})
var reclaimed int64
for _, meta := range candidates {
if err := os.Remove(filepath.Join(s.bucketPath(meta.Class), meta.Path)); err != nil {
return err
}
s.removeClosedMetaLocked(meta.Class, meta.Path)
reclaimed += meta.Bytes
s.removeClosedMetaLocked(class, filepath.Base(path))
if reclaimed >= needed {
return nil
}
}
}
return fmt.Errorf("%w: protected audit reservation cannot be reclaimed", ErrCapacityExhausted)
return fmt.Errorf("%w: insufficient log segments to rotate", ErrCapacityExhausted)
}
func (s *Store) reclaimOldestLocked(bucket string, needed int64) error {

95
backend/internal/portservice/gateway.go

@ -40,6 +40,7 @@ type Options struct {
Listen ListenFunc
Dial DialFunc
ListenHost string
ListenInterface func(string, string) (net.Listener, error)
SSHSigner ssh.Signer
KnownHostsPath string
RetryDelay time.Duration
@ -67,6 +68,7 @@ type Gateway struct {
mu sync.Mutex
services map[string]*service
sequence atomic.Uint64
pools map[string]*sessionPool
}
type serviceConfig struct {
@ -85,6 +87,7 @@ type service struct {
done chan struct{}
clients atomic.Int32
pending atomic.Int32
pool *sessionPool
}
type Snapshot struct {
@ -99,6 +102,8 @@ type ServiceSnapshot struct {
Host string `json:"host,omitempty"`
State string `json:"state"`
ActiveClients int `json:"active_clients"`
SessionIPs []string `json:"session_ips"`
Interface string `json:"interface"`
Sources []string `json:"sources,omitempty"`
Targets []string `json:"targets,omitempty"`
}
@ -124,7 +129,7 @@ func New(options Options) (*Gateway, error) {
if err != nil {
return nil, err
}
return &Gateway{options: options, broker: broker, services: make(map[string]*service)}, nil
return &Gateway{options: options, broker: broker, services: make(map[string]*service), pools: make(map[string]*sessionPool)}, nil
}
// Apply reconciles the active operation set. Unchanged services are retained so
@ -218,6 +223,7 @@ func (g *Gateway) Snapshot() Snapshot {
Channel: item.config.channel, Operation: item.config.number, Mode: item.config.operation.Mode,
Port: item.config.operation.Port, Host: item.config.operation.Host, State: serviceState(item.config),
ActiveClients: int(item.clients.Load()),
SessionIPs: item.pool.snapshot(), Interface: fmt.Sprintf("eth%d", item.config.number-1),
Sources: append([]string(nil), item.config.sources...),
Targets: append([]string(nil), item.config.targets...),
})
@ -258,7 +264,7 @@ func (s *service) stop() {
func operationConfigs(ports []domain.Port) ([]serviceConfig, error) {
result := make([]serviceConfig, 0, len(ports)*2)
listeners := make(map[int]string)
listeners := make(map[string]string)
redirects := make(map[string][]string)
for _, port := range ports {
if directstream.ValidateChannel(port.Channel) != nil {
@ -268,16 +274,18 @@ func operationConfigs(ports []domain.Port) ([]serviceConfig, error) {
if operation.Mode == "" || operation.Mode == "disabled" {
continue
}
operation.MaxClients = port.Operation1.MaxClients
config := serviceConfig{key: fmt.Sprintf("%s-operation-%d", port.Channel, index+1), channel: port.Channel, number: index + 1, operation: operation}
switch operation.Mode {
case "reverse_telnet", "reverse_ssh":
if operation.Port < 1 || operation.Port > 65535 {
return nil, fmt.Errorf("%s listener port is invalid", config.key)
}
if previous := listeners[operation.Port]; previous != "" {
listenerKey := fmt.Sprintf("%d:%d", index, operation.Port)
if previous := listeners[listenerKey]; previous != "" {
return nil, fmt.Errorf("listener port %d conflicts between %s and %s", operation.Port, previous, config.key)
}
listeners[operation.Port] = config.key
listeners[listenerKey] = config.key
case "telnet", "ssh":
if strings.TrimSpace(operation.Host) == "" || operation.Port < 1 || operation.Port > 65535 {
return nil, fmt.Errorf("%s normal mode requires host and port", config.key)
@ -360,7 +368,12 @@ func serviceState(config serviceConfig) string {
func (g *Gateway) startLocked(config serviceConfig) (*service, error) {
ctx, cancel := context.WithCancel(context.Background())
current := &service{config: config, cancel: cancel, done: make(chan struct{})}
pool := g.pools[config.channel]
if pool == nil {
pool = &sessionPool{}
g.pools[config.channel] = pool
}
current := &service{config: config, cancel: cancel, done: make(chan struct{}), pool: pool}
switch config.operation.Mode {
case "reverse_telnet", "reverse_ssh":
if config.operation.Mode == "reverse_ssh" && g.options.SSHSigner == nil {
@ -371,7 +384,14 @@ func (g *Gateway) startLocked(config serviceConfig) (*service, error) {
cancel()
return nil, err
}
listener, err := g.options.Listen("tcp", net.JoinHostPort(g.options.ListenHost, strconv.Itoa(config.operation.Port)))
address := net.JoinHostPort(g.options.ListenHost, strconv.Itoa(config.operation.Port))
var listener net.Listener
var err error
if g.options.ListenInterface != nil {
listener, err = g.options.ListenInterface(fmt.Sprintf("eth%d", config.number-1), address)
} else {
listener, err = g.options.Listen("tcp", address)
}
if err != nil {
cancel()
return nil, err
@ -492,11 +512,12 @@ func (g *Gateway) serveTelnet(ctx context.Context, connection net.Conn, current
} else if config.operation.Authentication != "none" {
return
}
if !beginTerminalSession(current) {
release, accepted := beginTerminalSession(current, connection.RemoteAddr().String())
if !accepted {
_, _ = connection.Write([]byte("Too many clients.\r\n"))
return
}
defer current.clients.Add(-1)
defer release()
g.bridge(ctx, connection, reader, config, actor, remoteEndpoint(connection.RemoteAddr()), true)
}
@ -551,7 +572,8 @@ func (g *Gateway) serveSSH(ctx context.Context, connection net.Conn, current *se
continue
}
go acceptShellRequests(channelRequests)
if !beginTerminalSession(current) {
release, accepted := beginTerminalSession(current, serverConnection.RemoteAddr().String())
if !accepted {
_, _ = channel.Write([]byte("Too many clients.\r\n"))
_ = channel.Close()
return
@ -561,22 +583,65 @@ func (g *Gateway) serveSSH(ctx context.Context, connection net.Conn, current *se
actor = serverConnection.Permissions.Extensions["actor"]
}
g.bridge(ctx, channel, channel, config, actor, remoteEndpoint(serverConnection.RemoteAddr()), true)
current.clients.Add(-1)
release()
_ = channel.Close()
return
}
}
func beginTerminalSession(current *service) bool {
type sessionPool struct {
mu sync.Mutex
slots [4]string
}
func (p *sessionPool) snapshot() []string {
if p == nil {
return []string{"", "", "", ""}
}
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.slots[:]...)
}
func beginTerminalSession(current *service, endpoint string) (func(), bool) {
maximum := current.config.operation.MaxClients
if maximum < 1 {
maximum = 1
}
if current.clients.Add(1) <= int32(maximum) {
return true
if maximum > 4 {
maximum = 4
}
current.clients.Add(-1)
return false
host, _, err := net.SplitHostPort(endpoint)
if err != nil {
host = endpoint
}
if host == "" {
host = "unknown"
}
pool := current.pool
pool.mu.Lock()
defer pool.mu.Unlock()
active := 0
for _, value := range pool.slots {
if value != "" {
active++
}
}
if active >= maximum {
return nil, false
}
for index := range pool.slots {
if pool.slots[index] != "" {
continue
}
pool.slots[index] = host
current.clients.Add(1)
var once sync.Once
return func() {
once.Do(func() { pool.mu.Lock(); pool.slots[index] = ""; pool.mu.Unlock(); current.clients.Add(-1) })
}, true
}
return nil, false
}
func acceptShellRequests(requests <-chan *ssh.Request) {

26
backend/internal/portservice/listen_linux.go

@ -0,0 +1,26 @@
//go:build linux
package portservice
import (
"context"
"net"
"syscall"
"golang.org/x/sys/unix"
)
// Bind the socket to the physical interface, including before DHCP assigns an
// address. A wildcard listener must never expose operation 1 on eth1 or vice versa.
func ListenOnInterface(device, address string) (net.Listener, error) {
config := net.ListenConfig{Control: func(_, _ string, raw syscall.RawConn) error {
var socketErr error
if err := raw.Control(func(fd uintptr) {
socketErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, device)
}); err != nil {
return err
}
return socketErr
}}
return config.Listen(context.Background(), "tcp4", address)
}

12
backend/internal/portservice/listen_other.go

@ -0,0 +1,12 @@
//go:build !linux
package portservice
import (
"fmt"
"net"
)
func ListenOnInterface(device, address string) (net.Listener, error) {
return nil, fmt.Errorf("physical interface binding for %s requires Linux", device)
}

52
backend/internal/portservice/shared_sessions_test.go

@ -0,0 +1,52 @@
package portservice
import (
"testing"
"tera-clone/services/tera-api/internal/domain"
)
func TestOperationsShareSessionLimitAndStableIPSlots(t *testing.T) {
pool := &sessionPool{}
ssh := &service{config: serviceConfig{operation: domain.PortOperation{MaxClients: 2}}, pool: pool}
telnet := &service{config: serviceConfig{operation: domain.PortOperation{MaxClients: 2}}, pool: pool}
first, ok := beginTerminalSession(ssh, "192.168.0.100:12345")
if !ok {
t.Fatal("first SSH session rejected")
}
second, ok := beginTerminalSession(telnet, "192.168.200.100:23456")
if !ok {
t.Fatal("second Telnet session rejected")
}
if _, ok := beginTerminalSession(telnet, "192.168.200.101:34567"); ok {
t.Fatal("combined limit exceeded")
}
if slots := pool.snapshot(); slots[0] != "192.168.0.100" || slots[1] != "192.168.200.100" {
t.Fatalf("slots = %v", slots)
}
first()
first()
third, ok := beginTerminalSession(telnet, "[2001:db8::100]:45678")
if !ok {
t.Fatal("released slot not reusable")
}
if slots := pool.snapshot(); slots[0] != "2001:db8::100" || slots[1] != "192.168.200.100" {
t.Fatalf("stable slots = %v", slots)
}
third()
second()
if ssh.clients.Load() != 0 || telnet.clients.Load() != 0 {
t.Fatal("session counter leaked")
}
}
func TestOperationTwoUsesOperationOneLimitAndSeparateInterfacePort(t *testing.T) {
ports := []domain.Port{{Channel: "CH20", Operation1: domain.PortOperation{Mode: "reverse_ssh", Port: 10020, MaxClients: 2}, Operation2: domain.PortOperation{Mode: "reverse_telnet", Port: 10020, MaxClients: 4}}}
configs, err := operationConfigs(ports)
if err != nil {
t.Fatal(err)
}
if len(configs) != 2 || configs[0].operation.MaxClients != 2 || configs[1].operation.MaxClients != 2 {
t.Fatalf("configs = %+v", configs)
}
}

23
backend/internal/serial/connection_status.go

@ -0,0 +1,23 @@
package serial
import "time"
// Allow several normal four-second sweeps to recover from a slow/noisy reply.
// A CR timeout alone cannot establish physical cable disconnection.
const CRResponseGracePeriod = 15 * time.Second
func CRConnectionStatus(probe CRProbeState, now time.Time) string {
if !probe.Enabled {
return "unknown"
}
if probe.Up {
return "up"
}
if probe.EverUp && probe.LastResponseAt != nil {
age := now.Sub(*probe.LastResponseAt)
if age >= 0 && age <= CRResponseGracePeriod {
return "up"
}
}
return "unknown"
}

34
backend/internal/serial/connection_status_test.go

@ -0,0 +1,34 @@
package serial
import (
"testing"
"time"
)
func TestCRConnectionStatusToleratesTransientMissWithoutInventingCableDown(t *testing.T) {
now := time.Date(2026, 9, 14, 1, 0, 0, 0, time.UTC)
probe := CRProbeState{Enabled: true, EverUp: true, LastResponseAt: &now}
for _, state := range []string{CRProbeStateNoResponse, CRProbeStateBusy, CRProbeStateSuppressed, CRProbeStateError} {
probe.State = state
for _, age := range []time.Duration{0, 4 * time.Second, CRResponseGracePeriod} {
if got := CRConnectionStatus(probe, now.Add(age)); got != "up" {
t.Fatalf("transient %s at %s = %s", state, age, got)
}
}
if got := CRConnectionStatus(probe, now.Add(CRResponseGracePeriod+time.Millisecond)); got != "unknown" {
t.Fatalf("stale %s = %s; no response cannot prove cable down", state, got)
}
}
probe.EverUp = false
if CRConnectionStatus(probe, now) != "unknown" {
t.Fatal("unconfirmed response promoted")
}
probe.Up = true
if CRConnectionStatus(probe, now) != "up" {
t.Fatal("confirmed reply ignored")
}
probe.Enabled = false
if CRConnectionStatus(probe, now) != "unknown" {
t.Fatal("disabled probe promoted")
}
}

40
demo-guide/07-system-improvements-20260914.md

@ -0,0 +1,40 @@
# 2026-09-14 수정본 확인 절차
**현재 상태: 192.168.0.223 장비 배포 완료, 준비 상태 12/12 및 바이너리 해시 일치 확인.** 대시보드 위치는 사용자 정정에 따라 1~48번으로 고정했습니다. 아래 절차로 실제 기능을 확인합니다. 기존 문서의 포트 고급 설정, 별칭 위치, 로그 삭제 확인 문구는 이 문서로 대체합니다.
현재 환경의 기준은 관리 주소 `192.168.0.223`, PC `192.168.0.100`, 외부 장비가 연결된 CH20입니다. eth1의 `192.168.200.4`는 이전 DHCP 주소이므로 적용 전 네트워크 화면에서 다시 확인합니다. COM4는 TERA 관리 콘솔이며 CH20 송수신 시험 창과 다릅니다.
| 기능 | UI 설정·시험 | 확인할 결과 |
|---|---|---|
| 별칭 | **포트 상태 → 포트 별칭**에서 CH20 선택 → 구분 가능한 긴 이름 입력 → 변경 | 다시 CH20을 선택해 같은 이름 확인. 대시보드 포트 위치는 1~48번으로 고정되고 채널·상태가 같은 줄에 표시. 아래 별칭은 넘칠 때 `…`, 마우스를 올리면 전체 이름 표시 |
| 상태·이벤트 | 포트 상태 표와 **로그 → 포트 상태 이벤트** 확인 | 별칭 열과 이벤트의 채널 옆 별칭 표시. 링크 변화가 없으면 새 이벤트가 생기지 않아도 정상 |
| 설정 변경 | **포트 설정**에서 다른 채널의 개별 보기 선택 → 해당 채널 설정 변경 | 기존 채널의 설정을 PATCH로 변경. 개별 보기로 이동하면 이전의 다중 변경 대상이 따라붙지 않음. 여러 채널 적용은 ‘변경 대상’을 명시적으로 선택 |
| 포트 설정 탭 | Port 1 설정을 열고 포트 선택에서 26 클릭 | 기존 탭의 같은 위치에서 ‘포트 26 설정’으로 바뀌며 탭이 추가되지 않음. 새로고침해도 포트 설정 탭은 하나만 유지 |
| 변경 대상 체크박스 | 확대된 카드에서 ‘변경 대상’ 옆 체크박스를 선택·해제 | 체크와 카드 선택색이 함께 바뀜. 카드 위쪽 숫자는 개별 설정 전환, 아래 체크박스는 여러 포트의 변경 대상 선택 |
| 순간적인 응답 누락 | CH20·CH26을 연결한 채 대시보드와 상태 이벤트를 관찰 | 마지막 CR 응답 후 15초 이내의 일시적인 누락은 UP 유지. 계속 응답이 없으면 Unknown(미확인). CR 무응답만으로 케이블이 끊겼다고 판단하거나 DOWN 알림을 발생시키지 않음 |
| 동작 1 | **포트 설정 → CH20 → 동작 설정 1 · eth0**에서 Reverse SSH, 최대 세션 2 설정 | Listen Port는 `10020` 고정. PC PuTTY에서 관리 주소 `192.168.0.223`, SSH, 포트 `10020`으로 접속. 장비에 등록된 로그인 계정 사용 |
| 동작 2 | **동작 설정 2 · eth1**에서 Reverse Telnet 설정 | Listen Port `10020`, 최대 세션 2가 읽기 전용으로 표시. 두 설정을 합쳐 2개까지 접속 가능 |
| 합산 제한 | 위 SSH 연결을 유지하고 eth1 주소의 `10020`에 Telnet으로 접속. 그 뒤 세 번째 연결 시도 | 앞의 두 연결은 유지되고 세 번째는 `Too many clients`로 거절. 포트 상태에서 세션 1·2 IP 확인. 하나를 종료하면 해당 칸이 비워지고 다시 연결 가능 |
| eth1 접근 조건 | PC에서 현재 eth1 주소까지 통신 가능한지 먼저 확인 | 지금 PC가 관리망에만 연결돼 있고 eth1 경로가 없으면 SSH/Telnet 혼합 시험은 아직 수행할 수 없음. 이것을 세션 제한 실패로 판정하지 않음. 관리망의 SSH 두 창으로 총 세션·IP 표시부터 시험 가능 |
| 수신 로그 | **CH20 → 데이터 로그**에서 Receive 기록, ASCII 선택. CH20 상대 장비의 기존 인터페이스 입력 화면에서 `d` 입력 | `Invalid interface`가 실제 수신되어 포트 로그에 표시. 줄바꿈과 ASCII 대소문자 유지. HEX로 바꾸고 같은 동작을 반복하면 A~F 대문자 표시 |
| 키워드 알림 | **기타 설정 → 알림 설정 → 시리얼 이벤트**, CH20, 수신 키워드 감지 켜기, `Invalid interface`, 메일 전달 켜기 → 적용 | 같은 CH20 화면에서 `d` 입력 후 감지·메일 결과 확인. 삭제된 포트 고급 설정 탭으로 이동하지 않음. SMTP 자체 전달 여부는 기존 Gmail → Daum 메일 시험으로 먼저 확인 |
| 로그 삭제 | 관리자 로그인 → 로그 화면에서 대상 선택 → 로그 클리어 → 현재 로그인 계정의 비밀번호 | 틀린 비밀번호는 거절, 기존 로그 유지. 올바른 비밀번호는 지정 범위 삭제. 취소는 삭제 요청을 보내지 않음 |
| 전체 삭제 범위 | 포트 로그에서 전체 포트 선택 / 상태 이벤트 / 시스템 로그를 각각 시험 | 포트 통신 로그 전체 / 상태 이벤트 전체 / 시스템·감사 로그가 각각 삭제. 필터 기간·화면 표시 개수와 무관하며, 새로 발생한 로그는 계속 저장 |
| 관리자 제한 | 포트 운영자로 로그인 | 포트 로그 메뉴·데이터 로그 설정 탭 접근 불가. 관련 API 변경도 관리자 권한 필요 |
| 저장 공간 | 로그 화면의 저장 공간 확인 | 90% 고정 표시. 파일시스템 최소 10% 여유 공간을 확보하고, 클래스별 저장 한도나 여유 공간 한도에 걸리면 오래된 로그 세그먼트부터 회수. 원격 전송만 켜더라도 기존 기본 로컬 기록은 유지 |
| 직접 연결 | **직접 연결 모드** 열기 | 기존 포트 디버그의 새 이름. 데이터 형식 선택은 ASCII, HEX 순서. 내려받은 직접 연결 기록도 ASCII 다음 HEX |
| 진단 | **관리자 운영 → 진단 도구**에서 PC `192.168.0.100` 핑 | 진단 결과 표시. 별칭 편집 항목 없음. 네트워크 고급 설정은 유지 |
| 백업·복원 | 현재 설정 백업 → 구분 가능한 별칭 변경 → 백업 복원 | 복원 적용·영구 저장 성공 후 ‘복원 완료’ 알림과 자동 재시작. 재접속해서 백업 당시 별칭 확인. 복원이 실패하면 재시작하지 않음 |
| 초기화 | 백업 확보 후 **공장 초기화** → 현재 계정 비밀번호 → 최종 확인 버튼 | 문자 ‘공장 초기화’ 입력 불필요. 잘못된 비밀번호로는 초기화되지 않음. 승인·인증 성공 후 초기화 및 재시작 |
비밀번호 변경은 사이드바 메뉴만 제거했습니다. 최초 로그인 필수 변경과 관리자의 계정 비밀번호 재설정은 유지한 상태입니다.
## 검증 범위
- 백엔드 전체 Go 테스트 통과. 이후 수정한 API·저장소·로그 정책·포트 서비스도 재검증했습니다.
- 프런트엔드 전체 Go 테스트 통과.
- Edge의 로컬 시험 데이터로 대시보드 고정 위치·말줄임표, 포트 상태의 세션 IP 4열, 별칭 PATCH, 동작 2 세션 읽기 전용, 삭제 비밀번호 입력·취소를 확인했습니다.
- Linux ARM64 실행 파일 3종을 만들었습니다. `.pkg` 펌웨어 패키지나 장비 배포 완료를 뜻하지 않습니다.
- 추가 수정본은 API·웹을 다시 배포했습니다. 포트 위치 1~48 유지, 기존 포트 탭 통합, 체크박스 선택·해제와 새로고침, 1440/1024/800/480px 화면 너비, 콘솔 아이콘 제거를 로컬 Edge에서 확인했습니다. 장비 준비 상태 12/12 및 설치 파일·정적 JS 해시 일치를 확인했습니다.
- 장비 CH26에서 100ms 응답 대기 두 번 실패 후 다음 주기 정상 응답을 직접 관찰했습니다. 15초 재확인 및 지속 무응답의 미확인 판정은 Go 회귀 테스트로 검증했습니다. 케이블을 실제로 뽑는 시험은 진행하지 않았습니다.
- eth0/eth1 동일 포트 바인딩은 장비의 일반 사용자 권한으로 검증했습니다. 실제 SSH/Telnet 혼합 접속, 실제 저장 공간 회전, 실제 복원·초기화 후 재부팅은 장비에 실행하지 않았습니다. 종전 부팅 지연도 해결됐다고 판정하지 않았습니다.

2
demo-guide/README.md

@ -1,5 +1,7 @@
# 현재 장비·현재 PC로 하는 TERA 시연
> **2026-09-14 수정본:** 장비 배포 및 준비 상태 확인을 완료했습니다. 새 UI에서는 [수정본 확인 절차](07-system-improvements-20260914.md)를 우선 적용하세요. 아래의 포트 고급 설정·별칭 위치·로그 삭제 확인 문구는 구버전 기준입니다.
**이 문서부터 따라 하세요.** 장비 `192.168.0.223`, 지금 사용하는 PC `192.168.0.100`, 이미 실행 중인 Docker 인증 서버, 이전에 사용한 Gmail → Daum 조합에 맞춘 절차입니다. 새로운 PC나 스위치가 있다고 가정하지 않습니다.
확인일: 2026-09-10. 아래 ‘현재 확인’은 읽기 점검 결과이며, 버튼을 눌러 수행할 시연 결과는 별도로 기록합니다.

65
final-build/README.md

@ -1,57 +1,28 @@
# 최종 장비 빌드
릴리스: **20260910-demo-log-controls-1** · Linux ARM64 · 시연 장비: http://192.168.0.223/
**릴리스 20260914-port-selection-3 · Linux ARM64 · 192.168.0.223 배포 완료**
2026-09-10 프론트엔드·API·로그 데몬에 이어 메일 알림 데몬을 갱신했습니다. 설치 SHA256 일치와 서비스 준비 **12/12**를 확인했습니다. 나머지 네 파일은 이전 최종 빌드 세트에서 유지했습니다.
장비 준비 상태 12/12, HTTP 80·HTTPS 443 서비스, 설치 파일 8개와 final-build 폴더의 SHA256 일치를 확인했습니다. API·웹은 이번 수정본으로 빌드·배포했고, logd는 같은 날 배포한 시스템 개선 빌드입니다. 나머지 데몬은 현재 장비 설치본과 맞춘 파일입니다.
메일 수정: 발송 시 현재 비밀번호 저장 파일을 읽어 UI에서 새로 저장한 SMTP/Trap 자격 증명을 반영합니다. 기존 테스트 메일 재시도는 **16:29:10 KST에 delivered**로 완료됐습니다. 이 상태는 SMTP 서버의 수락이며 수신함 도착 확인과는 구분합니다. 메일 데몬의 이전 실행 파일은 `/userdata/tera-backups/alarm-live-secrets-20260910`에 보관했습니다.
final-build는 애플리케이션·데몬 실행 파일 모음이며 boot/rootfs를 포함한 전체 펌웨어 `.pkg`가 아닙니다. 사용자 계정, 장비 설정, 인증서 개인키와 런타임 로그는 포함하지 않습니다.
시리얼 키워드 수정: 실제 seriald가 logd에 기록한 새 RX 데이터도 알림 검사 대상에 포함합니다. 분할 수신을 이어서 비교하고 같은 기록을 반복 발송하지 않습니다. API와 배포 환경의 검사 주기는 2초입니다. 장비 적용 해시 및 12/12 준비 완료를 확인했으며 이전 API/환경 파일은 `/userdata/tera-backups/serial-keyword-20260910`에 보관했습니다. 과거 로그 재발송은 하지 않으므로 새 RX로 시연합니다.
| 파일 | 장비 설치 경로 |
|---|---|
| frontend/tera-clone-demo-web | /usr/bin/tera-clone-demo-web |
| backend/tera-clone-demo-api | /usr/bin/tera-api |
| backend/tera-logd | /usr/bin/tera-logd |
| backend/tera-clone-demo-alarmd | /usr/bin/tera-alarmd |
| backend/tera-netd | /usr/bin/tera-netd |
| backend/tera-seriald | /usr/bin/tera-seriald |
| backend/tera-sessiond | /usr/bin/tera-sessiond |
| backend/tera-timed | /usr/bin/tera-timed |
이 디렉터리는 실행 파일 모음입니다. boot/rootfs를 포함한 전체 펌웨어 `.pkg`는 아닙니다. 장비 설정, 사용자 계정, 인증서 개인키와 런타임 로그는 포함하지 않습니다.
이번 수정은 포트 설정 탭 하나에서 채널 전환, 확대된 카드와 변경 대상 체크박스, 직접 연결 모드의 콘솔 아이콘 제거를 포함합니다. 대시보드 포트 위치는 1~48번으로 고정합니다. CR 응답이 잠시 누락돼 발생하던 DOWN 표시에는 15초 재확인을 적용했고, 지속적인 CR 무응답은 케이블 단절로 단정하지 않고 미확인으로 표시합니다. API와 상태 이벤트가 같은 CR 판정 함수를 사용합니다.
## 파일과 설치 경로
앞선 시스템 개선에는 포트 별칭 관리와 세션 IP, eth0/eth1 공유 세션 제한, Receive/HEX 표시, 90% 로그 회전, 관리자 로그 관리, 현재 계정 비밀번호 재인증, 복원 완료 후 재시작이 포함됩니다. 기존 메일·시리얼 키워드 알림 수정도 유지합니다.
| 파일 | 장비 설치 경로 | 서비스 | 이번 변경 |
|---|---|---|---|
| frontend/tera-clone-demo-web | /usr/bin/tera-clone-demo-web | S61teraweb | 갱신 |
| backend/tera-clone-demo-api | /usr/bin/tera-api | S50tera-api | 갱신 |
| backend/tera-logd | /usr/bin/tera-logd | S44tera-logd | 갱신 |
| backend/tera-timed | /usr/bin/tera-timed | S45tera-timed | 유지 |
| backend/tera-clone-demo-alarmd | /usr/bin/tera-alarmd | S47tera-alarmd | 비밀번호 갱신 반영 수정 |
| backend/tera-seriald | /usr/bin/tera-seriald | S40tera-seriald | 유지 |
| backend/tera-sessiond | /usr/bin/tera-sessiond | S55tera-sessiond | 유지 |
| backend/tera-netd | /usr/bin/tera-netd | S43tera-netd | 유지 |
검증: 변경된 API·시리얼·상태 이벤트 Go 테스트, 프런트엔드 전체 Go 테스트, 탭·화면 전환·대시보드 JavaScript 테스트 19개, 로컬 Edge UI 검증을 통과했습니다. UI 검증은 시험 데이터로 수행했으며 장비에서 로그 삭제·복원·초기화·전원 재부팅은 실행하지 않았습니다.
설치 실행 권한은 0755입니다. 이 장비의 웹 설정은 HTTP 80과 기존 서비스 구성을 유지합니다. 이번 로그 기능에는 일치하는 API·웹·로그 데몬이 모두 필요합니다. 배포 시 기존 바이너리를 백업하고 로그 데몬의 소켓 준비를 확인한 다음 API를 시작해야 합니다.
이전 실행 파일 백업: `/userdata/tera-backups/port-selection-20260914` (API·웹), `/userdata/tera-backups/system-improvements-20260914` (앞선 시스템 개선).
## 반영 내용
- 사용자 계정 작업 셀 정렬 수정.
- 포트 운영자의 로그 메뉴와 로그 API 접근 제거.
- 인증 서버의 ‘장애 시 인증’, 주소 필터의 ‘안전 안내’ 고정 UI 제거.
- DDNS IPv6 항목 제거. DDNS 화면에서 적용하면 IPv6 사용을 끄고 IPv6 도메인을 비웁니다.
- 시리얼 이벤트 감지 키워드 편집 및 포트 고급 설정과 공유.
- 시스템 로그의 기존 감사 기록 포함 삭제.
- 포트 로그 단일 채널 및 전체 48포트 삭제(확인 문자열 ALL).
- 포트 상태 이벤트 전체 삭제(확인 문자열 LINKS). 현재 링크 상태는 변경하지 않습니다.
- 삭제 체크포인트 및 다른 접속의 캐시 갱신 처리.
## 검증 결과
- 백엔드: logstorage, logservice, api, adapters/mock 패키지 전체 테스트 통과.
- 프론트엔드: handlers, mock 패키지 테스트 통과.
- JavaScript: 삭제 제어 11개, 로그 표시 7개, 시리얼 키워드 1개 테스트 통과.
- 전체 포트 삭제 후 다른 종류 로그 유지, 상태 이벤트 삭제 후 재시작 복구 방지, 새 이벤트 수집과 다른 클라이언트 캐시 갱신 검증.
- 8개 실행 파일의 ELF64 AArch64 형식과 체크섬 확인.
- 장비 배포 후 웹의 ALL/LINKS 코드 제공 및 준비 상태 12/12 확인.
검증을 위해 장비의 기존 로그를 실제로 삭제하지 않았습니다. SNMP·인증서 신뢰·48채널 전체 트래픽·실기 기록 중 전원 차단 내성은 이 릴리스에서 완료했다고 주장하지 않습니다.
## 파일 무결성과 배포 기록
[SHA256SUMS](SHA256SUMS)에 모든 실행 파일의 해시가 있습니다. [release-manifest.json](release-manifest.json)에 크기·설치 경로·검증 범위가 있습니다.
이번 장비 백업: `/userdata/tera-backups/ui-settings-20260910-125259`.
[시연 자료](../demo-guide/README.md)에는 실제 주소 필터 차단 사례와 복구, 제공된 SSL 인증서 입력 화면, SNMP 시험, 알림·로그 삭제 절차와 결과 기록표가 포함되어 있습니다.
설정·시연 순서는 [시스템 개선 확인 절차](../demo-guide/07-system-improvements-20260914.md)를 참고하세요. 파일별 해시와 검증 범위는 `SHA256SUMS`, `release-manifest.json`에 기록했습니다.

14
final-build/SHA256SUMS

@ -1,8 +1,8 @@
7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0 frontend/tera-clone-demo-web
2ef9c70584f275deebac08711809be556f76f3601417f08cc62a0bc234b17cd2 frontend/tera-clone-demo-web
a7225f0786111cd3c714fe7f23a34778085c86b923d08bbc5e67dc6e8e611860 backend/tera-clone-demo-api
b5856899698bfafb1f382a3dd45db02b034ba785d56a075ea964c937b57a48b3 backend/tera-logd
ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd
bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1 backend/tera-clone-demo-api
5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c backend/tera-logd
0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef backend/tera-netd
c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4 backend/tera-seriald
34f1755b54a562d988ef5f922c101ab10af9f7a246fb604478595651449ee3f1 backend/tera-sessiond
d0babd9ae746bc4b7ec5931d66ddae0ad0782df0703256160c7233fbdea169a1 backend/tera-timed
2864dbde4fcde16898a3b187716b325883365a1fd39f16264a5186877f94c50c backend/tera-netd
1263fe6823fb07704ecbd0bc5be18db56f9f2b60ebafc1692e873c40bac8f01f backend/tera-seriald
a1d868818fa2bbb57996e5fe5786fb1838d337e223a23729cd243762c751aadf backend/tera-sessiond
c0cc73a1d390ef9964adc3a1623b90b8c7950a798af71a7201c93b1b58f12b12 backend/tera-timed

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

Binary file not shown.

BIN
final-build/backend/tera-logd

Binary file not shown.

BIN
final-build/backend/tera-netd

Binary file not shown.

BIN
final-build/backend/tera-seriald

Binary file not shown.

BIN
final-build/backend/tera-sessiond

Binary file not shown.

BIN
final-build/backend/tera-timed

Binary file not shown.

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

Binary file not shown.

106
final-build/release-manifest.json

@ -1,97 +1,93 @@
{
"release_id": "20260910-demo-log-controls-1",
"release_id": "20260914-port-selection-3",
"platform": "linux-arm64",
"scope": "application-and-daemon-binaries-not-full-firmware",
"generated_at": "2026-09-14T05:22:41.7644664+00:00",
"files": [
{
"path": "frontend/tera-clone-demo-web",
"bytes": 19063465,
"sha256": "7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0",
"bytes": 13238434,
"sha256": "2ef9c70584f275deebac08711809be556f76f3601417f08cc62a0bc234b17cd2",
"install_path": "/usr/bin/tera-clone-demo-web",
"updated_in_this_release": true
},
{
"path": "backend/tera-clone-demo-alarmd",
"bytes": 7711921,
"sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006",
"install_path": "/usr/bin/tera-alarmd",
"updated_in_this_release": true
"provenance": "built-and-deployed"
},
{
"path": "backend/tera-clone-demo-api",
"bytes": 9961634,
"sha256": "bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1",
"bytes": 10027170,
"sha256": "a7225f0786111cd3c714fe7f23a34778085c86b923d08bbc5e67dc6e8e611860",
"install_path": "/usr/bin/tera-api",
"updated_in_this_release": true
"provenance": "built-and-deployed"
},
{
"path": "backend/tera-logd",
"bytes": 4760736,
"sha256": "5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c",
"bytes": 3211426,
"sha256": "b5856899698bfafb1f382a3dd45db02b034ba785d56a075ea964c937b57a48b3",
"install_path": "/usr/bin/tera-logd",
"updated_in_this_release": true
"provenance": "built-and-deployed-earlier-today"
},
{
"path": "backend/tera-clone-demo-alarmd",
"bytes": 7711921,
"sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006",
"install_path": "/usr/bin/tera-alarmd",
"provenance": "existing-device-baseline"
},
{
"path": "backend/tera-netd",
"bytes": 3211426,
"sha256": "0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef",
"bytes": 4661671,
"sha256": "2864dbde4fcde16898a3b187716b325883365a1fd39f16264a5186877f94c50c",
"install_path": "/usr/bin/tera-netd",
"updated_in_this_release": false
"provenance": "copied-from-device"
},
{
"path": "backend/tera-seriald",
"bytes": 3408034,
"sha256": "c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4",
"bytes": 5020690,
"sha256": "1263fe6823fb07704ecbd0bc5be18db56f9f2b60ebafc1692e873c40bac8f01f",
"install_path": "/usr/bin/tera-seriald",
"updated_in_this_release": false
"provenance": "copied-from-device"
},
{
"path": "backend/tera-sessiond",
"bytes": 6226082,
"sha256": "34f1755b54a562d988ef5f922c101ab10af9f7a246fb604478595651449ee3f1",
"bytes": 9089559,
"sha256": "a1d868818fa2bbb57996e5fe5786fb1838d337e223a23729cd243762c751aadf",
"install_path": "/usr/bin/tera-sessiond",
"updated_in_this_release": false
"provenance": "copied-from-device"
},
{
"path": "backend/tera-timed",
"bytes": 2883746,
"sha256": "d0babd9ae746bc4b7ec5931d66ddae0ad0782df0703256160c7233fbdea169a1",
"bytes": 4329693,
"sha256": "c0cc73a1d390ef9964adc3a1623b90b8c7950a798af71a7201c93b1b58f12b12",
"install_path": "/usr/bin/tera-timed",
"updated_in_this_release": false
"provenance": "copied-from-device"
}
],
"source": {
"base_commit": "8d5936102974bf1fa2a2b10f63b04beac6cdaeef",
"changes": "Release source is included with this manifest in the Git commit; baseline daemons have individual provenance above."
},
"deployment": {
"device": "192.168.0.223",
"date": "2026-09-10",
"date": "2026-09-14",
"ready": true,
"completed": 12,
"total": 12,
"backup": "/userdata/tera-backups/ui-settings-20260910-125259",
"verified_updated_files": [
"tera-clone-demo-api",
"tera-clone-demo-web",
"tera-logd"
],
"verification": "Installed SHA256 values matched these three build artifacts. Other five artifacts retained from the previous final-build set."
"backup": "/userdata/tera-backups/port-selection-20260914",
"verified": "Eight binary SHA256 values match installed files; HTTP/HTTPS listeners and served workspace-tabs.js verified."
},
"validation": {
"backend": "go test ./internal/logstorage ./internal/logservice ./internal/api ./internal/adapters/mock passed",
"frontend": "go test ./internal/handlers ./internal/mock passed",
"javascript": "11 clear-control tests, 7 log-view tests and 1 serial-keyword test passed",
"limitations": "No existing device logs were deleted for verification. SNMP, certificate trust, full 48-port traffic and power-loss-on-device acceptance remain demonstration procedures, not claimed completed tests."
},
"mail_fix": {
"date": "2026-09-10",
"description": "Reload current secret snapshot for email and SNMP Trap delivery",
"backup": "/userdata/tera-backups/alarm-live-secrets-20260910",
"verified_sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006"
"backend": "Changed API, serial and logruntime tests passed; earlier system improvements passed the full backend suite.",
"frontend": "Full Go suite passed, then final page fixtures regenerated and checked.",
"javascript": "19 workspace, navigation and dashboard tests passed.",
"browser": "Local Edge fixtures: fixed positions, one settings tab, legacy migration, checkbox, refresh, 1440/1024/800/480 layouts, icon removal.",
"live": "Read-only CH20/CH26 CR cache samples observed; transient CH26 miss followed by normal reply.",
"limitations": "No cable-unplug, destructive action or power-cycle acceptance test; authenticated live UI was not tested in this run."
},
"serial_keyword_fix": {
"date": "2026-09-10",
"description": "Evaluate new seriald RX journal events and split keyword matches",
"backup": "/userdata/tera-backups/serial-keyword-20260910",
"poll_interval_seconds": 2,
"ready": true,
"ready_stages": "12/12"
}
"changes": [
"Keep channel positions fixed from 1 to 48",
"Replace port settings in one tab",
"Larger cards with target checkboxes",
"Remove serial console icon",
"Share CR response classification with 15-second grace; sustained silence is unknown, not cable down",
"Include earlier system/session/log/reauthentication improvements"
]
}

2
frontend/internal/handlers/account_roles_test.go

@ -47,7 +47,7 @@ func TestAccountRoleSelectorsExcludeViewer(t *testing.T) {
t.Errorf("page still offers removed viewer role: %s", removed)
}
}
for _, required := range []string{`value="administrator"`, `value="port_operator"`, "integration.js?v=20260909-config-read-1"} {
for _, required := range []string{`value="administrator"`, `value="port_operator"`, "integration.js?v=20260914-fixed-port-positions-2"} {
if !strings.Contains(body, required) {
t.Errorf("page is missing %s", required)
}

2
frontend/internal/handlers/admin_controls_test.go

@ -119,7 +119,7 @@ func TestFactoryResetPageRequiresAdministratorAndExplainsScope(t *testing.T) {
}
continue
}
for _, expected := range []string{"공장 초기화", `name="confirmation"`, "terauser / terauser", "자동 재부팅됩니다"} {
for _, expected := range []string{"공장 초기화", `name="password"`, "terauser / terauser", "자동 재부팅됩니다"} {
if !strings.Contains(res.Body.String(), expected) {
t.Errorf("missing reset warning: %s", expected)
}

2
frontend/internal/handlers/dashboard_test.go

@ -33,7 +33,7 @@ func TestDashboardRendersLiveMetricPlaceholdersAndAccessiblePortFilters(t *testi
t.Fatalf("dashboard status = %d", response.Code)
}
body := response.Body.String()
for _, required := range []string{"data-dashboard", `data-dashboard-value="cpu" data-i18n-skip>—`, `data-port-filter="all" aria-pressed="true"`, `data-port-filter="unknown"`, `role="status"`, "dashboard.js?v=20260907-storage-logs-1", "미확인은 아직 장비 응답", "실시간 속도가 아닙니다."} {
for _, required := range []string{"data-dashboard", `data-dashboard-value="cpu" data-i18n-skip>—`, `data-port-filter="all" aria-pressed="true"`, `data-port-filter="unknown"`, `role="status"`, "dashboard.js?v=20260914-fixed-port-positions-2", "미확인은 아직 장비 응답", "실시간 속도가 아닙니다."} {
if !strings.Contains(body, required) {
t.Errorf("missing dashboard element: %s", required)
}

25
frontend/internal/handlers/device_handler.go

@ -80,7 +80,7 @@ type loginPageData struct {
Error string
}
const sessionCookieName = "teraclone_session"
const sessionCookieName = "teraclone_session_http"
func NewDeviceHandler(appService *service.AppService, apiUpstream string) (*DeviceHandler, error) {
return NewDeviceHandlerWithSessionUpstream(appService, apiUpstream, "http://127.0.0.1:8081")
@ -142,7 +142,7 @@ func NewDeviceHandlerWithSessionUpstream(appService *service.AppService, apiUpst
director := proxy.Director
proxy.Director = func(request *http.Request) {
clientIP, publicProtocol, publicPort := browserClientMetadata(request)
if cookie, cookieErr := request.Cookie(sessionCookieName); cookieErr == nil && cookie.Value != "" {
if cookie, cookieErr := request.Cookie(browserSessionCookieName(secureBrowserRequest(request))); cookieErr == nil && cookie.Value != "" {
*request = *request.WithContext(context.WithValue(request.Context(), browserSessionContextKey{}, cookie.Value))
}
token, authenticated := handler.backendToken(request)
@ -525,7 +525,7 @@ func (h *DeviceHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Name: browserSessionCookieName(secureBrowserRequest(r)),
Value: token,
Path: "/",
HttpOnly: true,
@ -549,7 +549,7 @@ func (h *DeviceHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
return
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
if cookie, err := r.Cookie(browserSessionCookieName(secureBrowserRequest(r))); err == nil && cookie.Value != "" {
h.mu.Lock()
session, ok := h.sessions[cookie.Value]
delete(h.sessions, cookie.Value)
@ -593,7 +593,7 @@ func portOperatorPageAllowed(path string) bool {
if path == "/" || path == "/port-status" || path == "/password" {
return true
}
return strings.HasPrefix(path, "/ports/") && path != "/ports/restart"
return strings.HasPrefix(path, "/ports/") && path != "/ports/restart" && !strings.HasSuffix(path, "/data-log")
}
func (h *DeviceHandler) requireAPIAuth(w http.ResponseWriter, r *http.Request) (string, bool) {
@ -616,7 +616,7 @@ func (h *DeviceHandler) authenticatedUser(r *http.Request) (string, bool) {
}
func (h *DeviceHandler) authenticatedSession(r *http.Request) (browserSession, bool) {
cookie, err := r.Cookie(sessionCookieName)
cookie, err := r.Cookie(browserSessionCookieName(secureBrowserRequest(r)))
if err != nil || cookie.Value == "" {
return browserSession{}, false
}
@ -636,9 +636,16 @@ func (h *DeviceHandler) invalidateBrowserSession(browserToken string) {
h.mu.Unlock()
}
func browserSessionCookieName(secure bool) string {
if secure {
return "__Host-teraclone_session"
}
return sessionCookieName
}
func expiredSessionCookie(secure bool) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Name: browserSessionCookieName(secure),
Value: "",
Path: "/",
MaxAge: -1,
@ -683,8 +690,8 @@ func browserClientMetadata(r *http.Request) (string, string, string) {
}
func desktopClientID(r *http.Request) string {
address, _, _ := browserClientMetadata(r)
return address
address, protocol, _ := browserClientMetadata(r)
return protocol + ":" + address
}
func secureBrowserRequest(r *http.Request) bool {

57
frontend/internal/handlers/device_handler_integration_test.go

@ -546,7 +546,7 @@ func TestPortOperatorSeesOnlyDashboardPortStatusAndSerialPortMenus(t *testing.T)
t.Fatalf("port parameter page renders a guessed UP state before API synchronization: %d %s", parameterResponse.Code, parameterBody)
}
for _, section := range []string{"operation-2", "data-log", "advanced"} {
for _, section := range []string{"operation-1", "operation-2"} {
request := httptest.NewRequest(http.MethodGet, "/ports/1/"+section, nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
@ -692,3 +692,58 @@ func TestDirectStreamPathValidation(t *testing.T) {
}
}
}
func TestHTTPLoginAfterHTTPSKeepsIndependentCookiesAndSessions(t *testing.T) {
var calls atomic.Int32
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
_ = json.NewEncoder(w).Encode(map[string]any{"session_token": "backend-" + strconv.Itoa(int(calls.Add(1))), "actor": "terauser", "role": "administrator"})
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer backend.Close()
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
defer app.Close()
handler, err := NewDeviceHandler(app, backend.URL)
if err != nil {
t.Fatal(err)
}
login := func(scheme string) *http.Cookie {
request := httptest.NewRequest(http.MethodPost, scheme+"://192.168.0.223/login", strings.NewReader(url.Values{"username": {"terauser"}, "password": {"test"}}.Encode()))
request.RemoteAddr = "192.168.0.100:40000"
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.handleLogin(response, request)
if response.Code != http.StatusSeeOther || len(response.Result().Cookies()) != 1 {
t.Fatalf("login failed: %d", response.Code)
}
return response.Result().Cookies()[0]
}
secure := login("https")
plain := login("http")
if secure.Name == plain.Name || !secure.Secure || plain.Secure {
t.Fatal("HTTP can collide with secure cookie")
}
if secure.Name != "__Host-teraclone_session" || plain.Name == "teraclone_session" {
t.Fatal("legacy cookie collision")
}
for _, scheme := range []string{"http", "https"} {
request := httptest.NewRequest(http.MethodGet, scheme+"://192.168.0.223/", nil)
request.AddCookie(secure)
request.AddCookie(plain)
session, ok := handler.authenticatedSession(request)
if !ok || session.Username != "terauser" {
t.Fatalf("%s session lost", scheme)
}
if expiredSessionCookie(scheme == "https").Name != browserSessionCookieName(scheme == "https") {
t.Fatal("wrong cookie expired")
}
}
if len(handler.sessions) != 2 {
t.Fatal("HTTP login revoked HTTPS recovery session")
}
}

2
frontend/internal/handlers/log_view_test.go

@ -41,7 +41,7 @@ func TestLogExplorerPagesPreserveFiltersAndRoleRestrictions(t *testing.T) {
}
body := response.Body.String()
for _, required := range []string{
`log-view.js?v=20260907-storage-logs-1`, `port-log-clear.js?v=20260907-storage-logs-1`, `integration.js?v=20260909-config-read-1`,
`log-view.js?v=20260914-fixed-port-positions-2`, `port-log-clear.js?v=20260914-fixed-port-positions-2`, `integration.js?v=20260914-fixed-port-positions-2`,
`name="keyword"`, `name="from"`, `name="to"`, `name="limit"`, `name="auto_refresh"`,
`data-ui-action="primary-action"`, `data-ui-action="secondary-action"`, "로그 다운로드", "검색 초기화",
} {

2
frontend/internal/handlers/proxy_metadata_test.go

@ -20,7 +20,7 @@ func TestHTTPSGatewayMetadataTrustBoundary(t *testing.T) {
if ip != tc.ip || protocol != tc.protocol || port != tc.port {
t.Fatalf("peer %s: got %s/%s/%s", tc.peer, ip, protocol, port)
}
if desktopClientID(r) != tc.ip || secureBrowserRequest(r) != (tc.protocol == "https") {
if desktopClientID(r) != tc.protocol+":"+tc.ip || secureBrowserRequest(r) != (tc.protocol == "https") {
t.Fatal("session identity or secure cookie uses incorrect proxy metadata")
}
}

64
frontend/internal/handlers/system_improvements_test.go

@ -0,0 +1,64 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"teraclone/internal/service"
)
func TestSystemImprovementPagesAndRemovedNavigation(t *testing.T) {
handler, err := NewDeviceHandler(service.NewStatelessAppService(), "http://127.0.0.1:1")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
handler.sessions["qa-admin"] = browserSession{Username: "admin", BackendToken: "test-token", Role: "administrator", WorkspaceID: "local-ui-qa"}
for _, path := range []string{"/", "/port-status", "/ports/1/parameters", "/ports/26/parameters", "/ports/20/parameters", "/ports/20/operation-1", "/ports/20/operation-2", "/ports/20/data-log", "/port-debug", "/tools/nettest/ping", "/logs/ports", "/device/factory-reset"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "qa-admin"})
response := httptest.NewRecorder()
mux.ServeHTTP(response, req)
if response.Code != 200 {
t.Fatalf("%s: %d", path, response.Code)
}
html := response.Body.String()
for _, removed := range []string{`href="/tools/alias"`, `href="/password"`, `href="/ports/20/advanced"`} {
if strings.Contains(html, removed) {
t.Errorf("%s still contains %s", path, removed)
}
}
if path == "/port-status" && (!strings.Contains(html, "세션 4 IP") || !strings.Contains(html, "포트 별칭")) {
t.Fatal("port alias/session controls missing")
}
if strings.Contains(path, "operation-") && !strings.Contains(html, `value="10020"`) {
t.Fatal("channel listen port is not fixed")
}
if directory := os.Getenv("TERA_UI_QA_OUTPUT"); directory != "" {
if err := os.MkdirAll(directory, 0755); err != nil {
t.Fatal(err)
}
name := strings.ReplaceAll(strings.Trim(path, "/"), "/", "-")
if name == "" {
name = "dashboard"
}
if err := os.WriteFile(filepath.Join(directory, name+".html"), response.Body.Bytes(), 0644); err != nil {
t.Fatal(err)
}
}
}
for _, path := range []string{"/ports/20/advanced", "/tools/alias"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "qa-admin"})
response := httptest.NewRecorder()
mux.ServeHTTP(response, req)
if response.Code != 404 {
t.Fatalf("removed page %s returned %d", path, response.Code)
}
}
}

78
frontend/internal/mock/mock_data.go

@ -182,7 +182,7 @@ func ResolvePage(path string) (PageData, bool) {
return newPage(path, "공장 초기화", "공장 초기화", []string{"공장 초기화"}, nil, []ContentBlock{{
Kind: "form", Title: "공장 초기화", Subtitle: "저장된 장비 구성을 기본값으로 되돌리는 작업입니다. 실행 전 구성 백업을 권장합니다.",
Form: &FormData{Action: "factory-reset", PrimaryLabel: "공장 초기화 실행", Fields: []FormField{
{Label: "확인 문구", Name: "confirmation", Type: "text", Required: true, Placeholder: "공장 초기화", Hint: "실행하려면 ‘공장 초기화’를 정확히 입력하세요."},
{Label: "현재 계정 비밀번호", Name: "password", Type: "password", Required: true, Hint: "현재 로그인한 계정의 비밀번호로 인증합니다."},
}, Notes: []string{
"네트워크·포트·외부 인증 등 저장된 구성과 보류 중인 변경이 기본값으로 초기화됩니다.",
"사용자·사용자 정의 그룹·세션·로그·인증서·저장된 인증정보와 장비 내 진단 파일을 삭제합니다. 기본 계정 terauser / terauser만 복원됩니다.",
@ -365,7 +365,6 @@ func portPage(path string) (PageData, bool) {
{"동작 설정 1", fmt.Sprintf("/ports/%d/operation-1", portID), subPath == "operation-1"},
{"동작 설정 2", fmt.Sprintf("/ports/%d/operation-2", portID), subPath == "operation-2"},
{"데이터 로그", fmt.Sprintf("/ports/%d/data-log", portID), subPath == "data-log"},
{"고급 설정", fmt.Sprintf("/ports/%d/advanced", portID), subPath == "advanced"},
}
switch subPath {
@ -379,7 +378,6 @@ func portPage(path string) (PageData, bool) {
Action: "#",
PrimaryLabel: "적용",
Fields: []FormField{
textField("별칭 이름", "alias", fmt.Sprintf("Port-%02d", portID), false),
selectField("시리얼 타입", "serialType", "rs232", []string{"rs232"}),
selectField("Baudrate", "baudrate", "115200", []string{"110", "300", "600", "1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200", "230400", "460800"}),
selectField("Databit", "databit", "8", []string{"5", "6", "7", "8"}),
@ -397,9 +395,9 @@ func portPage(path string) (PageData, bool) {
case "operation-1":
return newPortPage(path, portID, subtabs, []ContentBlock{
portSelectorBlock(),
formBlock("동작 설정 1", "port-operation-1", []FormField{
formBlock("동작 설정 1 · eth0", "port-operation-1", []FormField{
portModeField("reverse_ssh"),
textField("Listen Port", "port", fmt.Sprintf("100%02d", portID), false),
fixedTextField("Listen Port", "port", fmt.Sprintf("%d", 10000+portID)),
fixedTextField("Keep alive(초)", "keepalive", "3"),
fixedTextField("ACK 타임아웃(ms)", "timeout", "10000"),
fixedTextField("접속(유휴) 타임아웃(ms)", "connectionTimeout", "0"),
@ -416,13 +414,13 @@ func portPage(path string) (PageData, bool) {
case "operation-2":
return newPortPage(path, portID, subtabs, []ContentBlock{
portSelectorBlock(),
formBlock("동작 설정 2", "port-operation-2", []FormField{
formBlock("동작 설정 2 · eth1", "port-operation-2", []FormField{
portModeField("disabled"),
textField("Listen Port", "port", fmt.Sprintf("110%02d", portID), false),
fixedTextField("Listen Port", "port", fmt.Sprintf("%d", 10000+portID)),
fixedTextField("Keep alive(초)", "keepalive", "3"),
fixedTextField("ACK 타임아웃(ms)", "timeout", "10000"),
fixedTextField("접속(유휴) 타임아웃(ms)", "connectionTimeout", "0"),
boundedNumberField("최대 세션 수", "maxClients", "4", true, "1", "4"),
fixedTextField("최대 세션 수 (동작 설정 1과 공유)", "maxClients", "4"),
authenticationField(),
selectField("줄바꿈", "newline", "cr", []string{"cr", "lf", "crlf"}),
shortcutField("제어 단축키", "controlShortcut", "^]", "Ctrl+] 후 T/Q/B/I/? 명령을 사용합니다."),
@ -436,7 +434,7 @@ func portPage(path string) (PageData, bool) {
return newPortPage(path, portID, subtabs, []ContentBlock{
portSelectorBlock(),
formBlock("데이터 로그", "port-data-log", []FormField{
toggleField("Read 기록", "read", true),
toggleField("Receive 기록", "read", true),
toggleField("Send 기록", "send", true),
toggleField("타임스탬프 표시", "showTimestamp", true),
toggleField("방향 표시", "showDirection", true),
@ -446,13 +444,6 @@ func portPage(path string) (PageData, bool) {
selectField("로그 형식", "format", "ascii", []string{"ascii", "hex"}),
}, "적용", ""),
}), true
case "advanced":
return newPortPage(path, portID, subtabs, []ContentBlock{
portSelectorBlock(),
formBlock("고급 설정", "port-advanced", []FormField{
FormField{Label: "Alarm 키워드", Name: "alarmKeyword", Type: "text", Hint: "대소문자를 구분하지 않는 문자열 일치 방식입니다. 수신 키워드 감지는 알림 설정에서 켤 수 있습니다."},
}, "적용", ""),
}), true
default:
return PageData{}, false
}
@ -489,7 +480,7 @@ func portDebugPage(path string) (PageData, bool) {
})
}
return newPage(path, "포트 디버그", fmt.Sprintf("포트 디버그 / Port %d", portID), []string{"포트 디버그"}, nil, []ContentBlock{
return newPage(path, "직접 연결 모드", fmt.Sprintf("직접 연결 모드 / Port %d", portID), []string{"직접 연결 모드"}, nil, []ContentBlock{
{
Kind: "serial-terminal",
Title: "시리얼 포트 CMD 콘솔",
@ -739,7 +730,7 @@ func toolsPage(path string) (PageData, bool) {
fixedTextField("핑 패킷 크기", "size", "56 bytes"),
fixedTextField("핑 응답 타임아웃", "replyTimeout", "1초 (고정)"),
}
return newPage(path, "진단 도구", "핑 테스트", []string{"진단 도구", "핑 테스트"}, nil, []ContentBlock{
return newPage(path, "관리자 운영", "진단 도구", []string{"관리자 운영", "진단 도구"}, nil, []ContentBlock{
{
Kind: "tool",
Title: "핑 테스트",
@ -751,18 +742,6 @@ func toolsPage(path string) (PageData, bool) {
},
},
}), true
case "/tools/alias":
portOptions := make([]Option, 0, 48)
for portNumber := 1; portNumber <= 48; portNumber++ {
portOptions = append(portOptions, Option{
Label: fmt.Sprintf("Port %d (CH%02d)", portNumber, portNumber),
Value: fmt.Sprintf("CH%02d", portNumber),
})
}
return simpleFormPage(path, "진단 도구", []string{"진단 도구", "포트 별칭"}, "포트 별칭", []FormField{
selectOptionsField("포트 선택", "channel", "CH01", portOptions),
textField("포트 별칭", "alias", "", true),
}), true
default:
return PageData{}, false
}
@ -911,12 +890,20 @@ func systemLogSettingsTabs(path string) []SubTab {
}
func portStatusPage() PageData {
options := make([]Option, 0, 48)
for number := 1; number <= 48; number++ {
options = append(options, Option{Label: fmt.Sprintf("CH%02d", number), Value: fmt.Sprintf("CH%02d", number)})
}
return newPage("/port-status", "포트 상태", "포트 상태", []string{"포트 상태"}, nil, []ContentBlock{
formBlock("포트 별칭", "port-alias", []FormField{
selectOptionsField("포트 선택", "channel", "CH01", options),
textField("포트 별칭", "alias", "", false),
}, "변경", ""),
{
Kind: "table",
Title: "포트 상태 요약",
Table: &TableData{
Columns: []string{"포트", "상태", "프로토콜", "세션", "RX/TX"},
Columns: []string{"포트", "상태", "별칭", "프로토콜", "세션", "RX/TX", "세션 1 IP", "세션 2 IP", "세션 3 IP", "세션 4 IP"},
EmptyMessage: "데이터가 없습니다.",
},
},
@ -997,6 +984,13 @@ func ApplyRole(page PageData, role string) PageData {
}
}
page.Menu = filtered
tabs := page.SubTabs[:0]
for _, tab := range page.SubTabs {
if !strings.HasSuffix(tab.Path, "/data-log") {
tabs = append(tabs, tab)
}
}
page.SubTabs = tabs
return page
}
@ -1191,13 +1185,11 @@ func menu(path string) []MenuGroup {
{Label: "대시보드", Icon: "DB", Path: "/"},
{Label: "네트워크 설정", Icon: "NW", Path: "/network/interface-1"},
{Label: "포트 설정", Icon: "PT", Path: "/ports/1/parameters"},
{Label: "포트 디버그", Icon: "DG", Path: "/port-debug"},
{Label: "직접 연결 모드", Icon: "DG", Path: "/port-debug"},
{Label: "관리자 운영", Icon: "AD", Path: "/admin/users"},
{Label: "기타 설정", Icon: "ST", Path: "/settings/alert/event"},
{Label: "진단 도구", Icon: "TL", Path: "/tools/nettest/ping"},
{Label: "로그", Icon: "LG", Path: "/logs/system"},
{Label: "포트 상태", Icon: "PS", Path: "/port-status"},
{Label: "비밀번호 변경", Icon: "PW", Path: "/password"},
{Label: "공장 초기화", Icon: "FR", Path: "/device/factory-reset"},
}
@ -1214,7 +1206,8 @@ func menu(path string) []MenuGroup {
case "포트 설정":
groups[i].Expanded = false
case "관리자 운영":
groups[i].Expanded = strings.HasPrefix(path, "/admin/")
groups[i].Expanded = strings.HasPrefix(path, "/admin/") || strings.HasPrefix(path, "/tools/nettest/")
groups[i].Active = groups[i].Expanded
groups[i].Children = append(groups[i].Children,
MenuChild{"사용자 계정", "/admin/users", strings.HasPrefix(path, "/admin/users")},
MenuChild{"인증 서버", "/admin/auth/radius", strings.HasPrefix(path, "/admin/auth/")},
@ -1223,6 +1216,7 @@ func menu(path string) []MenuGroup {
MenuChild{"SSL 인증서", "/admin/ssl", strings.HasPrefix(path, "/admin/ssl")},
MenuChild{"백업/복원", "/admin/backup/export", strings.HasPrefix(path, "/admin/backup/")},
MenuChild{"펌웨어", "/admin/firmware", path == "/admin/firmware"},
MenuChild{"진단 도구", "/tools/nettest/ping", strings.HasPrefix(path, "/tools/nettest/")},
)
case "기타 설정":
groups[i].Expanded = strings.HasPrefix(path, "/settings/")
@ -1231,12 +1225,6 @@ func menu(path string) []MenuGroup {
MenuChild{"시스템 로그", "/settings/system-log/remote", strings.HasPrefix(path, "/settings/system-log/")},
MenuChild{"날짜와 시간", "/settings/datetime", path == "/settings/datetime"},
)
case "진단 도구":
groups[i].Expanded = strings.HasPrefix(path, "/tools/")
groups[i].Children = append(groups[i].Children,
MenuChild{"네트워크 테스트", "/tools/nettest/ping", strings.HasPrefix(path, "/tools/nettest/")},
MenuChild{"포트 별칭", "/tools/alias", path == "/tools/alias"},
)
case "로그":
groups[i].Expanded = strings.HasPrefix(path, "/logs/")
groups[i].Children = append(groups[i].Children,
@ -1415,13 +1403,7 @@ func tabKeyForPath(path string) string {
case strings.HasPrefix(path, "/network/"):
return "network"
case strings.HasPrefix(path, "/ports/"):
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) >= 2 {
if portID, err := strconv.Atoi(parts[1]); err == nil {
return fmt.Sprintf("port-%d", portID)
}
}
return "ports"
return "port-settings"
case strings.HasPrefix(path, "/port-debug"):
return "port-debug"
case strings.HasPrefix(path, "/admin/snmp"):

84
frontend/web/static/css/style.css

@ -416,6 +416,7 @@ body[data-current-path="/"] .page-header h1 { font-size: 25px; letter-spacing: -
.dashboard .port-link { display: flex; flex-direction: column; gap: 6px; padding: 9px 10px; border: 1px solid #e1e7ef; background: #f6f8fb; border-radius: 7px; text-align: left; min-width: 0; }
.dashboard .port-link:hover { border-color: #6897d1; box-shadow: 0 0 0 2px #4488d815; }
.dashboard-port-top { display: flex; align-items: center; justify-content: space-between; gap: 5px; }
.dashboard-port-alias { display: block; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; color: #50627a; }
.dashboard .port-link-number { font-size: 13px; font-variant-numeric: tabular-nums; color: #3b4f69; }
.dashboard .port-link-state { font-size: 10px; margin: 0; color: #6a788c; line-height: 1.3; overflow-wrap: anywhere; }
.dashboard .port-link[data-port-state="up"] { background: #ecf8f2; border-color: #8dc6af; }
@ -548,6 +549,11 @@ body[data-current-path="/"] .page-header h1 { font-size: 25px; letter-spacing: -
.port-link-state { margin-top: 4px; color: #718197; }
.port-link-caption { font-size: 10px; color: #4f86d6; font-weight: 700; }
.port-link-apply {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
box-sizing: border-box;
width: 100%;
border: 0;
border-top: 1px solid rgba(94, 112, 135, 0.16);
@ -562,9 +568,12 @@ body[data-current-path="/"] .page-header h1 { font-size: 25px; letter-spacing: -
background: #3f97f7;
color: #fff;
}
.port-link-apply input[type="checkbox"] { width: 16px; height: 16px; margin: 0; flex: 0 0 16px; accent-color: #236dc7; cursor: pointer; }
.port-link-apply:focus-within { outline: 2px solid #236dc7; outline-offset: -2px; }
.port-link-apply span { white-space: nowrap; }
body[data-current-path^="/ports/"] .blocks {
grid-template-columns: clamp(360px, 23vw, 400px) minmax(0, 1fr);
grid-template-columns: clamp(510px, 34vw, 600px) minmax(0, 1fr);
column-gap: 18px;
align-items: stretch;
min-height: calc(100vh - 205px);
@ -611,23 +620,23 @@ 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: 5px;
gap: 7px;
}
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: 42px;
padding: 5px 3px;
gap: 1px;
font-size: 10px;
min-height: 58px;
padding: 8px 4px;
gap: 3px;
font-size: 12px;
}
body[data-current-path^="/ports/"] .port-selector-panel .port-link-number {
font-size: 11px;
font-size: 14px;
}
body[data-current-path^="/ports/"] .port-selector-panel .port-link-state {
margin-top: 0;
font-size: 9.5px;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
@ -635,12 +644,12 @@ body[data-current-path^="/ports/"] .port-selector-panel .port-link-caption {
display: none;
}
body[data-current-path^="/ports/"] .port-selector-panel .port-link-apply {
min-height: 21px;
padding: 4px 3px;
font-size: 10px;
min-height: 34px;
padding: 7px 3px;
font-size: 11px;
}
body[data-current-path^="/ports/"] .form-panel .form-grid {
grid-template-columns: repeat(2, minmax(240px, 360px));
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 360px));
}
body[data-current-path^="/ports/"] .form-panel .mock-form {
gap: 10px;
@ -936,22 +945,21 @@ body[data-current-path^="/network/interface-"] .network-mode-context { width: 78
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 12px 14px; border-bottom: 1px solid #eef2f6; text-align: left; color: #5e7087; }
.data-table th { background: #f3f6fa; font-weight: 700; color: #6d7d94; }
body[data-current-path="/port-status"] .blocks { justify-items: start; }
body[data-current-path="/port-status"] .panel { width: min(100%, 1120px); }
body[data-current-path="/port-status"] .data-table { table-layout: fixed; }
body[data-current-path="/port-status"] .blocks { justify-items: stretch; }
body[data-current-path="/port-status"] .panel { width: 100%; min-width: 0; }
body[data-current-path="/port-status"] .table-wrap { max-height: 65vh; }
body[data-current-path="/port-status"] .data-table { table-layout: fixed; min-width: 1040px; }
body[data-current-path="/port-status"] .data-table th,
body[data-current-path="/port-status"] .data-table td { padding: 8px 12px; }
body[data-current-path="/port-status"] .data-table th:nth-child(1),
body[data-current-path="/port-status"] .data-table td:nth-child(1) { width: 12%; }
body[data-current-path="/port-status"] .data-table th:nth-child(2),
body[data-current-path="/port-status"] .data-table td:nth-child(2) { width: 18%; }
body[data-current-path="/port-status"] .data-table th:nth-child(3),
body[data-current-path="/port-status"] .data-table td:nth-child(3) { width: 28%; }
body[data-current-path="/port-status"] .data-table th:nth-child(4),
body[data-current-path="/port-status"] .data-table td:nth-child(4) { width: 14%; }
body[data-current-path="/port-status"] .data-table th:nth-child(5),
body[data-current-path="/port-status"] .data-table td:nth-child(5) { width: 28%; }
.form-row.automation-hidden { display: none; }
body[data-current-path="/port-status"] .data-table td { padding: 9px 8px; overflow-wrap: anywhere; }
body[data-current-path="/port-status"] .data-table th { position: sticky; top: 0; z-index: 1; background: #f3f6fa; white-space: nowrap; }
body[data-current-path="/port-status"] .data-table th:nth-child(1) { width: 6%; }
body[data-current-path="/port-status"] .data-table th:nth-child(2) { width: 9%; }
body[data-current-path="/port-status"] .data-table th:nth-child(3) { width: 15%; }
body[data-current-path="/port-status"] .data-table th:nth-child(4) { width: 10%; }
body[data-current-path="/port-status"] .data-table th:nth-child(5) { width: 5%; }
body[data-current-path="/port-status"] .data-table th:nth-child(n+6) { width: 11%; }
body[data-current-path="/port-status"] .data-table td:nth-child(3) { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.port-status-badge {
display: inline-flex;
align-items: center;
@ -1219,7 +1227,7 @@ body[data-current-path^="/logs/"] .log-result-panel .table-wrap { max-height: ma
.log-result-panel[data-log-kind="system"] th:nth-child(3) { width: 84px; }
.log-result-panel[data-log-kind="system"] th:nth-child(4) { width: 118px; }
.log-result-panel:not([data-log-kind="system"]) .data-table { min-width: 890px; }
.log-result-panel:not([data-log-kind="system"]) th:nth-child(2) { width: 76px; }
.log-result-panel:not([data-log-kind="system"]) th:nth-child(2) { width: 160px; }
.log-result-panel:not([data-log-kind="system"]) th:nth-child(3) { width: 84px; }
.log-result-panel:not([data-log-kind="system"]) th:nth-child(4) { width: 114px; }
.log-result-panel:not([data-log-kind="system"]) th:nth-child(5) { width: 92px; }
@ -1385,18 +1393,6 @@ body[data-current-path="/tools/alias"] .mock-form .form-grid {
font-size: 11px;
color: #9fb0c5;
}
.serial-console-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 34px;
border: 1px solid #4b5c72;
border-radius: 4px;
background: #0b1220;
color: #7ee787;
font: 700 12px/1 Consolas, "Courier New", monospace;
}
.serial-console-controls {
display: flex;
align-items: center;
@ -1865,7 +1861,7 @@ body[data-current-path="/tools/alias"] .mock-form .form-grid {
grid-column: 1;
}
body[data-current-path^="/ports/"] .port-selector-panel .port-link-grid {
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
}
body[data-current-path^="/ports/"] .form-panel .form-grid {
grid-template-columns: 1fr;
@ -1897,3 +1893,9 @@ body[data-current-path="/tools/alias"] .mock-form .form-grid {
.login-progress[data-state="delayed"] { border-color: #e4c58d; background: #fff9ed; }
.login-progress[data-state="delayed"] p { color: #875d13; }
.login-progress[data-state="delayed"] .login-progress-track span { background: #c88b23; animation: none; }
.reauth-dialog { border: 1px solid #d7dfeb; border-radius: 12px; padding: 24px; width: min(440px, calc(100vw - 48px)); }
.reauth-dialog::backdrop { background: #10203580; }
.reauth-dialog label { display: block; margin: 20px 0; }
.reauth-dialog input { display: block; width: 100%; margin-top: 8px; }
.reauth-dialog button { margin-right: 8px; }

17
frontend/web/static/js/app.js

@ -185,7 +185,7 @@
writeOpenTabs(nextTabs);
if (getCurrentTab().key === tabKey) {
if (/^port-\d+$/.test(tabKey)) {
if (tabKey === "port-settings" || /^port-\d+$/.test(tabKey)) {
window.localStorage.removeItem(portTargetsKey);
}
const fallbackTab = nextTabs[tabIndex] || nextTabs[tabIndex - 1];
@ -421,12 +421,8 @@
const shortcutButton = card.querySelector('[data-port-shortcut="' + number + '"]');
if (shortcutButton) {
shortcutButton.classList.toggle("selected", checked);
const actionLabel = checked ? "제외" : "추가";
const accessibleLabel = "Port " + number + " 적용 대상 " + actionLabel;
shortcutButton.textContent = actionLabel;
shortcutButton.setAttribute("aria-label", accessibleLabel);
shortcutButton.title = accessibleLabel;
shortcutButton.checked = checked;
shortcutButton.closest(".port-link-apply").classList.toggle("selected", checked);
}
}
@ -449,13 +445,11 @@
card.classList.toggle("selected");
syncCard(card);
writeStoredTargets();
showToast(card.classList.contains("selected") ? ("Port " + number + " 추가됨") : ("Port " + number + " 제외됨"));
showToast(card.classList.contains("selected") ? ("Port " + number + " 변경 대상 선택") : ("Port " + number + " 변경 대상 해제"));
}
document.querySelectorAll("[data-port-shortcut]").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
button.addEventListener("change", function () {
const number = button.getAttribute("data-port-shortcut");
if (number) {
togglePort(number);
@ -469,6 +463,7 @@
if (!number) {
return;
}
portCards.forEach(function (card) { card.classList.toggle("selected", card.getAttribute("data-port-card") === number); syncCard(card); });
writeStoredTargets();
navigate("/ports/" + number + "/" + currentSection);
});

9
frontend/web/static/js/dashboard.js

@ -172,14 +172,17 @@
const port = byNumber.get(Number(tile.dataset.dashboardPort));
const view = port?.__linkView;
const state = ["up", "down", "session"].includes(view?.visualKey) ? view.visualKey : "unknown";
const label = state === "unknown" ? "미확인" : view.label;
const label = state === "up" || state === "session" ? "UP" : state.toUpperCase();
tile.dataset.portState = state;
text(tile.querySelector(".port-link-state"), label);
const description = "포트 " + tile.dataset.dashboardPort + ": " + label;
const alias = String(port?.alias || "");
text(tile.querySelector(".dashboard-port-alias"), alias || "—");
const description = "포트 " + tile.dataset.dashboardPort + ": " + label + (alias ? ", " + alias : "");
if (tile.getAttribute("aria-label") !== description) { tile.setAttribute("aria-label", description); }
const detail = view?.summary || "RX 또는 CR 응답 대기";
const detail = [alias, view?.summary || "RX 또는 CR 응답 대기"].filter(Boolean).join("\n");
if (tile.title !== detail) { tile.title = detail; }
});
// Channel positions stay fixed when link states change.
const summary = summarizePorts(ports, tiles.length);
value(container, "active-ports", summary.active);
value(container, "total-ports", summary.counts.all);

10
frontend/web/static/js/i18n.js

@ -139,7 +139,7 @@
"CPU 평균 (1 / 5 / 15분)": "CPU load (1 / 5 / 15 min)",
"네트워크 설정": "Network Settings",
"포트 설정": "Port Settings",
"포트 디버그": "Port Debug",
"직접 연결 모드": "Direct Connection Mode",
"관리자 운영": "Administration",
"기타 설정": "System Settings",
"진단 도구": "Diagnostics",
@ -497,7 +497,7 @@
"v3 암호화": "v3 Privacy",
"v3 암호화 키": "v3 Privacy Key",
"v3 접근 권한": "v3 Access",
"Read 기록": "Record Reads",
"Receive 기록": "Record Receives",
"Send 기록": "Record Sends",
"원격 로그": "Remote Log",
"원격 로그 전송 사용": "Enable Remote Log Forwarding",
@ -822,8 +822,8 @@
if (Object.prototype.hasOwnProperty.call(exact, label)) { return exact[label]; }
const portSettings = label.match(/^포트 설정 \/ Port (\d+)$/);
if (portSettings) { return "Port Settings / Port " + portSettings[1]; }
const portDebug = label.match(/^포트 디버그 \/ Port (\d+)$/);
if (portDebug) { return "Port Debug / Port " + portDebug[1]; }
const portDebug = label.match(/^직접 연결 모드 \/ Port (\d+)$/);
if (portDebug) { return "Direct Connection Mode / Port " + portDebug[1]; }
return label;
}
@ -841,7 +841,7 @@
[/^로그인 - (.+)$/, "Sign in - $1"],
[/^포트 (%d|\d+) 설정$/, "Port $1 Settings"],
[/^포트 설정 \/ Port (%d|\d+)$/, "Port Settings / Port $1"],
[/^포트 디버그 \/ Port (%d|\d+)$/, "Port Debug / Port $1"],
[/^직접 연결 모드 \/ Port (%d|\d+)$/, "Direct Connection Mode / Port $1"],
[/^Port (\d+) 개별 보기$/, "View Port $1"],
[/^Port (\d+) 적용 대상 추가$/, "Add Port $1 to Targets"],
[/^(\d+)건$/, "$1 Items"],

87
frontend/web/static/js/integration.js

@ -5,13 +5,10 @@
const loadedUIVersion = document.body.dataset.uiVersion || "";
let portSettingsPath = /^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2|data-log|advanced)$/.test(currentPath);
const toastRoot = document.getElementById("toastRoot");
const portCounterSnapshots = new Map();
const portVisualStates = new Map();
const portSyncIntervalMs = 500;
const dashboardSyncIntervalMs = 5000;
const portRuntimeStatusPath = "/ports/runtime-status";
const recentRXWindowMs = 10000;
const crProbeDownMisses = 2;
const portStateConfirmationSamples = 2;
const portUnknownConfirmationSamples = 8;
let portSyncTimer = null;
@ -656,47 +653,21 @@
}
function classifyPort(port) {
const runtime = port.runtime || {};
const channel = String(port.channel || "");
const rxBytes = Number(runtime.rx_bytes) || 0;
const txBytes = Number(runtime.tx_bytes) || 0;
const previous = portCounterSnapshots.get(channel);
// Local TX (including the CR probe itself) is never proof of a remote endpoint.
const rxAgeMillis = Number(runtime.rx_age_ms);
const receivedTraffic = Boolean(previous && rxBytes > previous.rxBytes) ||
(Number.isFinite(rxAgeMillis) && rxAgeMillis >= 0 && rxAgeMillis <= recentRXWindowMs);
const crProbeState = String(runtime.cr_probe_state || "").toLowerCase();
// The API applies the same CR response grace period as the event observer.
// Cached counters and a skipped probe must not independently resurrect UP.
const status = String(port.status || "").toLowerCase();
const connectedUsers = Number(port.connected_users) || 0;
const crProbeUp = runtime.cr_probe_up === true;
const crProbeEverUp = runtime.cr_probe_ever_up === true;
const crProbeMisses = Number(runtime.cr_probe_consecutive_misses) || 0;
const crProbeDown = crProbeEverUp && !crProbeUp &&
crProbeState === "no_response" && crProbeMisses >= crProbeDownMisses;
const sessionReported = runtime.session_active === true ||
status === "session" || status === "active" || connectedUsers > 0;
const sessionActive = sessionReported && (crProbeEverUp || crProbeUp || status === "session");
const sessionCount = sessionActive ? Math.max(connectedUsers, 1) : 0;
portCounterSnapshots.set(channel, { rxBytes: rxBytes, txBytes: txBytes });
if (sessionActive) {
if (status === "session") {
const sessionCount = Math.max(connectedUsers, 1);
return { active: true, tone: "session", label: "Session_" + sessionCount, sessionCount: sessionCount, summary: "포트 세션 " + sessionCount + "명 연결 중" };
}
// A confirmed probe failure must override stale RX history. Otherwise a
// disconnected port can remain visually UP until the RX history expires.
if (crProbeDown) {
return { active: false, tone: "error", label: "DOWN", summary: "이전에 확인된 장비의 통신 응답이 끊어짐" };
}
if (receivedTraffic || crProbeUp) {
return { active: true, tone: "connected", label: "UP", summary: "상대 장비 데이터 수신" };
if (status === "down") {
return { active: false, tone: "error", label: "DOWN", summary: "링크 신호 끊김 확인" };
}
// DOWN is only reachable after this port has previously been confirmed UP.
// Transient probe states must not introduce extra user-facing states.
if (crProbeEverUp) {
return { active: true, tone: "connected", label: "UP", summary: runtime.cr_probe_message || "마지막으로 확인된 연결 상태 유지" };
if (status === "up") {
return { active: true, tone: "connected", label: "UP", summary: "상대 장비 응답 확인 · 일시적 응답 누락은 15초간 재확인" };
}
return { active: false, tone: "waiting", label: "Unknown", summary: "RX 또는 CR 응답 대기" };
return { active: false, tone: "waiting", label: "Unknown", summary: "장비 응답 미확인 · 응답이 없다는 이유만으로 케이블 단절을 판정할 수 없습니다." };
}
function normalizePortView(view) {
@ -796,7 +767,8 @@
function renderPortStatusRows(ports) {
const rows = ports.map(function (port) {
const runtime = port.runtime || {};
return [port.channel, portView(port).label, port.mode, Number(port.connected_users) || 0, formatBytes(runtime.rx_bytes) + " / " + formatBytes(runtime.tx_bytes)];
const sessionIPs = (runtime.operations || []).find(function (operation) { return Array.isArray(operation.session_ips); })?.session_ips || [];
return [port.channel, portView(port).label, port.alias || "—", port.mode, Number(port.connected_users) || 0, formatBytes(runtime.rx_bytes) + " / " + formatBytes(runtime.tx_bytes)].concat([0, 1, 2, 3].map(function (index) { return sessionIPs[index] || "—"; }));
});
const body = panelByTitle("포트 상태 요약")?.querySelector("tbody");
if (!body) {
@ -804,10 +776,10 @@
}
const existingRows = Array.from(body.rows);
const reusable = existingRows.length === rows.length && existingRows.every(function (row) {
return row.cells.length === 5;
return row.cells.length === 10;
});
if (!reusable) {
renderRows("포트 상태 요약", rows, 5);
renderRows("포트 상태 요약", rows, 10);
} else {
rows.forEach(function (values, rowIndex) {
values.forEach(function (cellValue, cellIndex) {
@ -824,6 +796,7 @@
}
Array.from(body.rows).forEach(function (row, rowIndex) {
row.cells[2].title = String(ports[rowIndex].alias || "");
const view = portView(ports[rowIndex]);
const visualKey = ["up", "down", "session"].includes(view.visualKey) ? view.visualKey : "unknown";
const label = visualKey === "up" ? "UP" : visualKey === "down" ? "DOWN" : visualKey === "session" ? view.label : "Unknown";
@ -1863,7 +1836,9 @@
key: value(root, "key"), content: content, include_network: includeNetwork
}});
await applyPendingConfiguration({ includeNetwork: includeNetwork });
toast("백업 구성을 장비에 복원하고 영구 저장했습니다.", "success");
await api("/system/reboot", { method: "POST" });
toast("복원 완료. 시스템을 자동 재시작합니다.", "success");
window.setTimeout(function () { window.location.assign("/login"); }, 2000);
} catch (error) { report(error); }
});
}
@ -1958,7 +1933,7 @@
setValue(root, "ignore", port.ignore_jammed);
bindForm(root, function () {
return savePort(port, {
alias: value(root, "alias"), serial_type: value(root, "serialType"),
serial_type: value(root, "serialType"),
baud_rate: integer(root, "baudrate", 115200), data_bits: integer(root, "databit", 8),
parity: value(root, "parity"), stop_bits_mode: value(root, "stopbit"),
flow_control: value(root, "flow"), rts_control: value(root, "rts"),
@ -1971,6 +1946,8 @@
const property = section === "operation-1" ? "operation_1" : "operation_2";
const operation = port[property] || {};
fillOperation(root, operation);
setValue(root, "port", 10000 + portNumber(channel));
if (property === "operation_2") { setValue(root, "maxClients", port.operation_1.max_clients); }
bindForm(root, function () {
const update = {};
update[property] = readOperation(root, operation);
@ -2496,7 +2473,7 @@
async function mountLogManagement(parent) {
if (document.body.dataset.currentRole !== "administrator") { return; }
const section=document.createElement("section");section.className="panel";
section.innerHTML='<h3>로그 저장 공간 · 보존 설정</h3><p data-storage-status role="status">저장 공간을 확인하고 있습니다.</p><form data-retention-form><div class="form-row"><label>시스템 보존일<input name="system_days" type="number" min="1" max="365" required></label><label>감사 보존일<input name="audit_days" type="number" min="1" max="365" required></label><label>포트 보존일<input name="port_days" type="number" min="1" max="365" required></label><label>용량 경고 (%)<input name="protected_usage_warning_pct" type="number" min="50" max="95" required></label></div><p>보존기간은 1~365일입니다. 기간을 줄이면 만료 로그가 삭제될 수 있습니다. 용량 부족 시 설정 기간보다 먼저 회수될 수 있습니다. 공장초기화 시 전체 로그를 삭제합니다.</p><button class="btn primary" type="submit">보존 설정 저장</button><button class="btn secondary" type="button" data-storage-refresh>상태 새로고침</button></form>';
section.innerHTML='<h3>로그 저장 공간 · 보존 설정</h3><p data-storage-status role="status">저장 공간을 확인하고 있습니다.</p><form data-retention-form><div class="form-row"><label>시스템 보존일<input name="system_days" type="number" min="1" max="365" required></label><label>감사 보존일<input name="audit_days" type="number" min="1" max="365" required></label><label>포트 보존일<input name="port_days" type="number" min="1" max="365" required></label><label>로그 저장 한도 (%)<input name="protected_usage_warning_pct" type="number" value="90" readonly></label></div><p>보존기간은 1~365일입니다. 기간을 줄이면 만료 로그가 삭제될 수 있습니다. 용량 부족 시 설정 기간보다 먼저 회수될 수 있습니다. 공장초기화 시 전체 로그를 삭제합니다.</p><button class="btn primary" type="submit">보존 설정 저장</button><button class="btn secondary" type="button" data-storage-refresh>상태 새로고침</button></form>';
parent.after(section);
const status=section.querySelector('[data-storage-status]');const retentionForm=section.querySelector('form');
let previousSample = null;
@ -2553,6 +2530,9 @@
field(root,"channel")?.querySelectorAll("option").forEach(function(option){if(option.value && !allowed.has(option.value)){option.remove();}});
}
const logPortAliases = new Map();
if (!systemLog) { (await api("/ports?limit=48&runtime=false")).forEach(function (port) { logPortAliases.set(port.channel, port.alias); }); }
function channelWithAlias(channel) { const alias = logPortAliases.get(channel); return channel ? channel + (alias ? " · " + alias : "") : "-"; }
const logView = window.teraLogView.mount(logKind, root, tablePanel);
const logPagePath = currentPath;
const logRequests = new AbortController();
@ -2917,7 +2897,7 @@
function portMessage(event) {
const details = [];
const message = readablePortEventMessage(event);
const message = event.display_text || readablePortEventMessage(event);
if (message) {
details.push(message);
}
@ -2930,7 +2910,7 @@
if (event.remote_address) {
details.push("원격: " + event.remote_address + (event.remote_port ? ":" + event.remote_port : ""));
}
return details.join(" · ") || "-";
return details.join("\n") || "-";
}
function rowsFor(events) {
@ -2942,14 +2922,14 @@
}
if (portLog) {
return ordered.map(function (event) {
return [formatLogTime(event.timestamp), event.channel || "-", eventTypeLabel(event.event_type), directionLabel(event.direction), event.actor || "-", portMessage(event)];
return [formatLogTime(event.timestamp), channelWithAlias(event.channel), eventTypeLabel(event.event_type), directionLabel(event.direction), event.actor || "-", portMessage(event)];
});
}
return ordered.map(function (event) {
const previous = event.previous_state ? String(event.previous_state).toUpperCase() + " → " : "";
const state = previous + String(event.state || "unknown").toUpperCase();
const eventLabel = "상태 변경";
return [formatLogTime(event.timestamp), event.resource || "-", String(event.signal || "-").toUpperCase(), state, eventLabel, event.message || "-"];
return [formatLogTime(event.timestamp), channelWithAlias(event.resource), String(event.signal || "-").toUpperCase(), state, eventLabel, event.message || "-"];
});
}
@ -3146,6 +3126,7 @@
const ports = decoratePortStates(await api(portRuntimeStatusPath));
updatePortMap(ports);
renderPortStatusRows(ports);
await aliases();
}
function serialTerminal() {
@ -3242,7 +3223,7 @@
if (value === 9) { return "\\t"; }
return value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : ".";
}).join("");
trafficLog.push(new Date().toISOString() + " " + direction + " [" + bytes.byteLength + " B] HEX=" + hex + " ASCII=" + ascii);
trafficLog.push(new Date().toISOString() + " " + direction + " [" + bytes.byteLength + " B] ASCII=" + ascii + " HEX=" + hex);
if (trafficLog.length > 20000) { trafficLog.splice(0, trafficLog.length - 20000); }
logState.textContent = uiText("로그") + " " + readableBytes(trafficBytes);
}
@ -4335,20 +4316,20 @@
if (!root) { return; }
markIntegrated(root);
const button = root.querySelector("button[type='submit']");
const confirmation = field(root, "confirmation");
const confirmation = field(root, "password");
let busy = false;
button.className = "btn danger";
function update() { button.disabled = busy || confirmation.value !== "공장 초기화"; }
function update() { button.disabled = busy || !confirmation.value; }
confirmation.addEventListener("input", update);
update();
root.addEventListener("submit", async function (event) {
event.preventDefault();
if (busy || confirmation.value !== "공장 초기화") { return; }
if (busy || !confirmation.value) { return; }
if (!window.confirm("사용자·설정·로그·세션·인증서 등 저장 데이터를 모두 삭제하고 자동 재부팅하시겠습니까? 기본 계정 terauser / terauser만 복원됩니다. 이 작업은 되돌릴 수 없습니다.")) { return; }
busy = true;
update();
try {
await api("/system/factory-reset", { method: "POST" });
await api("/system/factory-reset", { method: "POST", body: { password: confirmation.value, confirmed: true } });
confirmation.value = "";
toast("공장 초기화가 완료되어 재부팅합니다. 재부팅 후 terauser / terauser로 로그인하세요.", "success");
window.setTimeout(function () { window.location.assign("/login"); }, 1500);

2
frontend/web/static/js/log-view.js

@ -186,7 +186,7 @@
metadata.appendChild(pair);
});
detail.appendChild(metadata);
[["원본 메시지", event.message], ["명령", event.command], ["결과", event.return_value]].forEach(function (field) {
[["수신/전송 데이터", event.display_text], ["원본 메시지", event.message], ["명령", event.command], ["결과", event.return_value]].forEach(function (field) {
if (field[1] === null || field[1] === undefined || field[1] === "") { return; }
detail.append(node("h3", "", field[0]), raw(node("pre", "", field[1])));
});

40
frontend/web/static/js/port-log-clear.js

@ -56,21 +56,47 @@
area.setAttribute("aria-busy", String(busy));
}
function requestPassword(channel) {
return new Promise(function (resolve) {
const dialog = doc.createElement("dialog");
dialog.className = "reauth-dialog";
const form = doc.createElement("form");
form.method = "dialog";
const heading = node("h3", "", "로그 삭제 확인");
const description = node("p", "", channel + " 로그 전체를 삭제합니다. 삭제한 기록은 복구할 수 없습니다.");
const label = node("label", "", "현재 로그인 계정의 비밀번호");
const input = doc.createElement("input");
input.type = "password";
input.autocomplete = "current-password";
input.required = true;
label.appendChild(input);
const cancel = node("button", "btn secondary", "취소");
cancel.type = "button";
const submit = node("button", "btn danger", "인증 후 삭제");
submit.type = "submit";
form.append(heading, description, label, cancel, submit);
dialog.appendChild(form);
doc.body.appendChild(dialog);
let result = "";
cancel.addEventListener("click", function () { dialog.close(); });
form.addEventListener("submit", function (event) { event.preventDefault(); result = input.value; input.value = ""; dialog.close(); });
dialog.addEventListener("close", function () { input.value = ""; dialog.remove(); resolve(result); }, { once: true });
dialog.showModal();
input.focus();
});
}
async function clear() {
// Capture the exact target: changing filters cannot retarget deletion.
const channel = system ? "SYSTEM" : link ? "LINKS" : (select.value || "ALL");
if (busy || disposed || options.isDisposed() || (!system && !link && channel !== "ALL" && !channelPattern.test(channel))) { return; }
const prompt = system
? "시스템 이벤트와 감사 기록 전체를 삭제합니다.\n기간·검색 조건·표시 개수와 관계없이 삭제되며 복구할 수 없습니다. 포트 로그·포트 상태 이벤트는 보존됩니다.\n내보내기는 현재 검색 조건과 표시 개수 범위만 포함합니다.\n계속하려면 SYSTEM을 정확히 입력하세요."
: link ? "저장된 포트 상태 이벤트 전체를 삭제합니다.\n검색 조건과 관계없이 삭제되며 복구할 수 없습니다. 현재 링크 상태와 시스템·감사·포트 통신 로그는 유지됩니다.\n계속하려면 LINKS를 정확히 입력하세요."
: (channel === "ALL" ? "전체 포트(CH01~CH48)" : channel) + "의 저장된 포트 로그 전체를 삭제합니다.\n기간·검색 조건·표시 개수와 관계없이 RX/TX·명령·세션 기록이 삭제되며 복구할 수 없습니다.\n필요한 기록은 먼저 내보내세요. 내보내기에는 현재 검색 조건과 표시 개수 제한이 적용됩니다. 설정 백업에는 로그 원문이 포함되지 않습니다.\n" + (channel === "ALL" ? "시스템·감사·포트 상태 이벤트는 유지됩니다." : "다른 포트와 시스템·감사·포트 상태 이벤트는 유지됩니다.") + " 새 로그 수집은 계속됩니다.\n계속하려면 " + channel + "을 정확히 입력하세요.";
const confirmation = options.confirm(prompt);
if (confirmation !== channel || disposed || options.isDisposed()) { return; }
const password = await requestPassword(channel);
if (!password || disposed || options.isDisposed()) { return; }
busy = true;
update();
options.onBusy();
try {
const result = await options.send(system || link ? { confirmation: confirmation } : { channel: channel, confirmation: confirmation });
const result = await options.send(system || link ? { password: password } : { channel: channel, password: password });
if (!disposed && !options.isDisposed()) { options.onSuccess(channel, result); }
} catch (error) {
if (!disposed && !options.isDisposed()) { options.onError(error); }

4
frontend/web/static/js/workspace-tabs.js

@ -8,7 +8,7 @@
/^\/(?:api|static|login|logout)(?:\/|$)/.test(path)) { return false; }
if (role !== "port_operator") { return true; }
return ["/", "/port-status", "/password"].includes(path) ||
/^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2|data-log|advanced)$/.test(path);
/^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2)$/.test(path);
}
function sanitizeTabs(tabs, role) {
@ -19,7 +19,7 @@
typeof tab.label !== "string" || !tab.label || !allowedPath(tab.path, role)) { return result; }
const match = tab.path.match(/^\/ports\/(\d+)\/(?:parameters|operation-1|operation-2|data-log|advanced)$/);
const normalized = match
? { key: "port-" + Number(match[1]), path: tab.path, label: "포트 " + Number(match[1]) + " 설정" }
? { key: "port-settings", path: tab.path, label: "포트 " + Number(match[1]) + " 설정" }
: { key: tab.key, path: tab.path, label: tab.label };
if (!seen.has(normalized.key)) { result.push(normalized); seen.add(normalized.key); }
return result;

18
frontend/web/templates/layout.html

@ -5,9 +5,9 @@
<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=20260909-static-progress-1">
<link rel="stylesheet" href="/static/css/style.css?v=20260914-fixed-port-positions-2">
</head>
<body data-ui-version="20260908-firmware-progress-1" 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}}">
<body data-ui-version="20260914-port-selection-3" 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">
<aside class="sidebar">
<div class="brand">
@ -155,7 +155,7 @@
<div class="group-permission-cell heading">로그인</div>
<div class="group-permission-cell heading">네트워크 설정</div>
<div class="group-permission-cell heading">포트 설정</div>
<div class="group-permission-cell heading">포트 디버그</div>
<div class="group-permission-cell heading">직접 연결 모드</div>
<div class="group-permission-cell heading">Admin 설정</div>
<div class="group-permission-cell heading">공통 설정</div>
<div class="group-permission-cell heading">상태</div>
@ -181,14 +181,14 @@
</div>
</section>
{{end}}
<script src="/static/js/i18n.js?v=20260908-log-web-1"></script>
<script src="/static/js/i18n.js?v=20260914-fixed-port-positions-2"></script>
<script src="/static/js/workspace-tabs.js?v=20260907-workspace-tabs-1"></script>
<script src="/static/js/app.js?v=20260908-close-all-tabs-1"></script>
<script src="/static/js/dashboard.js?v=20260907-storage-logs-1"></script>
<script src="/static/js/log-view.js?v=20260907-storage-logs-1"></script>
<script src="/static/js/port-log-clear.js?v=20260907-storage-logs-1"></script>
<script src="/static/js/app.js?v=20260914-fixed-port-positions-2"></script>
<script src="/static/js/dashboard.js?v=20260914-fixed-port-positions-2"></script>
<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=20260909-config-read-1"></script>
<script src="/static/js/integration.js?v=20260914-fixed-port-positions-2"></script>
</body>
</html>
{{end}}

7
frontend/web/templates/partials/block_port-selector.html

@ -11,9 +11,10 @@
<div class="port-link-state">{{if .Up}}UP{{else}}Unknown{{end}}</div>
<div class="port-link-caption">개별 보기</div>
</button>
<button type="button" class="port-link-apply" data-port-shortcut="{{.Number}}" aria-label="Port {{.Number}} 적용 대상 추가" title="Port {{.Number}} 적용 대상 추가">
추가
</button>
<label class="port-link-apply" title="Port {{.Number}} 변경 대상 선택">
<input type="checkbox" data-port-shortcut="{{.Number}}" aria-label="Port {{.Number}} 변경 대상 선택">
<span>변경 대상</span>
</label>
</div>
{{end}}
</div>

1
frontend/web/templates/partials/block_serial-terminal.html

@ -2,7 +2,6 @@
<section class="serial-console" data-serial-terminal data-default-port="{{.Terminal.Port}}">
<div class="serial-console-toolbar">
<div class="serial-console-heading">
<span class="serial-console-icon" aria-hidden="true">C:\&gt;_</span>
<div>
<h2>{{.Title}}</h2>
<p>선택한 채널에 연결된 장비의 실제 시리얼 콘솔</p>

4
frontend/web/templates/partials/dashboard.html

@ -53,8 +53,8 @@
<div class="port-link-grid">
{{range (index .Blocks 0).PortLinks}}
<a class="port-link" href="/ports/{{.Number}}/parameters" data-dashboard-port="{{.Number}}" data-port-state="unknown" aria-label="포트 {{.Number}}: 상태 확인 중">
<span class="dashboard-port-top"><span class="port-link-number" data-i18n-skip>{{.Number}}</span><i class="dashboard-status-dot" aria-hidden="true"></i></span>
<span class="port-link-state">미확인</span>
<span class="dashboard-port-top"><span class="port-link-number" data-i18n-skip>{{.Number}}</span><span class="port-link-state" data-i18n-skip>UNKNOWN</span></span>
<span class="dashboard-port-alias" data-i18n-skip>—</span>
</a>
{{end}}
</div>

18
scripts/test-workspace-tabs.cjs

@ -63,7 +63,8 @@ test('operator tabs reject admin/restart routes and malformed or external URLs',
for (const path of ['/admin/users', '/network/interface-1', '/port-debug', '/ports/restart', '/ports/49/parameters', '/ports/0/advanced', 'https://example.com', '//example.com', 'javascript:alert(1)', '/api/v1/auth/users', '/ports/../admin/users', '/logout', '/login', '/ports/1/parameters?unsafe=1']) {
assert.equal(tabs.allowedPath(path, 'port_operator'), false, path);
}
for (const path of ['/', '/port-status', '/password', '/ports/1/parameters', '/ports/48/advanced']) {
for (const path of ['/ports/48/advanced', '/ports/1/data-log']) assert.equal(tabs.allowedPath(path, 'port_operator'), false, path);
for (const path of ['/', '/port-status', '/password', '/ports/1/parameters', '/ports/48/operation-2']) {
assert.equal(tabs.allowedPath(path, 'port_operator'), true, path);
}
});
@ -82,7 +83,20 @@ test('legacy/malformed records and absent workspace identities are not restored'
test('port subpages normalize to one tab and corrupt entries are dropped', () => {
const result = tabs.sanitizeTabs([null, {}, port, {...port, path: '/ports/16/advanced'}, dashboard], 'port_operator');
assert.deepEqual(result, [{key: 'port-16', path: '/ports/16/parameters', label: '포트 16 설정'}, dashboard]);
assert.deepEqual(result, [{key: 'port-settings', path: '/ports/16/parameters', label: '포트 16 설정'}, dashboard]);
});
test('old per-port tabs collapse in place and one settings tab survives refresh', () => {
const data = storage();
const scope = () => ({id: 'login', user: 'admin', role: 'administrator'});
const store = tabs.createStore(() => data, scope);
data.setItem(tabs.storageKey, JSON.stringify({...scope(), tabs: [dashboard, port, users,
{...port, key: 'port-26', path: '/ports/26/operation-2'}]}));
const restored = store.read();
assert.deepEqual(restored.map(tab => tab.key), ['dashboard', 'port-settings', 'users']);
restored[1] = {...restored[1], path: '/ports/26/parameters', label: '포트 26 설정'};
store.write(restored);
assert.deepEqual(tabs.createStore(() => data, scope).read(), restored);
});
test('storage denied by browser falls back to working in-memory tabs', () => {

Loading…
Cancel
Save