Browse Source

Evaluate keyword alarms from serial RX journal records

main
gudae 2 months ago
parent
commit
8d59361029
  1. 2
      backend/cmd/tera-api/main.go
  2. 2
      backend/deploy/etc/tera/tera-api.env
  3. 103
      backend/internal/adapters/mock/received_keyword_alarms.go
  4. 88
      backend/internal/adapters/mock/received_keyword_alarms_test.go
  5. 6
      backend/internal/adapters/mock/store.go
  6. 13
      demo-guide/README.md
  7. 2
      final-build/README.md
  8. 2
      final-build/SHA256SUMS
  9. BIN
      final-build/backend/tera-clone-demo-api
  10. 12
      final-build/release-manifest.json

2
backend/cmd/tera-api/main.go

@ -416,7 +416,7 @@ func main() {
if logJournal != nil { if logJournal != nil {
go runOperationalAlarmEvaluator( go runOperationalAlarmEvaluator(
logRuntimeContext, store, 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) }, func(err error) { log.Printf("tera-api operational alarm evaluation degraded: %v", err) },
) )
} }

2
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_TLS_ACTIVE_KEY=/var/lib/tera-api/tls/active.key
TERA_API_LOG_SOCKET=/run/tera-logd/log-store.sock TERA_API_LOG_SOCKET=/run/tera-logd/log-store.sock
TERA_API_LOG_SCHEDULER_ENABLED=true 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_LOG_ETHERNET_INTERFACES=Interface-1=eth0,Interface-2=eth1
TERA_API_UPDATE_SOCKET=/run/tera-updated/control.sock TERA_API_UPDATE_SOCKET=/run/tera-updated/control.sock
TERA_API_UPDATE_MAX_BYTES=1342177280 TERA_API_UPDATE_MAX_BYTES=1342177280

103
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
}

88
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")
}
}

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

@ -175,6 +175,9 @@ type Store struct {
systemClearRevision uint64 systemClearRevision uint64
journalLocalSequences map[string]map[uint64]struct{} journalLocalSequences map[string]map[uint64]struct{}
storagePressureActive bool storagePressureActive bool
portKeywordCursor uint64
portKeywordStarted bool
portKeywordTails map[string]portKeywordTail
operations map[string]domain.Operation operations map[string]domain.Operation
auditEvents []domain.AuditEvent auditEvents []domain.AuditEvent
@ -1976,6 +1979,9 @@ func (s *Store) EvaluateOperationalAlarms(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("read protected log storage usage: %w", err) return fmt.Errorf("read protected log storage usage: %w", err)
} }
if err := s.evaluateReceivedPortKeywords(ctx, snapshot); err != nil {
return err
}
usagePercent := protectedLogUsagePercent(snapshot) usagePercent := protectedLogUsagePercent(snapshot)
s.mu.Lock() s.mu.Lock()
pressure := usagePercent >= s.runningRetention.ProtectedUsageWarningPct pressure := usagePercent >= s.runningRetention.ProtectedUsageWarningPct

13
demo-guide/README.md

@ -202,6 +202,17 @@ Remove-NetFirewallRule -DisplayName 'TERA Demo Syslog TCP'
여기서 ‘CH01’은 장비의 **1번 포트**라는 뜻입니다. PC의 COM 번호와 다른 이름입니다. 여기서 ‘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 입력이 아니므로 포트 키워드 이벤트가 발생하지 않습니다. 메일 자체의 동작은 먼저 메일 화면의 **테스트 발송**으로 확인합니다. **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 로그 시각·포트 번호를 비교합니다. 4. **테스트 발송 버튼은 누르지 않고**, Daum 받은편지함의 새 이벤트 메일과 RX 로그 시각·포트 번호를 비교합니다.
5. Trap 전달도 켜면 7번의 PC 수신창에서 같은 실제 이벤트를 확인할 수 있습니다. 5. Trap 전달도 켜면 7번의 PC 수신창에서 같은 실제 이벤트를 확인할 수 있습니다.
**아직 확인하지 못한 점:** 실제 포트 배선과 상대 장비 종류. 그러므로 지금 임의의 포트에 장비가 연결돼 있다고 단정하지 않습니다. 이 부분만은 연결 확인 후 실제 송수신·상태 DOWN·DCD/DSR 시험으로 이어집니다. **연결 확인 범위:** CH20에서는 위 인터페이스 입력 화면과 실제 RX를 확인했습니다. CH01이나 다른 번호에도 같은 장비가 연결돼 있다고 가정하지 않습니다. 실제 상태 DOWN·DCD/DSR 시험은 해당 케이블과 신호 조건을 확인한 뒤 진행합니다.
## 9. 사용자 계정·권한·비밀번호 ## 9. 사용자 계정·권한·비밀번호

2
final-build/README.md

@ -6,6 +6,8 @@
메일 수정: 발송 시 현재 비밀번호 저장 파일을 읽어 UI에서 새로 저장한 SMTP/Trap 자격 증명을 반영합니다. 기존 테스트 메일 재시도는 **16:29:10 KST에 delivered**로 완료됐습니다. 이 상태는 SMTP 서버의 수락이며 수신함 도착 확인과는 구분합니다. 메일 데몬의 이전 실행 파일은 `/userdata/tera-backups/alarm-live-secrets-20260910`에 보관했습니다. 메일 수정: 발송 시 현재 비밀번호 저장 파일을 읽어 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`는 아닙니다. 장비 설정, 사용자 계정, 인증서 개인키와 런타임 로그는 포함하지 않습니다. 이 디렉터리는 실행 파일 모음입니다. boot/rootfs를 포함한 전체 펌웨어 `.pkg`는 아닙니다. 장비 설정, 사용자 계정, 인증서 개인키와 런타임 로그는 포함하지 않습니다.
## 파일과 설치 경로 ## 파일과 설치 경로

2
final-build/SHA256SUMS

@ -1,6 +1,6 @@
7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0 frontend/tera-clone-demo-web 7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0 frontend/tera-clone-demo-web
ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd
0715323afbe9a472e7cb18e26b292e60f2c1a640dc084141921f8f27c4e49406 backend/tera-clone-demo-api bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1 backend/tera-clone-demo-api
5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c backend/tera-logd 5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c backend/tera-logd
0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef backend/tera-netd 0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef backend/tera-netd
c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4 backend/tera-seriald c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4 backend/tera-seriald

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

Binary file not shown.

12
final-build/release-manifest.json

@ -19,8 +19,8 @@
}, },
{ {
"path": "backend/tera-clone-demo-api", "path": "backend/tera-clone-demo-api",
"bytes": 14203482, "bytes": 9961634,
"sha256": "0715323afbe9a472e7cb18e26b292e60f2c1a640dc084141921f8f27c4e49406", "sha256": "bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1",
"install_path": "/usr/bin/tera-api", "install_path": "/usr/bin/tera-api",
"updated_in_this_release": true "updated_in_this_release": true
}, },
@ -85,5 +85,13 @@
"description": "Reload current secret snapshot for email and SNMP Trap delivery", "description": "Reload current secret snapshot for email and SNMP Trap delivery",
"backup": "/userdata/tera-backups/alarm-live-secrets-20260910", "backup": "/userdata/tera-backups/alarm-live-secrets-20260910",
"verified_sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006" "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"
} }
} }

Loading…
Cancel
Save