diff --git a/backend/cmd/tera-api/main.go b/backend/cmd/tera-api/main.go index 47a1883..feb4907 100644 --- a/backend/cmd/tera-api/main.go +++ b/backend/cmd/tera-api/main.go @@ -416,7 +416,7 @@ func main() { if logJournal != nil { go runOperationalAlarmEvaluator( logRuntimeContext, store, - getenvDurationSeconds("TERA_API_OPERATIONAL_ALARM_INTERVAL_SECONDS", time.Minute), + getenvDurationSeconds("TERA_API_OPERATIONAL_ALARM_INTERVAL_SECONDS", 2*time.Second), func(err error) { log.Printf("tera-api operational alarm evaluation degraded: %v", err) }, ) } diff --git a/backend/deploy/etc/tera/tera-api.env b/backend/deploy/etc/tera/tera-api.env index d1a14f3..4bde816 100644 --- a/backend/deploy/etc/tera/tera-api.env +++ b/backend/deploy/etc/tera/tera-api.env @@ -29,7 +29,7 @@ TERA_API_TLS_ACTIVE_CERT=/var/lib/tera-api/tls/active.crt TERA_API_TLS_ACTIVE_KEY=/var/lib/tera-api/tls/active.key TERA_API_LOG_SOCKET=/run/tera-logd/log-store.sock TERA_API_LOG_SCHEDULER_ENABLED=true -TERA_API_OPERATIONAL_ALARM_INTERVAL_SECONDS=60 +TERA_API_OPERATIONAL_ALARM_INTERVAL_SECONDS=2 TERA_API_LOG_ETHERNET_INTERFACES=Interface-1=eth0,Interface-2=eth1 TERA_API_UPDATE_SOCKET=/run/tera-updated/control.sock TERA_API_UPDATE_MAX_BYTES=1342177280 diff --git a/backend/internal/adapters/mock/received_keyword_alarms.go b/backend/internal/adapters/mock/received_keyword_alarms.go new file mode 100644 index 0000000..321c7ab --- /dev/null +++ b/backend/internal/adapters/mock/received_keyword_alarms.go @@ -0,0 +1,103 @@ +package mock + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "tera-clone/services/tera-api/internal/alarmdelivery" + "tera-clone/services/tera-api/internal/domain" + "tera-clone/services/tera-api/internal/logservice" + "tera-clone/services/tera-api/internal/logstorage" +) + +type portKeywordTail struct { + trigger string + text string + at time.Time +} + +// seriald writes RX directly to logd, bypassing RecordPortLog. Consume that +// journal with an independent cursor so viewing logs cannot consume alarms. +func (s *Store) evaluateReceivedPortKeywords(ctx context.Context, snapshot logstorage.Snapshot) error { + journal, ok := s.logJournal.(logservice.SequencedJournal) + if !ok { + return nil + } + s.mu.Lock() + if !s.portKeywordStarted { + s.portKeywordStarted = true + s.portKeywordTails = make(map[string]portKeywordTail) + // Existing history must not be mailed again on service restart. + if next := snapshot.NextSequence[logstorage.ClassPort]; next > 0 { + s.portKeywordCursor = next - 1 + s.mu.Unlock() + return nil + } + records, err := journal.ReadAfter(logstorage.ClassPort, 0) + if err != nil { + s.portKeywordStarted = false + s.mu.Unlock() + return err + } + for _, record := range records { + if record.Sequence > s.portKeywordCursor { + s.portKeywordCursor = record.Sequence + } + } + s.mu.Unlock() + return nil + } + records, err := journal.ReadAfter(logstorage.ClassPort, s.portKeywordCursor) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("read serial keyword events: %w", err) + } + var requests []alarmdelivery.Request + for _, record := range records { + if record.Sequence <= s.portKeywordCursor { + continue + } + var event domain.PortLogEvent + if err := json.Unmarshal(record.Payload, &event); err != nil { + s.mu.Unlock() + return err + } + s.portKeywordCursor = record.Sequence + if event.EventType != "traffic" || event.Direction != "RX" || len(event.Payload) == 0 || event.AlarmTriggered { + continue + } + for id, rule := range s.runningAlarms { + if !rule.Enabled || rule.Source != "port_log" || rule.Trigger == "" || (rule.Channel != "" && rule.Channel != event.Channel) { + continue + } + key := id + ":" + event.Channel + tail := s.portKeywordTails[key] + if tail.trigger != rule.Trigger || event.Timestamp.Sub(tail.at) > 5*time.Second || event.Timestamp.Before(tail.at) { + tail.text = "" + } + content := tail.text + string(event.Payload) + if strings.Contains(strings.ToLower(content), strings.ToLower(rule.Trigger)) { + message := fmt.Sprintf("%s received keyword matched: %s", event.Channel, rule.Trigger) + requests = append(requests, s.alarmRequestsForRulesLocked([]string{id}, message)...) + } + // Keep only a suffix shorter than the keyword: an old match cannot fire + // on the following chunk, but a keyword split between reads still can. + keep := len(rule.Trigger) - 1 + if keep > 1024 { + keep = 1024 + } + if len(content) > keep { + content = content[len(content)-keep:] + } + s.portKeywordTails[key] = portKeywordTail{trigger: rule.Trigger, text: content, at: event.Timestamp} + } + } + s.mu.Unlock() + for i := range requests { + s.deliverBestEffort(ctx, &requests[i]) + } + return nil +} diff --git a/backend/internal/adapters/mock/received_keyword_alarms_test.go b/backend/internal/adapters/mock/received_keyword_alarms_test.go new file mode 100644 index 0000000..79d5506 --- /dev/null +++ b/backend/internal/adapters/mock/received_keyword_alarms_test.go @@ -0,0 +1,88 @@ +package mock + +import ( + "context" + "testing" + "time" + + "tera-clone/services/tera-api/internal/domain" + "tera-clone/services/tera-api/internal/logstorage" +) + +func TestReceivedKeywordFromSerialJournalNotDependentOnLogViewer(t *testing.T) { + journal := &sequencedMemoryJournal{} + delivery := &recordingAlarmDeliveryController{} + store, err := NewStoreWithOptions(fixedClock, StoreOptions{LogJournal: journal, AlarmDeliveryController: delivery}) + if err != nil { + t.Fatal(err) + } + rule := store.runningAlarms["port_ch20_keyword"] + rule.Enabled = true + rule.Trigger = "Invalid interface" + rule.Delivery = []string{"web"} + store.runningAlarms["port_ch20_keyword"] = rule + now := fixedClock() + appendRX := func(channel, direction, text string, matched bool) { + t.Helper() + now = now.Add(time.Millisecond) + event := domain.PortLogEvent{Timestamp: now, Channel: channel, EventType: "traffic", Direction: direction, Actor: "tera-port-service", Message: "serial payload", Payload: []byte(text), PayloadBytes: len(text), PayloadEncoding: "base64", DisplayFormat: "ascii", AlarmTriggered: matched} + if err := journal.Append(logstorage.ClassPort, now, event); err != nil { + t.Fatal(err) + } + } + evaluate := func() { + t.Helper() + if err := store.EvaluateOperationalAlarms(context.Background()); err != nil { + t.Fatal(err) + } + } + appendRX("CH20", "RX", "Invalid interface", false) + evaluate() + if len(delivery.requests) != 0 { + t.Fatal("replayed old history on startup") + } + appendRX("CH20", "TX", "Invalid interface", false) + appendRX("CH19", "RX", "Invalid interface", false) + evaluate() + if len(delivery.requests) != 0 { + t.Fatal("matched wrong channel or TX") + } + appendRX("CH20", "RX", "\r\nInvalid inter", false) + evaluate() + if len(delivery.requests) != 0 { + t.Fatal("matched incomplete keyword") + } + appendRX("CH20", "RX", "face\r\nEnter interface name", false) + // A UI read advances its own journal cursor before the alarm evaluator. + if _, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{Channel: "CH20"}); err != nil { + t.Fatal(err) + } + evaluate() + evaluate() + if len(delivery.requests) != 1 || delivery.requests[0].RuleID != "port_ch20_keyword" { + t.Fatalf("requests=%+v", delivery.requests) + } + appendRX("CH20", "RX", "more prompt output", false) + appendRX("CH20", "RX", "Invalid interface", true) + evaluate() + if len(delivery.requests) != 1 { + t.Fatal("duplicate match or already processed API event") + } + rule.Enabled = false + store.runningAlarms["port_ch20_keyword"] = rule + appendRX("CH20", "RX", "Invalid interface", false) + evaluate() + if len(delivery.requests) != 1 { + t.Fatal("disabled rule fired") + } + rule.Enabled = true + rule.Trigger = "NEW KEYWORD" + store.runningAlarms["port_ch20_keyword"] = rule + appendRX("CH20", "RX", "Invalid interface", false) + evaluate() + appendRX("CH20", "RX", "new keyword", false) + evaluate() + if len(delivery.requests) != 2 { + t.Fatal("updated case insensitive keyword not observed") + } +} diff --git a/backend/internal/adapters/mock/store.go b/backend/internal/adapters/mock/store.go index d96c1c2..e774814 100644 --- a/backend/internal/adapters/mock/store.go +++ b/backend/internal/adapters/mock/store.go @@ -175,6 +175,9 @@ type Store struct { systemClearRevision uint64 journalLocalSequences map[string]map[uint64]struct{} storagePressureActive bool + portKeywordCursor uint64 + portKeywordStarted bool + portKeywordTails map[string]portKeywordTail operations map[string]domain.Operation auditEvents []domain.AuditEvent @@ -1976,6 +1979,9 @@ func (s *Store) EvaluateOperationalAlarms(ctx context.Context) error { if err != nil { return fmt.Errorf("read protected log storage usage: %w", err) } + if err := s.evaluateReceivedPortKeywords(ctx, snapshot); err != nil { + return err + } usagePercent := protectedLogUsagePercent(snapshot) s.mu.Lock() pressure := usagePercent >= s.runningRetention.ProtectedUsageWarningPct diff --git a/demo-guide/README.md b/demo-guide/README.md index 3121bf3..c1c1374 100644 --- a/demo-guide/README.md +++ b/demo-guide/README.md @@ -202,6 +202,17 @@ Remove-NetFirewallRule -DisplayName 'TERA Demo Syslog TCP' 여기서 ‘CH01’은 장비의 **1번 포트**라는 뜻입니다. PC의 COM 번호와 다른 이름입니다. +### 현재 확인된 CH20 실제 시험 + +2026-09-10 CH20의 RX 기록에서 연결된 장비가 출력한 `Invalid interface`를 확인했습니다. 이 연결을 사용하면 별도 시험 케이블을 가정할 필요가 없습니다. + +1. 시리얼 이벤트 → 대상 CH20, 수신 키워드 감지 켬, 감지 키워드 `Invalid interface`, 메일로 전달 켬 → 적용. +2. CH20 터미널에서 연결된 장비의 인터페이스 이름 입력 화면을 확인합니다. 그 화면에 `d`를 넣으면 `Invalid interface`가 반환됐던 현재 시험을 한 번 반복합니다. 유효한 인터페이스 이름을 입력해 상대 장비의 네트워크 설정을 진행하는 시험은 아닙니다. +3. 로그 → 포트 로그 → CH20에서 새 시각의 RX `Invalid interface`를 확인합니다. +4. 키워드 검사는 약 2초 주기이며 SMTP 처리 시간은 별도입니다. Daum에서 새 메일 본문의 CH20과 키워드를 확인합니다. 메일 테스트 발송 버튼은 누르지 않습니다. + +수정 전에는 실제 seriald 수신 로그가 키워드 알림 검사 경로를 거치지 않았습니다. 수정본은 새 RX를 검사하고 분할 수신도 이어서 비교합니다. API 재시작 전 과거 기록은 자동 재발송하지 않으므로 반드시 새 오류 응답을 발생시킵니다. + **COM4 PuTTY에 `root@iconsole-server`가 보이면 TERA 자체 관리 콘솔입니다.** 여기서 `DEMO_ALARM_0909`를 입력하면 셸이 명령으로 해석하고, `echo DEMO_ALARM_0909`는 관리 콘솔에만 글자를 출력합니다. 둘 다 CH01~CH48의 RX 입력이 아니므로 포트 키워드 이벤트가 발생하지 않습니다. 메일 자체의 동작은 먼저 메일 화면의 **테스트 발송**으로 확인합니다. ### 케이블을 몰라도 지금 할 수 있는 설정 시험 @@ -232,7 +243,7 @@ Remove-NetFirewallRule -DisplayName 'TERA Demo Syslog TCP' 4. **테스트 발송 버튼은 누르지 않고**, Daum 받은편지함의 새 이벤트 메일과 RX 로그 시각·포트 번호를 비교합니다. 5. Trap 전달도 켜면 7번의 PC 수신창에서 같은 실제 이벤트를 확인할 수 있습니다. -**아직 확인하지 못한 점:** 실제 포트 배선과 상대 장비 종류. 그러므로 지금 임의의 포트에 장비가 연결돼 있다고 단정하지 않습니다. 이 부분만은 연결 확인 후 실제 송수신·상태 DOWN·DCD/DSR 시험으로 이어집니다. +**연결 확인 범위:** CH20에서는 위 인터페이스 입력 화면과 실제 RX를 확인했습니다. CH01이나 다른 번호에도 같은 장비가 연결돼 있다고 가정하지 않습니다. 실제 상태 DOWN·DCD/DSR 시험은 해당 케이블과 신호 조건을 확인한 뒤 진행합니다. ## 9. 사용자 계정·권한·비밀번호 diff --git a/final-build/README.md b/final-build/README.md index 7f89dd5..ad3e667 100644 --- a/final-build/README.md +++ b/final-build/README.md @@ -6,6 +6,8 @@ 메일 수정: 발송 시 현재 비밀번호 저장 파일을 읽어 UI에서 새로 저장한 SMTP/Trap 자격 증명을 반영합니다. 기존 테스트 메일 재시도는 **16:29:10 KST에 delivered**로 완료됐습니다. 이 상태는 SMTP 서버의 수락이며 수신함 도착 확인과는 구분합니다. 메일 데몬의 이전 실행 파일은 `/userdata/tera-backups/alarm-live-secrets-20260910`에 보관했습니다. +시리얼 키워드 수정: 실제 seriald가 logd에 기록한 새 RX 데이터도 알림 검사 대상에 포함합니다. 분할 수신을 이어서 비교하고 같은 기록을 반복 발송하지 않습니다. API와 배포 환경의 검사 주기는 2초입니다. 장비 적용 해시 및 12/12 준비 완료를 확인했으며 이전 API/환경 파일은 `/userdata/tera-backups/serial-keyword-20260910`에 보관했습니다. 과거 로그 재발송은 하지 않으므로 새 RX로 시연합니다. + 이 디렉터리는 실행 파일 모음입니다. boot/rootfs를 포함한 전체 펌웨어 `.pkg`는 아닙니다. 장비 설정, 사용자 계정, 인증서 개인키와 런타임 로그는 포함하지 않습니다. ## 파일과 설치 경로 diff --git a/final-build/SHA256SUMS b/final-build/SHA256SUMS index 0cb04e8..6d50855 100644 --- a/final-build/SHA256SUMS +++ b/final-build/SHA256SUMS @@ -1,6 +1,6 @@ 7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0 frontend/tera-clone-demo-web ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd -0715323afbe9a472e7cb18e26b292e60f2c1a640dc084141921f8f27c4e49406 backend/tera-clone-demo-api +bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1 backend/tera-clone-demo-api 5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c backend/tera-logd 0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef backend/tera-netd c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4 backend/tera-seriald diff --git a/final-build/backend/tera-clone-demo-api b/final-build/backend/tera-clone-demo-api index 167038d..96bdd57 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/release-manifest.json b/final-build/release-manifest.json index 18cb2ff..405aa0e 100644 --- a/final-build/release-manifest.json +++ b/final-build/release-manifest.json @@ -19,8 +19,8 @@ }, { "path": "backend/tera-clone-demo-api", - "bytes": 14203482, - "sha256": "0715323afbe9a472e7cb18e26b292e60f2c1a640dc084141921f8f27c4e49406", + "bytes": 9961634, + "sha256": "bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1", "install_path": "/usr/bin/tera-api", "updated_in_this_release": true }, @@ -85,5 +85,13 @@ "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" } }