diff --git a/backend/cmd/tera-api/main.go b/backend/cmd/tera-api/main.go index feb4907..40c8b3a 100644 --- a/backend/cmd/tera-api/main.go +++ b/backend/cmd/tera-api/main.go @@ -333,6 +333,7 @@ func main() { } streamFactory := serial.NewStreamSocketClient(getenv("TERA_API_SERIAL_STREAM_SOCKET", serial.DefaultStreamSocketPath), "tera-port-service") gateway, gatewayErr := portservice.New(portservice.Options{ + ListenInterface: portservice.ListenOnInterface, TransportFactory: streamFactory, ListenHost: getenv("TERA_API_PORT_LISTEN_HOST", "0.0.0.0"), SSHSigner: signer, diff --git a/backend/internal/adapters/mock/log_journal_test.go b/backend/internal/adapters/mock/log_journal_test.go index a5be084..0cae2b0 100644 --- a/backend/internal/adapters/mock/log_journal_test.go +++ b/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) diff --git a/backend/internal/adapters/mock/reauthenticate.go b/backend/internal/adapters/mock/reauthenticate.go new file mode 100644 index 0000000..5fffd76 --- /dev/null +++ b/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() +} diff --git a/backend/internal/adapters/mock/reauthenticate_test.go b/backend/internal/adapters/mock/reauthenticate_test.go new file mode 100644 index 0000000..33f8663 --- /dev/null +++ b/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) + } +} diff --git a/backend/internal/adapters/mock/store.go b/backend/internal/adapters/mock/store.go index e774814..ee197e5 100644 --- a/backend/internal/adapters/mock/store.go +++ b/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() diff --git a/backend/internal/adapters/mock/store_test.go b/backend/internal/adapters/mock/store_test.go index d3f8319..f8b3604 100644 --- a/backend/internal/adapters/mock/store_test.go +++ b/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, diff --git a/backend/internal/api/clear_all_logs_test.go b/backend/internal/api/clear_all_logs_test.go index 5279d9f..de679da 100644 --- a/backend/internal/api/clear_all_logs_test.go +++ b/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") } diff --git a/backend/internal/api/clear_link_logs.go b/backend/internal/api/clear_link_logs.go index 2f3398f..5834345 100644 --- a/backend/internal/api/clear_link_logs.go +++ b/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) diff --git a/backend/internal/api/clear_port_logs.go b/backend/internal/api/clear_port_logs.go index 2a137c5..dbccf24 100644 --- a/backend/internal/api/clear_port_logs.go +++ b/backend/internal/api/clear_port_logs.go @@ -11,14 +11,17 @@ func (r *Router) handleClearPortLogs(w http.ResponseWriter, req *http.Request) { return } var body struct { - Channel string `json:"channel"` - Confirmation string `json:"confirmation"` + Channel string `json:"channel"` + 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) diff --git a/backend/internal/api/clear_port_logs_test.go b/backend/internal/api/clear_port_logs_test.go index 0cac2ba..fe6ff03 100644 --- a/backend/internal/api/clear_port_logs_test.go +++ b/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) } diff --git a/backend/internal/api/clear_system_logs.go b/backend/internal/api/clear_system_logs.go index e2a6317..64d5b8c 100644 --- a/backend/internal/api/clear_system_logs.go +++ b/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) diff --git a/backend/internal/api/clear_system_logs_test.go b/backend/internal/api/clear_system_logs_test.go index a8eaeb6..3c0171b 100644 --- a/backend/internal/api/clear_system_logs_test.go +++ b/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) } diff --git a/backend/internal/api/port_log_display.go b/backend/internal/api/port_log_display.go new file mode 100644 index 0000000..41c46b3 --- /dev/null +++ b/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 +} diff --git a/backend/internal/api/port_log_display_test.go b/backend/internal/api/port_log_display_test.go new file mode 100644 index 0000000..0f0ed06 --- /dev/null +++ b/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) + } +} diff --git a/backend/internal/api/port_log_settings_auth.go b/backend/internal/api/port_log_settings_auth.go new file mode 100644 index 0000000..e1b943a --- /dev/null +++ b/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 +} diff --git a/backend/internal/api/reauthenticate.go b/backend/internal/api/reauthenticate.go new file mode 100644 index 0000000..a9236c8 --- /dev/null +++ b/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 +} diff --git a/backend/internal/api/reauthenticate_test.go b/backend/internal/api/reauthenticate_test.go new file mode 100644 index 0000000..534f1f0 --- /dev/null +++ b/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()) + } + }) + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 65b888d..e13a378 100644 --- a/backend/internal/api/router.go +++ b/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) } diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 7612f00..9847bae 100644 --- a/backend/internal/api/router_test.go +++ b/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 { diff --git a/backend/internal/api/serial_runtime.go b/backend/internal/api/serial_runtime.go index d8ba355..179ad90 100644 --- a/backend/internal/api/serial_runtime.go +++ b/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 { diff --git a/backend/internal/api/serial_runtime_test.go b/backend/internal/api/serial_runtime_test.go index 00a48c0..1b96b30 100644 --- a/backend/internal/api/serial_runtime_test.go +++ b/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"}, } diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 84bf5b4..35775bf 100644 --- a/backend/internal/domain/models.go +++ b/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"` diff --git a/backend/internal/domain/store.go b/backend/internal/domain/store.go index b250e35..d170be9 100644 --- a/backend/internal/domain/store.go +++ b/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 diff --git a/backend/internal/logpolicy/policy.go b/backend/internal/logpolicy/policy.go index 4e55f64..cc965a0 100644 --- a/backend/internal/logpolicy/policy.go +++ b/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) - value = strings.Join(strings.Fields(value), " ") + if !multiline { + value = strings.Join(strings.Fields(value), " ") + } if maximum > 0 { runes := []rune(value) if len(runes) > maximum { diff --git a/backend/internal/logruntime/scheduler.go b/backend/internal/logruntime/scheduler.go index 4a3335a..206add0 100644 --- a/backend/internal/logruntime/scheduler.go +++ b/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, diff --git a/backend/internal/logruntime/scheduler_test.go b/backend/internal/logruntime/scheduler_test.go index cb497ee..745c499 100644 --- a/backend/internal/logruntime/scheduler_test.go +++ b/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) } } diff --git a/backend/internal/logstorage/free_linux.go b/backend/internal/logstorage/free_linux.go index 0409241..82ee405 100644 --- a/backend/internal/logstorage/free_linux.go +++ b/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 { diff --git a/backend/internal/logstorage/free_other.go b/backend/internal/logstorage/free_other.go index e9417da..9fc8088 100644 --- a/backend/internal/logstorage/free_other.go +++ b/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) } diff --git a/backend/internal/logstorage/free_windows.go b/backend/internal/logstorage/free_windows.go index 376537d..89f8427 100644 --- a/backend/internal/logstorage/free_windows.go +++ b/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 { diff --git a/backend/internal/logstorage/rotation_limit_test.go b/backend/internal/logstorage/rotation_limit_test.go new file mode 100644 index 0000000..dc55111 --- /dev/null +++ b/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) + } +} diff --git a/backend/internal/logstorage/store.go b/backend/internal/logstorage/store.go index 8142bb3..206766d 100644 --- a/backend/internal/logstorage/store.go +++ b/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 { - return err - } - } else if err := s.reclaimOldestLocked(bucket, usage+incoming-capBytes); err != nil { + if err := s.sealBucketLocked(bucket); err != nil { + return err + } + 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 { - return err +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 + } + } + } + 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 + } + } + 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 _, path := range candidates { - class := classFromSegment(path) - meta, err := inspectSegment(path, class, false, nil) - if err != nil { + for _, meta := range candidates { + if err := os.Remove(filepath.Join(s.bucketPath(meta.Class), meta.Path)); err != nil { return err } - if meta.Newest.Before(cutoff) { - if err := os.Remove(path); err != nil { - return err - } - reclaimed += meta.Bytes - s.removeClosedMetaLocked(class, filepath.Base(path)) - if reclaimed >= needed { - return nil - } + s.removeClosedMetaLocked(meta.Class, meta.Path) + reclaimed += meta.Bytes + 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 { diff --git a/backend/internal/portservice/gateway.go b/backend/internal/portservice/gateway.go index e98a5c2..6284aa5 100644 --- a/backend/internal/portservice/gateway.go +++ b/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,8 +223,9 @@ 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()), - Sources: append([]string(nil), item.config.sources...), - Targets: append([]string(nil), item.config.targets...), + 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...), }) } sort.Slice(result.Services, func(left, right int) bool { @@ -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 + } + 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++ + } } - current.clients.Add(-1) - return false + 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) { diff --git a/backend/internal/portservice/listen_linux.go b/backend/internal/portservice/listen_linux.go new file mode 100644 index 0000000..f979b21 --- /dev/null +++ b/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) +} diff --git a/backend/internal/portservice/listen_other.go b/backend/internal/portservice/listen_other.go new file mode 100644 index 0000000..82980da --- /dev/null +++ b/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) +} diff --git a/backend/internal/portservice/shared_sessions_test.go b/backend/internal/portservice/shared_sessions_test.go new file mode 100644 index 0000000..e037c57 --- /dev/null +++ b/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) + } +} diff --git a/backend/internal/serial/connection_status.go b/backend/internal/serial/connection_status.go new file mode 100644 index 0000000..eac5f06 --- /dev/null +++ b/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" +} diff --git a/backend/internal/serial/connection_status_test.go b/backend/internal/serial/connection_status_test.go new file mode 100644 index 0000000..9bae46f --- /dev/null +++ b/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") + } +} diff --git a/demo-guide/07-system-improvements-20260914.md b/demo-guide/07-system-improvements-20260914.md new file mode 100644 index 0000000..5d4057d --- /dev/null +++ b/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 혼합 접속, 실제 저장 공간 회전, 실제 복원·초기화 후 재부팅은 장비에 실행하지 않았습니다. 종전 부팅 지연도 해결됐다고 판정하지 않았습니다. diff --git a/demo-guide/README.md b/demo-guide/README.md index c1c1374..3a86006 100644 --- a/demo-guide/README.md +++ b/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. 아래 ‘현재 확인’은 읽기 점검 결과이며, 버튼을 눌러 수행할 시연 결과는 별도로 기록합니다. diff --git a/final-build/README.md b/final-build/README.md index ad3e667..bcfc517 100644 --- a/final-build/README.md +++ b/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`에 기록했습니다. diff --git a/final-build/SHA256SUMS b/final-build/SHA256SUMS index 6d50855..6c8f69c 100644 --- a/final-build/SHA256SUMS +++ b/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 diff --git a/final-build/backend/tera-clone-demo-api b/final-build/backend/tera-clone-demo-api index 96bdd57..f157cee 100644 Binary files a/final-build/backend/tera-clone-demo-api and b/final-build/backend/tera-clone-demo-api differ diff --git a/final-build/backend/tera-logd b/final-build/backend/tera-logd index 105176c..b9cbe99 100644 Binary files a/final-build/backend/tera-logd and b/final-build/backend/tera-logd differ diff --git a/final-build/backend/tera-netd b/final-build/backend/tera-netd index 07244bd..8c87858 100644 Binary files a/final-build/backend/tera-netd and b/final-build/backend/tera-netd differ diff --git a/final-build/backend/tera-seriald b/final-build/backend/tera-seriald index 79c7343..e1b2580 100644 Binary files a/final-build/backend/tera-seriald and b/final-build/backend/tera-seriald differ diff --git a/final-build/backend/tera-sessiond b/final-build/backend/tera-sessiond index 54b19e7..bfe5aac 100644 Binary files a/final-build/backend/tera-sessiond and b/final-build/backend/tera-sessiond differ diff --git a/final-build/backend/tera-timed b/final-build/backend/tera-timed index 0ba9f3c..30ec5a6 100644 Binary files a/final-build/backend/tera-timed and b/final-build/backend/tera-timed differ diff --git a/final-build/frontend/tera-clone-demo-web b/final-build/frontend/tera-clone-demo-web index 29f517f..783162c 100644 Binary files a/final-build/frontend/tera-clone-demo-web and b/final-build/frontend/tera-clone-demo-web differ diff --git a/final-build/release-manifest.json b/final-build/release-manifest.json index 405aa0e..c5531ea 100644 --- a/final-build/release-manifest.json +++ b/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." + "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." }, - "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" - }, - "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" + ] } diff --git a/frontend/internal/handlers/account_roles_test.go b/frontend/internal/handlers/account_roles_test.go index a2e3d91..9e311d6 100644 --- a/frontend/internal/handlers/account_roles_test.go +++ b/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) } diff --git a/frontend/internal/handlers/admin_controls_test.go b/frontend/internal/handlers/admin_controls_test.go index 7db229d..6fb9670 100644 --- a/frontend/internal/handlers/admin_controls_test.go +++ b/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) } diff --git a/frontend/internal/handlers/dashboard_test.go b/frontend/internal/handlers/dashboard_test.go index b7c8ddc..80117d2 100644 --- a/frontend/internal/handlers/dashboard_test.go +++ b/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) } diff --git a/frontend/internal/handlers/device_handler.go b/frontend/internal/handlers/device_handler.go index 4ca6215..ab45d9d 100644 --- a/frontend/internal/handlers/device_handler.go +++ b/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 { diff --git a/frontend/internal/handlers/device_handler_integration_test.go b/frontend/internal/handlers/device_handler_integration_test.go index 11ddeb5..4ddc7dd 100644 --- a/frontend/internal/handlers/device_handler_integration_test.go +++ b/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") + } +} diff --git a/frontend/internal/handlers/log_view_test.go b/frontend/internal/handlers/log_view_test.go index 9fa9f6e..28443a6 100644 --- a/frontend/internal/handlers/log_view_test.go +++ b/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"`, "로그 다운로드", "검색 초기화", } { diff --git a/frontend/internal/handlers/proxy_metadata_test.go b/frontend/internal/handlers/proxy_metadata_test.go index 6d2ce1b..923fcd0 100644 --- a/frontend/internal/handlers/proxy_metadata_test.go +++ b/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") } } diff --git a/frontend/internal/handlers/system_improvements_test.go b/frontend/internal/handlers/system_improvements_test.go new file mode 100644 index 0000000..d901abd --- /dev/null +++ b/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) + } + } +} diff --git a/frontend/internal/mock/mock_data.go b/frontend/internal/mock/mock_data.go index 4ab748f..817a1e7 100644 --- a/frontend/internal/mock/mock_data.go +++ b/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"): diff --git a/frontend/web/static/css/style.css b/frontend/web/static/css/style.css index 481ee52..0776077 100644 --- a/frontend/web/static/css/style.css +++ b/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; } diff --git a/frontend/web/static/js/app.js b/frontend/web/static/js/app.js index da3e6b3..e5f5f79 100644 --- a/frontend/web/static/js/app.js +++ b/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); }); diff --git a/frontend/web/static/js/dashboard.js b/frontend/web/static/js/dashboard.js index 38a3d2f..fad8c62 100644 --- a/frontend/web/static/js/dashboard.js +++ b/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); diff --git a/frontend/web/static/js/i18n.js b/frontend/web/static/js/i18n.js index 8712661..fe53e5e 100644 --- a/frontend/web/static/js/i18n.js +++ b/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"], diff --git a/frontend/web/static/js/integration.js b/frontend/web/static/js/integration.js index 0b1ab53..ae8bbd3 100644 --- a/frontend/web/static/js/integration.js +++ b/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='
저장 공간을 확인하고 있습니다.
'; + section.innerHTML='저장 공간을 확인하고 있습니다.
'; 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); diff --git a/frontend/web/static/js/log-view.js b/frontend/web/static/js/log-view.js index d22c785..da6d3ba 100644 --- a/frontend/web/static/js/log-view.js +++ b/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]))); }); diff --git a/frontend/web/static/js/port-log-clear.js b/frontend/web/static/js/port-log-clear.js index 4b04916..0bd0cbf 100644 --- a/frontend/web/static/js/port-log-clear.js +++ b/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); } diff --git a/frontend/web/static/js/workspace-tabs.js b/frontend/web/static/js/workspace-tabs.js index 3bb666b..a7dbd9e 100644 --- a/frontend/web/static/js/workspace-tabs.js +++ b/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; diff --git a/frontend/web/templates/layout.html b/frontend/web/templates/layout.html index 26a56a1..b42aaa2 100644 --- a/frontend/web/templates/layout.html +++ b/frontend/web/templates/layout.html @@ -5,9 +5,9 @@