licence-server/logger/web.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

366 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}}&amp;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>&lt;IP servera&gt;:{{.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}}">&larr; 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}}&amp;n={{.N}}&amp;q={{.Q}}{{if .Token}}&amp;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>`))