licence-server/logger/packet.go
Nenad Djukic 3480766c3e Prijemnik dijagnostike sa TERMINIA uredjaja (logger/)
- UDP prijemnik (podrazumevano 46465, NE 46464 gde uredjaji slusaju discovery):
  dnevnik rada sa zaglavljem #TERMINIA, heartbeat JSON, sve ostalo se odbacuje
- paket bez zaglavlja (FW < 0.13.4) se vodi kao "nepoznat uredjaj" po izvornoj
  IP adresi i tako je i oznacen u prikazu
- dnevnik u fajlove po uredjaju i danu, rotacija po velicini + tri kvote
  (starost, po uredjaju, ukupno); otvoren fajl se nikad ne brise
- heartbeat u memoriji; interval se meri iz razmaka javljanja, "tih" = 3x interval
- web stranica (podrazumevano 127.0.0.1:8081): spisak uredjaja, crveno za tihe,
  poslednjih N linija sa pretragom, /dnevnik.txt i /api/uredjaji
- bez baze i bez spoljnih zavisnosti; ne dira licencni servis, njegovu bazu,
  rute ni nginx
- sanitizacija svega iz paketa (duzine, kontrolni znaci, ime foldera) — bez
  izlaska iz foldera dnevnika
- systemd unit + primer env fajla + README sa uputstvom za pustanje na server
- testovi: parsiranje (zaglavlje/JSON/bez zaglavlja/smece), registar, rotacija
  i kvote, web rute

Nije pusteno na server — puštanje radi vlasnik po logger/README.md.
2026-07-25 17:40:27 +02:00

269 lines
7.0 KiB
Go

// Package logger je prijemnik servisne dijagnostike sa TERMINIA uredjaja.
//
// Uredjaj salje dve vrste UDP paketa na isto odrediste (cfg.svcLogHost):
// - dnevnik rada (dbg): cist tekst, prvi red je zaglavlje "#TERMINIA ..."
// - heartbeat: JSON identitet sa "hb":1
//
// Ugovor je opisan u ASP repou, docs/24-servisni-kanal.md.
package logger
import (
"encoding/json"
"net"
"strings"
"unicode"
"unicode/utf8"
)
// Vrsta paketa.
type Kind int
const (
KindGarbage Kind = iota // ne prepoznajemo ga - odbacuje se
KindHeartbeat // JSON identitet
KindLog // linije dnevnika
)
// Tvrde granice. UDP paket moze doci od bilo koga, pa se sve sto ulazi u
// memoriju ili u fajl mora ograniciti pre nego sto se dodirne.
const (
MaxPacketBytes = 4096 // uredjaj salje do ~1100 B; ostalo je visak
MaxLinesPerPacket = 64
MaxLineLen = 400
MaxNameLen = 40
MaxFWLen = 24
MaxSerialLen = 40
MaxKeyLen = 48
)
const headerPrefix = "#TERMINIA"
// Packet je rezultat parsiranja jednog UDP paketa. Nikad ne sadrzi sirove
// bajtove iz mreze - sva polja su prosla kroz sanitizaciju.
type Packet struct {
Kind Kind
Name string
MAC string // kanonski "AA:BB:CC:DD:EE:FF", prazan ako ga nema
IP string
FW string
Serial string
Identified bool // paket sam kaze ko je (zaglavlje ili heartbeat)
Lines []string
SourceIP string
}
// heartbeatJSON prati format iz firmvera (pure::discoveryReply + "hb").
// Polje "id" postoji zbog starijih/drugacijih klijenata koji serijski broj
// salju pod tim imenom - ne kostaje nista, a spasava kompatibilnost.
type heartbeatJSON struct {
App string `json:"app"`
Name string `json:"name"`
MAC string `json:"mac"`
IP string `json:"ip"`
FW string `json:"fw"`
Serial string `json:"serial"`
ID string `json:"id"`
HB json.RawMessage `json:"hb"`
}
// ParsePacket razvrstava jedan primljen paket. Nikad ne panici i nikad ne
// vraca gresku - los paket je samo KindGarbage.
func ParsePacket(data []byte, sourceIP string) Packet {
p := Packet{Kind: KindGarbage, SourceIP: sanitizeText(sourceIP, 45)}
if len(data) == 0 {
return p
}
if len(data) > MaxPacketBytes {
data = data[:MaxPacketBytes]
}
body := strings.TrimLeft(string(data), " \t\r\n\x00")
if body == "" {
return p
}
if body[0] == '{' {
return parseHeartbeat(body, p)
}
return parseLog(body, p)
}
func parseHeartbeat(body string, p Packet) Packet {
var hb heartbeatJSON
if err := json.Unmarshal([]byte(body), &hb); err != nil {
return p // krnj JSON - odbacujemo, servis se ne ruzi zbog toga
}
mac, okMac := NormalizeMAC(hb.MAC)
name := sanitizeText(hb.Name, MaxNameLen)
serial := hb.Serial
if serial == "" {
serial = hb.ID
}
// Bez ijednog upotrebljivog polja identiteta paket nema smisla: to je
// neki tudji JSON koji je slucajno stigao na nas port.
if !okMac && name == "" {
return p
}
p.Kind = KindHeartbeat
p.Identified = true
p.Name = name
p.MAC = mac
p.IP = sanitizeIP(hb.IP)
p.FW = sanitizeText(hb.FW, MaxFWLen)
p.Serial = sanitizeText(serial, MaxSerialLen)
return p
}
func parseLog(body string, p Packet) Packet {
p.Kind = KindLog
raw := strings.Split(body, "\n")
if len(raw) > 0 && strings.HasPrefix(raw[0], headerPrefix) {
parseHeader(raw[0], &p)
raw = raw[1:]
}
for _, ln := range raw {
if len(p.Lines) >= MaxLinesPerPacket {
break
}
s := sanitizeText(ln, MaxLineLen)
if s == "" {
continue
}
p.Lines = append(p.Lines, s)
}
if len(p.Lines) == 0 && !p.Identified {
p.Kind = KindGarbage
}
return p
}
// parseHeader cita "#TERMINIA <naziv> <MAC> <fw>".
//
// Naziv uredjaja SME da sadrzi razmake (korisnik ga slobodno kuca), pa se ne
// oslanjamo na redni broj polja: trazi se prvo polje koje je validan MAC, sve
// pre njega je naziv, prvo posle njega je verzija firmvera.
func parseHeader(line string, p *Packet) {
fields := strings.Fields(strings.TrimPrefix(line, headerPrefix))
macAt := -1
for i, f := range fields {
if _, ok := NormalizeMAC(f); ok {
macAt = i
break
}
}
if macAt < 0 {
// Zaglavlje bez MAC-a nije upotrebljivo za razvrstavanje; ipak
// uzimamo naziv ako ga ima, ali uredjaj ostaje "neidentifikovan".
if len(fields) > 0 {
p.Name = sanitizeText(strings.Join(fields, " "), MaxNameLen)
}
return
}
p.MAC, _ = NormalizeMAC(fields[macAt])
p.Name = sanitizeText(strings.Join(fields[:macAt], " "), MaxNameLen)
if macAt+1 < len(fields) {
p.FW = sanitizeText(fields[macAt+1], MaxFWLen)
}
p.Identified = true
}
// NormalizeMAC prima "1C:DB:D4:A6:A4:60", "1c-db-d4-a6-a4-60" ili
// "1CDBD4A6A460" i vraca kanonski oblik sa dvotackama.
func NormalizeMAC(s string) (string, bool) {
if len(s) > 24 {
return "", false
}
var hex strings.Builder
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'A' && r <= 'F':
hex.WriteRune(r)
case r >= 'a' && r <= 'f':
hex.WriteRune(unicode.ToUpper(r))
case r == ':' || r == '-' || r == '.':
// razdvajaci se preskacu
default:
return "", false
}
}
h := hex.String()
if len(h) != 12 {
return "", false
}
var out strings.Builder
for i := 0; i < 12; i += 2 {
if i > 0 {
out.WriteByte(':')
}
out.WriteString(h[i : i+2])
}
return out.String(), true
}
// DeviceKey je kljuc pod kojim se uredjaj vodi u registru I u imenu foldera.
// Kad MAC nedostaje (FW stariji od 0.13.4 salje dnevnik bez zaglavlja), jedino
// sto imamo je izvorna IP adresa - takav uredjaj se JASNO oznacava kao
// nepoznat, da se ne pravimo da znamo ko je.
func DeviceKey(mac, sourceIP string) string {
if m, ok := NormalizeMAC(mac); ok {
return SanitizeSegment(strings.ReplaceAll(m, ":", ""))
}
ip := sourceIP
if ip == "" {
ip = "bez-adrese"
}
return SanitizeSegment("nepoznat-" + ip)
}
// SanitizeSegment pravi bezbedan deo putanje. Sve sto nije [A-Za-z0-9_-]
// postaje '_', pa ".." i "/" ne mogu da prezive - podatak iz paketa nikad ne
// sme da odluci gde se pise.
func SanitizeSegment(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
if b.Len() >= MaxKeyLen {
break
}
}
out := strings.Trim(b.String(), "_-")
if out == "" {
return "nepoznat"
}
return out
}
// sanitizeText izbacuje kontrolne znake (ukljucujuci CR i ESC sekvence koje bi
// unakazile ispis u terminalu) i sece string na zadatu duzinu.
func sanitizeText(s string, max int) string {
var b strings.Builder
n := 0
for _, r := range s {
if r == '\t' {
r = ' '
}
// utf8.RuneError se javlja na neispravnim bajtovima - to je binarno
// smece koje je stiglo na port, ne tekst uredjaja.
if r < 0x20 || r == 0x7f || r == utf8.RuneError || (!unicode.IsPrint(r) && r != ' ') {
continue
}
b.WriteRune(r)
n++
if n >= max {
break
}
}
return strings.TrimSpace(b.String())
}
func sanitizeIP(s string) string {
s = strings.TrimSpace(s)
if ip := net.ParseIP(s); ip != nil {
return ip.String()
}
return ""
}