// 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 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 "" }