Izvuci logger/ - dijagnostika firmvera nema veze sa licencom
Prijemnik dijagnostike sa TERMINIA uredjaja je alat za trazenje kvarova u FIRMVERU. Nema nikakve veze sa izdavanjem i proverom licenci, a stajao je u ovom repou samo zato sto je i on serverski Go kod. Posledica koja je to i otkrila: da bi se pustio dijagnosticki alat, morao bi 'git pull' u repou od kog zavisi naplata - na produkciji. Dve nepovezane stvari ne smeju da se povlace jedna za drugom. Preseljeno u repo asp-terminia (uz firmver koji i sluzi da se debaguje). NIJE deployovano nigde.
This commit is contained in:
parent
3480766c3e
commit
cee4a2c7dd
152
logger/README.md
152
logger/README.md
@ -1,152 +0,0 @@
|
|||||||
# Prijemnik dijagnostike sa TERMINIA uređaja
|
|
||||||
|
|
||||||
Mali samostalan servis: sluša UDP pakete koje terminal šalje (dnevnik rada i
|
|
||||||
heartbeat), piše ih u fajlove po uređaju i po danu, i prikazuje na jednoj
|
|
||||||
web stranici.
|
|
||||||
|
|
||||||
- **Nema bazu**, nema spoljnih Go zavisnosti (samo standardna biblioteka).
|
|
||||||
- **Ne dira licencni servis** — zaseban proces, zaseban port, zaseban
|
|
||||||
systemd unit. Deli samo Go modul (isti repo).
|
|
||||||
- Ugovor o formatu paketa: ASP repo, `docs/24-servisni-kanal.md`.
|
|
||||||
|
|
||||||
## Šta prima
|
|
||||||
|
|
||||||
| Vrsta | Kako se prepoznaje | Šta radi sa njim |
|
|
||||||
|---|---|---|
|
|
||||||
| Dnevnik rada (dbg) | čist tekst; prvi red `#TERMINIA <naziv> <MAC> <fw>` | linije se upisuju u fajl uređaja |
|
|
||||||
| Heartbeat | paket počinje sa `{` (JSON sa `"hb":1`) | pamti se u memoriji (naziv, MAC, IP, FW, vreme) |
|
|
||||||
| Sve ostalo | — | tiho se odbacuje, brojač „odbačeno" na stranici |
|
|
||||||
|
|
||||||
**Zaglavlje dnevnika postoji tek od FW 0.13.4.** Stariji uređaj šalje goli
|
|
||||||
tekst — takav se vodi kao **nepoznat uređaj**, razvrstava po izvornoj IP
|
|
||||||
adresi i tako je i **označen na stranici** (ne pravimo se da znamo ko je).
|
|
||||||
|
|
||||||
Prijemnik **ne odgovara** na pakete. Prijem naloga na uređaju nije
|
|
||||||
implementiran i traži potpis (docs/24 §3.2), pa slanja naloga ovde nema.
|
|
||||||
|
|
||||||
## Web stranica
|
|
||||||
|
|
||||||
| Ruta | Šta daje |
|
|
||||||
|---|---|
|
|
||||||
| `/` | spisak uređaja: naziv, MAC, IP, FW, „poslednji put pre X"; **crveno** = nije se javio duže od 3× svog intervala |
|
|
||||||
| `/uredjaj?k=<ključ>&q=<pretraga>&n=<broj>` | poslednjih N linija dnevnika, sa pretragom po tekstu |
|
|
||||||
| `/dnevnik.txt?k=<ključ>&n=&q=` | isto, kao čist tekst (za `curl`) |
|
|
||||||
| `/api/uredjaji` | JSON spisak uređaja (za alate) |
|
|
||||||
|
|
||||||
Interval javljanja se **meri** iz razmaka između dva heartbeat-a; dok ga nema,
|
|
||||||
koristi se `LOGGER_HB_DEFAULT_SEC`.
|
|
||||||
|
|
||||||
## Pokretanje lokalno
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\Terminia\TERMINIA-SOFTWARE\licence-server
|
|
||||||
go build -o terminia-logger.exe .\logger\cmd\terminia-logger
|
|
||||||
|
|
||||||
$env:LOGGER_UDP_PORT="46465"
|
|
||||||
$env:LOGGER_HTTP_PORT="8081"
|
|
||||||
$env:LOGGER_DIR="D:\Terminia\TERMINIA-SOFTWARE\licence-server\data\logger"
|
|
||||||
.\terminia-logger.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
Zatim otvoriti `http://127.0.0.1:8081`.
|
|
||||||
|
|
||||||
Proba bez uređaja (PowerShell, u drugom prozoru):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$u=New-Object System.Net.Sockets.UdpClient; $u.Connect("127.0.0.1",46465)
|
|
||||||
function Send($s){ $b=[System.Text.Encoding]::UTF8.GetBytes($s); [void]$u.Send($b,$b.Length) }
|
|
||||||
Send '{"app":"TERMINIA","name":"TERM-001","mac":"1C:DB:D4:A6:A4:60","ip":"192.168.0.20","fw":"0.13.4","hb":1}'
|
|
||||||
Send "#TERMINIA TERM-001 1C:DB:D4:A6:A4:60 0.13.4`n[stampac] javio se na 192.168.123.100`n"
|
|
||||||
$u.Close()
|
|
||||||
```
|
|
||||||
|
|
||||||
Testovi:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
go test ./logger/...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Env promenljive
|
|
||||||
|
|
||||||
| Promenljiva | Podrazumevano | Značenje |
|
|
||||||
|---|---|---|
|
|
||||||
| `LOGGER_UDP_PORT` | `46465` | port na koji uređaji šalju. **Ne 46464** — tamo uređaji slušaju discovery |
|
|
||||||
| `LOGGER_UDP_HOST` | (prazno) | adresa za slušanje; prazno = sve |
|
|
||||||
| `LOGGER_HTTP_HOST` | `127.0.0.1` | web sluša samo lokalno jer dnevnik sadrži **brojeve karata** |
|
|
||||||
| `LOGGER_HTTP_PORT` | `8081` | port web pregleda |
|
|
||||||
| `LOGGER_TOKEN` | (prazno) | ako je postavljen, web traži `?t=<token>` (obavezno ako web nije samo lokalni) |
|
|
||||||
| `LOGGER_DIR` | `./data/logger` | folder sa dnevnicima |
|
|
||||||
| `LOGGER_KEEP_DAYS` | `14` | koliko dana se dnevnici čuvaju |
|
|
||||||
| `LOGGER_MAX_FILE_MB` | `10` | preko toga se dnevni fajl rotira (`.log.001`, `.002`…) |
|
|
||||||
| `LOGGER_MAX_DEVICE_MB` | `50` | kvota po uređaju; preko toga se briše najstarije |
|
|
||||||
| `LOGGER_MAX_TOTAL_MB` | `1024` | ukupna kvota za sve uređaje |
|
|
||||||
| `LOGGER_MAX_DEVICES` | `500` | koliko uređaja se drži u memoriji |
|
|
||||||
| `LOGGER_HB_DEFAULT_SEC` | `60` | pretpostavljeni interval dok se ne izmeri |
|
|
||||||
|
|
||||||
Neispravna vrednost = servis se **ne pokreće** i kaže koja promenljiva ne valja
|
|
||||||
(bolje nego da tiho radi sa pogrešnim podešavanjem).
|
|
||||||
|
|
||||||
## Šta se upisuje u uređaj
|
|
||||||
|
|
||||||
U SPA uređaja: **Podešavanja → Servis**
|
|
||||||
|
|
||||||
| Polje | Vrednost |
|
|
||||||
|---|---|
|
|
||||||
| `svcLogHost` | `157.180.119.151:46465` |
|
|
||||||
| „Šalji dnevnik rada na server" | **uključeno** |
|
|
||||||
| heartbeat (`svcHbSeconds`) | npr. `60` |
|
|
||||||
|
|
||||||
Uređaj šalje **samo brojčani IP:port** — domen se ne može upisati (docs/24 §1).
|
|
||||||
|
|
||||||
> ⚠️ Ovo ide preko interneta **u čistom tekstu**, a u dnevniku ima brojeva
|
|
||||||
> karata. Za trajno praćenje flote predviđen je HTTPS kanal (docs/24 §3);
|
|
||||||
> ovaj prijemnik je za dijagnostiku dok se problem lovi.
|
|
||||||
|
|
||||||
## Puštanje na server (radi vlasnik)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) build na serveru
|
|
||||||
cd /root/projects/licence-server
|
|
||||||
git pull
|
|
||||||
go build -o terminia-logger ./logger/cmd/terminia-logger
|
|
||||||
|
|
||||||
# 2) podešavanja
|
|
||||||
cp logger/deploy/terminia-logger.env.example logger.env
|
|
||||||
$EDITOR logger.env
|
|
||||||
|
|
||||||
# 3) systemd
|
|
||||||
cp logger/deploy/terminia-logger.service /etc/systemd/system/
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now terminia-logger
|
|
||||||
systemctl status terminia-logger
|
|
||||||
journalctl -u terminia-logger -f
|
|
||||||
|
|
||||||
# 4) firewall — SAMO taj port, UDP
|
|
||||||
ufw allow 46465/udp
|
|
||||||
|
|
||||||
# 5) provera sa strane (ne sme da vrati odgovor, samo da ne pukne)
|
|
||||||
echo '#TERMINIA PROBA AA:BB:CC:DD:EE:FF 0.13.4
|
|
||||||
proba veze' | nc -u -w1 157.180.119.151 46465
|
|
||||||
|
|
||||||
# 6) pregled (web je vezan za 127.0.0.1) — SSH tunel sa svog racunara:
|
|
||||||
ssh -L 8081:127.0.0.1:8081 root@157.180.119.151
|
|
||||||
# pa u pregledacu: http://127.0.0.1:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
Ako web treba da bude dostupan bez tunela: postaviti `LOGGER_TOKEN`, staviti
|
|
||||||
nginx ispred sa TLS-om i lozinkom, i tek onda promeniti `LOGGER_HTTP_HOST`.
|
|
||||||
**Ne otvarati port 8081 direktno na internet.**
|
|
||||||
|
|
||||||
Šta ovaj servis **ne dira**: bazu `licence_db`, proces `licence-server`, nginx
|
|
||||||
konfiguraciju, portove 8090/8091/8092.
|
|
||||||
|
|
||||||
## Granice i odbrane
|
|
||||||
|
|
||||||
- Paket može poslati bilo ko: sve iz paketa je ograničeno po dužini, kontrolni
|
|
||||||
znaci se izbacuju, a ime foldera se pravi isključivo od belih znakova
|
|
||||||
(`[A-Za-z0-9_-]`) — sadržaj paketa ne može da odluči gde se piše.
|
|
||||||
- Broj uređaja u memoriji je ograničen; kad se granica dostigne, izbacuje se
|
|
||||||
onaj koji se najduže nije javio.
|
|
||||||
- Dnevnici se brišu po tri pravila redom: starost → kvota po uređaju → ukupna
|
|
||||||
kvota. Fajl koji je trenutno otvoren za pisanje se nikad ne briše.
|
|
||||||
- Greška pri upisu ne obara prijem — vidi se kao brojač „greške upisa".
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# Podesavanja prijemnika dijagnostike (systemd EnvironmentFile).
|
|
||||||
# Kopirati u /root/projects/licence-server/logger.env i izmeniti po potrebi.
|
|
||||||
# Sve promenljive su opcione - bez njih vaze podrazumevane vrednosti iz koda.
|
|
||||||
|
|
||||||
# --- mreza ---------------------------------------------------------------
|
|
||||||
# UDP port na koji uredjaji salju dnevnik i heartbeat (svcLogHost = IP:PORT).
|
|
||||||
# NE koristiti 46464 - na tom portu uredjaji slusaju discovery.
|
|
||||||
LOGGER_UDP_PORT=46465
|
|
||||||
# Prazno = slusaj na svim adresama.
|
|
||||||
LOGGER_UDP_HOST=
|
|
||||||
|
|
||||||
# Web pregled. Podrazumevano SAMO lokalno, jer dnevnik sadrzi brojeve karata.
|
|
||||||
# Napolje ide kroz SSH tunel ili nginx sa lozinkom.
|
|
||||||
LOGGER_HTTP_HOST=127.0.0.1
|
|
||||||
LOGGER_HTTP_PORT=8081
|
|
||||||
|
|
||||||
# Ako se web ipak otvori ka mrezi, obavezno postaviti token (pristup: ?t=<token>).
|
|
||||||
# LOGGER_TOKEN=
|
|
||||||
|
|
||||||
# --- dnevnici ------------------------------------------------------------
|
|
||||||
# Folder sa dnevnicima (systemd StateDirectory pravi /var/lib/terminia-logger).
|
|
||||||
LOGGER_DIR=/var/lib/terminia-logger
|
|
||||||
|
|
||||||
# Granice da dnevnici ne napune disk (najstarije se brise prvo).
|
|
||||||
LOGGER_KEEP_DAYS=14
|
|
||||||
LOGGER_MAX_FILE_MB=10
|
|
||||||
LOGGER_MAX_DEVICE_MB=50
|
|
||||||
LOGGER_MAX_TOTAL_MB=1024
|
|
||||||
|
|
||||||
# Najveci broj uredjaja koji se drzi u memoriji (paket moze poslati bilo ko).
|
|
||||||
LOGGER_MAX_DEVICES=500
|
|
||||||
|
|
||||||
# Pretpostavljeni interval javljanja kad ga uredjaj jos nije pokazao.
|
|
||||||
# Uredjaj je "tih" kad se ne javi duze od 3x ovog intervala.
|
|
||||||
LOGGER_HB_DEFAULT_SEC=60
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# Prijemnik servisne dijagnostike sa TERMINIA uredjaja.
|
|
||||||
# Obrazac: licence-server.service (vidi docs/SETUP.md), ali BEZ MySQL zavisnosti -
|
|
||||||
# ovaj servis nema bazu i ne dodiruje licencni servis.
|
|
||||||
#
|
|
||||||
# Instalacija:
|
|
||||||
# cp logger/deploy/terminia-logger.service /etc/systemd/system/
|
|
||||||
# cp logger/deploy/terminia-logger.env.example /root/projects/licence-server/logger.env
|
|
||||||
# systemctl daemon-reload && systemctl enable --now terminia-logger
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=TERMINIA prijemnik dijagnostike (UDP dnevnik + heartbeat)
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/root/projects/licence-server
|
|
||||||
ExecStart=/root/projects/licence-server/terminia-logger
|
|
||||||
EnvironmentFile=-/root/projects/licence-server/logger.env
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=terminia-logger
|
|
||||||
KillMode=mixed
|
|
||||||
KillSignal=SIGTERM
|
|
||||||
TimeoutStopSec=15
|
|
||||||
|
|
||||||
# Dnevnici sadrze BROJEVE KARATA - folder je citljiv samo vlasniku procesa.
|
|
||||||
StateDirectory=terminia-logger
|
|
||||||
StateDirectoryMode=0750
|
|
||||||
|
|
||||||
# Osnovno ogradjivanje: servis samo slusa UDP i pise u svoj folder.
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=full
|
|
||||||
# ProtectHome se NE ukljucuje: binar i EnvironmentFile stoje pod /root/projects
|
|
||||||
# (isto kao licence-server), a ProtectHome bi taj folder sakrio od servisa.
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
268
logger/packet.go
268
logger/packet.go
@ -1,268 +0,0 @@
|
|||||||
// 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 ""
|
|
||||||
}
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseLogSaZaglavljem(t *testing.T) {
|
|
||||||
raw := "#TERMINIA TERM-001 1C:DB:D4:A6:A4:60 0.13.4\n" +
|
|
||||||
"[stampac] opet se javlja na 192.168.123.100 (ping)\n" +
|
|
||||||
"[izvestaj] dan 20659: 14 karata, 2100 RSD\n"
|
|
||||||
p := ParsePacket([]byte(raw), "192.168.0.20")
|
|
||||||
|
|
||||||
if p.Kind != KindLog {
|
|
||||||
t.Fatalf("vrsta = %v, ocekivano KindLog", p.Kind)
|
|
||||||
}
|
|
||||||
if !p.Identified {
|
|
||||||
t.Error("paket sa zaglavljem mora biti identifikovan")
|
|
||||||
}
|
|
||||||
if p.Name != "TERM-001" || p.MAC != "1C:DB:D4:A6:A4:60" || p.FW != "0.13.4" {
|
|
||||||
t.Errorf("identitet = %q / %q / %q", p.Name, p.MAC, p.FW)
|
|
||||||
}
|
|
||||||
if len(p.Lines) != 2 {
|
|
||||||
t.Fatalf("linija = %d, ocekivano 2: %q", len(p.Lines), p.Lines)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(p.Lines[0], "[stampac]") {
|
|
||||||
t.Errorf("prva linija = %q", p.Lines[0])
|
|
||||||
}
|
|
||||||
if got := DeviceKey(p.MAC, p.SourceIP); got != "1CDBD4A6A460" {
|
|
||||||
t.Errorf("kljuc = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseZaglavljeSaRazmakomUNazivu(t *testing.T) {
|
|
||||||
// Naziv uredjaja kuca korisnik i sme da ima razmake.
|
|
||||||
p := ParsePacket([]byte("#TERMINIA Bazen Ulaz A 1c-db-d4-a6-a4-60 0.13.4\nzdravo\n"), "10.0.0.5")
|
|
||||||
if p.Name != "Bazen Ulaz A" {
|
|
||||||
t.Errorf("naziv = %q", p.Name)
|
|
||||||
}
|
|
||||||
if p.MAC != "1C:DB:D4:A6:A4:60" {
|
|
||||||
t.Errorf("mac = %q", p.MAC)
|
|
||||||
}
|
|
||||||
if p.FW != "0.13.4" {
|
|
||||||
t.Errorf("fw = %q", p.FW)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHeartbeat(t *testing.T) {
|
|
||||||
raw := `{"app":"TERMINIA","name":"TERM-001","mac":"1C:DB:D4:A6:A4:60",` +
|
|
||||||
`"ip":"192.168.0.20","fw":"0.13.4","serial":"1CDBD4A6A460","hb":1}`
|
|
||||||
p := ParsePacket([]byte(raw), "192.168.0.20")
|
|
||||||
|
|
||||||
if p.Kind != KindHeartbeat {
|
|
||||||
t.Fatalf("vrsta = %v, ocekivano KindHeartbeat", p.Kind)
|
|
||||||
}
|
|
||||||
if !p.Identified || p.MAC != "1C:DB:D4:A6:A4:60" || p.IP != "192.168.0.20" ||
|
|
||||||
p.FW != "0.13.4" || p.Serial != "1CDBD4A6A460" || p.Name != "TERM-001" {
|
|
||||||
t.Errorf("identitet = %+v", p)
|
|
||||||
}
|
|
||||||
if len(p.Lines) != 0 {
|
|
||||||
t.Errorf("heartbeat ne sme imati linije: %q", p.Lines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHeartbeatSaIdPoljem(t *testing.T) {
|
|
||||||
// Stariji/drugaciji klijent salje serijski pod imenom "id".
|
|
||||||
p := ParsePacket([]byte(`{"name":"T","mac":"AABBCCDDEEFF","id":"XYZ","hb":1}`), "10.0.0.1")
|
|
||||||
if p.Kind != KindHeartbeat || p.Serial != "XYZ" {
|
|
||||||
t.Errorf("rezultat = %+v", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseBezZaglavlja(t *testing.T) {
|
|
||||||
// FW stariji od 0.13.4: goli tekst, znamo samo izvornu adresu.
|
|
||||||
p := ParsePacket([]byte("nesto se desilo\ndrugi red\n"), "192.168.0.77")
|
|
||||||
if p.Kind != KindLog {
|
|
||||||
t.Fatalf("vrsta = %v", p.Kind)
|
|
||||||
}
|
|
||||||
if p.Identified {
|
|
||||||
t.Error("paket bez zaglavlja NE SME da vazi za identifikovan")
|
|
||||||
}
|
|
||||||
if p.MAC != "" {
|
|
||||||
t.Errorf("mac = %q, ocekivano prazno", p.MAC)
|
|
||||||
}
|
|
||||||
key := DeviceKey(p.MAC, p.SourceIP)
|
|
||||||
if key != "nepoznat-192_168_0_77" {
|
|
||||||
t.Errorf("kljuc = %q", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmeceNeObaraServis(t *testing.T) {
|
|
||||||
slucajevi := [][]byte{
|
|
||||||
nil,
|
|
||||||
{},
|
|
||||||
[]byte("\n\n\n"),
|
|
||||||
[]byte("{"), // krnj JSON
|
|
||||||
[]byte(`{"app":"TERMINIA"`), // presecen paket
|
|
||||||
[]byte(`{"nesto":"tudje"}`), // JSON bez identiteta
|
|
||||||
{0x00, 0x01, 0x02, 0xff, 0xfe}, // binarno smece
|
|
||||||
[]byte("#TERMINIA\n"), // zaglavlje bez ijednog polja
|
|
||||||
[]byte(strings.Repeat("A", MaxPacketBytes*3)), // predugacak paket
|
|
||||||
}
|
|
||||||
for i, c := range slucajevi {
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("slucaj %d: panika: %v", i, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
p := ParsePacket(c, "1.2.3.4")
|
|
||||||
// Bilo koji ishod je prihvatljiv osim pada; ako je nesto proslo,
|
|
||||||
// mora biti u granicama.
|
|
||||||
if len(p.Lines) > MaxLinesPerPacket {
|
|
||||||
t.Errorf("slucaj %d: previse linija %d", i, len(p.Lines))
|
|
||||||
}
|
|
||||||
for _, ln := range p.Lines {
|
|
||||||
if len([]rune(ln)) > MaxLineLen {
|
|
||||||
t.Errorf("slucaj %d: linija duza od granice", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSeceIKontrolneZnake(t *testing.T) {
|
|
||||||
dugacko := strings.Repeat("x", 1000)
|
|
||||||
raw := "#TERMINIA " + strings.Repeat("N", 200) + " 1C:DB:D4:A6:A4:60 " + strings.Repeat("9", 100) + "\n" +
|
|
||||||
"linija\x1b[31m sa ESC \x07 i \x00 nulom\n" + dugacko + "\n"
|
|
||||||
p := ParsePacket([]byte(raw), "1.2.3.4")
|
|
||||||
|
|
||||||
if len([]rune(p.Name)) > MaxNameLen {
|
|
||||||
t.Errorf("naziv nije skracen: %d", len([]rune(p.Name)))
|
|
||||||
}
|
|
||||||
if len([]rune(p.FW)) > MaxFWLen {
|
|
||||||
t.Errorf("fw nije skracen: %d", len([]rune(p.FW)))
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(p.Lines[0], "\x1b\x07\x00") {
|
|
||||||
t.Errorf("kontrolni znaci nisu uklonjeni: %q", p.Lines[0])
|
|
||||||
}
|
|
||||||
if len([]rune(p.Lines[1])) > MaxLineLen {
|
|
||||||
t.Errorf("linija nije skracena: %d", len([]rune(p.Lines[1])))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParsePreviseLinija(t *testing.T) {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("#TERMINIA T 1C:DB:D4:A6:A4:60 0.13.4\n")
|
|
||||||
for i := 0; i < 500; i++ {
|
|
||||||
b.WriteString("linija\n")
|
|
||||||
}
|
|
||||||
p := ParsePacket([]byte(b.String()), "1.2.3.4")
|
|
||||||
if len(p.Lines) != MaxLinesPerPacket {
|
|
||||||
t.Errorf("linija = %d, ocekivano %d", len(p.Lines), MaxLinesPerPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeMAC(t *testing.T) {
|
|
||||||
dobri := map[string]string{
|
|
||||||
"1C:DB:D4:A6:A4:60": "1C:DB:D4:A6:A4:60",
|
|
||||||
"1c:db:d4:a6:a4:60": "1C:DB:D4:A6:A4:60",
|
|
||||||
"1cdbd4a6a460": "1C:DB:D4:A6:A4:60",
|
|
||||||
"1C-DB-D4-A6-A4-60": "1C:DB:D4:A6:A4:60",
|
|
||||||
}
|
|
||||||
for in, want := range dobri {
|
|
||||||
got, ok := NormalizeMAC(in)
|
|
||||||
if !ok || got != want {
|
|
||||||
t.Errorf("NormalizeMAC(%q) = %q,%v", in, got, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
losi := []string{"", "1C:DB:D4:A6:A4", "1C:DB:D4:A6:A4:6G", "../../etc/passwd",
|
|
||||||
strings.Repeat("A", 200), "1C:DB:D4:A6:A4:60:70"}
|
|
||||||
for _, in := range losi {
|
|
||||||
if _, ok := NormalizeMAC(in); ok {
|
|
||||||
t.Errorf("NormalizeMAC(%q) je prosao, a ne bi smeo", in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeSegmentNemaIzlaskaIzFoldera(t *testing.T) {
|
|
||||||
slucajevi := map[string]string{
|
|
||||||
"../../etc/passwd": "etc_passwd",
|
|
||||||
"/apsolutno/putanja": "apsolutno_putanja",
|
|
||||||
"C:\\Windows\\win": "C__Windows_win",
|
|
||||||
"": "nepoznat",
|
|
||||||
"...": "nepoznat",
|
|
||||||
"1CDBD4A6A460": "1CDBD4A6A460",
|
|
||||||
}
|
|
||||||
for in, want := range slucajevi {
|
|
||||||
got := SanitizeSegment(in)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("SanitizeSegment(%q) = %q, ocekivano %q", in, got, want)
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(got, `/\`) || got == ".." {
|
|
||||||
t.Errorf("SanitizeSegment(%q) = %q — pusta izlazak iz foldera", in, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n := len(SanitizeSegment(strings.Repeat("a", 500))); n > MaxKeyLen {
|
|
||||||
t.Errorf("kljuc nije ogranicen: %d", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeviceKeyPoIPKadNemaMAC(t *testing.T) {
|
|
||||||
if got := DeviceKey("", "10.20.30.40"); got != "nepoznat-10_20_30_40" {
|
|
||||||
t.Errorf("kljuc = %q", got)
|
|
||||||
}
|
|
||||||
if got := DeviceKey("", ""); got != "nepoznat-bez-adrese" {
|
|
||||||
t.Errorf("kljuc bez adrese = %q", got)
|
|
||||||
}
|
|
||||||
// Lazna izvorna adresa ne sme da napravi putanju.
|
|
||||||
if got := DeviceKey("", "../../root"); strings.ContainsAny(got, `/\`) {
|
|
||||||
t.Errorf("kljuc = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Device je sve sto pamtimo o jednom uredjaju. Sve je u memoriji i sve je
|
|
||||||
// ograniceno po velicini - dnevnik ide u fajlove, ne ovde.
|
|
||||||
type Device struct {
|
|
||||||
Key string
|
|
||||||
Name string
|
|
||||||
MAC string
|
|
||||||
IP string // adresa koju uredjaj sam prijavi (heartbeat)
|
|
||||||
SourceIP string // adresa sa koje je paket stvarno stigao
|
|
||||||
FW string
|
|
||||||
Serial string
|
|
||||||
Identified bool // znamo li ko je (zaglavlje/heartbeat) ili samo nagadjamo po IP-u
|
|
||||||
|
|
||||||
FirstSeen time.Time
|
|
||||||
LastSeen time.Time
|
|
||||||
LastHeartbeat time.Time
|
|
||||||
|
|
||||||
Heartbeats uint64
|
|
||||||
LogPackets uint64
|
|
||||||
LogLines uint64
|
|
||||||
|
|
||||||
IntervalSec int // izmeren razmak izmedju dva heartbeata (0 = jos ne znamo)
|
|
||||||
LastLine string // poslednja linija dnevnika, samo za pregled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registry cuva uredjaje u memoriji. Broj uredjaja je ogranicen jer paket moze
|
|
||||||
// poslati bilo ko - bez granice bi jedan izvor sa laznim MAC-ovima pojeo RAM.
|
|
||||||
type Registry struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
devices map[string]*Device
|
|
||||||
maxDevices int
|
|
||||||
defaultHb int // pretpostavljeni interval kad uredjaj nikad nije poslao heartbeat
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRegistry(maxDevices, defaultHbSec int) *Registry {
|
|
||||||
if maxDevices <= 0 {
|
|
||||||
maxDevices = 500
|
|
||||||
}
|
|
||||||
if defaultHbSec <= 0 {
|
|
||||||
defaultHbSec = 60
|
|
||||||
}
|
|
||||||
return &Registry{
|
|
||||||
devices: make(map[string]*Device),
|
|
||||||
maxDevices: maxDevices,
|
|
||||||
defaultHb: defaultHbSec,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ubacuje paket u registar i vraca kljuc uredjaja (i da li je primljen).
|
|
||||||
// Paket koji nije prepoznat se ignorise.
|
|
||||||
func (r *Registry) Update(p Packet, now time.Time) (string, bool) {
|
|
||||||
if p.Kind == KindGarbage {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
key := DeviceKey(p.MAC, p.SourceIP)
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
d, ok := r.devices[key]
|
|
||||||
if !ok {
|
|
||||||
if len(r.devices) >= r.maxDevices {
|
|
||||||
r.evictOldestLocked()
|
|
||||||
}
|
|
||||||
d = &Device{Key: key, FirstSeen: now}
|
|
||||||
r.devices[key] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
d.LastSeen = now
|
|
||||||
d.SourceIP = p.SourceIP
|
|
||||||
if p.Name != "" {
|
|
||||||
d.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.MAC != "" {
|
|
||||||
d.MAC = p.MAC
|
|
||||||
}
|
|
||||||
if p.FW != "" {
|
|
||||||
d.FW = p.FW
|
|
||||||
}
|
|
||||||
if p.Serial != "" {
|
|
||||||
d.Serial = p.Serial
|
|
||||||
}
|
|
||||||
if p.Identified {
|
|
||||||
d.Identified = true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch p.Kind {
|
|
||||||
case KindHeartbeat:
|
|
||||||
if p.IP != "" {
|
|
||||||
d.IP = p.IP
|
|
||||||
}
|
|
||||||
// Interval ne dolazi u paketu - meri se izmedju dva javljanja.
|
|
||||||
// Uzima se poslednji razmak, uz clamp da jedan izgubljen paket ili
|
|
||||||
// restart uredjaja ne razvuku prag na sate.
|
|
||||||
if !d.LastHeartbeat.IsZero() {
|
|
||||||
if sec := int(now.Sub(d.LastHeartbeat).Seconds()); sec >= 5 && sec <= 3600 {
|
|
||||||
d.IntervalSec = sec
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.LastHeartbeat = now
|
|
||||||
d.Heartbeats++
|
|
||||||
case KindLog:
|
|
||||||
d.LogPackets++
|
|
||||||
d.LogLines += uint64(len(p.Lines))
|
|
||||||
if n := len(p.Lines); n > 0 {
|
|
||||||
d.LastLine = p.Lines[n-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictOldestLocked izbacuje uredjaj koji se najduze nije javio. Poziva se samo
|
|
||||||
// kad je registar pun.
|
|
||||||
func (r *Registry) evictOldestLocked() {
|
|
||||||
var oldestKey string
|
|
||||||
var oldest time.Time
|
|
||||||
for k, d := range r.devices {
|
|
||||||
if oldestKey == "" || d.LastSeen.Before(oldest) {
|
|
||||||
oldestKey, oldest = k, d.LastSeen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if oldestKey != "" {
|
|
||||||
delete(r.devices, oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interval vraca interval po kom se procenjuje da li je uredjaj tih.
|
|
||||||
func (r *Registry) Interval(d *Device) int {
|
|
||||||
if d.IntervalSec > 0 {
|
|
||||||
return d.IntervalSec
|
|
||||||
}
|
|
||||||
return r.defaultHb
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale je tacno kad se uredjaj nije javio duze od 3x svog intervala.
|
|
||||||
func (r *Registry) Stale(d *Device, now time.Time) bool {
|
|
||||||
limit := time.Duration(3*r.Interval(d)) * time.Second
|
|
||||||
return now.Sub(d.LastSeen) > limit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot vraca kopije uredjaja, sortirane: prvo tihi (da bode oci), pa po
|
|
||||||
// nazivu/kljucu.
|
|
||||||
func (r *Registry) Snapshot(now time.Time) []Device {
|
|
||||||
r.mu.RLock()
|
|
||||||
out := make([]Device, 0, len(r.devices))
|
|
||||||
for _, d := range r.devices {
|
|
||||||
out = append(out, *d)
|
|
||||||
}
|
|
||||||
r.mu.RUnlock()
|
|
||||||
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
si, sj := r.Stale(&out[i], now), r.Stale(&out[j], now)
|
|
||||||
if si != sj {
|
|
||||||
return si
|
|
||||||
}
|
|
||||||
ni, nj := out[i].Name, out[j].Name
|
|
||||||
if ni == "" {
|
|
||||||
ni = out[i].Key
|
|
||||||
}
|
|
||||||
if nj == "" {
|
|
||||||
nj = out[j].Key
|
|
||||||
}
|
|
||||||
if ni != nj {
|
|
||||||
return ni < nj
|
|
||||||
}
|
|
||||||
return out[i].Key < out[j].Key
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get vraca kopiju jednog uredjaja.
|
|
||||||
func (r *Registry) Get(key string) (Device, bool) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
d, ok := r.devices[key]
|
|
||||||
if !ok {
|
|
||||||
return Device{}, false
|
|
||||||
}
|
|
||||||
return *d, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count vraca broj uredjaja u registru.
|
|
||||||
func (r *Registry) Count() int {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
return len(r.devices)
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func hbPaket(mac string) Packet {
|
|
||||||
return ParsePacket([]byte(fmt.Sprintf(
|
|
||||||
`{"app":"TERMINIA","name":"TERM","mac":"%s","ip":"192.168.0.20","fw":"0.13.4","hb":1}`, mac)),
|
|
||||||
"192.168.0.20")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryMeriIntervalHeartbeata(t *testing.T) {
|
|
||||||
r := NewRegistry(10, 60)
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
r.Update(hbPaket("1C:DB:D4:A6:A4:60"), t0)
|
|
||||||
key, _ := r.Update(hbPaket("1C:DB:D4:A6:A4:60"), t0.Add(30*time.Second))
|
|
||||||
|
|
||||||
d, ok := r.Get(key)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("uredjaj nije u registru")
|
|
||||||
}
|
|
||||||
if d.IntervalSec != 30 {
|
|
||||||
t.Errorf("interval = %d, ocekivano 30", d.IntervalSec)
|
|
||||||
}
|
|
||||||
if d.Heartbeats != 2 {
|
|
||||||
t.Errorf("heartbeata = %d", d.Heartbeats)
|
|
||||||
}
|
|
||||||
// 3x30 s = 90 s praga (poslednji paket je stigao u t0+30 s)
|
|
||||||
if r.Stale(&d, t0.Add(150*time.Second)) == false {
|
|
||||||
t.Error("posle 90 s tisine uredjaj mora biti oznacen kao tih")
|
|
||||||
}
|
|
||||||
if r.Stale(&d, t0.Add(40*time.Second)) {
|
|
||||||
t.Error("uredjaj koji se javio pre 10 s ne sme biti tih")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryBezHeartbeataKoristiPodrazumevaniInterval(t *testing.T) {
|
|
||||||
r := NewRegistry(10, 60)
|
|
||||||
t0 := time.Now()
|
|
||||||
key, _ := r.Update(ParsePacket([]byte("#TERMINIA T 1C:DB:D4:A6:A4:60 0.13.4\nred\n"), "1.2.3.4"), t0)
|
|
||||||
d, _ := r.Get(key)
|
|
||||||
if got := r.Interval(&d); got != 60 {
|
|
||||||
t.Errorf("interval = %d, ocekivano 60 (podrazumevani)", got)
|
|
||||||
}
|
|
||||||
if !r.Stale(&d, t0.Add(200*time.Second)) {
|
|
||||||
t.Error("posle 180 s mora biti tih")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryOgranicavaBrojUredjaja(t *testing.T) {
|
|
||||||
r := NewRegistry(3, 60)
|
|
||||||
t0 := time.Now()
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
p := ParsePacket([]byte("neka linija\n"), fmt.Sprintf("10.0.0.%d", i))
|
|
||||||
r.Update(p, t0.Add(time.Duration(i)*time.Second))
|
|
||||||
}
|
|
||||||
if n := r.Count(); n != 3 {
|
|
||||||
t.Errorf("uredjaja u registru = %d, ocekivano 3 (granica)", n)
|
|
||||||
}
|
|
||||||
// Najstariji mora ispasti, najnoviji ostati.
|
|
||||||
if _, ok := r.Get("nepoznat-10_0_0_49"); !ok {
|
|
||||||
t.Error("poslednji uredjaj mora ostati u registru")
|
|
||||||
}
|
|
||||||
if _, ok := r.Get("nepoznat-10_0_0_0"); ok {
|
|
||||||
t.Error("najstariji uredjaj je morao biti izbacen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryZadrzavaIdentitetIzZaglavlja(t *testing.T) {
|
|
||||||
r := NewRegistry(10, 60)
|
|
||||||
t0 := time.Now()
|
|
||||||
key, _ := r.Update(ParsePacket([]byte("#TERMINIA TERM-001 1C:DB:D4:A6:A4:60 0.13.4\nprvi red\n"), "1.2.3.4"), t0)
|
|
||||||
// Kasniji paket bez zaglavlja sa istim MAC-om ne postoji, ali heartbeat
|
|
||||||
// dopunjuje IP i serijski.
|
|
||||||
r.Update(hbPaket("1C:DB:D4:A6:A4:60"), t0.Add(time.Second))
|
|
||||||
|
|
||||||
d, _ := r.Get(key)
|
|
||||||
if !d.Identified || d.MAC != "1C:DB:D4:A6:A4:60" || d.IP != "192.168.0.20" {
|
|
||||||
t.Errorf("uredjaj = %+v", d)
|
|
||||||
}
|
|
||||||
if d.LogLines != 1 || d.LastLine != "prvi red" {
|
|
||||||
t.Errorf("linije = %d / %q", d.LogLines, d.LastLine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistrySnapshotStavljaTiheNaVrh(t *testing.T) {
|
|
||||||
r := NewRegistry(10, 60)
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
r.Update(hbPaket("AA:AA:AA:AA:AA:AA"), t0.Add(-time.Hour))
|
|
||||||
r.Update(hbPaket("BB:BB:BB:BB:BB:BB"), t0)
|
|
||||||
|
|
||||||
snap := r.Snapshot(t0)
|
|
||||||
if len(snap) != 2 {
|
|
||||||
t.Fatalf("snapshot = %d", len(snap))
|
|
||||||
}
|
|
||||||
if snap[0].MAC != "AA:AA:AA:AA:AA:AA" {
|
|
||||||
t.Errorf("tih uredjaj mora biti prvi, dobijeno %s", snap[0].MAC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryIgnorisSmece(t *testing.T) {
|
|
||||||
r := NewRegistry(10, 60)
|
|
||||||
if _, ok := r.Update(ParsePacket([]byte("{"), "1.2.3.4"), time.Now()); ok {
|
|
||||||
t.Error("smece ne sme uci u registar")
|
|
||||||
}
|
|
||||||
if r.Count() != 0 {
|
|
||||||
t.Errorf("registar nije prazan: %d", r.Count())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config je citav podesivi deo prijemnika. Sve dolazi iz env promenljivih
|
|
||||||
// (systemd EnvironmentFile), nista nije zakucano u kodu.
|
|
||||||
type Config struct {
|
|
||||||
UDPHost string // prazno = sve adrese
|
|
||||||
UDPPort int
|
|
||||||
HTTPHost string
|
|
||||||
HTTPPort int
|
|
||||||
Dir string
|
|
||||||
Token string // ako je postavljen, web trazi ?t=<token>
|
|
||||||
KeepDays int
|
|
||||||
MaxFileMB int
|
|
||||||
MaxDeviceMB int
|
|
||||||
MaxTotalMB int
|
|
||||||
MaxDevices int
|
|
||||||
HbDefaultSec int
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultConfig su podrazumevane vrednosti.
|
|
||||||
//
|
|
||||||
// UDP 46465 namerno NIJE 46464: na tom portu uredjaji slusaju discovery, pa bi
|
|
||||||
// prijemnik na istoj mrezi ulazio u sukob sa njima.
|
|
||||||
//
|
|
||||||
// Web sluza samo na 127.0.0.1 jer dnevnik sadrzi BROJEVE KARATA - napolje se
|
|
||||||
// pusta iskljucivo kroz SSH tunel ili nginx sa autentikacijom.
|
|
||||||
func DefaultConfig() Config {
|
|
||||||
return Config{
|
|
||||||
UDPHost: "",
|
|
||||||
UDPPort: 46465,
|
|
||||||
HTTPHost: "127.0.0.1",
|
|
||||||
HTTPPort: 8081,
|
|
||||||
Dir: "./data/logger",
|
|
||||||
KeepDays: 14,
|
|
||||||
MaxFileMB: 10,
|
|
||||||
MaxDeviceMB: 50,
|
|
||||||
MaxTotalMB: 1024,
|
|
||||||
MaxDevices: 500,
|
|
||||||
HbDefaultSec: 60,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigFromEnv cita podesavanja preko podrazumevanih vrednosti.
|
|
||||||
func ConfigFromEnv() (Config, error) {
|
|
||||||
c := DefaultConfig()
|
|
||||||
var err error
|
|
||||||
str := func(key string, dst *string) {
|
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
|
||||||
*dst = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
num := func(key string, dst *int, min, max int) {
|
|
||||||
v, ok := os.LookupEnv(key)
|
|
||||||
if !ok || v == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, e := strconv.Atoi(v)
|
|
||||||
if e != nil || n < min || n > max {
|
|
||||||
err = fmt.Errorf("%s: neispravna vrednost %q (ocekuje se broj %d-%d)", key, v, min, max)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*dst = n
|
|
||||||
}
|
|
||||||
str("LOGGER_UDP_HOST", &c.UDPHost)
|
|
||||||
str("LOGGER_HTTP_HOST", &c.HTTPHost)
|
|
||||||
str("LOGGER_DIR", &c.Dir)
|
|
||||||
str("LOGGER_TOKEN", &c.Token)
|
|
||||||
num("LOGGER_UDP_PORT", &c.UDPPort, 1, 65535)
|
|
||||||
num("LOGGER_HTTP_PORT", &c.HTTPPort, 1, 65535)
|
|
||||||
num("LOGGER_KEEP_DAYS", &c.KeepDays, 1, 3650)
|
|
||||||
num("LOGGER_MAX_FILE_MB", &c.MaxFileMB, 1, 10240)
|
|
||||||
num("LOGGER_MAX_DEVICE_MB", &c.MaxDeviceMB, 1, 102400)
|
|
||||||
num("LOGGER_MAX_TOTAL_MB", &c.MaxTotalMB, 1, 1024000)
|
|
||||||
num("LOGGER_MAX_DEVICES", &c.MaxDevices, 1, 100000)
|
|
||||||
num("LOGGER_HB_DEFAULT_SEC", &c.HbDefaultSec, 5, 86400)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
if c.MaxDeviceMB > c.MaxTotalMB {
|
|
||||||
return c, fmt.Errorf("LOGGER_MAX_DEVICE_MB (%d) ne sme biti vece od LOGGER_MAX_TOTAL_MB (%d)",
|
|
||||||
c.MaxDeviceMB, c.MaxTotalMB)
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats su brojaci prijemnika za prikaz na stranici.
|
|
||||||
type Stats struct {
|
|
||||||
Packets atomic.Uint64
|
|
||||||
Heartbeat atomic.Uint64
|
|
||||||
LogPkts atomic.Uint64
|
|
||||||
Garbage atomic.Uint64
|
|
||||||
WriteErrs atomic.Uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service spaja UDP prijem, registar uredjaja, pisanje u fajlove i web.
|
|
||||||
type Service struct {
|
|
||||||
cfg Config
|
|
||||||
reg *Registry
|
|
||||||
store *Store
|
|
||||||
stats *Stats
|
|
||||||
started time.Time
|
|
||||||
now func() time.Time // zamenjivo u testovima
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(cfg Config) (*Service, error) {
|
|
||||||
st, err := NewStore(StoreConfig{
|
|
||||||
Dir: cfg.Dir,
|
|
||||||
MaxFileBytes: int64(cfg.MaxFileMB) << 20,
|
|
||||||
MaxDeviceBytes: int64(cfg.MaxDeviceMB) << 20,
|
|
||||||
MaxTotalBytes: int64(cfg.MaxTotalMB) << 20,
|
|
||||||
KeepDays: cfg.KeepDays,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Service{
|
|
||||||
cfg: cfg,
|
|
||||||
reg: NewRegistry(cfg.MaxDevices, cfg.HbDefaultSec),
|
|
||||||
store: st,
|
|
||||||
stats: &Stats{},
|
|
||||||
started: time.Now(),
|
|
||||||
now: time.Now,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle obradjuje jedan primljen paket. Izdvojeno iz petlje da bi moglo da se
|
|
||||||
// testira bez mreze.
|
|
||||||
func (s *Service) Handle(data []byte, sourceIP string) {
|
|
||||||
s.stats.Packets.Add(1)
|
|
||||||
p := ParsePacket(data, sourceIP)
|
|
||||||
if p.Kind == KindGarbage {
|
|
||||||
s.stats.Garbage.Add(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := s.now()
|
|
||||||
key, ok := s.reg.Update(p, now)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch p.Kind {
|
|
||||||
case KindHeartbeat:
|
|
||||||
s.stats.Heartbeat.Add(1)
|
|
||||||
case KindLog:
|
|
||||||
s.stats.LogPkts.Add(1)
|
|
||||||
if err := s.store.Write(key, now, p.Lines); err != nil {
|
|
||||||
// Greska pisanja ne sme da obori prijem: paket se izgubi, a
|
|
||||||
// brojac gresaka se vidi na stranici.
|
|
||||||
s.stats.WriteErrs.Add(1)
|
|
||||||
log.Printf("prijemnik: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run pokrece UDP prijem, web i odrzavanje. Vraca se kad se kontekst otkaze.
|
|
||||||
func (s *Service) Run(ctx context.Context) error {
|
|
||||||
udpAddr := net.JoinHostPort(s.cfg.UDPHost, strconv.Itoa(s.cfg.UDPPort))
|
|
||||||
pc, err := net.ListenPacket("udp", udpAddr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UDP %s: %w", udpAddr, err)
|
|
||||||
}
|
|
||||||
defer pc.Close()
|
|
||||||
|
|
||||||
httpAddr := net.JoinHostPort(s.cfg.HTTPHost, strconv.Itoa(s.cfg.HTTPPort))
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: httpAddr,
|
|
||||||
Handler: s.Handler(),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
ln, err := net.Listen("tcp", httpAddr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("HTTP %s: %w", httpAddr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("prijemnik dijagnostike: UDP %s, web http://%s, dnevnici u %s",
|
|
||||||
udpAddr, httpAddr, s.cfg.Dir)
|
|
||||||
if s.cfg.Token == "" && s.cfg.HTTPHost != "127.0.0.1" && s.cfg.HTTPHost != "localhost" {
|
|
||||||
log.Printf("UPOZORENJE: web je otvoren ka mrezi bez tokena (LOGGER_TOKEN), a dnevnik sadrzi brojeve karata")
|
|
||||||
}
|
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
errCh <- err
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
go s.readLoop(pc, errCh)
|
|
||||||
go s.maintenance(ctx)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case err = <-errCh:
|
|
||||||
}
|
|
||||||
|
|
||||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
srv.Shutdown(shutCtx)
|
|
||||||
pc.Close()
|
|
||||||
s.store.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) readLoop(pc net.PacketConn, errCh chan<- error) {
|
|
||||||
buf := make([]byte, MaxPacketBytes)
|
|
||||||
for {
|
|
||||||
n, addr, err := pc.ReadFrom(buf)
|
|
||||||
if err != nil {
|
|
||||||
// Zatvoren soket je uredan kraj; sve ostalo je greska mreze
|
|
||||||
// koju ne pokusavamo da lecimo.
|
|
||||||
if errors.Is(err, net.ErrClosed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
errCh <- fmt.Errorf("UDP prijem: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
src := ""
|
|
||||||
if ua, ok := addr.(*net.UDPAddr); ok && ua.IP != nil {
|
|
||||||
src = ua.IP.String()
|
|
||||||
}
|
|
||||||
s.Handle(buf[:n], src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maintenance povremeno cisti stare dnevnike i zatvara mirne fajlove.
|
|
||||||
func (s *Service) maintenance(ctx context.Context) {
|
|
||||||
if n, err := s.store.Cleanup(s.now()); err != nil {
|
|
||||||
log.Printf("prijemnik: ciscenje: %v", err)
|
|
||||||
} else if n > 0 {
|
|
||||||
log.Printf("prijemnik: obrisano %d starih dnevnika", n)
|
|
||||||
}
|
|
||||||
t := time.NewTicker(time.Minute)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
now := s.now()
|
|
||||||
s.store.CloseIdle(now, 5*time.Minute)
|
|
||||||
if n, err := s.store.Cleanup(now); err != nil {
|
|
||||||
log.Printf("prijemnik: ciscenje: %v", err)
|
|
||||||
} else if n > 0 {
|
|
||||||
log.Printf("prijemnik: obrisano %d starih dnevnika", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testService(t *testing.T, prilagodi func(*Config)) *Service {
|
|
||||||
t.Helper()
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.Dir = t.TempDir()
|
|
||||||
if prilagodi != nil {
|
|
||||||
prilagodi(&cfg)
|
|
||||||
}
|
|
||||||
s, err := New(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(s.store.Close)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceHandleUpisujeDnevnikIPamtiHeartbeat(t *testing.T) {
|
|
||||||
s := testService(t, nil)
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
s.now = func() time.Time { return t0 }
|
|
||||||
|
|
||||||
s.Handle([]byte(`{"app":"TERMINIA","name":"TERM-001","mac":"1C:DB:D4:A6:A4:60","ip":"192.168.0.20","fw":"0.13.4","hb":1}`), "192.168.0.20")
|
|
||||||
s.Handle([]byte("#TERMINIA TERM-001 1C:DB:D4:A6:A4:60 0.13.4\n[stampac] javio se\n"), "192.168.0.20")
|
|
||||||
s.Handle([]byte{0x00, 0x01, 0x02, 0xff, 0xfe}, "9.9.9.9") // binarno smece
|
|
||||||
|
|
||||||
if got := s.stats.Heartbeat.Load(); got != 1 {
|
|
||||||
t.Errorf("heartbeata = %d", got)
|
|
||||||
}
|
|
||||||
if got := s.stats.LogPkts.Load(); got != 1 {
|
|
||||||
t.Errorf("paketa dnevnika = %d", got)
|
|
||||||
}
|
|
||||||
if got := s.stats.WriteErrs.Load(); got != 0 {
|
|
||||||
t.Errorf("gresaka upisa = %d", got)
|
|
||||||
}
|
|
||||||
lines, _ := s.store.Tail("1CDBD4A6A460", 10, "")
|
|
||||||
if len(lines) != 1 || !strings.Contains(lines[0], "[stampac] javio se") {
|
|
||||||
t.Errorf("dnevnik = %q", lines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceStranicaUredjaja(t *testing.T) {
|
|
||||||
s := testService(t, nil)
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
s.now = func() time.Time { return t0 }
|
|
||||||
s.Handle([]byte("#TERMINIA TERM-001 1C:DB:D4:A6:A4:60 0.13.4\n[stampac] javio se\n[kapija] prolaz\n"), "192.168.0.20")
|
|
||||||
h := s.Handler()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("/ = %d", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "TERM-001") || !strings.Contains(body, "1C:DB:D4:A6:A4:60") {
|
|
||||||
t.Errorf("spisak ne prikazuje uredjaj: %s", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/uredjaj?k=1CDBD4A6A460&q=stampac", nil))
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("/uredjaj = %d", rec.Code)
|
|
||||||
}
|
|
||||||
body = rec.Body.String()
|
|
||||||
if !strings.Contains(body, "[stampac] javio se") {
|
|
||||||
t.Error("pretraga ne vraca ocekivanu liniju")
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "[kapija] prolaz") {
|
|
||||||
t.Error("pretraga vraca i linije koje ne odgovaraju upitu")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceNepoznatUredjajJeOznacen(t *testing.T) {
|
|
||||||
s := testService(t, nil)
|
|
||||||
s.Handle([]byte("goli tekst bez zaglavlja\n"), "192.168.0.77")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "nepoznat uređaj") {
|
|
||||||
t.Errorf("uredjaj bez zaglavlja nije oznacen kao nepoznat:\n%s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "192.168.0.77") {
|
|
||||||
t.Error("nije prikazana izvorna adresa nepoznatog uredjaja")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceTihUredjajJeCrven(t *testing.T) {
|
|
||||||
s := testService(t, nil)
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
s.now = func() time.Time { return t0 }
|
|
||||||
s.Handle([]byte(`{"name":"TERM-001","mac":"1C:DB:D4:A6:A4:60","hb":1}`), "192.168.0.20")
|
|
||||||
|
|
||||||
s.now = func() time.Time { return t0.Add(10 * time.Minute) } // 3x60 s je davno proslo
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
|
||||||
if !strings.Contains(rec.Body.String(), `class="tih"`) {
|
|
||||||
t.Error("tih uredjaj nije oznacen crvenim redom")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceHTMLEscapeIzPaketa(t *testing.T) {
|
|
||||||
// Sadrzaj paketa je tudji tekst - ne sme da postane HTML.
|
|
||||||
s := testService(t, nil)
|
|
||||||
s.Handle([]byte("#TERMINIA <script>alert(1)</script> 1C:DB:D4:A6:A4:60 0.13.4\n<img src=x onerror=alert(2)>\n"), "1.2.3.4")
|
|
||||||
|
|
||||||
for _, url := range []string{"/", "/uredjaj?k=1CDBD4A6A460"} {
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
|
|
||||||
if strings.Contains(rec.Body.String(), "<script>alert(1)</script>") ||
|
|
||||||
strings.Contains(rec.Body.String(), "<img src=x") {
|
|
||||||
t.Errorf("%s: HTML iz paketa nije ekranovan", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceToken(t *testing.T) {
|
|
||||||
s := testService(t, func(c *Config) { c.Token = "tajna" })
|
|
||||||
h := s.Handler()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("bez tokena = %d, ocekivano 403", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/?t=tajna", nil))
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("sa tokenom = %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceAPIJson(t *testing.T) {
|
|
||||||
s := testService(t, nil)
|
|
||||||
s.Handle([]byte(`{"name":"TERM-001","mac":"1C:DB:D4:A6:A4:60","ip":"192.168.0.20","fw":"0.13.4","hb":1}`), "192.168.0.20")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/uredjaji", nil))
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("/api/uredjaji = %d", rec.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), `"mac": "1C:DB:D4:A6:A4:60"`) {
|
|
||||||
t.Errorf("JSON = %s", rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceRawTekst(t *testing.T) {
|
|
||||||
s := testService(t, nil)
|
|
||||||
s.Handle([]byte("#TERMINIA T 1C:DB:D4:A6:A4:60 0.13.4\nprva\ndruga\n"), "1.2.3.4")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/dnevnik.txt?k=1CDBD4A6A460", nil))
|
|
||||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
|
||||||
t.Errorf("Content-Type = %q", ct)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rec.Body.String(), "prva") {
|
|
||||||
t.Errorf("telo = %q", rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigFromEnv(t *testing.T) {
|
|
||||||
t.Setenv("LOGGER_UDP_PORT", "46465")
|
|
||||||
t.Setenv("LOGGER_HTTP_PORT", "8081")
|
|
||||||
t.Setenv("LOGGER_DIR", "/var/log/terminia")
|
|
||||||
cfg, err := ConfigFromEnv()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConfigFromEnv: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.UDPPort != 46465 || cfg.HTTPPort != 8081 || cfg.Dir != "/var/log/terminia" {
|
|
||||||
t.Errorf("cfg = %+v", cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("LOGGER_UDP_PORT", "nije-broj")
|
|
||||||
if _, err := ConfigFromEnv(); err == nil {
|
|
||||||
t.Error("neispravan port mora dati gresku, ne tihu podrazumevanu vrednost")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("LOGGER_UDP_PORT", "46465")
|
|
||||||
t.Setenv("LOGGER_MAX_DEVICE_MB", "5000")
|
|
||||||
t.Setenv("LOGGER_MAX_TOTAL_MB", "100")
|
|
||||||
if _, err := ConfigFromEnv(); err == nil {
|
|
||||||
t.Error("kvota po uredjaju veca od ukupne mora dati gresku")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultConfigNijeNaPortuUredjaja(t *testing.T) {
|
|
||||||
// 46464 je port na kom UREDJAJI slusaju discovery - prijemnik tamo ne sme.
|
|
||||||
if DefaultConfig().UDPPort == 46464 {
|
|
||||||
t.Error("podrazumevani UDP port se sudara sa discovery portom uredjaja")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
476
logger/store.go
476
logger/store.go
@ -1,476 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Imena fajlova: dnevni fajl je "2026-07-25.log", a kad prebaci granicu
|
|
||||||
// velicine nastavlja se "2026-07-25.log.001", ".002" ...
|
|
||||||
//
|
|
||||||
// Nastavci su nula-dopunjeni namerno: fajlovi se brisu redom po IMENU, a
|
|
||||||
// leksikografski redosled ovakvih imena je i hronoloski (".log" < ".log.001"
|
|
||||||
// < ".log.002" < sledeci dan). Bez dopune bi ".log.10" doslo pre ".log.2".
|
|
||||||
const (
|
|
||||||
dayLayout = "2006-01-02"
|
|
||||||
logExt = ".log"
|
|
||||||
maxOpenFiles = 64
|
|
||||||
tailReadCap = 2 << 20 // koliko najvise bajtova citamo iz jednog fajla pri prikazu
|
|
||||||
)
|
|
||||||
|
|
||||||
// StoreConfig su granice pisanja. Sve u bajtovima osim KeepDays.
|
|
||||||
type StoreConfig struct {
|
|
||||||
Dir string
|
|
||||||
MaxFileBytes int64 // preko ovoga se rotira na sledeci nastavak
|
|
||||||
MaxDeviceBytes int64 // ukupno po uredjaju
|
|
||||||
MaxTotalBytes int64 // ukupno za sve uredjaje
|
|
||||||
KeepDays int // stariji dnevnici se brisu
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store pise linije dnevnika u fajlove i pazi da ne napuni disk.
|
|
||||||
type Store struct {
|
|
||||||
cfg StoreConfig
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
open map[string]*openFile
|
|
||||||
}
|
|
||||||
|
|
||||||
type openFile struct {
|
|
||||||
f *os.File
|
|
||||||
w *bufio.Writer
|
|
||||||
path string
|
|
||||||
day string
|
|
||||||
seq int
|
|
||||||
size int64
|
|
||||||
used time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(cfg StoreConfig) (*Store, error) {
|
|
||||||
if cfg.Dir == "" {
|
|
||||||
return nil, fmt.Errorf("store: prazna putanja direktorijuma")
|
|
||||||
}
|
|
||||||
if cfg.MaxFileBytes <= 0 {
|
|
||||||
cfg.MaxFileBytes = 10 << 20
|
|
||||||
}
|
|
||||||
if cfg.MaxDeviceBytes <= 0 {
|
|
||||||
cfg.MaxDeviceBytes = 50 << 20
|
|
||||||
}
|
|
||||||
if cfg.MaxTotalBytes <= 0 {
|
|
||||||
cfg.MaxTotalBytes = 1 << 30
|
|
||||||
}
|
|
||||||
if cfg.KeepDays <= 0 {
|
|
||||||
cfg.KeepDays = 14
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(cfg.Dir, 0o750); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: kreiranje %s: %w", cfg.Dir, err)
|
|
||||||
}
|
|
||||||
return &Store{cfg: cfg, open: make(map[string]*openFile)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write dopisuje linije u dnevnik uredjaja. Kljuc MORA biti vec sanitizovan
|
|
||||||
// (DeviceKey), ali se sanitizuje jos jednom - pisanje je poslednja odbrana.
|
|
||||||
func (s *Store) Write(key string, when time.Time, lines []string) error {
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
key = SanitizeSegment(key)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
of, err := s.fileForLocked(key, when)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stamp := when.Format("2006-01-02 15:04:05")
|
|
||||||
for _, ln := range lines {
|
|
||||||
n, err := fmt.Fprintf(of.w, "%s %s\n", stamp, ln)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store: upis u %s: %w", of.path, err)
|
|
||||||
}
|
|
||||||
of.size += int64(n)
|
|
||||||
}
|
|
||||||
if err := of.w.Flush(); err != nil {
|
|
||||||
return fmt.Errorf("store: pranjenje bafera %s: %w", of.path, err)
|
|
||||||
}
|
|
||||||
of.used = when
|
|
||||||
if of.size >= s.cfg.MaxFileBytes {
|
|
||||||
s.closeLocked(key) // sledeci upis otvara naredni nastavak
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) fileForLocked(key string, when time.Time) (*openFile, error) {
|
|
||||||
day := when.Format(dayLayout)
|
|
||||||
if of, ok := s.open[key]; ok {
|
|
||||||
if of.day == day && of.size < s.cfg.MaxFileBytes {
|
|
||||||
return of, nil
|
|
||||||
}
|
|
||||||
s.closeLocked(key)
|
|
||||||
}
|
|
||||||
if len(s.open) >= maxOpenFiles {
|
|
||||||
s.closeIdlestLocked()
|
|
||||||
}
|
|
||||||
dir := filepath.Join(s.cfg.Dir, key)
|
|
||||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: kreiranje %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
path, size, seq, err := nextPath(dir, day, s.cfg.MaxFileBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: otvaranje %s: %w", path, err)
|
|
||||||
}
|
|
||||||
of := &openFile{f: f, w: bufio.NewWriter(f), path: path, day: day, seq: seq, size: size, used: when}
|
|
||||||
s.open[key] = of
|
|
||||||
return of, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextPath bira u koji fajl se pise za dati dan: osnovni ako jos nije pun,
|
|
||||||
// inace prvi slobodan nastavak.
|
|
||||||
func nextPath(dir, day string, maxBytes int64) (string, int64, int, error) {
|
|
||||||
base := filepath.Join(dir, day+logExt)
|
|
||||||
st, err := os.Stat(base)
|
|
||||||
switch {
|
|
||||||
case os.IsNotExist(err):
|
|
||||||
return base, 0, 0, nil
|
|
||||||
case err != nil:
|
|
||||||
return "", 0, 0, fmt.Errorf("store: stat %s: %w", base, err)
|
|
||||||
case st.Size() < maxBytes:
|
|
||||||
return base, st.Size(), 0, nil
|
|
||||||
}
|
|
||||||
for seq := 1; seq < 1000; seq++ {
|
|
||||||
p := fmt.Sprintf("%s.%03d", base, seq)
|
|
||||||
st, err := os.Stat(p)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return p, 0, seq, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, 0, fmt.Errorf("store: stat %s: %w", p, err)
|
|
||||||
}
|
|
||||||
if st.Size() < maxBytes {
|
|
||||||
return p, st.Size(), seq, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", 0, 0, fmt.Errorf("store: previse nastavaka za %s", base)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) closeLocked(key string) {
|
|
||||||
if of, ok := s.open[key]; ok {
|
|
||||||
of.w.Flush()
|
|
||||||
of.f.Close()
|
|
||||||
delete(s.open, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) closeIdlestLocked() {
|
|
||||||
var key string
|
|
||||||
var oldest time.Time
|
|
||||||
for k, of := range s.open {
|
|
||||||
if key == "" || of.used.Before(oldest) {
|
|
||||||
key, oldest = k, of.used
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if key != "" {
|
|
||||||
s.closeLocked(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseIdle zatvara fajlove u koje se duze nista ne pise (da se ne drze
|
|
||||||
// otvoreni deskriptori i da rotacija/brisanje ne nailazi na njih).
|
|
||||||
func (s *Store) CloseIdle(now time.Time, idle time.Duration) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for k, of := range s.open {
|
|
||||||
if now.Sub(of.used) > idle {
|
|
||||||
s.closeLocked(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close zatvara sve otvorene fajlove.
|
|
||||||
func (s *Store) Close() {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for k := range s.open {
|
|
||||||
s.closeLocked(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type logFile struct {
|
|
||||||
key string
|
|
||||||
path string
|
|
||||||
name string
|
|
||||||
day string
|
|
||||||
size int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// list nabraja sve dnevnicke fajlove, sortirane od najstarijeg ka najnovijem.
|
|
||||||
func (s *Store) list() ([]logFile, error) {
|
|
||||||
dirs, err := os.ReadDir(s.cfg.Dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: citanje %s: %w", s.cfg.Dir, err)
|
|
||||||
}
|
|
||||||
var out []logFile
|
|
||||||
for _, d := range dirs {
|
|
||||||
if !d.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := d.Name()
|
|
||||||
files, err := os.ReadDir(filepath.Join(s.cfg.Dir, key))
|
|
||||||
if err != nil {
|
|
||||||
continue // uredjaj bez foldera nije razlog za pad prijemnika
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
name := f.Name()
|
|
||||||
if f.IsDir() || !strings.Contains(name, logExt) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
day := name
|
|
||||||
if i := strings.Index(name, logExt); i > 0 {
|
|
||||||
day = name[:i]
|
|
||||||
}
|
|
||||||
if _, err := time.Parse(dayLayout, day); err != nil {
|
|
||||||
continue // tudji fajl u folderu - ne diramo ga
|
|
||||||
}
|
|
||||||
info, err := f.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, logFile{
|
|
||||||
key: key,
|
|
||||||
path: filepath.Join(s.cfg.Dir, key, name),
|
|
||||||
name: name,
|
|
||||||
day: day,
|
|
||||||
size: info.Size(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].name != out[j].name {
|
|
||||||
return out[i].name < out[j].name
|
|
||||||
}
|
|
||||||
return out[i].key < out[j].key
|
|
||||||
})
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup sprovodi tri granice redom: starost, kvota po uredjaju, ukupna
|
|
||||||
// kvota. Vraca broj obrisanih fajlova.
|
|
||||||
//
|
|
||||||
// Fajl koji je trenutno otvoren za pisanje se NIKAD ne brise: na Linuxu
|
|
||||||
// brisanje otvorenog fajla ne oslobodi prostor dok se ne zatvori, a upisi
|
|
||||||
// posle toga tiho odlaze u nepostojeci fajl.
|
|
||||||
func (s *Store) Cleanup(now time.Time) (int, error) {
|
|
||||||
files, err := s.list()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
busy := make(map[string]bool, len(s.open))
|
|
||||||
for _, of := range s.open {
|
|
||||||
busy[of.path] = true
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
deleted := 0
|
|
||||||
remove := func(f logFile) bool {
|
|
||||||
if busy[f.path] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := os.Remove(f.path); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
deleted++
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) starost
|
|
||||||
cutoff := now.AddDate(0, 0, -s.cfg.KeepDays)
|
|
||||||
kept := files[:0]
|
|
||||||
for _, f := range files {
|
|
||||||
d, err := time.Parse(dayLayout, f.day)
|
|
||||||
if err == nil && d.Before(cutoff) && remove(f) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, f)
|
|
||||||
}
|
|
||||||
files = kept
|
|
||||||
|
|
||||||
// 2) kvota po uredjaju
|
|
||||||
perDevice := map[string]int64{}
|
|
||||||
for _, f := range files {
|
|
||||||
perDevice[f.key] += f.size
|
|
||||||
}
|
|
||||||
kept = files[:0]
|
|
||||||
for _, f := range files {
|
|
||||||
if perDevice[f.key] > s.cfg.MaxDeviceBytes && remove(f) {
|
|
||||||
perDevice[f.key] -= f.size
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, f)
|
|
||||||
}
|
|
||||||
files = kept
|
|
||||||
|
|
||||||
// 3) ukupna kvota
|
|
||||||
var total int64
|
|
||||||
for _, f := range files {
|
|
||||||
total += f.size
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
if total <= s.cfg.MaxTotalBytes {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if remove(f) {
|
|
||||||
total -= f.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.pruneEmptyDirs()
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneEmptyDirs brise foldere uredjaja koji su ostali bez ijednog dnevnika.
|
|
||||||
func (s *Store) pruneEmptyDirs() {
|
|
||||||
dirs, err := os.ReadDir(s.cfg.Dir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, d := range dirs {
|
|
||||||
if !d.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p := filepath.Join(s.cfg.Dir, d.Name())
|
|
||||||
if entries, err := os.ReadDir(p); err == nil && len(entries) == 0 {
|
|
||||||
os.Remove(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage vraca ukupno zauzece na disku (bajtova) i broj fajlova.
|
|
||||||
func (s *Store) Usage() (int64, int) {
|
|
||||||
files, err := s.list()
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
for _, f := range files {
|
|
||||||
total += f.size
|
|
||||||
}
|
|
||||||
return total, len(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tail vraca poslednjih n linija dnevnika jednog uredjaja, opciono filtriranih
|
|
||||||
// po tekstu (bez razlike u velicini slova). Redosled je hronoloski.
|
|
||||||
func (s *Store) Tail(key string, n int, query string) ([]string, error) {
|
|
||||||
key = SanitizeSegment(key)
|
|
||||||
if n <= 0 || n > 5000 {
|
|
||||||
n = 200
|
|
||||||
}
|
|
||||||
// Bafer se prazni da bi i linije primljene pre sekund bile vidljive.
|
|
||||||
s.mu.Lock()
|
|
||||||
if of, ok := s.open[key]; ok {
|
|
||||||
of.w.Flush()
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
files, err := s.list()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var mine []logFile
|
|
||||||
for _, f := range files {
|
|
||||||
if f.key == key {
|
|
||||||
mine = append(mine, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
q := strings.ToLower(strings.TrimSpace(query))
|
|
||||||
|
|
||||||
out := make([]string, 0, n)
|
|
||||||
// od najnovijeg fajla unazad, dok se ne skupi n linija
|
|
||||||
for i := len(mine) - 1; i >= 0 && len(out) < n; i-- {
|
|
||||||
lines, err := readTailLines(mine[i].path, tailReadCap)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for j := len(lines) - 1; j >= 0 && len(out) < n; j-- {
|
|
||||||
if q != "" && !strings.Contains(strings.ToLower(lines[j]), q) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, lines[j])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// out je od najnovije ka najstarijoj - okrecemo
|
|
||||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
out[i], out[j] = out[j], out[i]
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTailLines cita poslednjih najvise cap bajtova fajla i vraca cele linije.
|
|
||||||
func readTailLines(path string, cap int64) ([]string, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
st, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
size := st.Size()
|
|
||||||
off := int64(0)
|
|
||||||
if size > cap {
|
|
||||||
off = size - cap
|
|
||||||
}
|
|
||||||
if _, err := f.Seek(off, io.SeekStart); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
buf := make([]byte, size-off)
|
|
||||||
if _, err := io.ReadFull(f, buf); err != nil && err != io.ErrUnexpectedEOF {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
text := string(buf)
|
|
||||||
if off > 0 {
|
|
||||||
// prva linija je verovatno presecena na pola - odbacujemo je
|
|
||||||
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
|
||||||
text = text[i+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines := strings.Split(text, "\n")
|
|
||||||
out := lines[:0]
|
|
||||||
for _, ln := range lines {
|
|
||||||
if strings.TrimSpace(ln) != "" {
|
|
||||||
out = append(out, ln)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Days vraca datume za koje postoji dnevnik datog uredjaja (najnoviji prvi).
|
|
||||||
func (s *Store) Days(key string) []string {
|
|
||||||
key = SanitizeSegment(key)
|
|
||||||
files, err := s.list()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []string
|
|
||||||
for i := len(files) - 1; i >= 0; i-- {
|
|
||||||
if files[i].key != key || seen[files[i].day] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[files[i].day] = true
|
|
||||||
out = append(out, files[i].day)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testStore(t *testing.T, cfg StoreConfig) *Store {
|
|
||||||
t.Helper()
|
|
||||||
cfg.Dir = t.TempDir()
|
|
||||||
s, err := NewStore(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(s.Close)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStorePiseIVracaLinije(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
if err := s.Write("1CDBD4A6A460", t0, []string{"prva", "druga"}); err != nil {
|
|
||||||
t.Fatalf("Write: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Write("1CDBD4A6A460", t0.Add(time.Second), []string{"treca"}); err != nil {
|
|
||||||
t.Fatalf("Write: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines, err := s.Tail("1CDBD4A6A460", 10, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Tail: %v", err)
|
|
||||||
}
|
|
||||||
if len(lines) != 3 {
|
|
||||||
t.Fatalf("linija = %d: %q", len(lines), lines)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(lines[0], "prva") || !strings.HasSuffix(lines[2], "treca") {
|
|
||||||
t.Errorf("redosled nije hronoloski: %q", lines)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(lines[0], "2026-07-25 12:00:00") {
|
|
||||||
t.Errorf("nedostaje vremenska oznaka: %q", lines[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStorePretraga(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
t0 := time.Now()
|
|
||||||
s.Write("dev", t0, []string{"[stampac] greska", "[kapija] prolaz", "[stampac] ok"})
|
|
||||||
|
|
||||||
lines, _ := s.Tail("dev", 10, "STAMPAC")
|
|
||||||
if len(lines) != 2 {
|
|
||||||
t.Fatalf("pretraga = %d linija: %q", len(lines), lines)
|
|
||||||
}
|
|
||||||
lines, _ = s.Tail("dev", 10, "nepostojece")
|
|
||||||
if len(lines) != 0 {
|
|
||||||
t.Errorf("ocekivano 0 linija, dobijeno %q", lines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreOgranicavaBrojLinija(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
t0 := time.Now()
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
s.Write("dev", t0, []string{fmt.Sprintf("linija %d", i)})
|
|
||||||
}
|
|
||||||
lines, _ := s.Tail("dev", 10, "")
|
|
||||||
if len(lines) != 10 {
|
|
||||||
t.Fatalf("linija = %d", len(lines))
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(lines[9], "linija 99") {
|
|
||||||
t.Errorf("poslednja linija = %q", lines[9])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreRotacijaPoVelicini(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{MaxFileBytes: 200})
|
|
||||||
t0 := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
for i := 0; i < 30; i++ {
|
|
||||||
if err := s.Write("dev", t0, []string{fmt.Sprintf("popunjavanje %02d", i)}); err != nil {
|
|
||||||
t.Fatalf("Write: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
files, err := os.ReadDir(filepath.Join(s.cfg.Dir, "dev"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadDir: %v", err)
|
|
||||||
}
|
|
||||||
if len(files) < 2 {
|
|
||||||
t.Fatalf("ocekivana rotacija, fajlova = %d", len(files))
|
|
||||||
}
|
|
||||||
var imena []string
|
|
||||||
for _, f := range files {
|
|
||||||
imena = append(imena, f.Name())
|
|
||||||
if info, _ := f.Info(); info.Size() > 400 {
|
|
||||||
t.Errorf("fajl %s je %d B — granica se ne postuje", f.Name(), info.Size())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imena[0] != "2026-07-25.log" || imena[1] != "2026-07-25.log.001" {
|
|
||||||
t.Errorf("imena fajlova = %q", imena)
|
|
||||||
}
|
|
||||||
// Sve linije se i dalje citaju, hronoloski.
|
|
||||||
lines, _ := s.Tail("dev", 100, "")
|
|
||||||
if len(lines) != 30 {
|
|
||||||
t.Errorf("procitano %d linija, ocekivano 30", len(lines))
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(lines[0], "popunjavanje 00") || !strings.HasSuffix(lines[29], "popunjavanje 29") {
|
|
||||||
t.Errorf("redosled kroz rotirane fajlove nije dobar: %q ... %q", lines[0], lines[29])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreNoviDanNoviFajl(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
t0 := time.Date(2026, 7, 25, 23, 59, 0, 0, time.UTC)
|
|
||||||
s.Write("dev", t0, []string{"juce"})
|
|
||||||
s.Write("dev", t0.Add(2*time.Minute), []string{"danas"})
|
|
||||||
|
|
||||||
dani := s.Days("dev")
|
|
||||||
if len(dani) != 2 || dani[0] != "2026-07-26" || dani[1] != "2026-07-25" {
|
|
||||||
t.Errorf("dani = %q", dani)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupBriseStareDnevnike(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{KeepDays: 3})
|
|
||||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
s.Write("dev", now.AddDate(0, 0, -i), []string{"nesto"})
|
|
||||||
}
|
|
||||||
s.Close() // da ciscenje sme da dira i tekuci fajl
|
|
||||||
|
|
||||||
n, err := s.Cleanup(now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Cleanup: %v", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
t.Fatal("nista nije obrisano")
|
|
||||||
}
|
|
||||||
dani := s.Days("dev")
|
|
||||||
for _, d := range dani {
|
|
||||||
dd, _ := time.Parse(dayLayout, d)
|
|
||||||
if dd.Before(now.AddDate(0, 0, -3)) {
|
|
||||||
t.Errorf("ostao stari dnevnik %s", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupPostujeKvotuPoUredjaju(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{MaxFileBytes: 300, MaxDeviceBytes: 600, KeepDays: 3650})
|
|
||||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
s.Write("dev", base.AddDate(0, 0, i), []string{strings.Repeat("x", 200)})
|
|
||||||
}
|
|
||||||
s.Close()
|
|
||||||
|
|
||||||
if _, err := s.Cleanup(base.AddDate(0, 0, 20)); err != nil {
|
|
||||||
t.Fatalf("Cleanup: %v", err)
|
|
||||||
}
|
|
||||||
total, _ := s.Usage()
|
|
||||||
if total > 600 {
|
|
||||||
t.Errorf("posle ciscenja %d B, kvota je 600 B", total)
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
t.Error("ciscenje je obrisalo sve — najnoviji dnevnik mora ostati")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupPostujeUkupnuKvotu(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{MaxFileBytes: 300, MaxDeviceBytes: 1 << 20, MaxTotalBytes: 800, KeepDays: 3650})
|
|
||||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
s.Write(fmt.Sprintf("dev%d", i), base.AddDate(0, 0, i), []string{strings.Repeat("y", 200)})
|
|
||||||
}
|
|
||||||
s.Close()
|
|
||||||
|
|
||||||
if _, err := s.Cleanup(base.AddDate(0, 0, 10)); err != nil {
|
|
||||||
t.Fatalf("Cleanup: %v", err)
|
|
||||||
}
|
|
||||||
total, _ := s.Usage()
|
|
||||||
if total > 800 {
|
|
||||||
t.Errorf("posle ciscenja %d B, ukupna kvota je 800 B", total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupNeBriseOtvoreniFajl(t *testing.T) {
|
|
||||||
// Fajl u koji se trenutno pise ne sme da nestane: na Linuxu bi upisi
|
|
||||||
// posle brisanja tiho odlazili u nepostojeci fajl.
|
|
||||||
s := testStore(t, StoreConfig{MaxTotalBytes: 1, MaxDeviceBytes: 1, KeepDays: 1})
|
|
||||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
|
|
||||||
s.Write("dev", now, []string{"vazna linija"})
|
|
||||||
|
|
||||||
if _, err := s.Cleanup(now); err != nil {
|
|
||||||
t.Fatalf("Cleanup: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(s.cfg.Dir, "dev", "2026-07-25.log")); err != nil {
|
|
||||||
t.Errorf("otvoreni fajl je obrisan: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreOdbijaIzlazakIzFoldera(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
now := time.Now()
|
|
||||||
if err := s.Write("../../../pobegao", now, []string{"linija"}); err != nil {
|
|
||||||
t.Fatalf("Write: %v", err)
|
|
||||||
}
|
|
||||||
// Sve mora ostati unutar Dir.
|
|
||||||
var vanFoldera []string
|
|
||||||
filepath.Walk(filepath.Dir(s.cfg.Dir), func(p string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil || info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(p, s.cfg.Dir) {
|
|
||||||
vanFoldera = append(vanFoldera, p)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if len(vanFoldera) > 0 {
|
|
||||||
t.Errorf("fajlovi napravljeni van foldera dnevnika: %q", vanFoldera)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTailPreseceneLinijeNaGranici(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
now := time.Now()
|
|
||||||
s.Write("dev", now, []string{"cela linija jedan", "cela linija dva"})
|
|
||||||
lines, _ := s.Tail("dev", 5, "")
|
|
||||||
for _, ln := range lines {
|
|
||||||
if !strings.Contains(ln, "cela linija") {
|
|
||||||
t.Errorf("krnja linija u ispisu: %q", ln)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTailNepostojeciUredjaj(t *testing.T) {
|
|
||||||
s := testStore(t, StoreConfig{})
|
|
||||||
lines, err := s.Tail("nema-ga", 10, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Tail: %v", err)
|
|
||||||
}
|
|
||||||
if len(lines) != 0 {
|
|
||||||
t.Errorf("linije = %q", lines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
365
logger/web.go
365
logger/web.go
@ -1,365 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler vraca ceo web sloj prijemnika (bez baze, bez spoljnih biblioteka).
|
|
||||||
func (s *Service) Handler() http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /", s.pageIndex)
|
|
||||||
mux.HandleFunc("GET /uredjaj", s.pageDevice)
|
|
||||||
mux.HandleFunc("GET /dnevnik.txt", s.pageRaw)
|
|
||||||
mux.HandleFunc("GET /api/uredjaji", s.apiDevices)
|
|
||||||
return s.withToken(mux)
|
|
||||||
}
|
|
||||||
|
|
||||||
// withToken je minimalna zastita kad web nije samo na 127.0.0.1. Token se
|
|
||||||
// prihvata iz ?t= ili iz zaglavlja X-Token; posle prvog uspesnog upita pamti se
|
|
||||||
// u kolacicu da linkovi ostanu kratki.
|
|
||||||
//
|
|
||||||
// Ovo NIJE zamena za nginx + TLS + lozinku - dnevnik sadrzi brojeve karata.
|
|
||||||
func (s *Service) withToken(next http.Handler) http.Handler {
|
|
||||||
if s.cfg.Token == "" {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ok := r.URL.Query().Get("t") == s.cfg.Token || r.Header.Get("X-Token") == s.cfg.Token
|
|
||||||
if !ok {
|
|
||||||
if c, err := r.Cookie("logger_token"); err == nil && c.Value == s.cfg.Token {
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Pristup odbijen: nedostaje token.", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "logger_token", Value: s.cfg.Token, Path: "/",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 12 * 3600,
|
|
||||||
})
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type deviceView struct {
|
|
||||||
Key string
|
|
||||||
Naziv string
|
|
||||||
MAC string
|
|
||||||
IP string
|
|
||||||
FW string
|
|
||||||
Serijski string
|
|
||||||
Poznat bool
|
|
||||||
Tih bool
|
|
||||||
Vidjen string
|
|
||||||
Heartbeat string
|
|
||||||
Interval int
|
|
||||||
Paketa uint64
|
|
||||||
Linija uint64
|
|
||||||
Poslednja string
|
|
||||||
IzvornaIP string
|
|
||||||
PrviPutStr string
|
|
||||||
}
|
|
||||||
|
|
||||||
type indexData struct {
|
|
||||||
Uredjaji []deviceView
|
|
||||||
Ukupno int
|
|
||||||
Paketa uint64
|
|
||||||
Heartbeata uint64
|
|
||||||
Dnevnika uint64
|
|
||||||
Smece uint64
|
|
||||||
Greske uint64
|
|
||||||
DiskMB string
|
|
||||||
Fajlova int
|
|
||||||
UDP string
|
|
||||||
Port int
|
|
||||||
Dir string
|
|
||||||
Vreme string
|
|
||||||
Token string
|
|
||||||
}
|
|
||||||
|
|
||||||
type deviceData struct {
|
|
||||||
U deviceView
|
|
||||||
Linije []string
|
|
||||||
Q string
|
|
||||||
N int
|
|
||||||
Dani []string
|
|
||||||
Vreme string
|
|
||||||
Token string
|
|
||||||
Nadjen bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) view(d Device, now time.Time) deviceView {
|
|
||||||
naziv := d.Name
|
|
||||||
if naziv == "" {
|
|
||||||
naziv = "(bez naziva)"
|
|
||||||
}
|
|
||||||
hb := "nikad"
|
|
||||||
if !d.LastHeartbeat.IsZero() {
|
|
||||||
hb = humanAgo(now.Sub(d.LastHeartbeat))
|
|
||||||
}
|
|
||||||
return deviceView{
|
|
||||||
Key: d.Key,
|
|
||||||
Naziv: naziv,
|
|
||||||
MAC: d.MAC,
|
|
||||||
IP: d.IP,
|
|
||||||
FW: d.FW,
|
|
||||||
Serijski: d.Serial,
|
|
||||||
Poznat: d.Identified,
|
|
||||||
Tih: s.reg.Stale(&d, now),
|
|
||||||
Vidjen: humanAgo(now.Sub(d.LastSeen)),
|
|
||||||
Heartbeat: hb,
|
|
||||||
Interval: s.reg.Interval(&d),
|
|
||||||
Paketa: d.LogPackets + d.Heartbeats,
|
|
||||||
Linija: d.LogLines,
|
|
||||||
Poslednja: d.LastLine,
|
|
||||||
IzvornaIP: d.SourceIP,
|
|
||||||
PrviPutStr: d.FirstSeen.Format("02.01.2006. 15:04:05"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// humanAgo pise razmak vremena na srpskom, kratko.
|
|
||||||
func humanAgo(d time.Duration) string {
|
|
||||||
switch {
|
|
||||||
case d < 2*time.Second:
|
|
||||||
return "upravo sada"
|
|
||||||
case d < time.Minute:
|
|
||||||
return fmt.Sprintf("pre %d s", int(d.Seconds()))
|
|
||||||
case d < time.Hour:
|
|
||||||
return fmt.Sprintf("pre %d min", int(d.Minutes()))
|
|
||||||
case d < 48*time.Hour:
|
|
||||||
return fmt.Sprintf("pre %d h", int(d.Hours()))
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("pre %d dana", int(d.Hours()/24))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) pageIndex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := s.now()
|
|
||||||
devs := s.reg.Snapshot(now)
|
|
||||||
views := make([]deviceView, 0, len(devs))
|
|
||||||
for _, d := range devs {
|
|
||||||
views = append(views, s.view(d, now))
|
|
||||||
}
|
|
||||||
bytes, files := s.store.Usage()
|
|
||||||
data := indexData{
|
|
||||||
Uredjaji: views,
|
|
||||||
Ukupno: len(views),
|
|
||||||
Paketa: s.stats.Packets.Load(),
|
|
||||||
Heartbeata: s.stats.Heartbeat.Load(),
|
|
||||||
Dnevnika: s.stats.LogPkts.Load(),
|
|
||||||
Smece: s.stats.Garbage.Load(),
|
|
||||||
Greske: s.stats.WriteErrs.Load(),
|
|
||||||
DiskMB: fmt.Sprintf("%.1f", float64(bytes)/(1<<20)),
|
|
||||||
Fajlova: files,
|
|
||||||
UDP: fmt.Sprintf("%s:%d", orAll(s.cfg.UDPHost), s.cfg.UDPPort),
|
|
||||||
Port: s.cfg.UDPPort,
|
|
||||||
Dir: s.cfg.Dir,
|
|
||||||
Vreme: now.Format("02.01.2006. 15:04:05"),
|
|
||||||
Token: s.cfg.Token,
|
|
||||||
}
|
|
||||||
render(w, tmplIndex, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) pageDevice(w http.ResponseWriter, r *http.Request) {
|
|
||||||
key := SanitizeSegment(r.URL.Query().Get("k"))
|
|
||||||
q := r.URL.Query().Get("q")
|
|
||||||
if len(q) > 200 {
|
|
||||||
q = q[:200]
|
|
||||||
}
|
|
||||||
n := 200
|
|
||||||
if v := r.URL.Query().Get("n"); v != "" {
|
|
||||||
if x, err := strconv.Atoi(v); err == nil && x > 0 && x <= 5000 {
|
|
||||||
n = x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
now := s.now()
|
|
||||||
data := deviceData{Q: q, N: n, Vreme: now.Format("02.01.2006. 15:04:05"), Token: s.cfg.Token}
|
|
||||||
if d, ok := s.reg.Get(key); ok {
|
|
||||||
data.U = s.view(d, now)
|
|
||||||
data.Nadjen = true
|
|
||||||
} else {
|
|
||||||
// Uredjaj moze postojati samo na disku (posle restarta prijemnika).
|
|
||||||
data.U = deviceView{Key: key, Naziv: "(nije se javio od pokretanja prijemnika)"}
|
|
||||||
}
|
|
||||||
lines, err := s.store.Tail(key, n, q)
|
|
||||||
if err != nil {
|
|
||||||
lines = []string{"Greska pri citanju dnevnika: " + err.Error()}
|
|
||||||
}
|
|
||||||
data.Linije = lines
|
|
||||||
data.Dani = s.store.Days(key)
|
|
||||||
render(w, tmplDevice, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageRaw daje isti sadrzaj kao stranica, ali kao cist tekst - za curl sa
|
|
||||||
// servera i za lepljenje u prepisku.
|
|
||||||
func (s *Service) pageRaw(w http.ResponseWriter, r *http.Request) {
|
|
||||||
key := SanitizeSegment(r.URL.Query().Get("k"))
|
|
||||||
n := 500
|
|
||||||
if v := r.URL.Query().Get("n"); v != "" {
|
|
||||||
if x, err := strconv.Atoi(v); err == nil && x > 0 && x <= 5000 {
|
|
||||||
n = x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines, err := s.store.Tail(key, n, r.URL.Query().Get("q"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Greska: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.Write([]byte(strings.Join(lines, "\n") + "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) apiDevices(w http.ResponseWriter, r *http.Request) {
|
|
||||||
now := s.now()
|
|
||||||
devs := s.reg.Snapshot(now)
|
|
||||||
out := make([]map[string]any, 0, len(devs))
|
|
||||||
for _, d := range devs {
|
|
||||||
out = append(out, map[string]any{
|
|
||||||
"kljuc": d.Key,
|
|
||||||
"naziv": d.Name,
|
|
||||||
"mac": d.MAC,
|
|
||||||
"ip": d.IP,
|
|
||||||
"izvorna_ip": d.SourceIP,
|
|
||||||
"fw": d.FW,
|
|
||||||
"serijski": d.Serial,
|
|
||||||
"identifikovan": d.Identified,
|
|
||||||
"tih": s.reg.Stale(&d, now),
|
|
||||||
"interval_sek": s.reg.Interval(&d),
|
|
||||||
"poslednji_put": d.LastSeen.Format(time.RFC3339),
|
|
||||||
"heartbeata": d.Heartbeats,
|
|
||||||
"paketa_dnevnik": d.LogPackets,
|
|
||||||
"linija": d.LogLines,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
enc.Encode(map[string]any{"uredjaji": out, "vreme": now.Format(time.RFC3339)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func orAll(h string) string {
|
|
||||||
if h == "" {
|
|
||||||
return "0.0.0.0"
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(w http.ResponseWriter, t *template.Template, data any) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := t.Execute(w, data); err != nil {
|
|
||||||
http.Error(w, "Greska pri prikazu: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssCommon = `
|
|
||||||
body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:0;background:#f4f6f8;color:#1c2733}
|
|
||||||
header{background:#12354f;color:#fff;padding:12px 18px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}
|
|
||||||
header h1{font-size:18px;margin:0}
|
|
||||||
header a{color:#9fd3ff;text-decoration:none}
|
|
||||||
main{padding:16px 18px;max-width:1200px}
|
|
||||||
table{border-collapse:collapse;width:100%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.12)}
|
|
||||||
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e3e8ee;font-size:14px;vertical-align:top}
|
|
||||||
th{background:#eef2f6;font-weight:600}
|
|
||||||
tr.tih td{background:#ffe9e9}
|
|
||||||
.znak{display:inline-block;padding:1px 7px;border-radius:9px;font-size:12px;font-weight:600}
|
|
||||||
.ok{background:#d6f5dd;color:#0f6b2b}
|
|
||||||
.lose{background:#ffd2d2;color:#a11}
|
|
||||||
.nepoznat{background:#ffeccc;color:#8a5300}
|
|
||||||
.mali{color:#5a6b7b;font-size:12px}
|
|
||||||
pre{background:#0f1b26;color:#dbe7f0;padding:12px;border-radius:4px;overflow:auto;font-size:13px;line-height:1.45;max-height:70vh}
|
|
||||||
form.pretraga{margin:12px 0;display:flex;gap:8px;flex-wrap:wrap}
|
|
||||||
input,select{padding:6px 8px;border:1px solid #c6d0da;border-radius:4px;font-size:14px}
|
|
||||||
button{padding:6px 14px;border:0;border-radius:4px;background:#12354f;color:#fff;font-size:14px;cursor:pointer}
|
|
||||||
.prazno{padding:24px;background:#fff;color:#5a6b7b}
|
|
||||||
`
|
|
||||||
|
|
||||||
var tmplIndex = template.Must(template.New("index").Parse(`<!doctype html>
|
|
||||||
<html lang="sr"><head><meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<meta http-equiv="refresh" content="10">
|
|
||||||
<title>Prijemnik dijagnostike — TERMINIA</title>
|
|
||||||
<style>` + cssCommon + `</style></head>
|
|
||||||
<body>
|
|
||||||
<header><h1>Prijemnik dijagnostike</h1>
|
|
||||||
<span class="mali">UDP {{.UDP}} · dnevnici: {{.Dir}} ({{.DiskMB}} MB / {{.Fajlova}} fajlova)</span>
|
|
||||||
<span class="mali">osveženo {{.Vreme}}</span></header>
|
|
||||||
<main>
|
|
||||||
<p class="mali">Uređaja: <b>{{.Ukupno}}</b> · primljeno paketa: {{.Paketa}}
|
|
||||||
(heartbeat {{.Heartbeata}}, dnevnik {{.Dnevnika}}, odbačeno {{.Smece}}) · greške upisa: {{.Greske}}</p>
|
|
||||||
{{if .Uredjaji}}
|
|
||||||
<table>
|
|
||||||
<tr><th>Uređaj</th><th>MAC</th><th>IP</th><th>FW</th><th>Stanje</th><th>Poslednji put</th><th>Heartbeat</th><th>Linija</th><th>Poslednja linija</th></tr>
|
|
||||||
{{range .Uredjaji}}
|
|
||||||
<tr {{if .Tih}}class="tih"{{end}}>
|
|
||||||
<td><a href="/uredjaj?k={{.Key}}{{if $.Token}}&t={{$.Token}}{{end}}"><b>{{.Naziv}}</b></a>
|
|
||||||
{{if not .Poznat}}<br><span class="znak nepoznat">nepoznat uređaj</span>
|
|
||||||
<span class="mali">FW stariji od 0.13.4 — prepoznat samo po adresi {{.IzvornaIP}}</span>{{end}}</td>
|
|
||||||
<td>{{if .MAC}}{{.MAC}}{{else}}<span class="mali">—</span>{{end}}</td>
|
|
||||||
<td>{{if .IP}}{{.IP}}{{else}}{{.IzvornaIP}}{{end}}</td>
|
|
||||||
<td>{{if .FW}}{{.FW}}{{else}}<span class="mali">—</span>{{end}}</td>
|
|
||||||
<td>{{if .Tih}}<span class="znak lose">tih</span>{{else}}<span class="znak ok">javlja se</span>{{end}}</td>
|
|
||||||
<td>{{.Vidjen}}</td>
|
|
||||||
<td>{{.Heartbeat}}<br><span class="mali">interval ~{{.Interval}} s</span></td>
|
|
||||||
<td>{{.Linija}}</td>
|
|
||||||
<td class="mali">{{.Poslednja}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</table>
|
|
||||||
<p class="mali">Crveno = uređaj nije poslao ništa duže od 3× svog intervala javljanja.</p>
|
|
||||||
{{else}}
|
|
||||||
<div class="prazno">Nijedan uređaj se još nije javio.<br><br>
|
|
||||||
Na uređaju: <b>Podešavanja → Servis</b> → <code>svcLogHost</code> = <code><IP servera>:{{.Port}}</code>,
|
|
||||||
uključi „Šalji dnevnik rada na server", heartbeat npr. 60 s.</div>
|
|
||||||
{{end}}
|
|
||||||
</main></body></html>`))
|
|
||||||
|
|
||||||
var tmplDevice = template.Must(template.New("device").Parse(`<!doctype html>
|
|
||||||
<html lang="sr"><head><meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>{{.U.Naziv}} — dnevnik</title>
|
|
||||||
<style>` + cssCommon + `</style></head>
|
|
||||||
<body>
|
|
||||||
<header><h1>{{.U.Naziv}}</h1>
|
|
||||||
<span class="mali">{{if .U.MAC}}{{.U.MAC}} · {{end}}{{if .U.FW}}FW {{.U.FW}} · {{end}}{{if .U.IP}}{{.U.IP}}{{end}}</span>
|
|
||||||
<a href="/{{if .Token}}?t={{.Token}}{{end}}">← svi uređaji</a>
|
|
||||||
<span class="mali">{{.Vreme}}</span></header>
|
|
||||||
<main>
|
|
||||||
{{if .Nadjen}}
|
|
||||||
<p class="mali">
|
|
||||||
{{if .U.Tih}}<span class="znak lose">tih</span>{{else}}<span class="znak ok">javlja se</span>{{end}}
|
|
||||||
· poslednji paket {{.U.Vidjen}} · heartbeat {{.U.Heartbeat}} (interval ~{{.U.Interval}} s)
|
|
||||||
· linija dnevnika: {{.U.Linija}} · prvi put viđen {{.U.PrviPutStr}}
|
|
||||||
{{if not .U.Poznat}}<br><span class="znak nepoznat">nepoznat uređaj</span>
|
|
||||||
identifikovan samo po izvornoj adresi {{.U.IzvornaIP}} — paket nije imao zaglavlje (FW stariji od 0.13.4){{end}}
|
|
||||||
</p>
|
|
||||||
{{else}}
|
|
||||||
<p class="mali">Uređaj se nije javio od pokretanja prijemnika — prikazano je ono što je ostalo na disku.</p>
|
|
||||||
{{end}}
|
|
||||||
<form class="pretraga" method="get" action="/uredjaj">
|
|
||||||
<input type="hidden" name="k" value="{{.U.Key}}">
|
|
||||||
{{if .Token}}<input type="hidden" name="t" value="{{.Token}}">{{end}}
|
|
||||||
<input type="text" name="q" value="{{.Q}}" placeholder="pretraga po tekstu (npr. stampac)" size="40">
|
|
||||||
<select name="n">
|
|
||||||
<option value="100" {{if eq .N 100}}selected{{end}}>100 linija</option>
|
|
||||||
<option value="200" {{if eq .N 200}}selected{{end}}>200 linija</option>
|
|
||||||
<option value="500" {{if eq .N 500}}selected{{end}}>500 linija</option>
|
|
||||||
<option value="2000" {{if eq .N 2000}}selected{{end}}>2000 linija</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit">Prikaži</button>
|
|
||||||
</form>
|
|
||||||
<p class="mali">Dani sa dnevnikom: {{if .Dani}}{{range .Dani}}{{.}} {{end}}{{else}}nema zapisa{{end}}
|
|
||||||
· <a href="/dnevnik.txt?k={{.U.Key}}&n={{.N}}&q={{.Q}}{{if .Token}}&t={{.Token}}{{end}}">preuzmi kao tekst</a></p>
|
|
||||||
{{if .Linije}}<pre>{{range .Linije}}{{.}}
|
|
||||||
{{end}}</pre>{{else}}<div class="prazno">Nema linija za zadate uslove.</div>{{end}}
|
|
||||||
</main></body></html>`))
|
|
||||||
Loading…
Reference in New Issue
Block a user