diff --git a/README.md b/README.md index a9d0999..4e28ecd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,13 @@ -# 최종 빌드 파일 위치 +# TERA 시연 프로젝트 -- 프론트엔드: `final-build/frontend/tera-clone-demo-web` -- 백엔드: `final-build/backend/tera-clone-demo-api` +- [시연 자료 및 시험 체크표](demo-guide/README.md) +- [최종 빌드 안내](final-build/README.md) +- [빌드 체크섬](final-build/SHA256SUMS) + +최종 프론트엔드: `final-build/frontend/tera-clone-demo-web` + +최종 API: `final-build/backend/tera-clone-demo-api` + +최종 로그 데몬: `final-build/backend/tera-logd` + +전체 포트 로그 및 포트 상태 이벤트 삭제 기능은 위 세 파일을 함께 갱신해야 합니다. diff --git a/backend/internal/adapters/mock/clear_link_logs.go b/backend/internal/adapters/mock/clear_link_logs.go new file mode 100644 index 0000000..5f9bb78 --- /dev/null +++ b/backend/internal/adapters/mock/clear_link_logs.go @@ -0,0 +1,40 @@ +package mock + +import ( + "context" + "fmt" + + "tera-clone/services/tera-api/internal/domain" + "tera-clone/services/tera-api/internal/logservice" +) + +func (s *Store) ClearLinkLogs(ctx context.Context) (domain.LinkLogClearResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + deleted := 0 + if s.logJournal != nil { + cleaner, ok := s.logJournal.(logservice.LinkLogCleaner) + if !ok { + return domain.LinkLogClearResult{}, fmt.Errorf("link log clearing is not supported by this journal") + } + count, err := cleaner.ClearLink() + if err != nil { + return domain.LinkLogClearResult{}, err + } + deleted = count + if err := s.refreshLinkJournalLocked(); err != nil { + return domain.LinkLogClearResult{}, err + } + } else { + before, loaded := s.linkLogEvents, s.linkLogLoaded + deleted = len(before) + s.linkLogEvents, s.linkLogLoaded = []domain.LinkLogEvent{}, true + if err := s.persistLinkLogStoreLocked(); err != nil { + s.linkLogEvents, s.linkLogLoaded = before, loaded + return domain.LinkLogClearResult{}, err + } + + } + id := s.recordAuditLocked(ctx, "log.link.clear", "link", "succeeded", fmt.Sprintf("cleared %d link-state events; system, audit and port logs preserved", deleted)) + return domain.LinkLogClearResult{Deleted: deleted, AuditID: id}, nil +} diff --git a/backend/internal/adapters/mock/clear_link_logs_test.go b/backend/internal/adapters/mock/clear_link_logs_test.go new file mode 100644 index 0000000..4244c2f --- /dev/null +++ b/backend/internal/adapters/mock/clear_link_logs_test.go @@ -0,0 +1,65 @@ +package mock + +import ( + "context" + "net" + "os" + "path/filepath" + "tera-clone/services/tera-api/internal/domain" + "tera-clone/services/tera-api/internal/logservice" + "tera-clone/services/tera-api/internal/logstorage" + "testing" +) + +func TestClearLinkLogsInvalidatesOtherClientCaches(t *testing.T) { + journal, err := logstorage.Open(logstorage.Options{Root: t.TempDir(), Clock: fixedClock}) + if err != nil { + t.Fatal(err) + } + dir, err := os.MkdirTemp("", "link-clear-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + socket := filepath.Join(dir, "log.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- (logservice.Server{Store: journal}).Serve(listener) }() + defer func() { listener.Close(); <-done }() + options := StoreOptions{LogJournal: logservice.NewClient(socket)} + first, err := NewStoreWithOptions(fixedClock, options) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := NewStoreWithOptions(fixedClock, options) + if err != nil { + t.Fatal(err) + } + defer second.Close() + event := domain.LinkLogEvent{Timestamp: fixedClock(), Kind: "serial", Resource: "CH01", State: "down", PreviousState: "up", Signal: "dcd", EventType: "transition", Message: "test down"} + if _, err := journal.Append(logstorage.ClassLink, event.Timestamp, event); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if rows, err := second.LinkLogEvents(ctx, domain.LinkLogFilter{}); err != nil || len(rows) != 1 { + t.Fatalf("prime %v %v", rows, err) + } + if result, err := first.ClearLinkLogs(ctx); err != nil || result.Deleted != 1 { + t.Fatalf("clear %v %v", result, err) + } + if rows, err := second.LinkLogEvents(ctx, domain.LinkLogFilter{}); err != nil || len(rows) != 0 { + t.Fatalf("stale %v %v", rows, err) + } + event.State = "up" + event.PreviousState = "down" + if _, err := journal.Append(logstorage.ClassLink, event.Timestamp, event); err != nil { + t.Fatal(err) + } + if rows, err := second.LinkLogEvents(ctx, domain.LinkLogFilter{}); err != nil || len(rows) != 1 { + t.Fatalf("new %v %v", rows, err) + } +} diff --git a/backend/internal/adapters/mock/clear_port_logs.go b/backend/internal/adapters/mock/clear_port_logs.go index ddba372..4b1cec9 100644 --- a/backend/internal/adapters/mock/clear_port_logs.go +++ b/backend/internal/adapters/mock/clear_port_logs.go @@ -9,7 +9,7 @@ import ( ) func (s *Store) ClearPortLogs(ctx context.Context, channel string) (domain.PortLogClearResult, error) { - if _, err := parseChannel(channel); err != nil { + if _, err := parseChannel(channel); channel != "ALL" && err != nil { return domain.PortLogClearResult{}, err } // Refuse deletion if its audit request cannot be persisted. @@ -36,7 +36,7 @@ func (s *Store) ClearPortLogs(ctx context.Context, channel string) (domain.PortL before := s.portLogEvents kept := make([]domain.PortLogEvent, 0, len(before)) for _, event := range before { - if event.Channel == channel { + if channel == "ALL" || event.Channel == channel { deleted++ continue } diff --git a/backend/internal/adapters/mock/clear_system_logs.go b/backend/internal/adapters/mock/clear_system_logs.go index d5b67e1..926b97e 100644 --- a/backend/internal/adapters/mock/clear_system_logs.go +++ b/backend/internal/adapters/mock/clear_system_logs.go @@ -9,9 +9,6 @@ import ( ) func (s *Store) ClearSystemLogs(ctx context.Context) (domain.SystemLogClearResult, error) { - if _, err := s.RecordAudit(ctx, domain.AuditEvent{Action: "log.system.clear.request", Target: "system", Result: "succeeded", Message: "administrator requested clearing system events; audit records are preserved"}); err != nil { - return domain.SystemLogClearResult{}, err - } s.mu.Lock() defer s.mu.Unlock() deleted := 0 @@ -28,15 +25,24 @@ func (s *Store) ClearSystemLogs(ctx context.Context) (domain.SystemLogClearResul if err := s.refreshSystemJournalLocked(); err != nil { return domain.SystemLogClearResult{}, err } + if err := s.refreshAuditJournalLocked(); err != nil { + return domain.SystemLogClearResult{}, err + } } else { before, loaded := s.systemLogEvents, s.systemLogLoaded - deleted = len(before) + audits := s.auditEvents + deleted = len(before) + len(audits) s.systemLogEvents, s.systemLogLoaded = []domain.SystemLogEvent{}, true if err := s.persistSystemLogStoreLocked(); err != nil { s.systemLogEvents, s.systemLogLoaded = before, loaded return domain.SystemLogClearResult{}, err } + s.auditEvents = nil + if err := s.persistAuditLogLocked(); err != nil { + s.auditEvents = audits + return domain.SystemLogClearResult{}, err + } } - id := s.recordAuditLocked(ctx, "log.system.clear", "system", "succeeded", fmt.Sprintf("cleared %d system event records; audit, port and link logs preserved", deleted)) + id := s.recordAuditLocked(ctx, "log.system.clear", "system", "succeeded", fmt.Sprintf("cleared %d system and audit records; port and link logs preserved", deleted)) return domain.SystemLogClearResult{Deleted: deleted, AuditID: id}, nil } diff --git a/backend/internal/adapters/mock/clear_system_logs_test.go b/backend/internal/adapters/mock/clear_system_logs_test.go index d8eb92d..a3c399e 100644 --- a/backend/internal/adapters/mock/clear_system_logs_test.go +++ b/backend/internal/adapters/mock/clear_system_logs_test.go @@ -11,7 +11,7 @@ import ( "testing" ) -func TestSystemClearInvalidatesAllAPICachesAndPreservesAudit(t *testing.T) { +func TestSystemClearInvalidatesAllAPICachesAndClearsAudit(t *testing.T) { journal, err := logstorage.Open(logstorage.Options{Root: t.TempDir(), Clock: fixedClock}) if err != nil { t.Fatal(err) @@ -44,25 +44,25 @@ func TestSystemClearInvalidatesAllAPICachesAndPreservesAudit(t *testing.T) { if _, err := first.RecordSystemLog(ctx, domain.SystemLogEvent{Category: "system", Severity: "info", Source: "test", Message: "old"}); err != nil { t.Fatal(err) } - id, err := first.RecordAudit(ctx, domain.AuditEvent{Action: "auth.login", Target: "login", Result: "failed", Message: "keep"}) + _, err = first.RecordAudit(ctx, domain.AuditEvent{Action: "auth.login", Target: "login", Result: "failed", Message: "keep"}) if err != nil { t.Fatal(err) } if rows, err := second.SystemLogEvents(ctx, domain.SystemLogFilter{}); err != nil || len(rows) != 2 { t.Fatalf("prime cache=%+v,%v", rows, err) } - if result, err := first.ClearSystemLogs(ctx); err != nil || result.Deleted != 1 { + if result, err := first.ClearSystemLogs(ctx); err != nil || result.Deleted != 2 { t.Fatalf("clear=%+v,%v", result, err) } rows, err := second.SystemLogEvents(ctx, domain.SystemLogFilter{}) - if err != nil || len(rows) != 1 || rows[0].AuditID != id || !rows[0].AuditProtected { + if err != nil || len(rows) != 0 { t.Fatalf("stale cache or lost audit=%+v,%v", rows, err) } if _, err := second.RecordSystemLog(ctx, domain.SystemLogEvent{Category: "system", Severity: "info", Source: "test", Message: "new"}); err != nil { t.Fatal(err) } rows, err = first.SystemLogEvents(ctx, domain.SystemLogFilter{}) - if err != nil || len(rows) != 2 { + if err != nil || len(rows) != 1 { t.Fatalf("new capture=%+v,%v", rows, err) } } diff --git a/backend/internal/adapters/mock/serial_keyword_test.go b/backend/internal/adapters/mock/serial_keyword_test.go new file mode 100644 index 0000000..13c8a58 --- /dev/null +++ b/backend/internal/adapters/mock/serial_keyword_test.go @@ -0,0 +1,26 @@ +package mock + +import ( + "context" + "tera-clone/services/tera-api/internal/domain" + "testing" +) + +func TestSerialAlarmKeywordUpdatesPortConfiguration(t *testing.T) { + s := NewStore(fixedClock) + defer s.Close() + ctx := context.Background() + for _, keyword := range []string{"READY>", ""} { + enabled := keyword != "" + if _, err := s.UpdateAlarmRule(ctx, "port_ch12_keyword", domain.AlarmRuleUpdate{Trigger: &keyword, Enabled: &enabled}); err != nil { + t.Fatal(err) + } + if _, err := s.ApplyConfig(ctx); err != nil { + t.Fatal(err) + } + port, err := s.Port(ctx, "CH12") + if err != nil || port.AlarmKeyword != keyword { + t.Fatalf("port keyword = %q, want %q: %v", port.AlarmKeyword, keyword, err) + } + } +} diff --git a/backend/internal/adapters/mock/store.go b/backend/internal/adapters/mock/store.go index ebde5f4..d96c1c2 100644 --- a/backend/internal/adapters/mock/store.go +++ b/backend/internal/adapters/mock/store.go @@ -171,6 +171,7 @@ type Store struct { journalLast map[string]string journalSequence map[string]uint64 portClearRevision uint64 + linkClearRevision uint64 systemClearRevision uint64 journalLocalSequences map[string]map[uint64]struct{} storagePressureActive bool @@ -3066,7 +3067,7 @@ func (s *Store) SystemLogEvents(ctx context.Context, filter domain.SystemLogFilt } for _, audit := range audits { if event, ok := auditSystemLog(audit); ok { - event.AuditProtected = true + event.AuditProtected = false source = append(source, event) } } @@ -6521,6 +6522,9 @@ func (s *Store) loadJournalLocked() error { } func (s *Store) refreshAuditJournalLocked() error { + if err := s.refreshSystemClearLocked(); err != nil { + return err + } events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassAudit, s.journalSequence[logstorage.ClassAudit], s.auditMax, validateAuditEvent) if err != nil { return err @@ -6540,13 +6544,17 @@ func (s *Store) refreshAuditJournalLocked() error { return nil } -func (s *Store) refreshSystemJournalLocked() error { +func (s *Store) refreshSystemClearLocked() error { if cleaner, ok := s.logJournal.(logservice.SystemLogCleaner); ok { revision, err := cleaner.SystemClearRevision() if err != nil { return err } if revision != s.systemClearRevision { + s.auditEvents = nil + s.journalSequence[logstorage.ClassAudit] = 0 + delete(s.journalLocalSequences, logstorage.ClassAudit) + delete(s.journalLast, logstorage.ClassAudit) s.systemLogEvents = nil s.journalSequence[logstorage.ClassSystem] = 0 delete(s.journalLocalSequences, logstorage.ClassSystem) @@ -6554,6 +6562,13 @@ func (s *Store) refreshSystemJournalLocked() error { s.systemClearRevision = revision } } + return nil +} + +func (s *Store) refreshSystemJournalLocked() error { + if err := s.refreshSystemClearLocked(); err != nil { + return err + } events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassSystem, s.journalSequence[logstorage.ClassSystem], s.systemLogMax, validateSystemLogEvent) if err != nil { return err @@ -6600,6 +6615,20 @@ func (s *Store) refreshPortJournalLocked() error { } func (s *Store) refreshLinkJournalLocked() error { + if cleaner, ok := s.logJournal.(logservice.LinkLogCleaner); ok { + revision, err := cleaner.LinkClearRevision() + if err != nil { + return err + } + if revision != s.linkClearRevision { + s.linkLogEvents = nil + s.journalSequence[logstorage.ClassLink] = 0 + delete(s.journalLocalSequences, logstorage.ClassLink) + delete(s.journalLast, logstorage.ClassLink) + s.linkClearRevision = revision + } + } + events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassLink, s.journalSequence[logstorage.ClassLink], s.linkLogMax, validateLinkLogEvent) if err != nil { return err diff --git a/backend/internal/api/clear_all_logs_test.go b/backend/internal/api/clear_all_logs_test.go new file mode 100644 index 0000000..5279d9f --- /dev/null +++ b/backend/internal/api/clear_all_logs_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "tera-clone/services/tera-api/internal/adapters/mock" + "tera-clone/services/tera-api/internal/domain" + "testing" +) + +func TestClearAllLogsRequiresExactConfirmationAndAdmin(t *testing.T) { + s := mock.NewStore(fixedClock) + defer s.Close() + h := NewRouter(s) + for _, tc := range []struct { + path, body, actor string + status int + }{ + {"/api/v1/logs/port/clear", `{"channel":"ALL","confirmation":"CH01"}`, "mock-admin", 400}, + {"/api/v1/logs/port/clear", `{"channel":"ALL","confirmation":"ALL"}`, "mock-port-operator", 403}, + {"/api/v1/logs/links/clear", `{"confirmation":"LINKS"}`, "mock-port-operator", 403}, + {"/api/v1/logs/links/clear", `{"confirmation":"LINKS"}`, "", 401}, + {"/api/v1/logs/links/clear", `{"confirmation":"SYSTEM"}`, "mock-admin", 400}, + {"/api/v1/logs/links/clear", `{"confirmation":"LINKS","class":"audit"}`, "mock-admin", 400}, + } { + r := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) + r.Header.Set("X-Mock-Actor", tc.actor) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.status { + t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String()) + } + } + for _, ch := range []string{"CH01", "CH48"} { + if _, err := s.RecordPortLog(context.Background(), domain.PortLogEvent{Channel: ch, EventType: "command", Command: "test", ReturnValue: "ok", Message: "test", Actor: "test"}); err != nil { + t.Fatal(err) + } + } + result := getJSONAs(t, h, http.MethodPost, "/api/v1/logs/port/clear", `{"channel":"ALL","confirmation":"ALL"}`, "mock-admin") + if result["deleted"] != float64(2) { + t.Fatal(result) + } + getJSONAs(t, h, http.MethodPost, "/api/v1/logs/links/clear", `{"confirmation":"LINKS"}`, "mock-admin") +} diff --git a/backend/internal/api/clear_link_logs.go b/backend/internal/api/clear_link_logs.go new file mode 100644 index 0000000..2f3398f --- /dev/null +++ b/backend/internal/api/clear_link_logs.go @@ -0,0 +1,22 @@ +package api + +import "net/http" + +func (r *Router) handleClearLinkLogs(w http.ResponseWriter, req *http.Request) { + ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.links.clear", Target: "links", Permission: "log.clear"}) + if !ok { + return + } + var body struct { + Confirmation string `json:"confirmation"` + } + if ok, _ := decodeJSONBody(w, req, &body, false); !ok { + return + } + if body.Confirmation != "LINKS" { + writeError(w, http.StatusBadRequest, "validation_failed", "confirm LINKS exactly") + return + } + result, err := r.store.ClearLinkLogs(ctx) + writeResult(w, result, err) +} diff --git a/backend/internal/api/clear_port_logs.go b/backend/internal/api/clear_port_logs.go index f85674f..2a137c5 100644 --- a/backend/internal/api/clear_port_logs.go +++ b/backend/internal/api/clear_port_logs.go @@ -17,8 +17,8 @@ func (r *Router) handleClearPortLogs(w http.ResponseWriter, req *http.Request) { if ok, _ := decodeJSONBody(w, req, &body, false); !ok { return } - if !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 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)) || body.Confirmation != body.Channel { + writeError(w, http.StatusBadRequest, "validation_failed", "select ALL or one port CH01 through CH48 and confirm its exact channel") return } result, err := r.store.ClearPortLogs(ctx, body.Channel) diff --git a/backend/internal/api/clear_system_logs.go b/backend/internal/api/clear_system_logs.go index 97b07fc..e2a6317 100644 --- a/backend/internal/api/clear_system_logs.go +++ b/backend/internal/api/clear_system_logs.go @@ -14,7 +14,7 @@ func (r *Router) handleClearSystemLogs(w http.ResponseWriter, req *http.Request) return } if body.Confirmation != "SYSTEM" { - writeError(w, http.StatusBadRequest, "validation_failed", "confirm SYSTEM exactly; audit records are preserved") + writeError(w, http.StatusBadRequest, "validation_failed", "confirm SYSTEM exactly") 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 bac18ec..a8eaeb6 100644 --- a/backend/internal/api/clear_system_logs_test.go +++ b/backend/internal/api/clear_system_logs_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -func TestSystemClearRequiresConfirmationAndPreservesAuditProjection(t *testing.T) { +func TestSystemClearRequiresConfirmationAndDeletesAuditProjection(t *testing.T) { s := mock.NewStore(fixedClock) ctx := context.Background() _, err := s.RecordSystemLog(ctx, domain.SystemLogEvent{Severity: "info", Source: "test", Message: "delete ordinary"}) @@ -37,7 +37,7 @@ func TestSystemClearRequiresConfirmationAndPreservesAuditProjection(t *testing.T } } result := getJSONAs(t, h, http.MethodPost, "/api/v1/logs/system/clear", `{"confirmation":"SYSTEM"}`, "mock-admin") - if result["deleted"] != float64(1) { + if result["deleted"].(float64) < 2 { t.Fatalf("deleted=%v", result) } rows, err := s.SystemLogEvents(ctx, domain.SystemLogFilter{}) @@ -50,11 +50,11 @@ func TestSystemClearRequiresConfirmationAndPreservesAuditProjection(t *testing.T t.Fatal("ordinary event survived") } if row.AuditID == id { - found = row.AuditProtected + found = true } } - if !found { - t.Fatal("protected audit projection missing") + if found { + t.Fatal("old audit projection survived") } audits, err := s.AuditEvents(ctx) if err != nil { diff --git a/backend/internal/api/log_web_test.go b/backend/internal/api/log_web_test.go index 2386734..b53a64c 100644 --- a/backend/internal/api/log_web_test.go +++ b/backend/internal/api/log_web_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestPortOperatorLogAccessAndExportAreScoped(t *testing.T) { +func TestPortOperatorLogAccessAndExportAreDenied(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"logoperator","password":"strong-pass-123","role":"port_operator","force_change":false,"port_permissions":[{"channel":"CH18","access":"read"},{"channel":"CH01","access":"deny"}]}`, "mock-admin") session := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"logoperator","password":"strong-pass-123"}`) @@ -16,12 +16,14 @@ func TestPortOperatorLogAccessAndExportAreScoped(t *testing.T) { method, path, body string status int }{ - {"GET", "/api/v1/logs/port?channel=CH18", "", 200}, + {"GET", "/api/v1/logs/port?channel=CH18", "", 403}, + {"GET", "/api/v1/logs/port", "", 403}, {"GET", "/api/v1/logs/port?channel=CH01", "", 403}, {"GET", "/api/v1/logs/system", "", 403}, {"POST", "/api/v1/logs/export/download", `{"log_type":"system","format":"csv"}`, 403}, {"POST", "/api/v1/logs/export/download", `{"log_type":"port","channel":"CH01","format":"csv"}`, 403}, - {"POST", "/api/v1/logs/export/download", `{"log_type":"port","channel":"CH18","format":"csv"}`, 200}, + {"POST", "/api/v1/logs/export/download", `{"log_type":"port","channel":"CH18","format":"csv"}`, 403}, + {"POST", "/api/v1/logs/export", `{"log_type":"port","channel":"CH18","format":"csv"}`, 403}, {"POST", "/api/v1/logs/port/clear", `{"channel":"CH18","confirm":"CH18"}`, 403}, {"PATCH", "/api/v1/logs/retention", `{"port_days":30}`, 403}, } { @@ -34,9 +36,6 @@ func TestPortOperatorLogAccessAndExportAreScoped(t *testing.T) { if w.Code != tc.status { t.Fatalf("status %d want %d: %s", w.Code, tc.status, w.Body.String()) } - if tc.status == 200 && tc.method == "POST" && !strings.HasPrefix(w.Header().Get("Content-Type"), "text/csv") { - t.Fatal("CSV content type missing") - } }) } } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index d286e1c..65b888d 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -567,6 +567,7 @@ func (r *Router) routes() { r.mux.HandleFunc("GET /api/v1/logs/port", r.handlePortLogEvents) r.mux.HandleFunc("POST /api/v1/logs/port/clear", r.handleClearPortLogs) r.mux.HandleFunc("POST /api/v1/logs/system/clear", r.handleClearSystemLogs) + r.mux.HandleFunc("POST /api/v1/logs/links/clear", r.handleClearLinkLogs) r.mux.HandleFunc("GET /api/v1/logs/links", r.handleLinkLogEvents) r.mux.HandleFunc("GET /api/v1/logs/policy", r.handleLogPolicy) r.mux.HandleFunc("GET /api/v1/logs/storage", r.handleLogStorageStatus) @@ -2383,10 +2384,6 @@ func (r *Router) handleExportLogs(w http.ResponseWriter, req *http.Request) { if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &exportRequest) { return } - if actor, ok := domain.ActorFromContext(ctx); ok && actor.Role == "port_operator" && exportRequest.LogType != "" && !strings.EqualFold(exportRequest.LogType, "port") { - writeError(w, http.StatusForbidden, "authorization_denied", "only assigned port logs may be exported") - return - } if exportRequest.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(exportRequest.Channel), "read", spec) { return } @@ -2439,10 +2436,6 @@ func (r *Router) handleDownloadLogs(w http.ResponseWriter, req *http.Request) { if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &exportRequest) { return } - if actor, ok := domain.ActorFromContext(ctx); ok && actor.Role == "port_operator" && exportRequest.LogType != "" && !strings.EqualFold(exportRequest.LogType, "port") { - writeError(w, http.StatusForbidden, "authorization_denied", "only assigned port logs may be exported") - return - } if exportRequest.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(exportRequest.Channel), "read", spec) { return } @@ -3138,12 +3131,11 @@ func (r *Router) authorizeCommand(w http.ResponseWriter, req *http.Request, spec return req.Context(), false } - if actor.Role == "port_operator" && strings.HasPrefix(spec.Action, "log.") && spec.Action != "log.port.read" && spec.Action != "log.export" { - writeError(w, http.StatusForbidden, "authorization_denied", "port operators may only read and export assigned port logs") + if actor.Role == "port_operator" && strings.HasPrefix(spec.Action, "log.") { + writeError(w, http.StatusForbidden, "authorization_denied", "port operators cannot access logs") return req.Context(), false } - scopedPortLog := actor.Role == "port_operator" && actor.Permissions["port.read"] && (spec.Action == "log.port.read" || spec.Action == "log.export") - if !actor.Permissions[spec.Permission] && !scopedPortLog { + if !actor.Permissions[spec.Permission] { _, _ = r.store.RecordAudit(domain.WithActor(req.Context(), actor), domain.AuditEvent{ Action: spec.Action, Target: spec.Target, diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 9ffc8d1..84bf5b4 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -967,3 +967,9 @@ type ErrorBody struct { Code string `json:"code"` Message string `json:"message"` } + +// LinkLogClearResult reports deletion of historical link-state events. +type LinkLogClearResult struct { + Deleted int `json:"deleted"` + AuditID string `json:"audit_id"` +} diff --git a/backend/internal/domain/store.go b/backend/internal/domain/store.go index cc7d3ae..b250e35 100644 --- a/backend/internal/domain/store.go +++ b/backend/internal/domain/store.go @@ -59,6 +59,7 @@ type Store interface { PortLogEvents(ctx context.Context, filter PortLogFilter) ([]PortLogEvent, error) ClearPortLogs(ctx context.Context, channel string) (PortLogClearResult, error) ClearSystemLogs(ctx context.Context) (SystemLogClearResult, error) + ClearLinkLogs(ctx context.Context) (LinkLogClearResult, error) LinkLogEvents(ctx context.Context, filter LinkLogFilter) ([]LinkLogEvent, error) LogPolicy(ctx context.Context) (LogPolicySettings, error) LogRetention(ctx context.Context) (LogRetentionSettings, error) diff --git a/backend/internal/logservice/socket.go b/backend/internal/logservice/socket.go index 9d07480..b75c6ba 100644 --- a/backend/internal/logservice/socket.go +++ b/backend/internal/logservice/socket.go @@ -51,6 +51,11 @@ type PortLogCleaner interface { type FactoryResetter interface{ FactoryReset() error } +type LinkLogCleaner interface { + ClearLink() (int, error) + LinkClearRevision() (uint64, error) +} + type SystemLogCleaner interface { ClearSystem() (int, error) SystemClearRevision() (uint64, error) @@ -82,6 +87,7 @@ type command struct { } type response struct { + LinkClearRevision uint64 `json:"link_clear_revision"` Records []logstorage.Envelope `json:"records,omitempty"` Snapshot *logstorage.Snapshot `json:"snapshot,omitempty"` Code string `json:"code,omitempty"` @@ -149,9 +155,23 @@ func (s Server) handle(connection net.Conn) { return } + case "clear_link": + if value.Class != logstorage.ClassLink || value.Channel != "" { + err = errors.New("link clearing accepts no alternate class or channel") + break + } + var deleted int + deleted, err = s.Store.ClearLink() + if err == nil { + s.write(connection, response{Deleted: deleted}) + return + } + case "link_clear_revision": + s.write(connection, response{LinkClearRevision: s.Store.LinkClearRevision()}) + return case "clear_system": if value.Class != logstorage.ClassSystem || value.Channel != "" { - err = errors.New("only system events may be cleared; audit logs are preserved") + err = errors.New("system clearing accepts no alternate class or channel") break } var deleted int @@ -319,6 +339,16 @@ func (c *Client) ClearPort(channel string) (int, error) { return value.Deleted, err } +func (c *Client) ClearLink() (int, error) { + value, err := c.call(command{Action: "clear_link", Class: logstorage.ClassLink}) + return value.Deleted, err +} + +func (c *Client) LinkClearRevision() (uint64, error) { + value, err := c.call(command{Action: "link_clear_revision"}) + return value.LinkClearRevision, err +} + func (c *Client) ClearSystem() (int, error) { value, err := c.call(command{Action: "clear_system", Class: logstorage.ClassSystem}) return value.Deleted, err diff --git a/backend/internal/logstorage/clear_all_test.go b/backend/internal/logstorage/clear_all_test.go new file mode 100644 index 0000000..5144702 --- /dev/null +++ b/backend/internal/logstorage/clear_all_test.go @@ -0,0 +1,62 @@ +package logstorage + +import ( + "os" + "testing" + "time" +) + +func TestClearAllPortsAndLinksRemainDeletedAfterRestart(t *testing.T) { + root := t.TempDir() + s := openTestStore(t, Options{Root: root}) + now := time.Now() + for _, channel := range []string{"CH01", "CH48"} { + if _, err := s.Append(ClassPort, now, map[string]string{"channel": channel}); err != nil { + t.Fatal(err) + } + } + for _, class := range []string{ClassAudit, ClassSystem, ClassLink} { + if _, err := s.Append(class, now, map[string]string{"message": "old"}); err != nil { + t.Fatal(err) + } + } + oldLinks, err := os.ReadFile(s.activePath(ClassLink)) + if err != nil { + t.Fatal(err) + } + if n, err := s.ClearPort("ALL"); err != nil || n != 2 { + t.Fatalf("all ports: %d %v", n, err) + } + if rows, err := s.Read(ClassLink); err != nil || len(rows) != 1 { + t.Fatalf("link deleted by port clear: %v %v", rows, err) + } + if n, err := s.ClearLink(); err != nil || n != 1 { + t.Fatalf("links: %d %v", n, err) + } + // Simulate a crash after the checkpoint committed but before segment compaction. + if err := os.WriteFile(s.activePath(ClassLink), oldLinks, 0600); err != nil { + t.Fatal(err) + } + s = openTestStore(t, Options{Root: root}) + for _, class := range []string{ClassPort, ClassLink} { + if rows, err := s.Read(class); err != nil || len(rows) != 0 { + t.Fatalf("resurrected %s: %v %v", class, rows, err) + } + } + for _, class := range []string{ClassSystem, ClassAudit} { + if rows, err := s.Read(class); err != nil || len(rows) != 1 { + t.Fatalf("lost %s: %v %v", class, rows, err) + } + } + event, err := s.Append(ClassLink, now, map[string]string{"message": "new"}) + if err != nil || event.Sequence != 2 { + t.Fatalf("link sequence: %+v %v", event, err) + } + event, err = s.Append(ClassPort, now, map[string]string{"channel": "CH48"}) + if err != nil || event.Sequence != 3 { + t.Fatalf("port sequence: %+v %v", event, err) + } + if rows, err := s.Read(ClassLink); err != nil || len(rows) != 1 { + t.Fatalf("new link missing: %v %v", rows, err) + } +} diff --git a/backend/internal/logstorage/clear_link.go b/backend/internal/logstorage/clear_link.go new file mode 100644 index 0000000..02f977d --- /dev/null +++ b/backend/internal/logstorage/clear_link.go @@ -0,0 +1,77 @@ +package logstorage + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "tera-clone/services/tera-api/internal/storage/atomicfile" +) + +type linkClearState struct { + Version int `json:"version"` + Revision uint64 `json:"revision"` + NextSequence uint64 `json:"next_sequence"` +} + +func (s *Store) linkClearPath() string { + return filepath.Join(s.root, "port", "cleared-link-v1.json") +} + +func (s *Store) loadLinkClearLocked() error { + info, err := os.Lstat(s.linkClearPath()) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() > 1024 { + return errors.New("invalid link log deletion checkpoint") + } + data, err := os.ReadFile(s.linkClearPath()) + if err != nil { + return err + } + var state linkClearState + if err := json.Unmarshal(data, &state); err != nil { + return err + } + if state.Version != 1 || state.NextSequence == 0 || state.Revision == 0 { + return errors.New("invalid link log deletion checkpoint") + } + s.linkClear = state + return nil +} + +func (s *Store) linkRecordCleared(event Envelope) bool { + return event.Class == ClassLink && event.Sequence < s.linkClear.NextSequence +} + +func (s *Store) LinkClearRevision() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.linkClear.Revision +} + +// ClearLink clears stored link-state events without altering live link state. +func (s *Store) ClearLink() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + deleted := 0 + if err := s.walkClassLocked(ClassLink, false, func(Envelope) error { deleted++; return nil }); err != nil { + return 0, err + } + next := linkClearState{Version: 1, Revision: s.linkClear.Revision + 1, NextSequence: s.classes[ClassLink].NextSequence} + data, err := json.Marshal(next) + if err != nil { + return 0, err + } + if err := atomicfile.Write(s.linkClearPath(), append(data, '\n'), 0o600); err != nil { + _ = s.loadLinkClearLocked() + return 0, err + } + s.linkClear = next + return deleted, s.compactClearedClassLocked(ClassLink, s.linkRecordCleared) +} diff --git a/backend/internal/logstorage/clear_port.go b/backend/internal/logstorage/clear_port.go index 58ea633..d08b758 100644 --- a/backend/internal/logstorage/clear_port.go +++ b/backend/internal/logstorage/clear_port.go @@ -77,12 +77,12 @@ func (s *Store) PortClearRevision() uint64 { return s.portClears.Revision } -// ClearPort removes only one canonical port's communication logs. Audit, +// ClearPort removes one port or ALL ports' communication logs. Audit, // system, and link-state classes are never deletion targets. The mutex gives // the clear a precise boundary relative to concurrent appends. func (s *Store) ClearPort(channel string) (int, error) { - if !canonicalPort.MatchString(channel) { - return 0, errors.New("port log channel must be CH01 through CH48") + if channel != "ALL" && !canonicalPort.MatchString(channel) { + return 0, errors.New("port log channel must be ALL or CH01 through CH48") } s.mu.Lock() defer s.mu.Unlock() @@ -94,7 +94,7 @@ func (s *Store) ClearPort(channel string) (int, error) { if err := json.Unmarshal(event.Payload, &record); err != nil { return fmt.Errorf("invalid port log payload: %w", err) } - if record.Channel == channel { + if channel == "ALL" || record.Channel == channel { deleted++ } return nil @@ -105,7 +105,13 @@ func (s *Store) ClearPort(channel string) (int, error) { for key, value := range s.portClears.Cutoffs { next.Cutoffs[key] = value } - next.Cutoffs[channel] = next.NextSequence - 1 + if channel == "ALL" { + for i := 1; i <= 48; i++ { + next.Cutoffs[fmt.Sprintf("CH%02d", i)] = next.NextSequence - 1 + } + } else { + next.Cutoffs[channel] = next.NextSequence - 1 + } data, err := json.Marshal(next) if err != nil { return 0, err diff --git a/backend/internal/logstorage/clear_system.go b/backend/internal/logstorage/clear_system.go index 8d0f5d1..0ca3202 100644 --- a/backend/internal/logstorage/clear_system.go +++ b/backend/internal/logstorage/clear_system.go @@ -10,9 +10,10 @@ import ( ) type systemClearState struct { - Version int `json:"version"` - Revision uint64 `json:"revision"` - NextSequence uint64 `json:"next_sequence"` + Version int `json:"version"` + Revision uint64 `json:"revision"` + NextSequence uint64 `json:"next_sequence"` + AuditNextSequence uint64 `json:"audit_next_sequence,omitempty"` } func (s *Store) systemClearPath() string { @@ -46,11 +47,12 @@ func (s *Store) loadSystemClearLocked() error { } func (s *Store) systemRecordCleared(event Envelope) bool { - return event.Class == ClassSystem && event.Sequence < s.systemClear.NextSequence + return event.Class == ClassSystem && event.Sequence < s.systemClear.NextSequence || + event.Class == ClassAudit && event.Sequence < s.systemClear.AuditNextSequence } func (s *Store) recordCleared(event Envelope) bool { - return s.portRecordCleared(event) || s.systemRecordCleared(event) + return s.portRecordCleared(event) || s.systemRecordCleared(event) || s.linkRecordCleared(event) } func (s *Store) SystemClearRevision() uint64 { @@ -59,8 +61,7 @@ func (s *Store) SystemClearRevision() uint64 { return s.systemClear.Revision } -// ClearSystem never accepts a caller-supplied class or filesystem path. Audit, -// port, and link records are outside this operation, even in the same bucket. +// ClearSystem clears system and audit records. Port and link records are preserved. func (s *Store) ClearSystem() (int, error) { s.mu.Lock() defer s.mu.Unlock() @@ -68,7 +69,10 @@ func (s *Store) ClearSystem() (int, error) { if err := s.walkClassLocked(ClassSystem, false, func(Envelope) error { deleted++; return nil }); err != nil { return 0, err } - next := systemClearState{Version: 1, Revision: s.systemClear.Revision + 1, NextSequence: s.classes[ClassSystem].NextSequence} + if err := s.walkClassLocked(ClassAudit, false, func(Envelope) error { deleted++; return nil }); err != nil { + return 0, err + } + next := systemClearState{Version: 1, Revision: s.systemClear.Revision + 1, NextSequence: s.classes[ClassSystem].NextSequence, AuditNextSequence: s.classes[ClassAudit].NextSequence} data, err := json.Marshal(next) if err != nil { return 0, err @@ -78,5 +82,8 @@ func (s *Store) ClearSystem() (int, error) { return 0, err } s.systemClear = next - return deleted, s.compactClearedClassLocked(ClassSystem, s.systemRecordCleared) + if err := s.compactClearedClassLocked(ClassSystem, s.systemRecordCleared); err != nil { + return deleted, err + } + return deleted, s.compactClearedClassLocked(ClassAudit, s.systemRecordCleared) } diff --git a/backend/internal/logstorage/clear_system_test.go b/backend/internal/logstorage/clear_system_test.go index e24c031..dda4bc3 100644 --- a/backend/internal/logstorage/clear_system_test.go +++ b/backend/internal/logstorage/clear_system_test.go @@ -32,7 +32,7 @@ func TestClearSystemPreservesOtherClassesAndReclaimsStoredData(t *testing.T) { if err != nil { t.Fatal(err) } - if n, err := s.ClearSystem(); err != nil || n != 3 { + if n, err := s.ClearSystem(); err != nil || n != 4 { t.Fatalf("clear=%d,%v", n, err) } after, err := s.Snapshot() @@ -53,7 +53,11 @@ func TestClearSystemPreservesOtherClassesAndReclaimsStoredData(t *testing.T) { s = openTestStore(t, Options{Root: root}) for _, class := range []string{ClassAudit, ClassPort, ClassLink} { rows, err := s.Read(class) - if err != nil || len(rows) != 1 { + want := 1 + if class == ClassAudit { + want = 0 + } + if err != nil || len(rows) != want { t.Fatalf("%s changed: %s,%v", class, rows, err) } } diff --git a/backend/internal/logstorage/factory_reset.go b/backend/internal/logstorage/factory_reset.go index e5ced1c..ac2490b 100644 --- a/backend/internal/logstorage/factory_reset.go +++ b/backend/internal/logstorage/factory_reset.go @@ -40,5 +40,6 @@ func (s *Store) FactoryReset() error { } s.portClears = portClearState{} s.systemClear = systemClearState{} + s.linkClear = linkClearState{} return s.rebuildLocked() } diff --git a/backend/internal/logstorage/store.go b/backend/internal/logstorage/store.go index f338951..8142bb3 100644 --- a/backend/internal/logstorage/store.go +++ b/backend/internal/logstorage/store.go @@ -130,6 +130,7 @@ type Store struct { closed map[string][]segmentMeta portClears portClearState systemClear systemClearState + linkClear linkClearState } func Open(options Options) (*Store, error) { @@ -180,6 +181,9 @@ func Open(options Options) (*Store, error) { if err := store.loadPortClearsLocked(); err != nil { return nil, err } + if err := store.loadLinkClearLocked(); err != nil { + return nil, err + } if err := store.loadSystemClearLocked(); err != nil { return nil, err } @@ -195,6 +199,14 @@ func Open(options Options) (*Store, error) { if err := store.compactClearedClassLocked(ClassSystem, store.systemRecordCleared); err != nil { return nil, err } + if err := store.compactClearedClassLocked(ClassAudit, store.systemRecordCleared); err != nil { + return nil, err + } + } + if store.linkClear.Revision > 0 { + if err := store.compactClearedClassLocked(ClassLink, store.linkRecordCleared); err != nil { + return nil, err + } } return store, nil } @@ -610,6 +622,12 @@ func (s *Store) rebuildLocked() error { if class == ClassPort && state.NextSequence < s.portClears.NextSequence { state.NextSequence = s.portClears.NextSequence } + if class == ClassLink && state.NextSequence < s.linkClear.NextSequence { + state.NextSequence = s.linkClear.NextSequence + } + if class == ClassAudit && state.NextSequence < s.systemClear.AuditNextSequence { + state.NextSequence = s.systemClear.AuditNextSequence + } if class == ClassSystem && state.NextSequence < s.systemClear.NextSequence { state.NextSequence = s.systemClear.NextSequence } diff --git a/demo-guide/README.md b/demo-guide/README.md new file mode 100644 index 0000000..dd7040f --- /dev/null +++ b/demo-guide/README.md @@ -0,0 +1,209 @@ +# TERA 장비 시연 자료 + +작성일: 2026-09-10 · 대상: 현재 시연용 48포트 장비 · 권장 진행 시간: 35~45분 + +**시연 순서:** 로그인 → 포트 송수신 → 알림 → 사용자 권한 → 주소 필터 → SNMP → SSL 인증서 → 로그 조회·삭제 → 백업·복원. + +이 자료의 절차는 시연자가 수행할 시험입니다. 절차가 적혀 있다는 사실이 실기 시험 완료를 의미하지는 않습니다. 배포·자동 테스트 결과는 [최종 빌드 안내](../final-build/README.md)와 구분합니다. 첨부 화면은 사용자가 제공한 인증서 생성 입력 예시이며 활성화 완료 증빙은 아닙니다. + +## 1. 준비 및 기록 + +| 항목 | 시연 환경 / 입력 예시 | +|---|---| +| 장비 관리 주소 | http://192.168.0.223/ | +| HTTPS 주소 | https://192.168.0.223/ | +| 현재 관리 PC | 192.168.0.100 — 시연 전 실제 유선 IP 확인 | +| 복구 콘솔 | COM4, 115200 baud, 8 data bits, parity None, 1 stop bit | +| 시험 포트 | 예: CH01. 실제 연결된 포트로 대체 | +| 상대 시리얼 장비 | 통신 조건과 송신 방법을 사전 확인 | +| 보조 시험 PC | 관리 PC와 다른 IP. 주소 필터 거부 확인용 | +| SNMP 도구 | Net-SNMP snmpget 또는 SNMP 조회 도구 | +| 기록 도구 | 화면 캡처, 다운로드한 로그, 결과 체크표 | + +계정 비밀번호, Community, SMTP 비밀번호, 인증서 개인키는 발표 화면이나 저장소에 남기지 않습니다. 변경 전 포트 설정·알림 수신자·인증 방식·주소 필터·활성 인증서를 기록합니다. 로그 삭제는 복구할 수 있으리라 가정하지 말고, 필요한 범위를 먼저 내보냅니다. 설정 백업에는 로그 원문이 포함되지 않습니다. + +## 2. 로그인과 준비 상태 (2분) + +1. 장비 주소를 열고 준비 완료 표시를 기다립니다. 기존 화면이 남아 있으면 Ctrl+F5로 갱신합니다. +2. 관리자 계정과 CAPTCHA로 로그인합니다. 잘못된 CAPTCHA 1회는 로그인 거부를 확인하는 시험으로 사용할 수 있습니다. +3. 기타 설정의 날짜·시간을 확인하고, 틀리면 현재 PC 시간으로 동기화합니다. +4. 대시보드와 포트 상태 화면을 보여줍니다. + +**기대 결과:** 준비 완료 후 로그인 가능. 날짜·시간과 실제 연결 포트 상태가 일치합니다. + +발표 멘트: “장비 서비스가 준비된 뒤 관리 화면에 진입하며, 표시 시간은 이후 로그와 인증서 검증의 기준입니다.” + +## 3. 포트 설정과 실제 송수신 (5분) + +1. 포트 설정에서 시험 포트를 선택하고 현재 값을 기록합니다. +2. 상대 장비와 맞는 통신 조건을 적용합니다. 예: 9600 / 8 / None / 1 / 흐름 제어 None. 예시를 모든 장비에 강제하지 않습니다. +3. 별칭을 `DEMO_PORT`로 바꾸고 다른 화면에 갔다가 돌아와 유지되는지 확인합니다. +4. 관리자 포트 디버그에서 접속해 상대 장비가 지원하는 조회 명령을 전송합니다. +5. 실제 응답을 확인하고 연결을 해제한 뒤 재접속합니다. + +**기대 결과:** 저장한 설정 유지, 실제 송수신 성공, 재접속 가능. 상태 확인용 신호나 CR 응답을 지원하지 않는 상대 장비는 상태 표시와 실제 송수신을 구분해 확인합니다. + +## 4. 시리얼 키워드와 알림 (5분) + +1. 포트 설정 → 고급 설정에서 시험 포트의 알림 키워드를 `DEMO_ALARM`으로 설정·적용합니다. +2. 기타 설정 → 알림 설정 → 시리얼 이벤트에서 동일한 대상 포트를 선택합니다. +3. 감지 키워드가 같은 값인지 확인하고 이 화면에서 `DEMO_READY`로 변경합니다. +4. 수신 키워드 감지를 켜고, 준비된 전달 방법(메일 / SNMP Trap / Syslog)을 선택한 뒤 적용합니다. +5. 다른 포트를 선택했다가 돌아와 값을 확인합니다. 포트 고급 설정에서도 `DEMO_READY`를 확인합니다. +6. 상대 장비에서 대상 포트로 `DEMO_READY`를 포함한 문자열을 송신합니다. 실제 RX 데이터로 시험합니다. +7. 포트 로그의 키워드 감지 결과와 선택한 수신처의 알림을 확인합니다. +8. 일치하지 않는 문자열로 새 키워드 알림이 발생하지 않는지도 확인합니다. + +**기대 결과:** 두 화면이 같은 포트 키워드를 공유합니다. 키워드 감지를 켠 상태에서 빈 키워드로 적용하면 입력 오류가 나타납니다. 감지를 끄면 빈 값 저장이 가능합니다. + +메일을 시연할 때는 SMTP 주소·포트·STARTTLS·인증·발신자·수신자를 실제 계정 조건에 맞춥니다. 테스트 발송 후 받은편지함과 스팸함에서 시각·내용을 확인합니다. 인증서 신뢰 오류가 발생하면 수신자를 바꾸는 대신 장비 시간과 CA 신뢰 설정을 점검합니다. + +SNMP Trap은 아래 SNMP 조회 시험과 별개입니다. PC의 Trap 수신기, 수신 포트(통상 UDP 162), 장비의 Trap 대상 및 이벤트 전달 설정을 준비한 뒤 실제 이벤트로 확인합니다. + +## 5. 사용자와 인증 서버 (4분) + +1. 관리자 운영 → 사용자 계정에서 시연용 포트 운영자를 만들고 시험 포트만 허용합니다. +2. 별도 브라우저 세션으로 로그인합니다. 첫 로그인 암호 변경 요구가 있으면 먼저 완료합니다. +3. 허용한 포트 접근과 허용하지 않은 포트의 거부를 확인합니다. +4. 포트 운영자에게 관리자 메뉴와 로그 메뉴가 보이지 않는지 확인합니다. `/logs/ports` 직접 접속도 거부되어야 합니다. +5. 관리자 세션으로 돌아와 사용자 수정·비밀번호 재설정 버튼의 배치를 확인합니다. + +외부 인증을 시연하는 경우 RADIUS 또는 TACACS+ 시험 서버를 준비합니다. 서버 주소·포트·공유 비밀·기본 역할을 맞추고 성공 로그인 및 오입력 1회 거부를 서버 로그와 함께 확인합니다. 외부 서버 중지 시험은 로컬 관리자 접속 경로를 확보한 상태에서 수행합니다. ‘장애 시 인증’ 고정 안내는 UI에서 제거되었으나, 그 UI 제거 자체가 인증 정책 변경을 뜻하지는 않습니다. + +## 6. 주소 필터 — 차단 재현과 복구 (5분) + +### 현재 사례 + +2026-09-10 조회 당시 차단 목록에 `192.168.0.0/24`, 전체 프로토콜 규칙이 켜져 있었습니다. 관리 PC `192.168.0.100`도 이 범위에 포함되어 `client address is denied by management address filter` 오류가 발생했습니다. 콘솔에서 설정을 백업하고 필터 사용만 꺼 접속을 복구했습니다. 이후 사용자가 변경했을 수 있으므로 시연 직전 상태를 다시 확인합니다. + +### 안전하게 시험하기 + +1. 관리자 운영 → 주소 필터에서 필터 사용을 끈 상태로 기존 규칙을 검토합니다. +2. 광범위한 `192.168.0.0/24` 차단 규칙은 삭제하거나 비활성화합니다. +3. IPv4 탭에 **접속하는 관리 PC의 IP** `192.168.0.100/32`, 프로토콜 전체, 규칙 사용 켬을 추가합니다. 장비 IP를 입력하는 것이 아닙니다. +4. 기본 탭에서 허용 목록을 선택하고 필터를 켭니다. +5. 현재 PC에서 새 로그인 성공, 보조 PC에서 거부를 확인합니다. +6. 차단 목록 시험은 보조 PC의 실제 IP `/32`를 대상으로 합니다. 현재 관리 PC를 제외한 상태에서 적용합니다. +7. 완료 후 시연 전 정책으로 복원합니다. + +| 표현 | 의미 | +|---|---| +| 192.168.0.100/32 | PC 한 대 | +| 192.168.0.0/24 | 192.168.0.x 대역 전체 | +| 허용 목록 | 일치하는 접속 허용, 나머지 거부 | +| 차단 목록 | 일치하는 접속 거부 | + +MAC 규칙은 장비에서 해당 PC의 MAC을 확인할 수 있는 동일 LAN에서 별도로 시험합니다. 라우터를 경유한 원격 PC의 MAC이 그대로 전달된다고 가정하지 않습니다. + +### 차단됐을 때 + +반복 로그인이나 공장 초기화 대신 COM4 콘솔 또는 허용된 다른 PC로 복구합니다. 콘솔 복구는 인증 설정 파일의 백업 → API 중지 → 주소 필터의 `enabled`만 false로 변경 → API 재시작 → 준비 상태와 로그인 확인 순서입니다. 현재 장비의 설정 경로는 `/var/lib/tera-api/auth.json`이었으며 다른 장비에서는 환경 설정으로 확인해야 합니다. 계정·암호 데이터 전체를 출력하거나 임의 문자열 치환으로 다른 `enabled` 필드를 바꾸지 않습니다. + +## 7. SNMP 조회·거부 시험 (4분) + +1. 관리자 운영 → SNMP에서 SNMP 사용 및 v2c를 켭니다. +2. Read Community를 시험용 값으로 정하고 장비 위치를 `TEST-RACK`, 관리자 연락처를 시연용 값으로 설정·적용합니다. +3. PC의 Net-SNMP 도구로 조회합니다. 아래 `TEST_COMMUNITY`는 실제 설정값으로 대체합니다. + +```bash +# 장비 위치 sysLocation +snmpget -v2c -c TEST_COMMUNITY -t 2 -r 0 192.168.0.223 1.3.6.1.2.1.1.6.0 +# 관리자 연락처 sysContact +snmpget -v2c -c TEST_COMMUNITY -t 2 -r 0 192.168.0.223 1.3.6.1.2.1.1.4.0 +# 잘못된 Community 거부 +snmpget -v2c -c WRONG_COMMUNITY -t 2 -r 0 192.168.0.223 1.3.6.1.2.1.1.6.0 +``` + +**기대 결과:** 올바른 Community는 설정한 문자열 반환. 잘못된 값은 정상 데이터를 반환하지 않으며 통상 시간 초과가 발생합니다. 정상 값도 시간 초과라면 장비 IP, SNMP 사용 여부, UDP 161, PC 방화벽을 점검합니다. TCP 포트 검사만으로 UDP SNMP 정상 여부를 판정하지 않습니다. + +v3를 추가 시연하려면 UI에서 v3와 사용자 사용을 켜고 사용자명·접근 권한·인증 방식·암호화 방식·각 키를 설정합니다. 조회 도구도 동일한 방식으로 맞춥니다. 예를 들어 UI를 SHA-1/AES로 정했다면: + +```bash +snmpget -v3 -l authPriv -u TEST_USER -a SHA -A AUTH_PASSWORD -x AES -X PRIV_PASSWORD 192.168.0.223 1.3.6.1.2.1.1.6.0 +``` + +위 명령은 SHA-1 설정의 예시입니다. UI가 SHA-256이면 도구가 지원하는 SHA-256 옵션을 사용해야 합니다. v3가 정상 동작한 뒤 시험에 사용하지 않는 v1/v2c는 원래 정책으로 복원합니다. + +## 8. SSL 인증서 생성·활성화·검증 (5분) + +사용자가 제공한 입력 화면: + +![SSL 인증서 생성 입력 예시](assets/ssl-certificate-example.png) + +| 필드 | 이번 입력값 | +|---|---| +| 인증서 ID | device-192-168-0-223 (자동 생성) | +| 공통 이름 CN | 192.168.0.223 | +| 국가 C | KR | +| 조직 O | TERA-TEST | +| 나머지 선택 항목 | 필요하지 않으면 빈 값 | + +1. 장비 날짜·시간을 먼저 확인합니다. +2. 관리자 운영 → SSL 인증서 → 새로 만들기에서 위 값으로 생성합니다. 이미 같은 ID가 있으면 목록에서 확인하고 중복 생성하지 않습니다. +3. 인증서 목록에서 해당 인증서의 유효 기간과 개인키 보유 상태를 확인합니다. +4. 활성 인증서로 선택하고 적용합니다. **생성만으로 적용된다고 판단하지 않습니다.** +5. 새 브라우저 연결로 `https://192.168.0.223/`을 엽니다. HTTP 주소에서는 인증서 적용 여부를 확인할 수 없습니다. +6. 브라우저 인증서 정보에서 조직 `TERA-TEST`, SAN의 IP `192.168.0.223`, 유효 기간, 지문 또는 일련번호를 확인합니다. +7. HTTPS 로그인과 메뉴 이동을 확인합니다. 필요하면 기존 인증서로 다시 적용해 지문이 바뀌는 것도 확인합니다. + +**자체 서명 인증서의 신뢰 경고와 적용 실패는 다릅니다.** 경고 없는 접속을 시연하려면 PC가 신뢰하는 CA에서 서명받고 접속 주소와 일치하는 SAN이 포함된 인증서가 필요합니다. 신뢰 경고를 무시한 접속은 신뢰 검증 성공으로 기록하지 않습니다. + +추가 시험: PEM 인증서와 일치하는 개인키 가져오기 → 목록 확인 → 활성화. 개인키가 다른 파일은 오류로 거부되어야 합니다. CSR 다운로드는 CA에 제출할 요청을 만드는 절차로, 다운로드만으로 새 인증서가 발급되는 것은 아닙니다. + +## 9. 로그 조회·내보내기·삭제 (5분) + +먼저 포트 송수신, 로그인 시험, 키워드 이벤트로 기록을 생성합니다. 포트 상태 이벤트는 실제 연결 변화로 생성하되, 장비 관리에 사용하는 네트워크 케이블을 시험 중 분리하지 않습니다. + +1. 시스템 로그에서 로그인·설정 변경 등의 발생 시각과 내용을 확인합니다. +2. 포트 로그에서 대상 포트, RX/TX, 키워드, 기간을 지정해 조회합니다. +3. 포트 상태 이벤트에서 UP/DOWN 변경을 확인합니다. 이력 삭제는 실제 연결 상태를 바꾸지 않습니다. +4. 필요한 로그를 CSV/지원 형식으로 내려받고 내용·시각·선택한 포트를 확인합니다. 내보내기 검색 조건과 최대 다운로드 건수를 확인합니다. + +### 삭제 범위 + +| 화면 / 선택 | 삭제 대상 | 확인창 입력 | 유지되는 항목 | +|---|---|---|---| +| 포트 로그 / CH01 등 | 선택한 포트의 통신 로그 전체 | CH01 등 선택한 채널 | 다른 포트, 시스템·감사·상태 이벤트 | +| 포트 로그 / 전체 | CH01~CH48 통신 로그 전체 | ALL | 시스템·감사·상태 이벤트 | +| 시스템 로그 | 기존 시스템 이벤트 및 감사 기록 | SYSTEM | 포트 통신·상태 이벤트 | +| 포트 상태 이벤트 | 저장된 상태 이벤트 전체 | LINKS | 현재 연결 상태, 포트 통신·시스템·감사 로그 | + +기간·검색어·표시 개수를 좁혀도 삭제 범위는 위 표와 같습니다. 관리자가 정확한 확인 문자열을 입력해야 실행됩니다. 취소하거나 틀리게 입력했을 때 삭제되지 않는 것도 먼저 확인합니다. + +**시험 순서:** 시험 로그 생성 → 내보내기 → CH01만 삭제해 CH02 유지 확인 → 전체 선택 후 ALL 삭제 → 새 송수신 로그 재생성 확인 → 상태 이벤트 LINKS 삭제 → 현재 연결 상태 유지와 다음 상태 변경 기록 확인 → 시스템 로그 SYSTEM 삭제. + +삭제 후 새 이벤트나 삭제 작업의 기록이 생길 수 있으므로, 화면이 영구적으로 0건인지를 기준으로 삼지 않습니다. 삭제 전에 있던 고유한 시험 메시지가 없어졌는지 확인합니다. 장비의 기존 로그를 실제로 지우는 시연은 필요한 자료를 확보한 뒤 수행합니다. + +## 10. DDNS·백업·마무리 (3~5분) + +DDNS는 IPv4 설정으로 시연합니다. 사용할 공급자·도메인·자격 증명이 있을 때 설정을 적용한 뒤 외부 DNS 조회로 주소를 확인합니다. 공급자 연결이나 DNS 전파 지연은 UI 저장 성공과 구분합니다. IPv6 항목은 제거되었습니다. + +구성 백업은 암호를 정해 다운로드하고, 시험 포트 별칭 하나를 변경한 뒤 백업을 복원해 이전 값으로 돌아오는지 확인합니다. 복원은 여러 설정을 함께 바꾸므로 시험 장비에서 진행하고, 네트워크 포함 옵션과 변경 범위를 확인합니다. 펌웨어 업데이트·공장 초기화는 별도 시나리오입니다. + +시연 후 계정·포트·알림·인증 방식·주소 필터·인증서·SNMP 설정을 원래 상태로 복원하고 시험용 계정을 정리합니다. 재시작은 저장 여부를 확인한 뒤 수행하며, 재로그인·포트 송수신·준비 상태를 다시 확인합니다. + +## 결과 기록표 + +| 시험 | 기대 결과 | 실제 결과 / 캡처 / 시각 | +|---|---|---| +| 로그인·서비스 준비 | 준비 완료 및 관리자 로그인 | 미실시 / 기입 | +| 포트 설정·송수신 | 설정 유지 및 실제 응답 | 미실시 / 기입 | +| 키워드 양방향 설정 | 두 화면 일치 및 RX 감지 | 미실시 / 기입 | +| 메일 / Trap / Syslog | 선택한 수신처에서 확인 | 미실시 / 기입 | +| 사용자 권한 | 허용 포트만 접근, 운영자 로그 차단 | 미실시 / 기입 | +| 외부 인증 | 올바른 인증 성공, 오입력 거부 | 미실시 / 기입 | +| 주소 필터 | 관리 PC 허용, 보조 PC 거부 | 미실시 / 기입 | +| SNMP | 정상 값 조회, 오입력 거부 | 미실시 / 기입 | +| SSL | 생성·활성화 및 HTTPS 제공 인증서 일치 | 미실시 / 기입 | +| 단일 / 전체 포트 로그 삭제 | 선택 범위 삭제 및 새 로그 수집 | 미실시 / 기입 | +| 상태 이벤트 삭제 | 이력 삭제, 현재 상태 유지 | 미실시 / 기입 | +| 시스템 로그 삭제 | 기존 시스템·감사 기록 삭제 | 미실시 / 기입 | +| 백업·복원·재시작 | 설정 복원 및 정상 재접속 | 미실시 / 기입 | + +## 발표 전 마지막 확인 + +- 실제 관리자 비밀번호·Community·인증서 개인키가 화면이나 캡처에 노출되지 않는가? +- 주소 필터를 켜기 전 현재 PC의 `/32` 규칙과 콘솔 복구 경로를 확인했는가? +- 인증서 “생성”, “활성화”, “브라우저 신뢰”를 각각 구분했는가? +- 로그 삭제 대상과 내보내기 범위를 확인했는가? +- 시험 성공을 실제 결과로 기록했으며 미실시 항목을 성공으로 표시하지 않았는가? diff --git a/demo-guide/assets/ssl-certificate-example.png b/demo-guide/assets/ssl-certificate-example.png new file mode 100644 index 0000000..b59d1d9 Binary files /dev/null and b/demo-guide/assets/ssl-certificate-example.png differ diff --git a/final-build/README.md b/final-build/README.md index ac1598d..fa6f80c 100644 --- a/final-build/README.md +++ b/final-build/README.md @@ -1,50 +1,53 @@ -# Final device builds +# 최종 장비 빌드 -Release: `20260909-config-read-static-progress-1` -Target: Linux ARM64 (RK3568) -UI: **http://192.168.0.223/** — HTTP port **80**. +릴리스: **20260910-demo-log-controls-1** · Linux ARM64 · 시연 장비: http://192.168.0.223/ -## Replacement device install mapping +2026-09-10 프론트엔드·API·로그 데몬을 장비에 배포했습니다. 세 파일의 설치 SHA256 일치와 서비스 준비 **12/12**를 확인했습니다. 나머지 다섯 파일은 이전 최종 빌드 세트에서 유지했습니다. -| Release file | Installed path | Existing init service | -| --- | --- | --- | -| `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-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-logd` | `/usr/bin/tera-logd` | `S44tera-logd` | -| `backend/tera-sessiond` | `/usr/bin/tera-sessiond` | `S55tera-sessiond` | -| `backend/tera-netd` | `/usr/bin/tera-netd` | `S43tera-netd` | +이 디렉터리는 실행 파일 모음입니다. boot/rootfs를 포함한 전체 펌웨어 `.pkg`는 아닙니다. 장비 설정, 사용자 계정, 인증서 개인키와 런타임 로그는 포함하지 않습니다. -The API/alarm release filenames retain the clone-demo naming; this replacement device uses the standard API/alarm install paths above. Install all eight compatible binaries together with mode 0755. Verify `sha256sum -c SHA256SUMS` before installation. Back up installed binaries and stop services before replacement. +## 파일과 설치 경로 -Preserve `/etc/tera/teraweb.env`: `PORT=80`, API upstream `http://127.0.0.1:8082`, session upstream `http://127.0.0.1:8081`. Keep the existing `S61teraweb` service. Do not install the previous device's port-8083 web environment or duplicate API services. Device configuration, accounts, private keys and serial mapping are not included in this release. +| 파일 | 장비 설치 경로 | 서비스 | 이번 변경 | +|---|---|---|---| +| 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 | 유지 | -## Readiness +설치 실행 권한은 0755입니다. 이 장비의 웹 설정은 HTTP 80과 기존 서비스 구성을 유지합니다. 이번 로그 기능에는 일치하는 API·웹·로그 데몬이 모두 필요합니다. 배포 시 기존 바이너리를 백업하고 로그 데몬의 소켓 준비를 확인한 다음 API를 시작해야 합니다. -Login remains blocked until all 12 startup stages finish: settings restore, TLS, SNMP, management access, serial configuration, service health, time/NTP settings, network settings, notification/authentication policies, log retention, fresh port observations, and port services. The UI uses neutral “설정 중…” wording and the normal progress color throughout initialization, including retries. Progress and elapsed time remain visible; delayed stages cannot be counted as complete. Detailed startup errors remain in device logs. +## 반영 내용 -The login page synchronizes an uninitialized device clock from the PC before login. Factory certificates created before year 2000 are reissued after clock recovery, retaining their key and subject. User certificates and normally dated expired certificates are not automatically replaced. +- 사용자 계정 작업 셀 정렬 수정. +- 포트 운영자의 로그 메뉴와 로그 API 접근 제거. +- 인증 서버의 ‘장애 시 인증’, 주소 필터의 ‘안전 안내’ 고정 UI 제거. +- DDNS IPv6 항목 제거. DDNS 화면에서 적용하면 IPv6 사용을 끄고 IPv6 도메인을 비웁니다. +- 시리얼 이벤트 감지 키워드 편집 및 포트 고급 설정과 공유. +- 시스템 로그의 기존 감사 기록 포함 삭제. +- 포트 로그 단일 채널 및 전체 48포트 삭제(확인 문자열 ALL). +- 포트 상태 이벤트 전체 삭제(확인 문자열 LINKS). 현재 링크 상태는 변경하지 않습니다. +- 삭제 체크포인트 및 다른 접속의 캐시 갱신 처리. -External NTP/SMTP/authentication availability is separate from local settings application. No-response serial ports may complete observation; every port is not required to be connected. Kernel drivers are unchanged. +## 검증 결과 -## Latest changes +- 백엔드: logstorage, logservice, api, adapters/mock 패키지 전체 테스트 통과. +- 프론트엔드: handlers, mock 패키지 테스트 통과. +- JavaScript: 삭제 제어 11개, 로그 표시 7개, 시리얼 키워드 1개 테스트 통과. +- 전체 포트 삭제 후 다른 종류 로그 유지, 상태 이벤트 삭제 후 재시작 복구 방지, 새 이벤트 수집과 다른 클라이언트 캐시 갱신 검증. +- 8개 실행 파일의 ELF64 AArch64 형식과 체크섬 확인. +- 장비 배포 후 웹의 ALL/LINKS 코드 제공 및 준비 상태 12/12 확인. -The API supports configuration-only port reads (`runtime=false`) so loading port configuration does not wait for runtime telemetry. Default port reads retain live telemetry. The frontend queues the most recent navigation while a page initializer is running and loads runtime observations separately from port configuration. +검증을 위해 장비의 기존 로그를 실제로 삭제하지 않았습니다. SNMP·인증서 신뢰·48채널 전체 트래픽·실기 기록 중 전원 차단 내성은 이 릴리스에서 완료했다고 주장하지 않습니다. -The login progress bar no longer runs an infinite CSS animation. Actual progress, elapsed time, readiness checks and CAPTCHA remain active. This reduces unnecessary rendering load in the device's internal browser. +## 파일 무결성과 배포 기록 -## Validation +[SHA256SUMS](SHA256SUMS)에 모든 실행 파일의 해시가 있습니다. [release-manifest.json](release-manifest.json)에 크기·설치 경로·검증 범위가 있습니다. -Backend startup/API, matched session-service protocol, TLS bootstrap recovery, time service, serial, network, log service/storage and policy tests passed. Frontend login/readiness tests and 10 JavaScript login tests passed. Linux ARM64 builds completed. This release does not claim a cold-boot timing test or full 48-channel traffic validation. +이번 장비 백업: `/userdata/tera-backups/ui-settings-20260910-125259`. -The latest API and frontend binaries in this directory match the SHA256 values recorded during successful replacement-device deployments on 2026-09-09. The other six binaries are unchanged from the preceding compatible release. This publication updates the repository artifacts; it does not perform another device deployment. - -Latest recorded deployment checks: HTTP80, readiness 12/12, CAPTCHA enabled. Configuration-only API tests, frontend handler tests and JavaScript regression checks were completed during the associated changes. The release artifacts were checked for ELF64 AArch64 headers and SHA256 manifest consistency before publication. - -The short internal-browser CPU sample after the animation change was 11–14%, compared with 41–44% before it. This is not a long-term or cold-boot guarantee. RCU stall and WCH IRQ95 disablement remain separate unresolved kernel issues. Full 48-channel communication and all external integrations have not been certified by this release. - -Deployment evidence is retained locally in `runtime/config-read-transfer.log` and `runtime/static-progress-transfer.log`. Device serial baud 9600 and CAPTCHA enablement are existing device settings, not configuration files included in this binary release. - -Binary-only release. Unrelated workspace source changes and runtime files are excluded. +[시연 자료](../demo-guide/README.md)에는 실제 주소 필터 차단 사례와 복구, 제공된 SSL 인증서 입력 화면, SNMP 시험, 알림·로그 삭제 절차와 결과 기록표가 포함되어 있습니다. diff --git a/final-build/SHA256SUMS b/final-build/SHA256SUMS index 8d0667d..b227c87 100644 --- a/final-build/SHA256SUMS +++ b/final-build/SHA256SUMS @@ -1,8 +1,8 @@ -1d6b8ba457e702a70fab9b03156d80a012ead37440fc8d93e5914ddd9c70c3f6 frontend/tera-clone-demo-web -6328d21ecf7057e6841adbec32982d19a55145a9c96b605d0457f6855924d8d4 backend/tera-clone-demo-api -d0babd9ae746bc4b7ec5931d66ddae0ad0782df0703256160c7233fbdea169a1 backend/tera-timed +7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0 frontend/tera-clone-demo-web 4f6c0e76b6fc158521adbf028aa57c0d3d0ac1ea3aea02ea62ed45bf065be859 backend/tera-clone-demo-alarmd -c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4 backend/tera-seriald -b905d8662088a69ab3ade775a5847426346f536f67cb4ed352a46d56b9c9d9d4 backend/tera-logd +0715323afbe9a472e7cb18e26b292e60f2c1a640dc084141921f8f27c4e49406 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 diff --git a/final-build/backend/tera-clone-demo-api b/final-build/backend/tera-clone-demo-api index 85f2cc8..167038d 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 8311365..105176c 100644 Binary files a/final-build/backend/tera-logd and b/final-build/backend/tera-logd differ diff --git a/final-build/frontend/tera-clone-demo-web b/final-build/frontend/tera-clone-demo-web index 03ea582..29f517f 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 new file mode 100644 index 0000000..0903a46 --- /dev/null +++ b/final-build/release-manifest.json @@ -0,0 +1,83 @@ +{ + "release_id": "20260910-demo-log-controls-1", + "platform": "linux-arm64", + "scope": "application-and-daemon-binaries-not-full-firmware", + "files": [ + { + "path": "frontend/tera-clone-demo-web", + "bytes": 19063465, + "sha256": "7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0", + "install_path": "/usr/bin/tera-clone-demo-web", + "updated_in_this_release": true + }, + { + "path": "backend/tera-clone-demo-alarmd", + "bytes": 5308578, + "sha256": "4f6c0e76b6fc158521adbf028aa57c0d3d0ac1ea3aea02ea62ed45bf065be859", + "install_path": "/usr/bin/tera-alarmd", + "updated_in_this_release": false + }, + { + "path": "backend/tera-clone-demo-api", + "bytes": 14203482, + "sha256": "0715323afbe9a472e7cb18e26b292e60f2c1a640dc084141921f8f27c4e49406", + "install_path": "/usr/bin/tera-api", + "updated_in_this_release": true + }, + { + "path": "backend/tera-logd", + "bytes": 4760736, + "sha256": "5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c", + "install_path": "/usr/bin/tera-logd", + "updated_in_this_release": true + }, + { + "path": "backend/tera-netd", + "bytes": 3211426, + "sha256": "0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef", + "install_path": "/usr/bin/tera-netd", + "updated_in_this_release": false + }, + { + "path": "backend/tera-seriald", + "bytes": 3408034, + "sha256": "c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4", + "install_path": "/usr/bin/tera-seriald", + "updated_in_this_release": false + }, + { + "path": "backend/tera-sessiond", + "bytes": 6226082, + "sha256": "34f1755b54a562d988ef5f922c101ab10af9f7a246fb604478595651449ee3f1", + "install_path": "/usr/bin/tera-sessiond", + "updated_in_this_release": false + }, + { + "path": "backend/tera-timed", + "bytes": 2883746, + "sha256": "d0babd9ae746bc4b7ec5931d66ddae0ad0782df0703256160c7233fbdea169a1", + "install_path": "/usr/bin/tera-timed", + "updated_in_this_release": false + } + ], + "deployment": { + "device": "192.168.0.223", + "date": "2026-09-10", + "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." + }, + "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." + } +} diff --git a/frontend/internal/handlers/device_handler.go b/frontend/internal/handlers/device_handler.go index 933176d..4ca6215 100644 --- a/frontend/internal/handlers/device_handler.go +++ b/frontend/internal/handlers/device_handler.go @@ -590,7 +590,7 @@ func (h *DeviceHandler) requirePageAuth(w http.ResponseWriter, r *http.Request) } func portOperatorPageAllowed(path string) bool { - if path == "/" || path == "/port-status" || path == "/password" || path == "/logs/ports" { + if path == "/" || path == "/port-status" || path == "/password" { return true } return strings.HasPrefix(path, "/ports/") && path != "/ports/restart" diff --git a/frontend/internal/handlers/device_handler_integration_test.go b/frontend/internal/handlers/device_handler_integration_test.go index e5f829c..11ddeb5 100644 --- a/frontend/internal/handlers/device_handler_integration_test.go +++ b/frontend/internal/handlers/device_handler_integration_test.go @@ -494,7 +494,7 @@ func TestPortOperatorSeesOnlyDashboardPortStatusAndSerialPortMenus(t *testing.T) if dashboardResponse.Code != http.StatusOK || !strings.Contains(body, "data-current-role=\"port_operator\"") { t.Fatalf("dashboard = %d %s", dashboardResponse.Code, body) } - for _, hidden := range []string{"네트워크 설정", "관리자 운영", "포트 디버그", "data-global-reboot"} { + for _, hidden := range []string{"네트워크 설정", "관리자 운영", "포트 디버그", "포트 로그", "/logs/ports", "data-global-reboot"} { if strings.Contains(body, hidden) { t.Fatalf("port operator dashboard exposed %q", hidden) } diff --git a/frontend/internal/handlers/log_view_test.go b/frontend/internal/handlers/log_view_test.go index 4118907..9fa9f6e 100644 --- a/frontend/internal/handlers/log_view_test.go +++ b/frontend/internal/handlers/log_view_test.go @@ -30,7 +30,7 @@ func TestLogExplorerPagesPreserveFiltersAndRoleRestrictions(t *testing.T) { request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: role}) response := httptest.NewRecorder() mux.ServeHTTP(response, request) - if role == "port_operator" && path != "/logs/ports" { + if role == "port_operator" { if response.Code != http.StatusForbidden { t.Fatalf("operator log access = %d, want 403", response.Code) } diff --git a/frontend/internal/mock/mock_data.go b/frontend/internal/mock/mock_data.go index 8e6aa42..4ab748f 100644 --- a/frontend/internal/mock/mock_data.go +++ b/frontend/internal/mock/mock_data.go @@ -335,11 +335,9 @@ func networkPage(path string) (PageData, bool) { toggleField("DDNS 사용", "ddns", false), selectField("DNS 공급자", "provider", "dnspod", []string{"dnspod", "dyndns", "cloudflare", "noip", "freedns", "dynv6", "freemyip", "cloudxns", "3322", "custom"}), toggleField("DDNS IPv4 사용", "ipv4Enabled", true), - toggleField("DDNS IPv6 사용", "ipv6Enabled", false), textField("사용자 이름(ID)", "username", "", false), passwordField("비밀번호(Token/Secret)", "password", ""), textField("IPv4 도메인", "ipv4Domain", "", false), - textField("IPv6 도메인", "ipv6Domain", "", false), textField("IP 확인 주소 (Check IP URL)", "checkIp", "", false), textField("Custom Provider", "customProvider", "", false), textField("Custom Provider Path", "customPath", "", false), @@ -519,7 +517,6 @@ func adminPage(path string) (PageData, bool) { passwordField("공유 Secret", "secret", "기존 값을 유지하려면 비워두세요"), boundedNumberField("응답 타임아웃(초)", "timeout", "5", true, "1", "30"), selectOptionsField("외부 사용자 기본 역할", "defaultRole", "port_operator", roleOptions()), - fixedTextField("장애 시 인증", "fallback", "Local 인증으로 안전하게 전환"), }, "적용", ""), }), true case "/admin/auth/tacacs": @@ -531,7 +528,6 @@ func adminPage(path string) (PageData, bool) { passwordField("공유 Secret", "secret", "기존 값을 유지하려면 비워두세요"), boundedNumberField("응답 타임아웃(초)", "timeout", "5", true, "1", "30"), selectOptionsField("외부 사용자 기본 역할", "defaultRole", "port_operator", roleOptions()), - fixedTextField("장애 시 인증", "fallback", "Local 인증으로 안전하게 전환"), }, "적용", ""), }), true case "/admin/address-filter": @@ -539,7 +535,6 @@ func adminPage(path string) (PageData, bool) { formBlock("관리 접속 주소 필터", "address-filter-policy", []FormField{ toggleField("주소 필터 사용", "enabled", false), selectOptionsField("필터 방식", "mode", "blacklist", []Option{{Label: "차단 목록", Value: "blacklist", Selected: true}, {Label: "허용 목록", Value: "whitelist"}}), - fixedTextField("안전 안내", "safety", "허용 목록 적용 전 현재 PC 주소를 반드시 등록하세요"), }, "적용", ""), }), true case "/admin/address-filter/ipv4", "/admin/address-filter/mac": @@ -656,7 +651,7 @@ func settingsPage(path string) (PageData, bool) { toggleField("DCD 상태 변경", "dcd", false), toggleField("DSR 상태 변경", "dsr", false), toggleField("수신 키워드 감지", "keyword", false), - fixedTextField("감지 키워드", "keywordValue", "포트 설정 > 고급 설정의 Alarm 키워드 사용"), + textField("감지 키워드", "keywordValue", "", false), toggleField("메일로 전달", "deliveryEmail", false), toggleField("SNMP Trap으로 전달", "deliverySNMP", false), toggleField("Syslog로 전달", "deliverySyslog", true), @@ -1001,10 +996,6 @@ func ApplyRole(page PageData, role string) PageData { filtered = append(filtered, group) } } - filtered = append(filtered, MenuGroup{Label: "포트 로그", Path: "/logs/ports"}) - if page.CurrentPath == "/logs/ports" { - page.SubTabs = []SubTab{{Label: "포트 로그", Path: "/logs/ports", Active: true}} - } page.Menu = filtered return page } diff --git a/frontend/web/static/css/style.css b/frontend/web/static/css/style.css index 6213e62..481ee52 100644 --- a/frontend/web/static/css/style.css +++ b/frontend/web/static/css/style.css @@ -998,9 +998,10 @@ body[data-current-path="/port-status"] .data-table td:nth-child(5) { width: 28%; .link-action.secondary { color: #61748d; } .link-action.danger { color: var(--danger); } .user-row-actions { - display: flex; - align-items: center; - gap: 10px; + vertical-align: middle; +} +.user-row-actions > * + * { + margin-inline-start: 10px; } .account-status-badge, .password-state-badge, diff --git a/frontend/web/static/js/integration.js b/frontend/web/static/js/integration.js index 9924b04..0b1ab53 100644 --- a/frontend/web/static/js/integration.js +++ b/frontend/web/static/js/integration.js @@ -1325,11 +1325,9 @@ setValue(root, "ddns", ddns.enabled || false); setValue(root, "provider", ddns.provider || "dnspod"); setValue(root, "ipv4Enabled", ddns.ipv4_enabled !== false); - setValue(root, "ipv6Enabled", ddns.ipv6_enabled || false); setValue(root, "username", ddns.username || ""); setValue(root, "password", ddns.password || ""); setValue(root, "ipv4Domain", ddns.ipv4_domain || ""); - setValue(root, "ipv6Domain", ddns.ipv6_domain || ""); setValue(root, "checkIp", ddns.check_ip_url || ""); setValue(root, "customProvider", ddns.custom_provider || ""); setValue(root, "customPath", ddns.custom_path || ""); @@ -1341,11 +1339,11 @@ enabled: checked(root, "ddns"), provider: value(root, "provider"), ipv4_enabled: checked(root, "ipv4Enabled"), - ipv6_enabled: checked(root, "ipv6Enabled"), + ipv6_enabled: false, username: value(root, "username"), password: value(root, "password"), ipv4_domain: value(root, "ipv4Domain"), - ipv6_domain: value(root, "ipv6Domain"), + ipv6_domain: "", check_ip_url: value(root, "checkIp"), custom_provider: value(root, "customProvider"), custom_path: value(root, "customPath") @@ -2240,19 +2238,29 @@ setValue(root, "keyword", Boolean(ruleByID.get(ids.keyword)?.enabled)); setValue(root, "dcd", Boolean(ruleByID.get(ids.dcd)?.enabled)); setValue(root, "dsr", Boolean(ruleByID.get(ids.dsr)?.enabled)); - setValue(root, "keywordValue", ruleByID.get(ids.keyword)?.trigger || "(not configured)"); + const trigger = ruleByID.get(ids.keyword)?.trigger || ""; + setValue(root, "keywordValue", trigger === "(not configured)" ? "" : trigger); fillAlarmDelivery(root, selected); } fillChannel(); field(root, "channel")?.addEventListener("change", fillChannel); - bindForm(root, function () { + bindForm(root, async function () { const ids = IDs(); const delivery = alarmDelivery(root); - return Promise.all(Object.entries(ids).map(function (entry) { - return api("/alarms/rules/" + entry[1], { method: "PATCH", body: { - enabled: checked(root, entry[0]), delivery: delivery - }}); + const trigger = value(root, "keywordValue").trim(); + if (checked(root, "keyword") && !trigger) { throw new Error("감지 키워드를 입력하세요."); } + const updates = Object.entries(ids).map(function (entry) { + const body = { enabled: checked(root, entry[0]), delivery: delivery }; + if (entry[0] === "keyword") { body.trigger = trigger; } + return { id: entry[1], body: body }; + }); + const results = await Promise.all(updates.map(function (update) { + return api("/alarms/rules/" + update.id, { method: "PATCH", body: update.body }); })); + updates.forEach(function (update) { + ruleByID.set(update.id, Object.assign({}, ruleByID.get(update.id), update.body)); + }); + return results; }); } @@ -3094,14 +3102,14 @@ parent: tablePanel.querySelector(".panel-header"), channelField: field(root, "channel"), isDisposed: function () { return disposed; }, confirm: function (message) { return window.prompt(window.teraI18n?.t ? window.teraI18n.t(message) : message); }, - send: function (body) { return api(systemLog ? "/logs/system/clear" : "/logs/port/clear", { method: "POST", body: body }); }, + send: function (body) { return api(systemLog ? "/logs/system/clear" : linkLog ? "/logs/links/clear" : "/logs/port/clear", { method: "POST", body: body }); }, onBusy: function () { clearInFlight = true; ++resultsGeneration; }, onSuccess: function (channel, result) { - toast(systemLog ? "시스템 이벤트 " + result.deleted + "건을 삭제했습니다. 감사 기록은 보존됩니다." : channel + " 포트 로그 " + result.deleted + "건을 삭제했습니다. 이후 발생하는 로그는 계속 저장됩니다.", "success"); + toast(linkLog ? "포트 상태 이벤트 " + result.deleted + "건을 삭제했습니다. 현재 링크 상태는 유지됩니다." : systemLog ? "시스템 로그 " + result.deleted + "건을 삭제했습니다. 이후 발생하는 로그는 계속 저장됩니다." : (channel === "ALL" ? "전체" : channel) + " 포트 로그 " + result.deleted + "건을 삭제했습니다. 이후 발생하는 로그는 계속 저장됩니다.", "success"); renderLogRows([]); }, onError: report, - onSettled: async function () { clearInFlight = false; if (!disposed) { await refreshLogs(false); } } + onSettled: async function () { clearInFlight = false; pageOffset = 0; if (!disposed) { await refreshLogs(false); } } }); autoRefresh?.addEventListener("change", scheduleRefresh); function onVisibilityChange() { diff --git a/frontend/web/static/js/port-log-clear.js b/frontend/web/static/js/port-log-clear.js index f361f3e..4b04916 100644 --- a/frontend/web/static/js/port-log-clear.js +++ b/frontend/web/static/js/port-log-clear.js @@ -4,12 +4,13 @@ const channelPattern = /^CH(?:0[1-9]|[1-3][0-9]|4[0-8])$/; function mount(options) { - // The API independently enforces log.clear. Audit/link classes have no clear action. - if (!["port", "system"].includes(options.kind) || options.role !== "administrator") { return null; } + // The API independently enforces log.clear. + if (!["port", "system", "link"].includes(options.kind) || options.role !== "administrator") { return null; } const system = options.kind === "system"; + const link = options.kind === "link"; const doc = options.document; const select = options.channelField; - if (!doc || (!system && !select) || !options.parent) { return null; } + if (!doc || (!system && !link && !select) || !options.parent) { return null; } let busy = false; let disposed = false; function node(tag, className, text) { @@ -19,48 +20,57 @@ return element; } const area = node("section", "port-log-clear", ""); - area.setAttribute("aria-label", system ? "시스템 로그 관리" : "포트 로그 관리"); + area.setAttribute("aria-label", system ? "시스템 로그 관리" : link ? "포트 상태 이벤트 관리" : "포트 로그 관리"); const copy = node("div", "port-log-clear-copy", ""); const target = node("strong", "port-log-clear-target", ""); target.setAttribute("role", "status"); const scope = node("p", "port-log-clear-scope", "기간·검색 조건·표시 개수와 관계없이 선택한 포트의 저장 로그 전체를 삭제합니다. 새 로그 수집은 계속됩니다."); - if (system) { scope.textContent = "기간·검색 조건·표시 개수와 관계없이 일반 시스템 이벤트 전체를 삭제합니다. 감사 로그·포트 로그·포트 상태 이벤트는 보존됩니다."; } + if (system) { scope.textContent = "기간·검색 조건·표시 개수와 관계없이 시스템 이벤트와 감사 기록 전체를 삭제합니다. 포트 로그·포트 상태 이벤트는 보존됩니다."; } + if (link) { scope.textContent = "기간·검색 조건·표시 개수와 관계없이 저장된 포트 상태 이벤트 전체를 삭제합니다. 현재 링크 상태와 새 이벤트 수집은 유지됩니다."; } scope.id = "port-log-clear-scope"; const note = node("p", "port-log-clear-note", "다른 포트와 시스템·감사·포트 상태 이벤트는 유지됩니다. 설정 백업에는 로그 원문이 포함되지 않습니다."); - if (system) { note.textContent = "감사 기록은 클리어 후에도 이 화면에 남습니다. 새 시스템 로그는 계속 저장되며, 설정 백업에는 로그 원문이 포함되지 않습니다."; } + if (system) { note.textContent = "새 시스템 로그는 계속 저장되며, 설정 백업에는 로그 원문이 포함되지 않습니다."; } + if (link) { note.textContent = "시스템·감사·포트 통신 로그는 유지됩니다. 설정 백업에는 로그 원문이 포함되지 않습니다."; } const button = node("button", "btn danger", "로그 클리어 (삭제)"); button.type = "button"; - button.dataset[system ? "systemLogClear" : "portLogClear"] = "true"; + button.dataset[system ? "systemLogClear" : link ? "linkLogClear" : "portLogClear"] = "true"; button.setAttribute("aria-describedby", scope.id); copy.append(target, scope, note); area.append(copy, button); options.parent.appendChild(area); function update() { - const channel = system ? "SYSTEM" : select.value; - const valid = system || channelPattern.test(channel); + const channel = system ? "SYSTEM" : link ? "LINKS" : (select.value || "ALL"); + const valid = system || link || channel === "ALL" || channelPattern.test(channel); button.disabled = busy || disposed || !valid; button.textContent = busy ? "삭제 중..." : "로그 클리어 (삭제)"; target.textContent = busy ? "로그를 삭제하고 있습니다." : (valid ? "삭제 대상: " + channel + " · 저장된 통신 로그 전체" : "위 검색 조건에서 포트 1개를 선택하세요."); - if (system && !busy) { target.textContent = "삭제 대상: 일반 시스템 이벤트 · 감사 기록 제외"; } + if (system && !busy) { target.textContent = "삭제 대상: 시스템 이벤트 및 감사 기록 전체"; } + if (link && !busy) { target.textContent = "삭제 대상: 포트 상태 이벤트 전체"; } + if (!system && !link) { + if (channel === "ALL" && !busy) { target.textContent = "삭제 대상: 전체 포트(CH01~CH48) 통신 로그"; } + scope.textContent = "기간·검색 조건·표시 개수와 관계없이 " + (channel === "ALL" ? "전체 포트의" : "선택한 포트의") + " 저장 로그 전체를 삭제합니다. 새 로그 수집은 계속됩니다."; + note.textContent = (channel === "ALL" ? "시스템·감사·포트 상태 이벤트는 유지됩니다." : "다른 포트와 시스템·감사·포트 상태 이벤트는 유지됩니다.") + " 설정 백업에는 로그 원문이 포함되지 않습니다."; + } area.dataset.ready = String(valid); area.setAttribute("aria-busy", String(busy)); } async function clear() { // Capture the exact target: changing filters cannot retarget deletion. - const channel = system ? "SYSTEM" : select.value; - if (busy || disposed || options.isDisposed() || (!system && !channelPattern.test(channel))) { return; } + 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을 정확히 입력하세요." - : channel + "의 저장된 포트 로그 전체를 삭제합니다.\n기간·검색 조건·표시 개수와 관계없이 RX/TX·명령·세션 기록이 삭제되며 복구할 수 없습니다.\n필요한 기록은 먼저 내보내세요. 내보내기에는 현재 검색 조건과 표시 개수 제한이 적용됩니다. 설정 백업에는 로그 원문이 포함되지 않습니다.\n다른 포트와 시스템·감사·포트 상태 이벤트는 유지됩니다. 새 로그 수집은 계속됩니다.\n계속하려면 " + channel + "을 정확히 입력하세요."; + ? "시스템 이벤트와 감사 기록 전체를 삭제합니다.\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; } busy = true; update(); options.onBusy(); try { - const result = await options.send(system ? { confirmation: confirmation } : { channel: channel, confirmation: confirmation }); + const result = await options.send(system || link ? { confirmation: confirmation } : { channel: channel, confirmation: confirmation }); if (!disposed && !options.isDisposed()) { options.onSuccess(channel, result); } } catch (error) { if (!disposed && !options.isDisposed()) { options.onError(error); } diff --git a/scripts/deploy-ui-settings.ps1 b/scripts/deploy-ui-settings.ps1 new file mode 100644 index 0000000..88d7c72 --- /dev/null +++ b/scripts/deploy-ui-settings.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = 'Stop' +$project = Split-Path -Parent $PSScriptRoot +Start-Transcript -Path (Join-Path $project 'runtime/ui-settings-transfer.log') -Force | Out-Null +$assets = @{'/api'=(Join-Path $project 'build/linux-arm64/tera-clone-demo-api'); '/web'=(Join-Path $project 'build/linux-arm64/tera-clone-demo-web'); '/logd'=(Join-Path $project 'build/linux-arm64/tera-logd')} +$hashes = @{} +foreach ($key in $assets.Keys) {$hashes[$key]=(Get-FileHash $assets[$key] -Algorithm SHA256).Hash.ToLowerInvariant()} +$script=[IO.File]::ReadAllText((Join-Path $PSScriptRoot 'deploy-ui-settings.sh')).Replace('__APIHASH__',$hashes['/api']).Replace('__WEBHASH__',$hashes['/web']).Replace('__LOGDHASH__',$hashes['/logd']).Replace("`r`n","`n") +$rule = 'TERA-replacement-default-port-' + [guid]::NewGuid().ToString('N') +$listener = New-Object System.Net.HttpListener +$listener.Prefixes.Add('http://192.168.0.100:9001/') +$serial = New-Object System.IO.Ports.SerialPort 'COM4',115200,'None',8,'One' +$serial.Encoding = [Text.Encoding]::UTF8 +try { + New-NetFirewallRule -DisplayName $rule -Direction Inbound -Action Allow -Protocol TCP -LocalPort 9001 -RemoteAddress 192.168.0.223 | Out-Null + $listener.Start() + $serial.Open() + $serial.WriteLine('wget -q -O /tmp/deploy-replacement-default-port.sh http://192.168.0.100:9001/deploy.sh && sh /tmp/deploy-replacement-default-port.sh') + $deadline = [DateTime]::UtcNow.AddSeconds(360) + $output = '' + $pending = $listener.GetContextAsync() + while ([DateTime]::UtcNow -lt $deadline) { + if ($pending.IsCompleted) { + $context = $pending.GetAwaiter().GetResult() + if ($context.Request.RemoteEndPoint.Address.ToString() -ne '192.168.0.223') { + $context.Response.StatusCode = 403 + $context.Response.Close() + } else { + $bytes = $null + $route = $context.Request.Url.AbsolutePath + if ($route -eq '/deploy.sh') { $bytes = [Text.Encoding]::UTF8.GetBytes($script) } + elseif ($assets.ContainsKey($route)) { $bytes = [IO.File]::ReadAllBytes($assets[$route]) } + else { $context.Response.StatusCode = 404 } + if ($null -ne $bytes) { + $context.Response.ContentLength64 = $bytes.Length + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + $context.Response.Close() + } + $pending = $listener.GetContextAsync() + } + $chunk = $serial.ReadExisting() + if ($chunk) { $output += $chunk; Write-Host -NoNewline $chunk } + if ($output.Contains('REPLACEMENT_DEFAULT_PORT_ROLLED_BACK')) { throw 'Readiness failed; old pair restored.' } + if ($output.Contains('REPLACEMENT_DEFAULT_PORT_DEPLOY_OK')) { break } + Start-Sleep -Milliseconds 100 + } + if (-not $output.Contains('REPLACEMENT_DEFAULT_PORT_DEPLOY_OK')) { throw 'Deployment did not report success; inspect device console.' } + foreach ($hash in $hashes.Values) { if (-not $output.Contains($hash)) { throw 'A deployed hash was not verified.' } } + Write-Output 'VERIFIED replacement device deployment on HTTP port 80' +} finally { + if ($serial.IsOpen) { $serial.Close() } + $serial.Dispose() + $listener.Close() + Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue + Stop-Transcript | Out-Null +} diff --git a/scripts/deploy-ui-settings.sh b/scripts/deploy-ui-settings.sh new file mode 100644 index 0000000..fc526f5 --- /dev/null +++ b/scripts/deploy-ui-settings.sh @@ -0,0 +1,88 @@ +#!/bin/sh +set -eu +stage=/tmp/tera-ui-settings +mkdir -p "$stage" +for name in api web logd; do wget -q -T 30 -O "$stage/$name" "http://192.168.0.100:9001/$name"; done +echo '__APIHASH__ '"$stage/api" | sha256sum -c - +echo '__WEBHASH__ '"$stage/web" | sha256sum -c - +echo '__LOGDHASH__ '"$stage/logd" | sha256sum -c - +grep -q '^PORT=80$' /etc/tera/teraweb.env +backup=/userdata/tera-backups/ui-settings-$(date +%Y%m%d-%H%M%S) +mkdir -p "$backup" +cp -p /usr/bin/tera-api "$backup/api" +cp -p /usr/bin/tera-clone-demo-web "$backup/web" +cp -p /usr/bin/tera-logd "$backup/logd" +echo "BACKUP=$backup" +start_logd() { + : > /run/tera-logd/deploy-startup.log + chown tera-logd:tera /run/tera-logd/deploy-startup.log + chmod 640 /run/tera-logd/deploy-startup.log + ( + set -a + . /etc/tera/tera-logd.env + set +a + start-stop-daemon -S -q -b -m -p /run/tera-logd/tera-logd.pid -c tera-logd:tera -x /bin/sh -- -c 'exec /usr/bin/tera-logd > /run/tera-logd/deploy-startup.log 2>&1' + ) + for n in $(seq 1 30); do + if [ -S /run/tera-logd/log-store.sock ] && grep -q 'serving authoritative log storage' /run/tera-logd/deploy-startup.log && kill -0 "$(cat /run/tera-logd/tera-logd.pid)" 2>/dev/null; then return 0; fi + sleep 1 + done + cat /run/tera-logd/deploy-startup.log + return 1 +} +start_api() { + ( + set -a + . /etc/tera/tera-api.env + set +a + start-stop-daemon -S -q -b -m -p /run/tera-api.pid -c tera:tera -x /bin/sh -- -c 'exec /usr/bin/tera-api > /run/tera-api/ui-settings-startup.log 2>&1' + ) +} +rollback() { + trap - EXIT + /etc/init.d/S61teraweb stop || true + /etc/init.d/S50tera-api stop || true + /etc/init.d/S44tera-logd stop || true + cp "$backup/api" /usr/bin/tera-api.restore + cp "$backup/web" /usr/bin/tera-clone-demo-web.restore + chmod 755 /usr/bin/tera-api.restore /usr/bin/tera-clone-demo-web.restore + mv /usr/bin/tera-api.restore /usr/bin/tera-api + mv /usr/bin/tera-clone-demo-web.restore /usr/bin/tera-clone-demo-web + cp "$backup/logd" /usr/bin/tera-logd.restore + chmod 755 /usr/bin/tera-logd.restore + mv /usr/bin/tera-logd.restore /usr/bin/tera-logd + start_logd || true + start_api || true + /etc/init.d/S61teraweb start || true + echo REPLACEMENT_DEFAULT_PORT_ROLLED_BACK +} +trap rollback EXIT +cp "$stage/logd" /usr/bin/tera-logd.next +chmod 755 /usr/bin/tera-logd.next +cp "$stage/api" /usr/bin/tera-api.next +cp "$stage/web" /usr/bin/tera-clone-demo-web.next +chmod 755 /usr/bin/tera-api.next /usr/bin/tera-clone-demo-web.next +/etc/init.d/S61teraweb stop +/etc/init.d/S50tera-api stop +/etc/init.d/S44tera-logd stop +mv /usr/bin/tera-logd.next /usr/bin/tera-logd +start_logd +mv /usr/bin/tera-api.next /usr/bin/tera-api +mv /usr/bin/tera-clone-demo-web.next /usr/bin/tera-clone-demo-web +start_api +/etc/init.d/S61teraweb start +ready=0 +for n in $(seq 1 120); do + if wget -q -T 2 -O "$stage/status" http://127.0.0.1/login/backend-status && grep -q '"ready":true' "$stage/status"; then ready=1; break; fi + sleep 1 +done +cat "$stage/status" +[ "$ready" = 1 ] +wget -q -T 3 -O "$stage/integration.js" http://127.0.0.1/static/js/integration.js +grep -q 'limit=48&runtime=false' "$stage/integration.js" +grep -q 'pendingNavigation' "$stage/integration.js" +grep -q '/logs/links/clear' "$stage/integration.js" +sha256sum /usr/bin/tera-api /usr/bin/tera-clone-demo-web /usr/bin/tera-logd +sync +trap - EXIT +echo REPLACEMENT_DEFAULT_PORT_DEPLOY_OK diff --git a/scripts/test-port-log-clear.cjs b/scripts/test-port-log-clear.cjs index d222c24..ab15d70 100644 --- a/scripts/test-port-log-clear.cjs +++ b/scripts/test-port-log-clear.cjs @@ -30,28 +30,28 @@ function setup(overrides = {}) { } test('administrator clear control is absent on protected log pages and for operators', () => { - for (const kind of ['link', 'audit']) assert.equal(setup({ kind }).control, null); + for (const kind of ['audit']) assert.equal(setup({ kind }).control, null); for (const role of ['port_operator', '', 'viewer']) assert.equal(setup({ role }).control, null); }); -test('system clear excludes protected records and requires exact SYSTEM confirmation', async () => { +test('system clear includes audit records and requires exact SYSTEM confirmation', async () => { const s=setup({kind:'system',channelField:null}); assert.equal(s.button.disabled,false); assert.equal(s.button.dataset.systemLogClear,'true'); - assert.match(s.copy.children[2].textContent,/감사 기록은 클리어 후에도/); + assert.match(s.copy.children[1].textContent,/시스템 이벤트와 감사 기록 전체/); for(const answer of [null,'','CH01','system',' SYSTEM ']){s.state.answer=answer;await s.button.events.click();} assert.equal(s.calls.length,0); s.state.answer='SYSTEM';await s.button.events.click(); assert.deepEqual(s.calls,[{confirmation:'SYSTEM'}]); - assert.match(s.prompts.at(-1),/감사 로그·포트 로그·포트 상태 이벤트는 보존/); + assert.match(s.prompts.at(-1),/포트 로그·포트 상태 이벤트는 보존/); s.control.dispose();assert.equal(s.button.disabled,true); }); -test('clear is visible but disabled for all ports or invalid targets; all 48 ports are supported', async () => { +test('clear supports all ports and all 48 individual ports but rejects invalid targets', async () => { const s = setup(); assert.equal(s.button.textContent, '로그 클리어 (삭제)'); - assert.equal(s.button.disabled, true); - assert.match(s.copy.children[0].textContent, /포트 1개/); + assert.equal(s.button.disabled, false); + assert.match(s.copy.children[0].textContent, /전체 포트/); for (let i = 1; i <= 48; i++) { s.choose('CH' + String(i).padStart(2, '0')); assert.equal(s.button.disabled, false); } - for (const channel of ['', 'ALL', 'CH00', 'CH49', 'ch01', '../system']) { + for (const channel of ['CH00', 'CH49', 'ch01', '../system']) { s.choose(channel); assert.equal(s.button.disabled, true); await s.button.events.click(); } assert.equal(s.prompts.length, 0); assert.equal(s.calls.length, 0); @@ -98,9 +98,10 @@ test('errors are reported without success and release refresh guards for retry', assert.equal(s.errors[0].message, 'disk unavailable'); assert.equal(s.notices.length, 0); assert.equal(s.button.disabled, false); assert.equal(s.state.settled, 1); }); -test('filter reset disables deletion, and leaving the page detaches the control', async () => { +test('filter reset targets all ports, and leaving the page detaches the control', async () => { const s = setup(); s.choose('CH01'); s.select.value = ''; s.control.update(); - assert.equal(s.button.disabled, true); + assert.equal(s.button.disabled, false); + assert.match(s.copy.children[0].textContent, /전체 포트/); s.choose('CH01'); const oldClick = s.button.events.click; s.control.dispose(); assert.equal(s.button.disabled, true); assert.equal(s.select.events.change, undefined); await oldClick(); assert.equal(s.calls.length, 0); @@ -112,3 +113,15 @@ test('a response after navigation does not update a new page but releases the gu finish({ deleted: 3 }); await pending; assert.equal(s.notices.length, 0); assert.equal(s.state.settled, 1); assert.equal(s.button.disabled, true); }); + +test('all ports require ALL confirmation and link events require LINKS', async () => { + const all = setup(); all.state.answer = 'CH01'; await all.button.events.click(); assert.equal(all.calls.length,0); + all.state.answer = 'ALL'; await all.button.events.click(); + assert.deepEqual(all.calls, [{channel:'ALL',confirmation:'ALL'}]); + const link = setup({kind:'link',channelField:null}); + assert.equal(link.button.disabled,false); + link.state.answer='SYSTEM'; await link.button.events.click(); assert.equal(link.calls.length,0); + link.state.answer='LINKS'; await link.button.events.click(); + assert.deepEqual(link.calls,[{confirmation:'LINKS'}]); + assert.match(link.prompts[0],/현재 링크 상태/); +}); diff --git a/scripts/test-serial-alert-keyword.cjs b/scripts/test-serial-alert-keyword.cjs new file mode 100644 index 0000000..b80e30d --- /dev/null +++ b/scripts/test-serial-alert-keyword.cjs @@ -0,0 +1,43 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +test('serial alert keyword saves to the shared rule and survives port switching', async () => { + const source = fs.readFileSync(require('node:path').join(__dirname, '../frontend/web/static/js/integration.js'), 'utf8'); + const fields = { channel: 'CH01' }; + const calls = []; + let submit, change; + const context = { + form: () => ({}), + value: (_, name) => fields[name] || '', + checked: (_, name) => Boolean(fields[name]), + setValue: (_, name, value) => { fields[name] = value; }, + field: () => ({ addEventListener: (_, fn) => { change = fn; } }), + fillAlarmDelivery: () => {}, alarmDelivery: () => ['syslog'], + bindForm: (_, fn) => { submit = fn; }, + api: async (path, options) => { + if (!options) return [{ rule_id: 'port_ch01_keyword', trigger: '(not configured)', enabled: false }]; + calls.push({ path, body: options.body }); + return {}; + } + }; + vm.createContext(context); + await vm.runInContext(source.slice(source.indexOf(' async function alarmSerialEvents()'), source.indexOf(' async function alarmEmailSettings()')) + '\nalarmSerialEvents()', context); + assert.equal(fields.keywordValue, ''); + fields.keyword = true; + await assert.rejects(submit(), /키워드/); + assert.equal(calls.length, 0); + fields.keywordValue = ' READY> '; + await submit(); + assert.equal(calls[0].path, '/alarms/rules/port_ch01_keyword'); + assert.equal(calls[0].body.trigger, 'READY>'); + assert.equal(calls[1].body.trigger, undefined); + fields.channel = 'CH02'; change(); + assert.equal(fields.keywordValue, ''); + fields.channel = 'CH01'; change(); + assert.equal(fields.keywordValue, 'READY>'); + fields.keyword = false; fields.keywordValue = ''; + await submit(); + assert.equal(calls[3].body.trigger, ''); +});