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

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

477 lines
11 KiB
Go

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
}