- 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.
269 lines
7.0 KiB
Go
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 ""
|
|
}
|