From e2cc22e3227f9cd9bd812d720333734c07309595 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:10:02 +0000 Subject: [PATCH 1/4] Interface direction on the overview, rack forms, syslog settings, links for other platforms - Overview: Inbound and Outbound split the charted interface by what came in and went out through it (new iface_in / iface_out filters), so the chart is no longer empty where the served addresses are public or private at both ends. Falls back to the address without an interface. - Rooms and racks redrawn, with one form per device for its place and every port's carrier and circuit. Filter chips are hidden on pages that are not about traffic. - Black is the default theme. - Syslog for findings is set from the findings page, kept in /syslog.json, live, with a test button and send counts. - /embed/vip?ip=&k= shows one address's bandwidth to other platforms: one key per platform, limited to address ranges, sites and optionally viewer networks, rate-limited, replaceable and withdrawable. - Version 2.5.0. --- CHANGELOG.md | 9 + cmd/traffic66/main.go | 69 +++-- internal/api/embed.go | 192 ++++++++++++++ internal/api/embed_test.go | 106 ++++++++ internal/api/racks.go | 25 +- internal/api/racks_test.go | 8 +- internal/api/server.go | 18 ++ internal/api/syslog.go | 95 +++++++ internal/api/syslog_test.go | 83 ++++++ internal/embed/embed.go | 424 +++++++++++++++++++++++++++++++ internal/embed/embed_test.go | 113 ++++++++ internal/store/bytesdir_test.go | 38 +++ internal/store/query.go | 29 ++- internal/syslogout/sender.go | 74 +++++- internal/web/static/app.css | 93 +++++-- internal/web/static/app.js | 286 ++++++++++++++++----- internal/web/static/embed.css | 41 +++ internal/web/static/embed.js | 138 ++++++++++ internal/web/static/i18n/ar.json | 64 ++++- internal/web/static/i18n/bn.json | 64 ++++- internal/web/static/i18n/de.json | 64 ++++- internal/web/static/i18n/en.json | 64 ++++- internal/web/static/i18n/es.json | 64 ++++- internal/web/static/i18n/fr.json | 64 ++++- internal/web/static/i18n/hi.json | 64 ++++- internal/web/static/i18n/id.json | 64 ++++- internal/web/static/i18n/ja.json | 64 ++++- internal/web/static/i18n/ko.json | 64 ++++- internal/web/static/i18n/pt.json | 64 ++++- internal/web/static/i18n/ru.json | 64 ++++- internal/web/static/i18n/ur.json | 64 ++++- internal/web/static/i18n/vi.json | 64 ++++- internal/web/static/i18n/zh.json | 72 +++++- internal/web/static/theme.js | 7 +- 34 files changed, 2678 insertions(+), 138 deletions(-) create mode 100644 internal/api/embed.go create mode 100644 internal/api/embed_test.go create mode 100644 internal/api/syslog.go create mode 100644 internal/api/syslog_test.go create mode 100644 internal/embed/embed.go create mode 100644 internal/embed/embed_test.go create mode 100644 internal/web/static/embed.css create mode 100644 internal/web/static/embed.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d316c..14278d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ The release notes on GitHub are taken from this file: the section whose heading is the version number. +## 2.5.0 + +- Overview: Inbound and Outbound split the interface charted above the way that chart does — what came in through it and what went out through it — so the two charts agree. Telling the direction by address instead left the chart empty wherever the served addresses are public and not listed as your own networks, or private at both ends: by address all of that is transit or internal. Without an interface the address is still what decides. The buttons read 入向 / 出向 in Chinese, like the interface chart, and an empty direction says so instead of saying the data is still arriving. The query filters `iface_in` and `iface_out` (exporter/ifindex) do the same in the API. +- Rooms and racks is redrawn: each rack with its devices as faceplates, each port with its carrier as a coloured tag (one colour per carrier) and its circuit ID beside it, and a count at the top of how many ports have a carrier. One form per device sets its name, room and rack and every port's name, carrier and circuit, saved with one click (`POST /api/racks/device` takes an `ifaces` list). The hint line is gone. +- Pages that are not about the traffic — settings, inventory, rooms and racks, offline analysis, data cleanup, help, account — no longer show the filter chips. +- Black is the default theme. A theme picked before is kept. +- Findings can be sent to syslog from the findings page: collector address, UDP / TCP / TLS, CEF, LEEF, JSON or plain text, the least severity, with a test button and how many messages went, failed or were dropped over the per-minute ceiling. The settings are kept in `/syslog.json` and take effect at once; the `-syslog` flags are the settings until something is saved there. Sending is for registered installations; the licence is checked at each finding, so a feed follows the licence without a restart. +- Links for other platforms (registered installations): `/embed/vip?ip=
&k=` shows one address's bandwidth, in and out, over 1 hour, 24 hours (the default), 7 or 30 days, and nothing else. An asset platform puts the address into one fixed link to give each of its VIPs a traffic button, or frames the page. Each platform has one key, set up on the settings page with the addresses it may show, the sites it may be opened or framed from, and optionally the viewer networks; the key is kept as a hash and shown once, can be replaced (the old one works for 24 more hours) or withdrawn at once, and each viewer may make 60 requests a minute. The links are kept in `/embeds.json`. + ## 2.4.1 - Overview: Inbound and Outbound now mean which way the bytes went — what arrived at your hosts, what they sent — instead of who opened the connection. On a network whose hosts only connect outwards, Inbound used to be empty (and said the data was still arriving), although most of its traffic is the replies it downloads. diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index 1f1508e..b8c0e3a 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -32,6 +32,7 @@ import ( "github.com/githubflyideas/traffic66/internal/collector" "github.com/githubflyideas/traffic66/internal/detect" "github.com/githubflyideas/traffic66/internal/dnsres" + "github.com/githubflyideas/traffic66/internal/embed" "github.com/githubflyideas/traffic66/internal/enrich" "github.com/githubflyideas/traffic66/internal/flow" "github.com/githubflyideas/traffic66/internal/license" @@ -56,7 +57,7 @@ var version = "" // CHANGELOG.md. TestDevVersionMatchesChangelog keeps the two in step: the // default used to be a hardcoded 0.1.0-dev, and because nothing checked it // it stayed that way for two major versions. -const devVersion = "2.4.1" +const devVersion = "2.5.0" func init() { if version == "" { @@ -412,11 +413,16 @@ func serve(args []string, demo bool) { } det := detect.New(st, inv, detect.Config{}) - if sender := startSyslog(f, lic); sender != nil { + var sender *syslogout.Sender + if offline == nil { + sender = startSyslog(f, lic) defer sender.Close() - base := f.syslogBase + // the licence is asked at each finding, so a feed stops when a + // licence runs out and starts when one is added, without a restart det.OnFinding = func(fi store.Finding) { - sender.Send(syslogout.FromFinding(fi, syslogout.PhaseStart, base)) + if lic != nil && lic.State().Allowed(license.FeatSyslog) { + sender.Send(syslogout.FromFinding(fi, syslogout.PhaseStart, sender.Config().BaseURL)) + } } } if demo { @@ -518,6 +524,15 @@ func serve(args []string, demo bool) { Demo: demo, Check: checker.Check, Exists: checker.Exists, Accounts: checker, LocalTok: tok, DataDir: f.data, Started: time.Now(), Listen: listens} srv.SNMP = poller.Status srv.License = lic + srv.Syslog = sender + if offline == nil { + emb, err := embed.Open(filepath.Join(f.data, "embeds.json")) + if err != nil { + fatalf("%v", err) + } + defer emb.Flush() + srv.Embeds = emb + } if lic != nil { go lic.Loop(ctx.Done(), 4*time.Hour) } @@ -980,27 +995,37 @@ func setGoMemoryLimit(frac float64) { log.Printf("memory: database %d MB and program %d MB (soft), from -memory %.2f", lim>>20, lim>>20, frac) } -// startSyslog prepares the syslog feed, or returns nil when there is -// nothing to send to. Unlike HTTPS, a feed that cannot run is not a reason -// to stop: findings keep being made and keep showing in the web UI, and -// the one thing lost is that they also go somewhere else. +// startSyslog prepares the syslog feed. The settings come from the +// findings page, kept in the data directory; the -syslog flags are the +// settings until something has been saved there. Unlike HTTPS, a feed +// that cannot run is not a reason to stop: findings keep being made and +// keep showing in the web UI, and the one thing lost is that they also go +// somewhere else. func startSyslog(f serveFlags, lic *license.Checker) *syslogout.Sender { - if f.syslog == "" { - return nil - } - if lic == nil || !lic.State().Allowed(license.FeatSyslog) { - log.Printf("syslog: sending findings to a collector is for registered installations; findings are still made and shown in the web UI") - return nil + syslogout.Version = version + cfg, saved, err := syslogout.LoadConfig(filepath.Join(f.data, "syslog.json")) + if err != nil { + log.Printf("%v", err) } - cfg := syslogout.Config{ - Enabled: true, Address: f.syslog, Network: f.syslogNet, - Format: syslogout.Format(f.syslogFmt), MinSev: f.syslogSev, - ASCII: f.syslogASCII, CAFile: f.syslogCA, BaseURL: f.syslogBase, + switch { + case saved && f.syslog != "": + log.Printf("syslog: using the settings saved from the web UI; -syslog %s is not used", f.syslog) + case !saved && f.syslog != "": + cfg = syslogout.Config{ + Enabled: true, Address: f.syslog, Network: f.syslogNet, + Format: syslogout.Format(f.syslogFmt), MinSev: f.syslogSev, + ASCII: f.syslogASCII, CAFile: f.syslogCA, BaseURL: f.syslogBase, + } + if err := cfg.Check(); err != nil { + fatalf("%v", err) + } } - if err := cfg.Check(); err != nil { - fatalf("%v", err) + if cfg.Enabled { + if lic == nil || !lic.State().Allowed(license.FeatSyslog) { + log.Printf("syslog: sending findings to a collector is for registered installations; findings are still made and shown in the web UI") + } else { + log.Printf("syslog: sending findings to %s over %s as %s", cfg.Address, cfg.Network, cfg.Format) + } } - syslogout.Version = version - log.Printf("syslog: sending findings to %s over %s as %s", cfg.Address, cfg.Network, cfg.Format) return syslogout.New(cfg) } diff --git a/internal/api/embed.go b/internal/api/embed.go new file mode 100644 index 0000000..c4bf415 --- /dev/null +++ b/internal/api/embed.go @@ -0,0 +1,192 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "html" + "net/http" + "net/netip" + "time" + + "github.com/githubflyideas/traffic66/internal/embed" + "github.com/githubflyideas/traffic66/internal/store" +) + +// Links for other platforms: /embed/vip?ip=
&k= is one +// address's bandwidth, in and out, and nothing else, for a button beside +// that address on the platform. The links are set up on the settings +// page; this is for registered installations. + +// embedRanges are the periods the page offers; a day unless asked. +var embedRanges = map[string]time.Duration{"1h": time.Hour, "24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour} + +func (s *Server) embedRegistered() bool { + return s.License != nil && s.License.State().Registered() +} + +func (s *Server) getEmbeds(w http.ResponseWriter, r *http.Request) { + out := map[string]any{"registered": s.embedRegistered(), "links": []embed.Source{}, "per_minute": embed.PerMinute} + if s.Embeds != nil { + out["links"] = s.Embeds.List() + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) putEmbed(w http.ResponseWriter, r *http.Request) { + if !s.embedReady(w) { + return + } + var in embed.Source + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)).Decode(&in); err != nil { + fail(w, errors.New("bad request")) + return + } + key, err := s.Embeds.Put(embed.Source{ID: in.ID, Name: in.Name, Ranges: in.Ranges, Origins: in.Origins, Clients: in.Clients}) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "key": key}) +} + +func (s *Server) rotateEmbed(w http.ResponseWriter, r *http.Request) { + if !s.embedReady(w) { + return + } + var in struct{ ID string } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + fail(w, errors.New("bad request")) + return + } + key, err := s.Embeds.Rotate(in.ID) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "key": key}) +} + +func (s *Server) deleteEmbed(w http.ResponseWriter, r *http.Request) { + if s.Embeds == nil { + fail(w, errors.New("links are not available here")) + return + } + // withdrawing is allowed without a licence: a link must be stoppable + // whatever happened to the licence since it was made + if err := s.Embeds.Delete(r.URL.Query().Get("id")); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// embedReady answers the request unless links can be made here. +func (s *Server) embedReady(w http.ResponseWriter) bool { + if s.Embeds == nil { + fail(w, errors.New("links are not available here")) + return false + } + if !s.embedRegistered() { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_registered"}) + return false + } + return true +} + +// embedCheck decides one request to the page or its data. It answers the +// request itself when refusing, and says why in plain words: the person +// who set the platform up needs to tell a typo from a block. +func (s *Server) embedCheck(w http.ResponseWriter, r *http.Request, page bool) (netip.Addr, embed.Source, bool) { + refuse := func(code int, err error) (netip.Addr, embed.Source, bool) { + if page { + embedRefusal(w, code, err.Error()) + } else { + writeJSON(w, code, map[string]string{"error": err.Error()}) + } + return netip.Addr{}, embed.Source{}, false + } + if s.Embeds == nil || !s.embedRegistered() { + return refuse(http.StatusForbidden, errors.New("links to traffic are for registered installations")) + } + q := r.URL.Query() + a, err := netip.ParseAddr(q.Get("ip")) + if err != nil { + return refuse(http.StatusBadRequest, errors.New("the link has no address to show (ip=)")) + } + viewer, _ := netip.ParseAddr(clientIP(r)) + src, err := s.Embeds.Check(embed.Request{Key: q.Get("k"), Addr: a.Unmap(), Viewer: viewer, Referer: r.Referer(), Page: page}) + switch { + case errors.Is(err, embed.ErrRate): + return refuse(http.StatusTooManyRequests, err) + case err != nil: + return refuse(http.StatusForbidden, err) + } + return a.Unmap(), src, true +} + +// embedPage is the page a platform opens or frames. +func (s *Server) embedPage(w http.ResponseWriter, r *http.Request) { + a, src, ok := s.embedCheck(w, r, true) + if !ok { + return + } + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; "+embed.FrameAncestors(src.Origins)) + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Content-Type-Options", "nosniff") + fmt.Fprintf(w, ` +%s · traffic66 +
`, html.EscapeString(a.String()), html.EscapeString(a.String())) +} + +// embedData is the bandwidth behind the page: bits per second to the +// address and from it, over the period asked. +func (s *Server) embedData(w http.ResponseWriter, r *http.Request) { + a, _, ok := s.embedCheck(w, r, false) + if !ok { + return + } + d, ok := embedRanges[r.URL.Query().Get("range")] + if !ok { + d = 24 * time.Hour + } + now := time.Now().UTC() + q := store.Query{From: now.Add(-d), To: now, ZoneOffset: zoneOffset(r.URL.Query().Get("tz"))} + if d > longRange { + q.From = q.From.Truncate(time.Hour) + } + series := func(field string) (*store.Series, error) { + qq := q + qq.Filters = []store.Filter{{Field: field, Value: a.String()}} + return s.Store.TotalSeries(qq) + } + in, err := series("dst") + if err != nil { + fail(w, err) + return + } + out, err := series("src") + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ip": a.String(), "step": in.Step, "times": in.Times, + "in": in.Values[0], "out": out.Values[0], "settled_to": in.SettledTo, "data_from": in.DataFrom, + }) +} + +// embedRefusal is the page shown instead, small enough to sit in a frame. +func embedRefusal(w http.ResponseWriter, code int, why string) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + w.WriteHeader(code) + fmt.Fprintf(w, `traffic66 + +

traffic66

%s

`, html.EscapeString(why)) +} diff --git a/internal/api/embed_test.go b/internal/api/embed_test.go new file mode 100644 index 0000000..8c6d1ea --- /dev/null +++ b/internal/api/embed_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/githubflyideas/traffic66/internal/embed" + "github.com/githubflyideas/traffic66/internal/license" + "github.com/githubflyideas/traffic66/internal/store" +) + +// A platform's button opens one VIP's bandwidth: in to it and out of it, +// for the addresses its key covers, from the sites it names. +func TestEmbedLinks(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(store.Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer st.Close() + ts := time.Now().UTC().Add(-20 * time.Minute) + vip, user := "203.0.113.10", "198.51.100.7" + base := store.Row{TS: ts, Client: user, Server: vip, Dir: store.DirTransit, Pkts: 1, Flows: 1, Exporter: "10.0.0.1"} + req, resp := base, base + req.Src, req.Dst, req.Wire = user, vip, 60_000 + resp.Src, resp.Dst, resp.Wire = vip, user, 600_000 + if err := st.AppendRows([]store.Row{req, resp}); err != nil { + t.Fatal(err) + } + emb, _ := embed.Open(filepath.Join(dir, "embeds.json")) + c, priv := signed(t, dir, license.File{}) + s := &Server{Store: st, License: c, Embeds: emb} + + page := func(url, referer string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", url, nil) + r.Header.Set("Referer", referer) + s.embedPage(w, r) + return w + } + + // links are for registered installations + w := httptest.NewRecorder() + s.putEmbed(w, httptest.NewRequest("POST", "/api/embeds", strings.NewReader(`{"name":"Assets","ranges":["203.0.113.0/24"],"origins":["assets.example.com"]}`))) + if w.Code != 403 { + t.Fatalf("unregistered: %d", w.Code) + } + install(t, s, sign(priv, license.File{Customer: "Example KK", Product: "traffic66", + License: license.TypeSubscription, Tier: license.TierStandard, MaxUsers: 25, Features: []string{license.FeatSyslog}})) + w = httptest.NewRecorder() + s.putEmbed(w, httptest.NewRequest("POST", "/api/embeds", strings.NewReader(`{"name":"Assets","ranges":["203.0.113.0/24"],"origins":["assets.example.com"]}`))) + var made struct{ Key string } + json.Unmarshal(w.Body.Bytes(), &made) + if w.Code != 200 || made.Key == "" { + t.Fatalf("make: %d %s", w.Code, w.Body) + } + + link := "/embed/vip?ip=" + vip + "&k=" + made.Key + from := "https://assets.example.com/vip/42" + if w := page(link, from); w.Code != 200 || !strings.Contains(w.Header().Get("Content-Security-Policy"), "frame-ancestors assets.example.com") { + t.Fatalf("page: %d %q", w.Code, w.Header().Get("Content-Security-Policy")) + } + if w := page(link, "https://elsewhere.example.org/"); w.Code != 403 { + t.Errorf("another site: %d", w.Code) + } + if w := page("/embed/vip?ip=198.51.100.7&k="+made.Key, from); w.Code != 403 { + t.Errorf("an address outside the key: %d", w.Code) + } + if w := page("/embed/vip?ip="+vip+"&k=t66e_wrong", from); w.Code != 403 { + t.Errorf("a wrong key: %d", w.Code) + } + + w = httptest.NewRecorder() + s.embedData(w, httptest.NewRequest("GET", link+"&range=1h", nil)) + var out struct { + IP string + In, Out []float64 + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 { + t.Fatalf("data: %d %s", w.Code, w.Body) + } + sum := func(v []float64) (n float64) { + for _, x := range v { + n += x + } + return + } + // in to the VIP and out of it, the download ten times the request + if in, o := sum(out.In), sum(out.Out); out.IP != vip || in <= 0 || o/in < 9.99 || o/in > 10.01 { + t.Fatalf("in %v out %v", sum(out.In), sum(out.Out)) + } + // nothing but the bandwidth + var raw map[string]any + json.Unmarshal(w.Body.Bytes(), &raw) + for k := range raw { + switch k { + case "ip", "step", "times", "in", "out", "settled_to", "data_from": + default: + t.Errorf("the data carries %q", k) + } + } +} diff --git a/internal/api/racks.go b/internal/api/racks.go index 8f155b2..d6d274e 100644 --- a/internal/api/racks.go +++ b/internal/api/racks.go @@ -131,8 +131,15 @@ func (s *Server) getRacks(w http.ResponseWriter, r *http.Request) { func (s *Server) putRackDevice(w http.ResponseWriter, r *http.Request) { var in struct { Exporter, Name, Room, Rack string + // the ports whose name, carrier or circuit changed, so a device + // and all its ports are filled in with one form + Ifaces []struct { + IfIndex uint32 + Name string + ISP, Circuit string + } } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 512<<10)).Decode(&in); err != nil { fail(w, errors.New("bad request")) return } @@ -141,10 +148,26 @@ func (s *Server) putRackDevice(w http.ResponseWriter, r *http.Request) { fail(w, errors.New("bad exporter address")) return } + if len(in.Ifaces) > 4096 { + fail(w, errors.New("too many interfaces")) + return + } + for _, f := range in.Ifaces { + if f.IfIndex == 0 { + fail(w, errors.New("bad interface")) + return + } + } if err := s.Inv.SetDevice(a, clip(in.Name, 80), clip(in.Room, 60), clip(in.Rack, 60)); err != nil { fail(w, err) return } + for _, f := range in.Ifaces { + if err := s.Inv.SetCircuit(a, f.IfIndex, clip(f.Name, 80), clip(f.ISP, 60), clip(f.Circuit, 80)); err != nil { + fail(w, err) + return + } + } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } diff --git a/internal/api/racks_test.go b/internal/api/racks_test.go index 6498b7f..1ddb32f 100644 --- a/internal/api/racks_test.go +++ b/internal/api/racks_test.go @@ -40,11 +40,13 @@ func TestRacks(t *testing.T) { } return w.Code } - if c := put("device", `{"exporter":"10.0.0.1","name":"Core router","room":"Room 1","rack":"A03"}`); c != 200 { + // the device and its ports in one go, as the page's one form sends them + if c := put("device", `{"exporter":"10.0.0.1","name":"Core router","room":"Room 1","rack":"A03", + "ifaces":[{"ifindex":3,"name":"Uplink","isp":"China Telecom","circuit":"SH-12345"}]}`); c != 200 { t.Fatal(c) } - if c := put("iface", `{"exporter":"10.0.0.1","ifindex":3,"name":"Uplink","isp":"China Telecom","circuit":"SH-12345"}`); c != 200 { - t.Fatal(c) + if c := put("device", `{"exporter":"10.0.0.1","ifaces":[{"ifindex":0}]}`); c != 400 { + t.Fatalf("an interface without an index was accepted: %d", c) } if c := put("iface", `{"exporter":"not an address","ifindex":3}`); c != 400 { t.Fatalf("a bad exporter was accepted: %d", c) diff --git a/internal/api/server.go b/internal/api/server.go index f61fe57..e85b920 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,11 +22,13 @@ import ( "github.com/githubflyideas/traffic66/internal/collector" "github.com/githubflyideas/traffic66/internal/detect" "github.com/githubflyideas/traffic66/internal/dnsres" + "github.com/githubflyideas/traffic66/internal/embed" "github.com/githubflyideas/traffic66/internal/enrich" "github.com/githubflyideas/traffic66/internal/license" "github.com/githubflyideas/traffic66/internal/pipeline" "github.com/githubflyideas/traffic66/internal/sandbox" "github.com/githubflyideas/traffic66/internal/store" + "github.com/githubflyideas/traffic66/internal/syslogout" ) // Server wires the API together. @@ -65,6 +67,12 @@ type Server struct { DataDir string // Listen is the UDP collectors, for the first-run guide on Overview. Listen []ListenInfo + // Syslog sends findings to the customer's collector; nil where there + // is none to configure (tests, the offline sandbox). + Syslog *syslogout.Sender + // Embeds are the links other platforms open to show one address's + // bandwidth; nil where there are none (tests, the offline sandbox). + Embeds *embed.Store mu sync.Mutex sessions map[string]session @@ -130,6 +138,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /logo", s.logo) mux.HandleFunc("GET /api/loginhint", s.loginHint) mux.HandleFunc("GET /tls.crt", s.certDownload) + // the links other platforms open: no session, the key in the link + mux.HandleFunc("GET /embed/vip", s.embedPage) + mux.HandleFunc("GET /embed/vip/data", s.embedData) api := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, s.auth(h)) } // adminAPI is api plus the administrator check. The permission belongs // in this table rather than inside each handler: a new route's gate is @@ -164,6 +175,13 @@ func (s *Server) Handler() http.Handler { adminAPI("POST /api/iface", s.putIface) api("GET /api/racks", s.getRacks) adminAPI("POST /api/threats/lists", s.putThreatList) + api("GET /api/syslog", s.getSyslog) + adminAPI("POST /api/syslog", s.putSyslog) + adminAPI("POST /api/syslog/test", s.testSyslog) + adminAPI("GET /api/embeds", s.getEmbeds) + adminAPI("POST /api/embeds", s.putEmbed) + adminAPI("POST /api/embeds/rotate", s.rotateEmbed) + adminAPI("DELETE /api/embeds", s.deleteEmbed) adminAPI("DELETE /api/threats/lists", s.deleteThreatList) adminAPI("POST /api/racks/device", s.putRackDevice) adminAPI("POST /api/racks/iface", s.putRackIface) diff --git a/internal/api/syslog.go b/internal/api/syslog.go new file mode 100644 index 0000000..297ac53 --- /dev/null +++ b/internal/api/syslog.go @@ -0,0 +1,95 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "path/filepath" + + "github.com/githubflyideas/traffic66/internal/license" + "github.com/githubflyideas/traffic66/internal/syslogout" +) + +// The syslog feed, set from the findings page: where findings are sent, +// in which format, and how it is going. Sending is for registered +// installations; reading the settings is not, so the page can show what +// would be sent and why it is not. + +// syslogFile is where the settings are kept. +func (s *Server) syslogFile() string { return filepath.Join(s.DataDir, "syslog.json") } + +func (s *Server) syslogAllowed() bool { + return s.License != nil && s.License.State().Allowed(license.FeatSyslog) +} + +func (s *Server) getSyslog(w http.ResponseWriter, r *http.Request) { + if s.Syslog == nil { + writeJSON(w, http.StatusOK, map[string]any{"available": false}) + return + } + cfg := s.Syslog.Config() + cfg.CAFile = filepath.Base(cfg.CAFile) // the name, not where it lives + if cfg.CAFile == "." { + cfg.CAFile = "" + } + writeJSON(w, http.StatusOK, map[string]any{ + "available": true, "registered": s.syslogAllowed(), "admin": s.isAdmin(r), + "config": cfg, "stats": s.Syslog.Stats(), "formats": syslogout.Formats, + }) +} + +func (s *Server) putSyslog(w http.ResponseWriter, r *http.Request) { + if s.Syslog == nil { + fail(w, errors.New("syslog is not available here")) + return + } + var in struct { + Enabled bool `json:"enabled"` + Address string `json:"address"` + Network string `json:"network"` + Format syslogout.Format `json:"format"` + MinSev int `json:"min_sev"` + ASCII bool `json:"ascii"` + Insecure bool `json:"insecure"` + BaseURL string `json:"base_url"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil { + fail(w, errors.New("bad request")) + return + } + if in.Enabled && !s.allowed(w, license.FeatSyslog) { + return + } + // what the page does not set (facility, ceiling, a CA file given on + // the command line) stays as it was + cfg := s.Syslog.Config() + cfg.Enabled, cfg.Address, cfg.Network, cfg.Format = in.Enabled, clip(in.Address, 255), in.Network, in.Format + cfg.MinSev, cfg.ASCII, cfg.Insecure, cfg.BaseURL = in.MinSev, in.ASCII, in.Insecure, clip(in.BaseURL, 255) + if err := cfg.Check(); err != nil { + fail(w, err) + return + } + if s.DataDir != "" { + if err := syslogout.SaveConfig(s.syslogFile(), cfg); err != nil { + fail(w, err) + return + } + } + s.Syslog.SetConfig(cfg) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) testSyslog(w http.ResponseWriter, r *http.Request) { + if s.Syslog == nil { + fail(w, errors.New("syslog is not available here")) + return + } + if !s.allowed(w, license.FeatSyslog) { + return + } + if err := s.Syslog.Test(); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/internal/api/syslog_test.go b/internal/api/syslog_test.go new file mode 100644 index 0000000..1fe5af5 --- /dev/null +++ b/internal/api/syslog_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "encoding/json" + "net" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/githubflyideas/traffic66/internal/license" + "github.com/githubflyideas/traffic66/internal/syslogout" +) + +// The feed is set from the findings page and in force at once; turning it +// on is for registered installations, reading or turning it off is not. +func TestSyslogSettings(t *testing.T) { + dir := t.TempDir() + c, priv := signed(t, dir, license.File{}) + s := &Server{License: c, DataDir: dir, Syslog: syslogout.New(syslogout.Config{})} + + udp, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer udp.Close() + on := `{"enabled":true,"address":"` + udp.LocalAddr().String() + `","network":"udp","format":"json","min_sev":2}` + post := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + s.putSyslog(w, httptest.NewRequest("POST", "/api/syslog", strings.NewReader(body))) + return w + } + test := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + s.testSyslog(w, httptest.NewRequest("POST", "/api/syslog/test", nil)) + return w + } + + if w := post(on); w.Code != 403 || !strings.Contains(w.Body.String(), "not_registered") { + t.Fatalf("unregistered, switching on: %d %s", w.Code, w.Body) + } + if w := post(`{"enabled":false,"address":"","network":"udp","format":"cef","min_sev":1}`); w.Code != 200 { + t.Fatalf("unregistered, switching off: %d %s", w.Code, w.Body) + } + if w := test(); w.Code != 403 { + t.Fatalf("unregistered test: %d", w.Code) + } + + install(t, s, sign(priv, license.File{Customer: "Example KK", Product: "traffic66", + License: license.TypeSubscription, Tier: license.TierStandard, MaxUsers: 25, Features: []string{license.FeatSyslog}})) + if w := post(`{"enabled":true,"address":"no port","network":"udp","format":"json","min_sev":1}`); w.Code != 400 { + t.Fatalf("an address without a port was accepted: %d", w.Code) + } + if w := post(on); w.Code != 200 { + t.Fatalf("registered, switching on: %d %s", w.Code, w.Body) + } + if w := test(); w.Code != 200 { + t.Fatalf("test: %d %s", w.Code, w.Body) + } + buf := make([]byte, 4096) + udp.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, _, err := udp.ReadFrom(buf) + if err != nil || !strings.Contains(string(buf[:n]), "test") { + t.Fatalf("the collector got %q, %v", buf[:n], err) + } + + // kept for the next start, and shown with how the feed is going + saved, ok, err := syslogout.LoadConfig(s.syslogFile()) + if err != nil || !ok || saved.Format != "json" || saved.MinSev != 2 { + t.Fatalf("saved %+v %v %v", saved, ok, err) + } + w := httptest.NewRecorder() + s.getSyslog(w, httptest.NewRequest("GET", "/api/syslog", nil)) + var out struct { + Registered bool `json:"registered"` + Stats syslogout.Stats `json:"stats"` + Config syslogout.Config `json:"config"` + } + json.Unmarshal(w.Body.Bytes(), &out) + if !out.Registered || out.Stats.Sent != 1 || out.Config.Address != udp.LocalAddr().String() { + t.Fatalf("status %s", w.Body) + } +} diff --git a/internal/embed/embed.go b/internal/embed/embed.go new file mode 100644 index 0000000..97078e0 --- /dev/null +++ b/internal/embed/embed.go @@ -0,0 +1,424 @@ +// Package embed keeps the links other platforms use to show one address's +// traffic: an asset platform puts a "traffic" button beside each of its +// service addresses (VIPs), and the button opens, or frames, a page here +// with that address's bandwidth and nothing else. +// +// One key covers a whole platform, so its links are built by putting the +// address into a fixed URL rather than by keeping one secret per address. +// What keeps that safe is everything around the key: it opens only the +// addresses in its ranges, only from the sites it names, optionally only +// for viewers on given networks, and only so many times a minute. It is +// stored as a hash, shown once when it is made, and can be replaced (the +// old one keeps working for a day, while the platform changes over) or +// withdrawn at once. +package embed + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/netip" + "net/url" + "os" + "slices" + "strings" + "sync" + "time" +) + +const ( + // KeyPrefix starts every key, so one pasted somewhere it should not be + // is recognisable for what it is. + KeyPrefix = "t66e_" + // Grace is how long a replaced key keeps working. + Grace = 24 * time.Hour + // PerMinute is how many requests one key may make in a minute from + // one viewer: enough for a page that redraws every minute to be open + // in several tabs, and nowhere near enough to read the data out fast. + PerMinute = 60 + // MaxSources keeps the file a size a person can read. + MaxSources = 200 +) + +// Errors a request can be refused with. The page says which, so whoever +// set the platform up can tell a typo from a block. +var ( + ErrKey = errors.New("unknown or withdrawn key") + ErrScope = errors.New("this address is not one the key may show") + ErrOrigin = errors.New("not opened from a site the key allows") + ErrViewer = errors.New("not opened from a network the key allows") + ErrRate = errors.New("too many requests; try again in a minute") + ErrUnknown = errors.New("no such link") +) + +// Source is one platform allowed to show traffic. +type Source struct { + ID string `json:"id"` + Name string `json:"name"` + Ranges []string `json:"ranges"` // addresses and prefixes it may show + Origins []string `json:"origins"` // sites it may be opened or framed from; "*" for any + Clients []string `json:"clients,omitempty"` // viewer networks; empty for any + + Prefix string `json:"prefix"` // the start of the key, to tell keys apart + Hash string `json:"hash"` + OldHash string `json:"old_hash,omitempty"` + OldUntil time.Time `json:"old_until,omitzero"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"last_used,omitzero"` + Hits int64 `json:"hits"` +} + +// Store is the list of sources, kept in one file. +type Store struct { + mu sync.Mutex + path string + list []*Source + saved time.Time + rate map[string]*window + now func() time.Time +} + +type window struct { + min time.Time + n int +} + +// Open reads the list; a missing file is an empty one. +func Open(path string) (*Store, error) { + s := &Store{path: path, rate: map[string]*window{}, now: time.Now} + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(b, &s.list); err != nil { + return nil, fmt.Errorf("embed: %s: %w", path, err) + } + return s, nil +} + +// List returns the sources without their key hashes. +func (s *Store) List() []Source { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Source, 0, len(s.list)) + for _, x := range s.list { + c := *x + c.Hash, c.OldHash = "", "" + if !c.OldUntil.After(s.now()) { + c.OldUntil = time.Time{} + } + out = append(out, c) + } + return out +} + +// Put adds a source, or changes the name and limits of one. A new source +// gets a key, returned this once; changing one keeps its key. +func (s *Store) Put(in Source) (key string, err error) { + if err := in.clean(); err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + if in.ID == "" { + if len(s.list) >= MaxSources { + return "", fmt.Errorf("at most %d links", MaxSources) + } + key = newKey() + x := &Source{ID: randHex(6), Name: in.Name, Ranges: in.Ranges, Origins: in.Origins, Clients: in.Clients, + Prefix: key[:len(KeyPrefix)+4], Hash: hash(key), Created: s.now().UTC()} + s.list = append(s.list, x) + return key, s.save() + } + x := s.find(in.ID) + if x == nil { + return "", ErrUnknown + } + x.Name, x.Ranges, x.Origins, x.Clients = in.Name, in.Ranges, in.Origins, in.Clients + return "", s.save() +} + +// Rotate gives a source a new key. The old one works for Grace more, so +// the platform can change over without its buttons failing meanwhile. +func (s *Store) Rotate(id string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + x := s.find(id) + if x == nil { + return "", ErrUnknown + } + key := newKey() + x.OldHash, x.OldUntil = x.Hash, s.now().UTC().Add(Grace) + x.Hash, x.Prefix = hash(key), key[:len(KeyPrefix)+4] + return key, s.save() +} + +// Delete withdraws a source: its key stops working at once. +func (s *Store) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + i := slices.IndexFunc(s.list, func(x *Source) bool { return x.ID == id }) + if i < 0 { + return ErrUnknown + } + s.list = slices.Delete(s.list, i, i+1) + return s.save() +} + +// Request is what one page or data request carries. +type Request struct { + Key string + Addr netip.Addr // the address to show + Viewer netip.Addr // who is asking + Referer string // the page it was opened from; checked when set to + Page bool // the page itself, which is what a site opens or frames +} + +// Check decides one request, and counts it. The page is checked against +// the sites the key allows; the data it then fetches comes from this +// server's own page, so for that the key, the address and the viewer are +// what is checked. +func (s *Store) Check(r Request) (Source, error) { + h := hash(r.Key) + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + var x *Source + for _, c := range s.list { + if eq(c.Hash, h) || (c.OldHash != "" && now.Before(c.OldUntil) && eq(c.OldHash, h)) { + x = c + } + } + if x == nil || !strings.HasPrefix(r.Key, KeyPrefix) { + return Source{}, ErrKey + } + if !s.allow(x.ID+"|"+r.Viewer.String(), now) { + return Source{}, ErrRate + } + if !inAny(r.Addr, x.Ranges) { + return Source{}, ErrScope + } + if len(x.Clients) > 0 && !inAny(r.Viewer, x.Clients) { + return Source{}, ErrViewer + } + if r.Page && !originOK(r.Referer, x.Origins) { + return Source{}, ErrOrigin + } + x.Hits++ + x.LastUsed = now.UTC() + // counts are kept, but not by writing the file at every request + if now.Sub(s.saved) > time.Minute { + s.save() + } + c := *x + c.Hash, c.OldHash = "", "" + return c, nil +} + +// Flush writes the counts kept since the last save. +func (s *Store) Flush() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.save() +} + +func (s *Store) allow(who string, now time.Time) bool { + m := now.Truncate(time.Minute) + if len(s.rate) > 10000 { + for k, w := range s.rate { + if w.min != m { + delete(s.rate, k) + } + } + } + w := s.rate[who] + if w == nil || w.min != m { + w = &window{min: m} + s.rate[who] = w + } + if w.n >= PerMinute { + return false + } + w.n++ + return true +} + +func (s *Store) find(id string) *Source { + for _, x := range s.list { + if x.ID == id { + return x + } + } + return nil +} + +func (s *Store) save() error { + s.saved = s.now() + if s.path == "" { + return nil + } + b, err := json.MarshalIndent(s.list, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// clean checks a source as typed into the settings page and puts it in +// one canonical form. +func (in *Source) clean() error { + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" || len([]rune(in.Name)) > 80 { + return errors.New("a name of up to 80 characters is needed") + } + var err error + if in.Ranges, err = prefixes(in.Ranges); err != nil { + return err + } + if len(in.Ranges) == 0 { + return errors.New("at least one address or range to show is needed") + } + if in.Clients, err = prefixes(in.Clients); err != nil { + return err + } + var origins []string + for _, o := range in.Origins { + o = strings.ToLower(strings.TrimSpace(o)) + if o == "" { + continue + } + // a pasted URL is taken for its host + if u, err := url.Parse(o); err == nil && u.Host != "" { + o = u.Hostname() + } + if o != "*" && !validHost(strings.TrimPrefix(o, "*.")) { + return fmt.Errorf("%q is not a site name", o) + } + if !slices.Contains(origins, o) { + origins = append(origins, o) + } + } + if len(origins) == 0 { + return errors.New("at least one site the link may be opened from is needed, or * for any") + } + in.Origins = origins + return nil +} + +func prefixes(ss []string) ([]string, error) { + var out []string + for _, x := range ss { + x = strings.TrimSpace(x) + if x == "" { + continue + } + p, err := netip.ParsePrefix(x) + if err != nil { + a, aerr := netip.ParseAddr(x) + if aerr != nil { + return nil, fmt.Errorf("%q is neither an address nor a range", x) + } + p = netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()) + } + s := p.Masked().String() + if !slices.Contains(out, s) { + out = append(out, s) + } + } + if len(out) > 1000 { + return nil, errors.New("at most 1000 ranges") + } + return out, nil +} + +func inAny(a netip.Addr, ranges []string) bool { + if !a.IsValid() { + return false + } + a = a.Unmap() + for _, r := range ranges { + if p, err := netip.ParsePrefix(r); err == nil && p.Contains(a) { + return true + } + } + return false +} + +// originOK: the page was opened from, or framed by, one of the sites. +// "*.example.com" covers example.com's subdomains. +func originOK(referer string, origins []string) bool { + if slices.Contains(origins, "*") { + return true + } + u, err := url.Parse(referer) + if err != nil || u.Host == "" { + return false + } + h := strings.ToLower(u.Hostname()) + for _, o := range origins { + if h == o || (strings.HasPrefix(o, "*.") && strings.HasSuffix(h, o[1:])) { + return true + } + } + return false +} + +// FrameAncestors is the Content-Security-Policy value that lets only the +// source's sites frame the page. +func FrameAncestors(origins []string) string { + if slices.Contains(origins, "*") { + return "frame-ancestors *" + } + return "frame-ancestors " + strings.Join(origins, " ") +} + +func validHost(h string) bool { + if h == "" || len(h) > 253 { + return false + } + if _, err := netip.ParseAddr(h); err == nil { + return true + } + for _, l := range strings.Split(h, ".") { + if l == "" || len(l) > 63 { + return false + } + for _, c := range l { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + } + return true +} + +func newKey() string { + b := make([]byte, 24) + rand.Read(b) + return KeyPrefix + base64.RawURLEncoding.EncodeToString(b) +} + +func randHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +func hash(key string) string { + h := sha256.Sum256([]byte(key)) + return hex.EncodeToString(h[:]) +} + +func eq(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } diff --git a/internal/embed/embed_test.go b/internal/embed/embed_test.go new file mode 100644 index 0000000..e8bfa53 --- /dev/null +++ b/internal/embed/embed_test.go @@ -0,0 +1,113 @@ +package embed + +import ( + "net/netip" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLinks(t *testing.T) { + path := filepath.Join(t.TempDir(), "embeds.json") + s, _ := Open(path) + now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) + s.now = func() time.Time { return now } + + key, err := s.Put(Source{Name: "Asset platform", Ranges: []string{"203.0.113.0/24", "2001:db8::10"}, + Origins: []string{"https://assets.example.com/vips", "*.portal.example.net"}, Clients: nil}) + if err != nil || !strings.HasPrefix(key, KeyPrefix) { + t.Fatalf("put: %q %v", key, err) + } + vip, from := netip.MustParseAddr("203.0.113.10"), "https://assets.example.com/vip/42" + ok := func(r Request) error { _, err := s.Check(r); return err } + req := Request{Key: key, Addr: vip, Viewer: netip.MustParseAddr("192.0.2.1"), Referer: from, Page: true} + if err := ok(req); err != nil { + t.Fatalf("the platform's own link: %v", err) + } + for name, c := range map[string]struct { + r Request + want error + }{ + "another address": {Request{Key: key, Addr: netip.MustParseAddr("198.51.100.1"), Referer: from, Page: true}, ErrScope}, + "another site": {Request{Key: key, Addr: vip, Referer: "https://evil.example.org/", Page: true}, ErrOrigin}, + "no referer": {Request{Key: key, Addr: vip, Page: true}, ErrOrigin}, + "a made-up key": {Request{Key: KeyPrefix + "guess", Addr: vip, Referer: from, Page: true}, ErrKey}, + "subdomain allowed": {Request{Key: key, Addr: vip, Referer: "https://a.portal.example.net/x", Page: true}, nil}, + "IPv6 single": {Request{Key: key, Addr: netip.MustParseAddr("2001:db8::10"), Referer: from, Page: true}, nil}, + "data, not the page": {Request{Key: key, Addr: vip}, nil}, + } { + if err := ok(c.r); err != c.want { + t.Errorf("%s: %v, want %v", name, err, c.want) + } + } + if list := s.List(); len(list) != 1 || list[0].Hash != "" || list[0].Hits == 0 { + t.Fatalf("list %+v", list) + } + + // viewers limited to the platform's office + id := s.List()[0].ID + if _, err := s.Put(Source{ID: id, Name: "Asset platform", Ranges: []string{"203.0.113.0/24"}, Origins: []string{"*"}, Clients: []string{"192.0.2.0/24"}}); err != nil { + t.Fatal(err) + } + if err := ok(Request{Key: key, Addr: vip, Viewer: netip.MustParseAddr("198.51.100.9")}); err != ErrViewer { + t.Errorf("a viewer outside: %v", err) + } + + // a new key: the old one keeps working for a day, then stops + key2, err := s.Rotate(id) + if err != nil { + t.Fatal(err) + } + req = Request{Key: key, Addr: vip, Viewer: netip.MustParseAddr("192.0.2.1"), Page: true} + if err := ok(req); err != nil { + t.Errorf("old key during the changeover: %v", err) + } + now = now.Add(Grace + time.Minute) + if err := ok(req); err != ErrKey { + t.Errorf("old key after the changeover: %v", err) + } + req.Key = key2 + if err := ok(req); err != nil { + t.Errorf("new key: %v", err) + } + + // so many a minute, then a pause + var last error + for range PerMinute + 1 { + last = ok(req) + } + if last != ErrRate { + t.Errorf("over the ceiling: %v", last) + } + + // kept on disk without the key itself + again, err := Open(path) + if err != nil || len(again.list) != 1 || again.list[0].Hash == "" || strings.Contains(again.list[0].Hash, key2) { + t.Fatalf("reopened %+v %v", again.list, err) + } + + // withdrawn: stops at once + if err := s.Delete(id); err != nil { + t.Fatal(err) + } + now = now.Add(time.Minute) + if err := ok(req); err != ErrKey { + t.Errorf("withdrawn: %v", err) + } +} + +func TestSourceChecks(t *testing.T) { + s, _ := Open("") + for _, bad := range []Source{ + {Name: "", Ranges: []string{"10.0.0.1"}, Origins: []string{"*"}}, + {Name: "x", Origins: []string{"*"}}, + {Name: "x", Ranges: []string{"not an address"}, Origins: []string{"*"}}, + {Name: "x", Ranges: []string{"10.0.0.1"}}, + {Name: "x", Ranges: []string{"10.0.0.1"}, Origins: []string{"bad host!"}}, + } { + if _, err := s.Put(bad); err == nil { + t.Errorf("accepted %+v", bad) + } + } +} diff --git a/internal/store/bytesdir_test.go b/internal/store/bytesdir_test.go index b86054a..4fc8a06 100644 --- a/internal/store/bytesdir_test.go +++ b/internal/store/bytesdir_test.go @@ -43,3 +43,41 @@ func TestBytesDirection(t *testing.T) { t.Error("a bad byte direction was accepted") } } + +// A data centre whose service addresses are public and not listed as its +// own networks: every flow is transit by address, so only the interface +// can say which way it went. In through the uplink is inbound, out +// through it is outbound, the same split as the interface's own chart. +func TestInterfaceDirection(t *testing.T) { + s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ts := time.Now().UTC().Add(-10 * time.Minute) + user, vip := "198.51.100.7", "203.0.113.10" + base := Row{TS: ts, Client: user, Server: vip, Cli4: 0xC6336407, Srv4: 0xCB00710A, Dir: DirTransit, Pkts: 1, Flows: 1, Exporter: "10.0.0.1"} + req, resp := base, base + req.Src, req.Dst, req.Wire, req.InIf, req.OutIf = user, vip, 1000, 7, 3 // in through the uplink 7 + resp.Src, resp.Dst, resp.Wire, resp.InIf, resp.OutIf = vip, user, 9000, 3, 7 // back out through it + resp.Dup = true // and seen by a second device too + if err := s.AppendRows([]Row{req, resp}); err != nil { + t.Fatal(err) + } + q := func(f ...Filter) uint64 { + tot, err := s.Totals(Query{From: ts.Add(-time.Minute), To: ts.Add(time.Minute), Filters: f}) + if err != nil { + t.Fatal(err) + } + return tot.Wire + } + if got := q(Filter{Field: "bytes", Value: "in"}); got != 0 { + t.Errorf("bytes in = %d: by address nothing is inside", got) + } + if got := q(Filter{Field: "iface_in", Value: "10.0.0.1/7"}); got != 1000 { + t.Errorf("in through the uplink = %d, want 1000", got) + } + if got := q(Filter{Field: "iface_out", Value: "10.0.0.1/7"}); got != 9000 { + t.Errorf("out through the uplink = %d, want 9000 (its own duplicate counts)", got) + } +} diff --git a/internal/store/query.go b/internal/store/query.go index 50466f9..fc3e0f4 100644 --- a/internal/store/query.go +++ b/internal/store/query.go @@ -92,7 +92,7 @@ func v4range(p netip.Prefix) (uint32, uint32, bool) { // device they are its own traffic and count. func (q Query) oneDevice() bool { for _, f := range q.Filters { - if !f.Neg && (f.Field == "exporter" || f.Field == "iface") { + if !f.Neg && (f.Field == "exporter" || strings.HasPrefix(f.Field, "iface")) { return true } } @@ -133,6 +133,15 @@ func (q Query) where() (string, []any, error) { } } c = "(" + strings.Join(parts, " OR ") + ")" + case "src", "dst": + // one address as the sender, or as the receiver, of the bytes: + // what went to an address and what came from it + a, err := netip.ParseAddr(v) + if err != nil { + return "", nil, fmt.Errorf("bad address %q", v) + } + c = f.Field + " = ?" + args = append(args, a.Unmap().String()) case "port": port := v if i := strings.IndexByte(v, '/'); i > 0 { @@ -226,8 +235,9 @@ func (q Query) where() (string, []any, error) { } c = "vlan = ?" args = append(args, n) - case "iface": - // exporter/ifindex, either direction + case "iface", "iface_in", "iface_out": + // exporter/ifindex: either direction, or only what came in + // through it, or only what went out through it i := strings.LastIndexByte(v, '/') if i <= 0 { return "", nil, fmt.Errorf("bad interface %q", v) @@ -236,8 +246,17 @@ func (q Query) where() (string, []any, error) { if err != nil { return "", nil, fmt.Errorf("bad interface %q", v) } - c = "(exporter = ? AND (in_if = ? OR out_if = ?))" - args = append(args, v[:i], n, n) + switch f.Field { + case "iface_in": + c = "(exporter = ? AND in_if = ?)" + args = append(args, v[:i], n) + case "iface_out": + c = "(exporter = ? AND out_if = ?)" + args = append(args, v[:i], n) + default: + c = "(exporter = ? AND (in_if = ? OR out_if = ?))" + args = append(args, v[:i], n, n) + } default: return "", nil, fmt.Errorf("unknown filter field %q", f.Field) } diff --git a/internal/syslogout/sender.go b/internal/syslogout/sender.go index 2a3cbba..e76f555 100644 --- a/internal/syslogout/sender.go +++ b/internal/syslogout/sender.go @@ -3,6 +3,7 @@ package syslogout import ( "crypto/tls" "crypto/x509" + "encoding/json" "errors" "fmt" "log" @@ -93,6 +94,42 @@ type Sender struct { minute time.Time sent int dropped int + + st Stats +} + +// Stats is what the settings page shows about the feed: whether messages +// are going out, and if not, why. +type Stats struct { + Sent int64 `json:"sent"` + Failed int64 `json:"failed"` + Dropped int `json:"dropped"` + LastSent time.Time `json:"last_sent,omitzero"` + LastError string `json:"last_error,omitempty"` + LastErrAt time.Time `json:"last_error_at,omitzero"` +} + +// Stats returns the counts since this process started. +func (s *Sender) Stats() Stats { + s.mu.Lock() + defer s.mu.Unlock() + st := s.st + st.Dropped = s.dropped + return st +} + +// note records how one message went. +func (s *Sender) note(err error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + if err != nil { + s.st.Failed++ + s.st.LastError, s.st.LastErrAt = err.Error(), now + return + } + s.st.Sent++ + s.st.LastSent = now } // New prepares a sender. It does not connect: a collector that is down @@ -148,7 +185,9 @@ func (s *Sender) Send(e Event) { } s.mu.Unlock() - if err := s.write(Message(cfg, e, time.Now())); err != nil { + err := s.write(Message(cfg, e, time.Now())) + s.note(err) + if err != nil { log.Printf("syslog: %v", err) } } @@ -169,7 +208,9 @@ func (s *Sender) Test() error { First: now, Last: now, Extra: map[string]string{}, } e.Text = Describe(e) - return s.write(Message(cfg, e, now)) + err := s.write(Message(cfg, e, now)) + s.note(err) + return err } // allow is the ceiling: a flood of findings must not become a flood of @@ -268,3 +309,32 @@ func title(s string) string { } return strings.ToUpper(s[:1]) + s[1:] } + +// LoadConfig reads the configuration saved from the settings page. ok is +// false when nothing has been saved yet. +func LoadConfig(path string) (cfg Config, ok bool, err error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Config{}, false, nil + } + if err != nil { + return Config{}, false, err + } + if err := json.Unmarshal(b, &cfg); err != nil { + return Config{}, false, fmt.Errorf("syslog: %s: %w", path, err) + } + return cfg, true, nil +} + +// SaveConfig writes the configuration so it outlives a restart. +func SaveConfig(path string, cfg Config) error { + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 0238b4f..a05af34 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -343,32 +343,53 @@ label.btn.disabled{opacity:.5;cursor:not-allowed!important} out to the carrier and circuit it goes to. */ .thrimp{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px} .thrimp input{border:1px solid var(--line-2);border-radius:6px;padding:6px 8px;font:inherit;background:var(--surface);color:var(--ink);min-width:220px} -.rkhint{margin:0 0 14px;font-size:13px;color:var(--ink-3)} -.rkroom{border:1px solid var(--line-2);border-radius:10px;padding:12px 14px 14px;margin-bottom:14px;background:var(--surface-2)} -.rkroom>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px} +.rksum{margin:0 0 14px;font-size:13px;color:var(--ink-2)} +.rkroom{margin-bottom:18px} +.rkroom>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid var(--line)} .rkroom>h3 .ni{opacity:.7} -.rkroom.unplaced{border-style:dashed;background:transparent} -.rkracks{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start} -.rkrack{flex:1 1 360px;max-width:560px;border:2px solid var(--ink-3);border-radius:6px;background:var(--surface);overflow:hidden} -.rkrh{background:var(--ink-3);color:var(--surface);font-size:12.5px;font-weight:600;padding:4px 10px;letter-spacing:.02em} -.rkdev{margin:8px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface)} -.rkdh{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);font-size:13.5px} -.rkdh .mono{color:var(--ink-3)} -.rkif{display:flex;align-items:center;gap:8px;padding:5px 10px;font-size:13px;min-width:0} -.rkif+.rkif{border-top:1px dashed var(--line)} -.rkport{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.rkport .muted{font-size:12px} -.rkwire{flex:1 1 24px;min-width:24px;border-top:2px dotted var(--line-2);align-self:center} -.rkisp{flex:0 0 auto;max-width:60%;padding:2px 9px;border-radius:999px;background:var(--accent);color:#fff;font-size:12.5px; - white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.rkisp .mono{font-size:11.5px;opacity:.9;color:#fff} -.rkisp.none{background:transparent;color:var(--ink-3);border:1px dashed var(--line-2)} -.rkedit{margin-inline-start:auto;flex:none;background:none;border:0;padding:0 2px;color:var(--ink-3);cursor:pointer;font-size:13px} -.rkdh .rkedit{margin-inline-start:auto} -.rkif .rkedit{margin-inline-start:0} -.rkedit:hover{color:var(--accent)} -.rkmore{padding:2px 10px 6px;font-size:12.5px} +.rkroom>h3 .muted{font-size:12.5px;font-weight:400;margin-inline-start:4px} +.rkroom.unplaced>h3{color:var(--ink-2)} +.rkracks{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:14px;align-items:start} +.rkrack{border:1px solid var(--line-2);border-radius:12px;background:var(--surface-2);overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.08)} +.rkrh{display:flex;align-items:center;gap:8px;padding:7px 12px;font-size:12.5px;font-weight:600;letter-spacing:.03em;color:var(--ink-2); + border-bottom:1px solid var(--line-2);background:var(--surface)} +.rkrh::before{content:"";width:10px;height:12px;border:2px solid currentColor;border-radius:2px;opacity:.6} +/* the rack's two mounting rails, as a row of holes down each side */ +.rkbay{position:relative;padding:10px 18px;display:flex;flex-direction:column;gap:10px} +.rkbay::before,.rkbay::after{content:"";position:absolute;top:6px;bottom:6px;width:6px; + background:radial-gradient(circle,var(--line-2) 1.4px,transparent 1.8px) 0 0/6px 9px repeat-y} +.rkbay::before{left:6px}.rkbay::after{right:6px} +.rkdev{border:1px solid var(--line-2);border-radius:9px;background:var(--surface);overflow:hidden} +.rkfp{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--line); + background:linear-gradient(180deg,var(--surface-2),var(--surface))} +.rkled{flex:none;width:8px;height:8px;border-radius:50%;background:var(--good);box-shadow:0 0 6px var(--good)} +.rkled.off{background:var(--ink-3);box-shadow:none} +.rkdn{display:flex;flex-direction:column;min-width:0;line-height:1.25} +.rkdn b{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rkdn .mono{font-size:11.5px;color:var(--ink-3)} +.rkfp .rkedit{margin-inline-start:auto;flex:none;padding:3px 12px;font-size:12.5px} +.rkports{padding:2px 0} +.rkif{display:grid;grid-template-columns:minmax(0,1.3fr) auto minmax(0,1fr);align-items:center;gap:10px;padding:6px 12px;font-size:13px} +.rkif+.rkif,.rkmore{border-top:1px solid var(--line)} +.rkport{display:flex;align-items:baseline;gap:6px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rkport b{font-weight:600;overflow:hidden;text-overflow:ellipsis} +.rkport .muted{font-size:11.5px} +.rkisp{justify-self:start;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap; + background:hsla(var(--h),65%,50%,.16);border:1px solid hsla(var(--h),65%,50%,.55);color:var(--ink)} +.rkisp.none{background:transparent;border:1px dashed var(--line-2);color:var(--ink-3);font-weight:400} +.rkcirc{font-size:12px;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rkmore{padding:4px 12px 6px;font-size:12.5px} .rkmore summary{cursor:pointer;color:var(--ink-2)} +.rkmore .rkif{padding-inline:0} +dialog.rkdlg{width:min(860px,96vw)} +dialog.rkdlg h2 .mono{font-size:12.5px;font-weight:400;margin-inline-start:6px} +.aform.rkdform{grid-template-columns:2fr 1fr 1fr;margin-bottom:14px} +.rkptab{max-height:52vh;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:12px} +.rkptab table{width:100%} +.rkptab th{position:sticky;top:0;background:var(--surface);z-index:1} +.rkptab td{padding:4px 6px} +.rkptab input{width:100%;font:inherit;font-size:13px;padding:4px 7px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface);color:var(--ink)} +@media (max-width:600px){.rkif{grid-template-columns:1fr auto}.rkcirc{grid-column:1/-1}.aform.rkdform{grid-template-columns:1fr}} .lnk{background:none;border:0;padding:0;color:var(--accent-ink);cursor:pointer;font:inherit;text-align:start} .lnk:hover{text-decoration:underline} .mono{font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;font-size:12.5px} @@ -542,3 +563,27 @@ body.marked .view.on::after{ .manual table { border-collapse: collapse; } .manual th, .manual td { border: 1px solid var(--line, rgba(127,127,127,.25)); padding: 4px 8px; text-align: start; vertical-align: top; } @media (max-width: 900px) { .help { grid-template-columns: 1fr; } .helptoc { position: static; max-height: none; flex-flow: row wrap; } } + +/* the syslog feed, under the findings */ +.slform{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 14px;align-items:end;margin-bottom:12px} +.slform .slchk{grid-column:1/-1} +.slform .slwide{grid-column:span 2} +.slchk{display:flex;align-items:center;gap:8px;font-size:13.5px} +.slstate{margin-inline-start:auto;padding:2px 10px;border-radius:999px;font-size:12px;border:1px solid var(--line-2);color:var(--ink-3)} +.slstate.on{border-color:var(--good);color:var(--good)} +.slmsg{align-self:center;font-size:13px}.slmsg.ok{color:var(--good)}.slmsg.bad{color:var(--crit)} +.slstats{margin:10px 0 0;font-size:12.5px;color:var(--ink-2)} +.slerr{color:var(--crit)} +@media (max-width:760px){.slform{grid-template-columns:1fr 1fr}.slform .slwide{grid-column:1/-1}} + +/* links for other platforms, on the settings page */ +.embt td{vertical-align:top} +.embt code{font-size:12px} +.embnote{margin:10px 0 0;font-size:12.5px;color:var(--ink-3)} +.embkey{border:1px solid var(--good);border-radius:10px;padding:12px 14px;margin:0 0 14px;display:grid;gap:6px;background:var(--surface-2)} +.embkey label{font-size:12px;color:var(--ink-2);margin-top:4px} +.embcopy{display:flex;gap:8px;align-items:flex-start} +.embcopy code{flex:1;min-width:0;word-break:break-all;font-size:12px;padding:6px 8px;border-radius:6px;background:var(--surface);border:1px solid var(--line)} +dialog.embdlg{width:min(620px,96vw)} +.embdlg textarea{font:inherit;font-size:13px;font-family:ui-monospace,monospace;padding:5px 7px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface);color:var(--ink);resize:vertical} +.afhint{font-size:11.5px;color:var(--ink-3)} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index a3e9f37..ce62f7a 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -169,7 +169,7 @@ async function loadLang(code) { } // the theme button shows the current theme; a click goes to the next one function themeLabel() { - const b = $('#theme'), cur = window.t66Theme ? t66Theme.get() : 'light'; + const b = $('#theme'), cur = window.t66Theme ? t66Theme.get() : 'black'; b.querySelector('span').textContent = t('theme.' + cur); b.title = t('theme.label') + ': ' + t('theme.' + cur) + ' · ' + t('theme.next'); b.setAttribute('aria-label', b.title); @@ -515,7 +515,7 @@ function axisTicks(from, to, max, span) { // dataFrom is when the stored data begins: the buckets that end before it // are left out (a gap), not drawn as traffic of zero. Lines marked keep // (a baseline from another day, device counters) are not cut. -function tsChart(el, {times, areas = [], lines = [], fmtY = fmtAxisBps, fmtV = fmtBps, settledTo = 0, dataFrom = 0}) { +function tsChart(el, {times, areas = [], lines = [], fmtY = fmtAxisBps, fmtV = fmtBps, settledTo = 0, dataFrom = 0, empty = ''}) { const stepMs = times && times.length > 1 ? times[1] - times[0] : 0; if (dataFrom && times) { const cut = d => (d || []).map((v, i) => times[i] + stepMs <= dataFrom ? null : v); @@ -526,7 +526,7 @@ function tsChart(el, {times, areas = [], lines = [], fmtY = fmtAxisBps, fmtV = f const W = el.clientWidth, H = el.clientHeight; if (W < 120 || H < 60 || !times || !times.length) { el.innerHTML = ''; return; } // nothing at all to draw: say so instead of an axis from 0 to 1 const has = d => (d || []).some(v => v != null && v >= 0); - if (!areas.some(a => has(a.data)) && !lines.some(l => has(l.data))) { el.innerHTML = `
${emptyText()}
`; return; } + if (!areas.some(a => has(a.data)) && !lines.some(l => has(l.data))) { el.innerHTML = `
${empty || emptyText()}
`; return; } const rtl = document.documentElement.dir === 'rtl'; const L = rtl ? 8 : 50, R = rtl ? 50 : 8, T = 8, B = 22, n = times.length; const x = i => rtl ? W - R - (W - L - R) * i / Math.max(1, n - 1) : L + (W - L - R) * i / Math.max(1, n - 1); @@ -740,7 +740,9 @@ const FBAR_VIEWS = new Set(['topn', 'traffic']); function renderFbar() { const bar = $('#fbar'), on = FBAR_VIEWS.has(state.v); bar.hidden = !on; - $('#filters').hidden = !on && !state.f.length; + // the pages that are not about the traffic (settings, the inventory, + // rooms and racks, cleanup...) are not filtered, so they show no chips + $('#filters').hidden = NOBAR_VIEWS.has(state.v) || (!on && !state.f.length); if (bar.hidden) return; bar.innerHTML = FBAR.map(([f, lab]) => { const cur = state.f.find(x => x.f === f && !x.neg); @@ -936,17 +938,23 @@ views.help = async (el) => { el.querySelectorAll('a[data-hlang]').forEach(a => a.onclick = e => { e.preventDefault(); helpLang = a.dataset.hlang; render(); window.scrollTo(0, 0); }); }; views.overview = async (el) => { - // total, or only the bytes that came in or only those that went out, - // from your networks' point of view — which way the bytes went, not who - // opened the connection: a network whose hosts only connect outwards - // still downloads, and those replies are its inbound traffic + // total, or only what came in or only what went out. Which way is read + // off the interface charted above, the same way that chart reads it: in + // through it is inbound, out through it outbound. Telling it by address + // instead (outside to inside) left the chart empty wherever the served + // addresses are public and not listed as your networks, or private at + // both ends: by address all of that is transit or internal. Without an + // interface, the address is all there is to go by. const od = ['inbound', 'outbound'].includes(state.od) ? state.od : ''; - const [d, ifs, fd] = await Promise.all([api('overview', {}, od ? [...state.f, {f: 'bytes', v: od === 'inbound' ? 'in' : 'out', neg: false}] : state.f), inSB() ? null : loadIfaces(), api('findings', {limit: 3}).catch(() => null)]); + const [d, ifs, fd] = await Promise.all([api('overview'), inSB() ? null : loadIfaces(), api('findings', {limit: 3}).catch(() => null)]); // the interface chart: the interface picked at the top, else the default, else the busiest const ifl = ifs?.ifaces || [], ikey = f => f.exporter + '/' + f.ifindex; const ifk = state.ifv && state.ifv !== 'all' ? state.ifv : (ifs?.default || ((ifl.find(f => !f.peer) || ifl[0]) ? ikey(ifl.find(f => !f.peer) || ifl[0]) : '')); const ifSel = ifl.find(f => ikey(f) === ifk); - const [iIn, iOut] = ifk && !inSB() ? await Promise.all(['if_in', 'if_out'].map(by => api('series', {by, measure: 'wire', top: 20, dup: 1}, [...state.f, {f: 'iface', v: ifk, neg: false}]).catch(() => null))) : [null, null]; + const viaIf = ifk && !inSB(); + const [iIn, iOut, dirSeries] = await Promise.all([ + ...(viaIf ? ['if_in', 'if_out'].map(by => api('series', {by, measure: 'wire', top: 20, dup: 1}, [...state.f, {f: 'iface', v: ifk, neg: false}]).catch(() => null)) : [null, null]), + od ? api('series', {by: 'app', measure: 'wire', top: 8, dup: 1}, [...state.f, viaIf ? {f: od === 'inbound' ? 'iface_in' : 'iface_out', v: ifk, neg: false} : {f: 'bytes', v: od === 'inbound' ? 'in' : 'out', neg: false}]).catch(() => null) : null]); const tot = d.totals, base = d.base_totals, basis = d.basis; // a baseline that was nearly idle gives figures like +57,695%: more than // ten times the baseline reads ">10×", and a baseline under 1 kB is no @@ -964,9 +972,9 @@ views.overview = async (el) => { acc = any ? {n: nf(worst * 100, 1) + '%', d: bad ? t('kpi.check') : t('kpi.trust'), kind: bad ? 'warn' : 'ok'} : {n: '—', d: t('kpi.no_flow'), kind: null}; } const peakTxt = d.peak_at ? t('kpi.peak', {v: fmtBps(d.peak_bps), t: fmtTime(d.peak_at, spanMs())}) : ''; - const series = d.series || {times: [], names: [], values: []}; + const series = (od ? dirSeries : d.series) || {times: [], names: [], values: []}; const areas = series.names.map((n, i) => ({name: appLabel(n), color: color(i, n), data: series.values[i]})); - const lines = d.baseline && basis ? [{name: baseText(d, 'line'), color: 'var(--base)', dash: true, keep: true, data: d.baseline}] : []; + const lines = !od && d.baseline && basis ? [{name: baseText(d, 'line'), color: 'var(--base)', dash: true, keep: true, data: d.baseline}] : []; const dirParts = (d.dir || []).map((p, i) => ({n: t('dir.' + p.key), v: p.wire, c: color(i), val: DIRS[p.key]})); const protoParts = (d.proto || []).map((p, i) => ({n: p.key === '__other__' ? t('other') : proto(+p.key), v: p.wire, c: color(i, p.key), val: p.key === '__other__' ? null : p.key})); const cl = d.top_clients || [], cmax = Math.max(1, ...cl.map(r => r.wire)); @@ -984,8 +992,8 @@ views.overview = async (el) => { ${iIn ? `

${t('if.bw')} · ${esc(ifSel ? ifName(ifSel) : ifk)}${ifSel?.tag ? `${esc(ifSel.tag)}` : ''}

${esc(t('ov.if_sub'))}
${t('if.in')}${t('if.out')}
` : ''} -

${t({'': 'ov.bw_all', inbound: 'ov.bw_in', outbound: 'ov.bw_out'}[od])}

${lines.length ? esc(baseText(d, 'sub')) : ''} -
${[['', 'ov.dir_total'], ['inbound', 'dir.2'], ['outbound', 'dir.1']].map(([k, l]) => ``).join('')}
+

${t({'': 'ov.bw_all', inbound: 'ov.bw_in', outbound: 'ov.bw_out'}[od])}

${od && viaIf ? esc(ifSel ? ifName(ifSel) : ifk) : lines.length ? esc(baseText(d, 'sub')) : ''} +
${[['', 'ov.dir_total'], ['inbound', 'if.in'], ['outbound', 'if.out']].map(([k, l]) => ``).join('')}
${areas.map((a, i) => `${series.names[i] === '__other__' ? esc(a.name) : V('app', series.names[i], a.name)}`).join('')}${lines.length ? `${esc(lines[0].name)}` : ''}
${panel('c6', t('ov.dir'), '', '
')} @@ -996,7 +1004,9 @@ views.overview = async (el) => { ${sv.map((r, i) => `${i + 1}${ipCell(r.key)}${V('port', r.key2, r.key2)} ${esc(r.extra)}${fmtBytes(r.wire)}${bar(r.wire, smax)}`).join('') || `${emptyText()}`}`)} ${fd ? findingsPanel(fd) : ''}
`; - tsChart($('#chStack'), {times: series.times, areas, lines, settledTo: series.settled_to, dataFrom: series.data_from}); + const dirWord = od && t(od === 'inbound' ? 'if.in' : 'if.out'); + tsChart($('#chStack'), {times: series.times, areas, lines, settledTo: series.settled_to, dataFrom: series.data_from, + empty: od && viaIf ? esc(t('ov.dir_empty', {if: ifSel ? ifName(ifSel) : ifk, d: /^[a-z]/i.test(dirWord) ? dirWord.toLocaleLowerCase(LANG) : dirWord})) : ''}); if (iIn) { const pk = s => { const i = (s?.names || []).indexOf(ifk); return i < 0 ? (s?.times || []).map(() => 0) : s.values[i]; }; tsChart($('#chIf'), {times: iIn.times, settledTo: iIn.settled_to, dataFrom: iIn.data_from, lines: [{name: t('if.in'), color: IF_IN, data: pk(iIn), fill: true}, {name: t('if.out'), color: IF_OUT, data: pk(iOut), fill: true}]}); @@ -1118,11 +1128,61 @@ views.findings = async (el) => {
${learning > Date.now() ? `
${esc(t('fd.learning', {t: new Intl.DateTimeFormat(LANG, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(learning))}))}
` : ''} ${fs.length ? `${findingRows(fs, true)}
` : `
${all ? t('fd.none') : status('ok', t('fd.none_open'))}
`} -

${esc(t('fd.note'))}

`; +

${esc(t('fd.note'))}

`; el.querySelectorAll('[data-fd]').forEach(b => b.onclick = () => { state.fd = b.dataset.fd; render(); }); bindFindings(el); + await syslogPanel($('#slPanel')); }; +// The syslog feed: the findings above, sent on to the customer's SIEM as +// they are made. Registered installations send; the others see the +// settings greyed out and why. Only an administrator changes them. +async function syslogPanel(el) { + const d = await fetch('/api/syslog').then(r => r.json()).catch(() => null); + if (!d || !d.available) { el.remove(); return; } + const c = d.config || {}, st = d.stats || {}, can = d.admin && d.registered; + const dis = can ? '' : ' disabled'; + const opt = (v, cur, lab) => ``; + const fmtLab = {cef: 'CEF', leef: 'LEEF', json: 'JSON', text: t('sl.fmt_text')}; + const when = x => x && !x.startsWith('0001') ? ago(Date.parse(x)) : ''; + el.innerHTML = `

${esc(t('sl.title'))}

${esc(t('sl.sub'))} + ${d.registered ? `${esc(t(c.enabled ? 'sl.on' : 'sl.off'))}` : ''}
+ ${d.registered ? '' : `
${esc(t('sl.locked'))}
`} +
+ +
+
+
+
+
+ +
+ ${can ? `
` : ''} + ${d.registered ? `

${esc(t('sl.stats', {s: nf(st.sent || 0), f: nf(st.failed || 0), d: nf(st.dropped || 0)}))}${st.last_sent && when(st.last_sent) ? ' · ' + esc(t('sl.last_sent', {t: when(st.last_sent)})) : ''} + ${st.last_error ? `
${esc(t('sl.last_err', {t: when(st.last_error_at), e: st.last_error}))}` : ''}

` : ''}`; + if (!can) return; + const msg = (ok, text) => { const m = $('#slMsg'); m.className = 'slmsg ' + (ok ? 'ok' : 'bad'); m.textContent = text; }; + const body = () => ({enabled: $('#slOn').checked, address: $('#slAddr').value.trim(), network: $('#slNet').value, format: $('#slFmt').value, + min_sev: +$('#slSev').value, base_url: $('#slBase').value.trim(), ascii: $('#slAscii').checked}); + const post = async (path, b) => { + const r = await fetch('/api/syslog' + path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: b ? JSON.stringify(b) : undefined}); + const j = await r.json().catch(() => ({})); + return r.ok ? null : j.error === 'not_registered' ? t('sl.locked') : (j.error || r.statusText); + }; + $('#slSave').onclick = async () => { + const err = await post('', body()); + if (err) { msg(false, err); return; } + await syslogPanel(el); msg(true, t('sl.saved')); + }; + // a test sends with what is saved: save first, so what is on screen is + // what is tried + $('#slTest').onclick = async () => { + const err = await post('', body()) || await post('/test'); + await syslogPanel(el); + msg(!err, err || t('sl.test_ok')); + }; +} + // ------------------------------------------------------------ series charts // One measure over time, split by a dimension: the top 8 values in the @@ -1709,14 +1769,19 @@ const admOnly = html => adm() ? html : ''; // beside the names in the inventory file, and nothing is looked up in it. // Devices and ports come from the flows themselves; only the places and // the carriers are typed in. -const RK_SHOWN = 6; // ports listed before the rest fold away +const RK_SHOWN = 8; // ports listed before the rest fold away +// one colour per carrier, the same on every device, so the eye finds all +// of one carrier's lines at once +const rkHue = s => { let h = 0; for (const c of s) h = (h * 31 + c.codePointAt(0)) % 360; return h; }; +const rkISP = f => f.isp || f.circuit + ? `${esc(f.isp || '—')}` + : `${esc(t('rk.no_carrier'))}`; views.racks = async (el) => { const res = await fetch('/api/racks'); if (res.status === 401) { showLogin(); return; } const d = await res.json().catch(() => ({})); const devs = d.devices || [], can = !!d.admin; - const head = `

${esc(t('rk.hint'))}

`; - if (!devs.length) { el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), head + `
${esc(t('rk.empty'))}
`)}
`; return; } + if (!devs.length) { el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), `
${esc(t('rk.empty'))}
`)}
`; return; } const rooms = new Map(); devs.forEach((x, i) => { const room = x.room || '', rack = x.rack || ''; @@ -1726,66 +1791,167 @@ views.racks = async (el) => { racks.get(rack).push(i); }); // rooms in name order, the devices nobody has placed yet last - const order = [...rooms.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true})); - const pencil = attrs => can ? `` : ''; - const port = (i, f) => `
- ${esc(f.name || t('rk.ifindex', {n: f.ifindex}))} #${f.ifindex}${f.speed ? ' · ' + esc(fmtBps(f.speed)) : ''} - - ${f.isp || f.circuit - ? esc(f.isp || '') + (f.circuit ? ` ${f.isp ? '· ' : ''}${esc(f.circuit)}` : '') - : esc(t('rk.no_carrier'))} - ${pencil(`data-rki="${i}:${f.ifindex}"`)}
`; + const byName = (a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true}); + const order = [...rooms.keys()].sort(byName); + const ports = devs.flatMap(x => x.ifaces || []); + const sum = t('rk.summary', {r: nf(order.filter(Boolean).length), k: nf([...rooms.values()].reduce((n, m) => n + [...m.keys()].filter(Boolean).length, 0)), + d: nf(devs.length), p: nf(ports.filter(f => f.isp || f.circuit).length), n: nf(ports.length)}); + const port = f => `
+ ${esc(f.name || t('rk.ifindex', {n: f.ifindex}))}#${f.ifindex}${f.speed ? ' · ' + esc(fmtBps(f.speed)) : ''} + ${rkISP(f)}${esc(f.circuit || '')}
`; const device = i => { const x = devs[i], fs = x.ifaces || []; // the ports with a carrier always show; of the rest, the busiest const lead = fs.filter((f, k) => f.isp || f.circuit || k < RK_SHOWN), rest = fs.filter(f => !lead.includes(f)); - return `
${esc(x.name || x.exporter)}${x.name ? ` ${esc(x.exporter)}` : ''} - ${x.seen ? '' : `${esc(t('rk.quiet'))}`}${pencil(`data-rkd="${i}"`)}
- ${lead.map(f => port(i, f)).join('') || `
${esc(t('rk.no_ifaces'))}
`} - ${rest.length ? `
${esc(t('rk.more', {n: nf(rest.length)}))}${rest.map(f => port(i, f)).join('')}
` : ''}
`; + return `
+ ${esc(x.name || x.exporter)}${x.name ? `${esc(x.exporter)}` : ''} + ${x.seen ? '' : `${esc(t('rk.quiet'))}`} + ${can ? `` : ''}
+
${lead.map(port).join('') || `
${esc(t('rk.no_ifaces'))}
`} + ${rest.length ? `
${esc(t('rk.more', {n: nf(rest.length)}))}${rest.map(port).join('')}
` : ''}
`; }; const roomIcon = ''; const body = order.map(room => { - const racks = rooms.get(room), names = [...racks.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true})); - return `

${roomIcon}${esc(room || t('rk.unplaced'))}

- ${names.map(rack => `
${esc(rack ? t('rk.rack_n', {x: rack}) : t('rk.no_rack'))}
${racks.get(rack).map(device).join('')}
`).join('')} + const racks = rooms.get(room), names = [...racks.keys()].sort(byName); + const n = [...racks.values()].reduce((k, v) => k + v.length, 0); + return `

${roomIcon}${esc(room || t('rk.unplaced'))}${esc(t('rk.room_n', {k: nf(names.filter(Boolean).length), d: nf(n)}))}

+ ${names.map(rack => `
${esc(rack ? t('rk.rack_n', {x: rack}) : t('rk.no_rack'))}
${racks.get(rack).map(device).join('')}
`).join('')}
`; }).join(''); - el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), head + body)}
`; + el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), `
${esc(sum)}
` + body)}
`; const uniq = k => [...new Set(devs.map(x => x[k]).filter(Boolean))].sort(); - const carriers = [...new Set(devs.flatMap(x => (x.ifaces || []).map(f => f.isp)).filter(Boolean))].sort(); - const send = async (path, body) => { - const r = await fetch('/api/racks/' + path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); - if (!r.ok) { const j = await r.json().catch(() => ({})); toast(j.error || r.statusText); return; } - ifList = null; render(); - }; + const carriers = [...new Set(ports.map(f => f.isp).filter(Boolean))].sort(); el.querySelectorAll('[data-rkd]').forEach(b => b.onclick = async () => { const x = devs[+b.dataset.rkd]; - const v = await rkForm(x.name || x.exporter, [['name', t('rk.device_name'), x.name], ['room', t('rk.room'), x.room, uniq('room')], ['rack', t('rk.rack'), x.rack, uniq('rack')]]); - if (v) send('device', {exporter: x.exporter, ...v}); - }); - el.querySelectorAll('[data-rki]').forEach(b => b.onclick = async () => { - const [i, idx] = b.dataset.rki.split(':').map(Number), x = devs[i], f = (x.ifaces || []).find(y => y.ifindex === idx) || {ifindex: idx}; - const v = await rkForm((x.name || x.exporter) + ' · #' + idx, [['name', t('rk.iface_name'), f.name], ['isp', t('rk.isp'), f.isp, carriers], ['circuit', t('rk.circuit'), f.circuit]]); - if (v) send('iface', {exporter: x.exporter, ifindex: idx, ...v}); + const v = await rkEdit(x, {rooms: uniq('room'), racks: uniq('rack'), carriers}); + if (!v) return; + const r = await fetch('/api/racks/device', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({exporter: x.exporter, ...v})}); + if (!r.ok) { const j = await r.json().catch(() => ({})); toast(j.error || r.statusText); return; } + ifList = null; render(); }); }; -// rkForm asks for a few short values and resolves to them, or to null when -// cancelled. A list offers what was typed for other devices before, so one -// room is not written three ways. -function rkForm(title, fields) { +// rkEdit is one form for a device and every port on it: where it stands, +// what it is called, and for each port the carrier and circuit. It +// resolves to what to save (only the ports that changed), or to null when +// cancelled. The lists offer what was typed before, so one room or one +// carrier is not written three ways. +function rkEdit(x, {rooms, racks, carriers}) { return new Promise(resolve => { + const fs = x.ifaces || []; + const dl = (id, list) => list.length ? `${list.map(o => `` : ''; + const inp = (name, v, list, ph) => ``; const dlg = document.createElement('dialog'); - dlg.className = 'aedit'; - dlg.innerHTML = `

${esc(title)}

${fields.map(([k, lab, v, list]) => `
- - ${list && list.length ? `${list.map(o => `` : ''}
`).join('')}
+ dlg.className = 'aedit rkdlg'; + dlg.innerHTML = `

${esc(x.name || x.exporter)} ${esc(x.exporter)}

+
+
${inp('name', x.name)}
+
${inp('room', x.room, 'rkl_room')}
+
${inp('rack', x.rack, 'rkl_rack')}
+
+ ${fs.length ? `
+ ${fs.map((f, k) => ` + `).join('')} +
${esc(t('rk.port'))}${esc(t('rk.iface_name'))}${esc(t('rk.isp'))}${esc(t('rk.circuit'))}
#${f.ifindex}${f.speed ? ` ${esc(fmtBps(f.speed))}` : ''}${inp('name', f.name, '', t('rk.ifindex', {n: f.ifindex}))}${inp('isp', f.isp, 'rkl_isp')}${inp('circuit', f.circuit)}
` : `

${esc(t('rk.no_ifaces'))}

`} + ${dl('rkl_room', rooms)}${dl('rkl_rack', racks)}${dl('rkl_isp', carriers)}
`; document.body.appendChild(dlg); dlg.addEventListener('close', () => { - const out = {}; - dlg.querySelectorAll('input').forEach(x => { out[x.name] = x.value.trim(); }); + const val = (root, n) => root.querySelector(`input[name="${n}"]`).value.trim(); + const top = dlg.querySelector('.rkdform'); + const out = {name: val(top, 'name'), room: val(top, 'room'), rack: val(top, 'rack'), ifaces: []}; + dlg.querySelectorAll('tr[data-k]').forEach(tr => { + const f = fs[+tr.dataset.k], v = {name: val(tr, 'name'), isp: val(tr, 'isp'), circuit: val(tr, 'circuit')}; + if (v.name !== (f.name || '') || v.isp !== (f.isp || '') || v.circuit !== (f.circuit || '')) out.ifaces.push({ifindex: f.ifindex, ...v}); + }); + const ok = dlg.returnValue === 'ok'; + dlg.remove(); + resolve(ok ? out : null); + }); + dlg.showModal(); + dlg.querySelector('input')?.focus(); + }); +} + +// ------------------------------------------------------------ links for other platforms +// One key per platform. Its links are the same URL with the address put +// in, so an asset platform adds a "traffic" button beside each of its +// service addresses without keeping a secret per address. The key is +// shown once, when it is made or replaced. +async function embedPanel(el) { + const d = await fetch('/api/embeds').then(r => r.json()).catch(() => null); + if (!d) { el.remove(); return; } + const links = d.links || [], reg = d.registered; + const when = x => x && !x.startsWith('0001') ? ago(Date.parse(x)) : t('emb.never'); + const list = xs => (xs || []).length ? xs.map(x => `${esc(x)}`).join(' ') : `${esc(t('emb.any'))}`; + el.innerHTML = `

${esc(t('emb.title'))}

${esc(t('emb.sub'))} + ${reg ? `` : ''}
+ ${reg ? '' : `
${esc(t('emb.locked'))}
`} + ${location.protocol === 'http:' ? `

${esc(t('emb.http_warn'))}

` : ''} +
+ ${links.length ? ` + ${links.map((x, i) => ` + + + `).join('')}
${esc(t('emb.col_name'))}${esc(t('emb.col_ranges'))}${esc(t('emb.col_origins'))}${esc(t('emb.col_clients'))}${esc(t('emb.col_key'))}${esc(t('emb.col_used'))}
${esc(x.name)}${list(x.ranges)}${list(x.origins)}${list(x.clients)}${esc(x.prefix)}…${x.old_until ? `
${esc(t('emb.old_until', {t: new Intl.DateTimeFormat(LANG, {dateStyle: 'short', timeStyle: 'short'}).format(new Date(x.old_until))}))}` : ''}
${esc(when(x.last_used))}
${esc(t('emb.hits', {n: nf(x.hits || 0)}))}
${reg ? ` ` : ''}
` + : `
${esc(t('emb.none'))}
`} +

${esc(t('emb.rate', {n: nf(d.per_minute || 60)}))}

`; + const send = async (method, path, body) => { + const r = await fetch('/api/embeds' + path, {method, headers: {'Content-Type': 'application/json'}, body: body ? JSON.stringify(body) : undefined}); + const j = await r.json().catch(() => ({})); + if (!r.ok) { toast(j.error === 'not_registered' ? t('emb.locked') : (j.error || r.statusText)); return null; } + return j; + }; + // the key, shown this once, as the link the platform builds + const showKey = async key => { + await embedPanel(el); + const link = `${location.origin}/embed/vip?ip={VIP}&k=${key}`; + const frame = ``; + $('#embKey').innerHTML = `
${esc(t('emb.key_once'))} +
${esc(link)}
+
${esc(frame)}
`; + el.querySelectorAll('[data-copy]').forEach(b => b.onclick = async () => { + try { await navigator.clipboard.writeText(b.dataset.copy); toast(t('emb.copied')); } catch (e) { toast(b.dataset.copy); } + }); + }; + const edit = async x => { + const v = await embedForm(x || {}); + if (!v) return; + const j = await send('POST', '', {...v, id: x?.id || ''}); + if (!j) return; + if (j.key) showKey(j.key); else embedPanel(el); + }; + if ($('#embNew')) $('#embNew').onclick = () => edit(null); + el.querySelectorAll('[data-emed]').forEach(b => b.onclick = () => edit(links[+b.dataset.emed])); + el.querySelectorAll('[data-emrot]').forEach(b => b.onclick = async () => { + const x = links[+b.dataset.emrot]; + if (!confirm(t('emb.rotate_confirm', {n: x.name}))) return; + const j = await send('POST', '/rotate', {id: x.id}); + if (j?.key) showKey(j.key); + }); + el.querySelectorAll('[data-emdel]').forEach(b => b.onclick = async () => { + const x = links[+b.dataset.emdel]; + if (!confirm(t('emb.revoke_confirm', {n: x.name}))) return; + if (await send('DELETE', '?id=' + encodeURIComponent(x.id))) embedPanel(el); + }); +} +// embedForm asks for one platform's name and limits; one entry per line. +function embedForm(x) { + return new Promise(resolve => { + const area = (k, v, hint, ph) => `
+ ${esc(t(hint))}
`; + const dlg = document.createElement('dialog'); + dlg.className = 'aedit embdlg'; + dlg.innerHTML = `

${esc(x.name || t('emb.new'))}

+
+ ${area('ranges', x.ranges, 'emb.f_ranges_hint', '203.0.113.0/24\n198.51.100.10')} + ${area('origins', x.origins, 'emb.f_origins_hint', 'assets.example.com')} + ${area('clients', x.clients, 'emb.f_clients_hint', '192.0.2.0/24')} +
`; + document.body.appendChild(dlg); + dlg.addEventListener('close', () => { + const lines = n => dlg.querySelector(`[name="${n}"]`).value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean); + const out = {name: dlg.querySelector('[name="name"]').value.trim(), ranges: lines('ranges'), origins: lines('origins'), clients: lines('clients')}; const ok = dlg.returnValue === 'ok'; dlg.remove(); resolve(ok ? out : null); @@ -2171,6 +2337,7 @@ views.sources = async (el) => {
`))} + ${admOnly('
')} ${admOnly(blocked.length ? panel('c12', t('blk.title'), t('blk.sub'), ` ${blocked.map(b => ` @@ -2208,6 +2375,7 @@ views.sources = async (el) => { loadStatus(); // the badge, the footer and the watermark all follow from it }; } + if (adm()) embedPanel($('#embPanel')); const geoMsg = (ok, text) => { const m = $('#geoMsg'); m.style.color = ok === null ? '' : ok ? 'var(--good)' : 'var(--crit)'; m.textContent = text; }; if (adm()) { const reblock = async (method, path, okMsg) => { diff --git a/internal/web/static/embed.css b/internal/web/static/embed.css new file mode 100644 index 0000000..b9e660b --- /dev/null +++ b/internal/web/static/embed.css @@ -0,0 +1,41 @@ +/* The page other platforms open for one address. It sits in a frame or a + tab of somebody else's site, so it is plain and fills what it is given. */ +:root{ + --bg:#000;--surface:#1c1c1e;--line:#2c2c2e;--ink:#fff;--ink-2:#d1d1d6;--ink-3:#8e8e93; + --in:#30d158;--out:#0a84ff;--accent:#0a84ff;color-scheme:dark} +:root[data-theme=light]{ + --bg:#fff;--surface:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--ink-2:#3a3a3c;--ink-3:#8e8e93; + --in:#34c759;--out:#007aff;--accent:#007aff;color-scheme:light} +*{box-sizing:border-box} +html,body{margin:0;height:100%;background:var(--bg);color:var(--ink); + font:14px/1.4 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} +main{display:flex;flex-direction:column;min-height:100%;padding:16px;gap:14px} +header{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px} +header h1{margin:0;font-size:19px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600} +header span{font-size:12.5px;color:var(--ink-3)} +nav{margin-inline-start:auto;display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden} +nav button{font:inherit;font-size:13px;padding:5px 12px;border:0;background:transparent;color:var(--ink-2);cursor:pointer} +nav button+button{border-inline-start:1px solid var(--line)} +nav button[aria-pressed=true]{background:var(--accent);color:#fff} +.emks{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.emk{background:var(--surface);border-radius:12px;padding:12px 14px;border-top:3px solid var(--in)} +.emk.out{border-top-color:var(--out)} +.emk>b{font-size:13px;color:var(--ink-2)} +.emk dl{display:flex;flex-wrap:wrap;gap:4px 22px;margin:6px 0 0} +.emk dl div{min-width:0} +.emk dt{font-size:11.5px;color:var(--ink-3)} +.emk dd{margin:0;font-size:18px;font-weight:600;font-variant-numeric:tabular-nums} +.emch{position:relative;flex:1;min-height:240px;background:var(--surface);border-radius:12px;overflow:hidden} +.emch svg{display:block;touch-action:none} +.g{stroke:var(--line);stroke-width:1} +.ya,.xa{fill:var(--ink-3);font-size:11px} +.fi{fill:var(--in);opacity:.18}.fo{fill:var(--out);opacity:.18} +.li{fill:none;stroke:var(--in);stroke-width:1.6}.lo{fill:none;stroke:var(--out);stroke-width:1.6} +.cur{stroke:var(--ink-3);stroke-dasharray:3 3} +.tip{position:absolute;top:10px;pointer-events:none;background:var(--bg);border:1px solid var(--line);border-radius:8px; + padding:6px 9px;font-size:12px;display:flex;flex-direction:column;gap:2px;white-space:nowrap} +.tip[hidden]{display:none} +.tip .in{color:var(--in)}.tip .out{color:var(--out)} +.emerr{padding:40px 16px;text-align:center;color:var(--ink-3)} +footer{font-size:11px;color:var(--ink-3);text-align:end} +@media (max-width:520px){.emks{grid-template-columns:1fr}nav{margin-inline-start:0}} diff --git a/internal/web/static/embed.js b/internal/web/static/embed.js new file mode 100644 index 0000000..88c3af3 --- /dev/null +++ b/internal/web/static/embed.js @@ -0,0 +1,138 @@ +// The page other platforms open for one address: its bandwidth in and out +// over a period, and nothing else. It stands alone: no session, no menu, +// the key from its own link, redrawn every minute. +(function () { + 'use strict'; + const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar', 'hi', 'bn', 'ur', 'id', 'vi']; + const RANGES = ['1h', '24h', '7d', '30d']; + const qs = new URLSearchParams(location.search); + const root = document.getElementById('em'), ip = root.dataset.ip, key = qs.get('k') || ''; + // dark unless the platform asks for light, to sit in a light page + document.documentElement.dataset.theme = qs.get('theme') === 'light' ? 'light' : 'dark'; + let range = RANGES.includes(qs.get('range')) ? qs.get('range') : '24h'; + let L = {}, lang = 'en', data = null, timer = 0; + const t = (k, p) => { let s = L[k] || k; for (const [a, b] of Object.entries(p || {})) s = s.split('{' + a + '}').join(b); return s; }; + const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + const nf = (v, d = 0) => new Intl.NumberFormat(lang, {maximumFractionDigits: d, minimumFractionDigits: d}).format(v); + function bps(v) { + if (v == null || !isFinite(v)) return '—'; + for (const [n, m] of [['Tbps', 1e12], ['Gbps', 1e9], ['Mbps', 1e6], ['Kbps', 1e3]]) if (v >= m) return nf(v / m, v / m < 10 ? 2 : v / m < 100 ? 1 : 0) + ' ' + n; + return nf(v) + ' bps'; + } + function axis(v) { + if (v === 0) return '0'; + for (const [n, m] of [['T', 1e12], ['G', 1e9], ['M', 1e6], ['K', 1e3]]) if (v >= m) return nf(v / m, v / m < 10 && v % m ? 1 : 0) + n; + return nf(v); + } + function pick() { + const want = (qs.get('lang') || '').toLowerCase(); + if (LANGS.includes(want)) return want; + for (const l of navigator.languages || [navigator.language || 'en']) { + const c = l.toLowerCase().split('-')[0]; + if (LANGS.includes(c)) return c; + } + return 'en'; + } + + async function load() { + clearTimeout(timer); + const p = new URLSearchParams({ip, k: key, range, tz: String(-new Date().getTimezoneOffset())}); + try { + const r = await fetch('/embed/vip/data?' + p, {cache: 'no-store'}); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || r.statusText); + data = j; + draw(); + } catch (e) { + data = null; + root.innerHTML = head() + `
${esc(e.message)}
`; + bind(); + } + timer = setTimeout(load, 60000); + } + + function head() { + return `

${esc(ip)}

${esc(t('em.sub'))}
+
`; + } + function bind() { + root.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { range = b.dataset.r; load(); }); + } + + // the minutes still arriving are left off the end rather than drawn as + // a fall to nothing + function settled(d, vals) { + return vals.map((v, i) => (d.settled_to && d.times[i] >= d.settled_to) || (d.data_from && d.times[i] + d.step * 1000 <= d.data_from) ? null : v); + } + function stats(v) { + const xs = v.filter(x => x != null); + if (!xs.length) return {now: null, peak: null, avg: null}; + return {now: xs[xs.length - 1], peak: Math.max(...xs), avg: xs.reduce((a, b) => a + b, 0) / xs.length}; + } + + function draw() { + const d = data, vin = settled(d, d.in || []), vout = settled(d, d.out || []); + const si = stats(vin), so = stats(vout); + const card = (cls, name, s) => `
${esc(name)} +
${esc(t('em.now'))}
${bps(s.now)}
${esc(t('em.peak'))}
${bps(s.peak)}
${esc(t('em.avg'))}
${bps(s.avg)}
`; + root.innerHTML = head() + `
${card('in', t('if.in'), si)}${card('out', t('if.out'), so)}
+
traffic66
`; + bind(); + chart(document.getElementById('ch'), d.times || [], vin, vout); + } + + function chart(el, times, a, b) { + const W = el.clientWidth, H = el.clientHeight, n = times.length; + const has = v => v.some(x => x != null && x > 0); + if (n < 2 || (!has(a) && !has(b))) { el.innerHTML = `
${esc(t('em.nodata'))}
`; return; } + const rtl = document.documentElement.dir === 'rtl'; + const Lp = rtl ? 10 : 52, Rp = rtl ? 52 : 10, T = 10, B = 24; + const top = Math.max(1, ...a.filter(x => x != null), ...b.filter(x => x != null)); + const mag = Math.pow(10, Math.floor(Math.log10(top))), nice = [1, 2, 2.5, 5, 10].map(m => m * mag).find(m => m * 4 >= top) || top; + const ymax = nice * 4; + const x = i => rtl ? W - Rp - (W - Lp - Rp) * i / (n - 1) : Lp + (W - Lp - Rp) * i / (n - 1); + const y = v => T + (H - T - B) * (1 - v / ymax); + const path = v => { let s = '', on = false; v.forEach((p, i) => { if (p == null) { on = false; return; } s += (on ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(p).toFixed(1); on = true; }); return s; }; + const area = v => { let s = '', run = []; const flush = () => { if (run.length) s += 'M' + x(run[0]).toFixed(1) + ' ' + y(0) + run.map(i => 'L' + x(i).toFixed(1) + ' ' + y(v[i]).toFixed(1)).join('') + 'L' + x(run[run.length - 1]).toFixed(1) + ' ' + y(0) + 'Z'; run = []; }; v.forEach((p, i) => p == null ? flush() : run.push(i)); flush(); return s; }; + const span = times[n - 1] - times[0]; + const fmt = new Intl.DateTimeFormat(lang, span > 2 * 864e5 ? {month: 'numeric', day: 'numeric'} : {hour: '2-digit', minute: '2-digit'}); + const tip = new Intl.DateTimeFormat(lang, {month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit'}); + let grid = ''; + for (let k = 0; k <= 4; k++) { + const v = nice * k, yy = y(v).toFixed(1); + grid += `${axis(v)}`; + } + const ticks = Math.max(2, Math.min(7, Math.floor((W - Lp - Rp) / 90))); + for (let k = 0; k < ticks; k++) { + const i = Math.round(k * (n - 1) / (ticks - 1)); + // the first and last labels keep inside the chart + const anchor = k === 0 ? (rtl ? 'end' : 'start') : k === ticks - 1 ? (rtl ? 'start' : 'end') : 'middle'; + grid += `${esc(fmt.format(new Date(times[i])))}`; + } + el.innerHTML = `${grid} + + `; + const svg = el.querySelector('svg'), cur = el.querySelector('.cur'), tp = el.querySelector('.tip'); + svg.addEventListener('pointermove', e => { + const r = svg.getBoundingClientRect(), px = e.clientX - r.left; + let i = Math.round((rtl ? W - Rp - px : px - Lp) / (W - Lp - Rp) * (n - 1)); + i = Math.max(0, Math.min(n - 1, i)); + cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible'); + tp.hidden = false; + tp.innerHTML = `${esc(tip.format(new Date(times[i])))}${esc(t('if.in'))} ${bps(a[i])}${esc(t('if.out'))} ${bps(b[i])}`; + tp.style.left = Math.min(W - tp.offsetWidth - 4, Math.max(4, x(i) + 10)) + 'px'; + }); + svg.addEventListener('pointerleave', () => { cur.setAttribute('visibility', 'hidden'); tp.hidden = true; }); + } + + async function start() { + lang = pick(); + document.documentElement.lang = lang; + if (['ar', 'ur'].includes(lang)) document.documentElement.dir = 'rtl'; + try { L = await (await fetch('/i18n/' + lang + '.json')).json(); } catch (e) { L = {}; } + await load(); + let w = innerWidth; + addEventListener('resize', () => { if (data && innerWidth !== w) { w = innerWidth; draw(); } }); + } + start(); +})(); diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index 9372571..717e2ee 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -276,6 +276,65 @@ "sev.high": "عالية", "sev.medium": "متوسطة", "sev.low": "منخفضة", +"emb.title": "روابط للمنصات الأخرى", +"emb.sub": "امنح منصة أخرى، مثل منصة الأصول، رابطًا لعرض النطاق لعنوان VIP واحد: زر بجانب كل VIP مع وضع عنوانه في الرابط. يعرض فقط عرض النطاق الوارد والصادر لهذا الـ VIP.", +"emb.locked": "روابط المنصات الأخرى ميزة للتثبيتات المسجّلة. لا يزال بالإمكان سحب الروابط الحالية.", +"emb.new": "رابط جديد", +"emb.none": "لا توجد روابط بعد.", +"emb.http_warn": "يقدّم هذا الجهاز الواجهة عبر HTTP؛ لتضمينها في منصة HTTPS يلزمه HTTPS أيضًا، وإلا سيحجبها المتصفح.", +"emb.col_name": "الاسم", +"emb.col_ranges": "عناوين VIP المسموح بعرضها", +"emb.col_origins": "المواقع المسموح بها", +"emb.col_clients": "شبكات المشاهدين", +"emb.col_key": "المفتاح", +"emb.col_used": "آخر استخدام", +"emb.never": "لم يُستخدم", +"emb.any": "أي", +"emb.hits": "{n} طلب", +"emb.old_until": "المفتاح القديم صالح حتى {t}", +"emb.edit": "تعديل", +"emb.rotate": "مفتاح جديد", +"emb.revoke": "سحب", +"emb.rotate_confirm": "منح «{n}» مفتاحًا جديدًا؟ يبقى القديم صالحًا 24 ساعة ريثما تنتقل المنصة.", +"emb.revoke_confirm": "سحب «{n}»؟ تتوقف روابطه فورًا.", +"emb.key_once": "يظهر المفتاح هذه المرة فقط: انسخه الآن. ضع عنوان الـ VIP مكان {VIP}.", +"emb.link": "الرابط (لزر يفتحه)", +"emb.frame": "شيفرة التضمين (iframe)", +"emb.copy": "نسخ", +"emb.copied": "تم النسخ", +"emb.rate": "لكل مشاهد {n} طلبًا في الدقيقة كحد أقصى؛ يُرفض ما يزيد لفترة. تُحدَّث الصفحة كل دقيقة.", +"emb.f_name": "الاسم (مثلًا: منصة أصول العميل A)", +"emb.f_ranges": "عناوين VIP المسموح بعرضها", +"emb.f_ranges_hint": "عنوان أو نطاق في كل سطر، مثل 203.0.113.0/24. يُرفض أي عنوان آخر.", +"emb.f_origins": "المواقع المسموح بها", +"emb.f_origins_hint": "اسم موقع في كل سطر، مثل assets.example.com؛ يشمل *.example.com النطاقات الفرعية؛ و* يسمح بأي موقع. تظهر الصفحة فقط عند فتحها أو تضمينها من هذه المواقع.", +"emb.f_clients": "شبكات المشاهدين (اختياري)", +"emb.f_clients_hint": "نطاق في كل سطر. عند ضبطه لا يفتحها إلا المشاهدون من هذه الشبكات.", +"em.sub": "عرض النطاق · الوارد والصادر", +"em.now": "الآن", +"em.peak": "الذروة", +"em.avg": "المتوسط", +"em.nodata": "لا توجد حركة في هذه الفترة.", +"sl.title": "الإرسال إلى syslog", +"sl.sub": "تُرسل النتائج إلى نظام SIEM أو منصة السجلات لديك فور ظهورها (Splunk وQRadar وArcSight وGraylog وElastic)", +"sl.on": "يُرسل", +"sl.off": "متوقف", +"sl.locked": "الإرسال إلى syslog ميزة للتثبيتات المسجّلة. تستمر النتائج في الظهور على هذه الصفحة.", +"sl.enabled": "إرسال النتائج", +"sl.address": "عنوان المُجمِّع (المضيف:المنفذ)", +"sl.network": "طريقة النقل", +"sl.format": "التنسيق", +"sl.fmt_text": "نص عادي", +"sl.min_sev": "أدنى خطورة", +"sl.base_url": "عنوان traffic66 هذا (لرابط الدليل، اختياري)", +"sl.ascii": "ASCII فقط (للمُجمِّعات القديمة)", +"sl.save": "حفظ", +"sl.test": "إرسال اختبار", +"sl.saved": "تم الحفظ", +"sl.test_ok": "أُرسلت رسالة الاختبار", +"sl.stats": "أُرسل {s} · فشل {f} · أُسقط {d} لتجاوز الحد في الدقيقة", +"sl.last_sent": "آخر إرسال {t}", +"sl.last_err": "آخر فشل ({t}): {e}", "fd.open": "غير معالَجة", "fd.all": "الكل", "fd.none": "لم يُكتشف شيء في هذه الفترة.", @@ -543,6 +602,7 @@ "ov.bw_all": "عرض النطاق (وارد + صادر)، حسب التطبيق", "ov.bw_in": "عرض النطاق الوارد، حسب التطبيق", "ov.bw_out": "عرض النطاق الصادر، حسب التطبيق", +"ov.dir_empty": "لا توجد حركة ({d}) عبر {if} في هذه الفترة.", "src.manual": "مضبوط يدويًا", "src.templates": "القوالب المستلمة (أرقام الحقول؛ s = النطاق):", "src.stale": "متوقفة (الجهاز يرسل الآن بطريقة أخرى):", @@ -685,7 +745,9 @@ "ob.copy": "نسخ", "nav.help": "المساعدة", "rk.sub": "أين يقف كل جهاز يرسل التدفقات، وإلى أي مشغّل ودائرة يخرج كل منفذ", -"rk.hint": "املأه مرة واحدة. الأجهزة والمنافذ تأتي من التدفقات؛ ولا يُكتب إلا الغرفة والخزانة والمشغّل والدائرة، وتُحفظ في ملف السجل.", +"rk.summary": "{r} غرف · {k} خزائن · {d} أجهزة · المزوّد محدد على {p} من {n} منافذ", +"rk.room_n": "{k} خزائن · {d} أجهزة", +"rk.port": "المنفذ", "rk.empty": "لم يرسل أي جهاز تدفقات بعد.", "rk.unplaced": "لم يُحدَّد مكانه بعد", "rk.no_rack": "بلا خزانة", diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 769833f..7a0c40e 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -276,6 +276,65 @@ "sev.high": "উচ্চ", "sev.medium": "মাঝারি", "sev.low": "নিম্ন", +"emb.title": "অন্য প্ল্যাটফর্মের জন্য লিঙ্ক", +"emb.sub": "অ্যাসেট প্ল্যাটফর্মের মতো অন্য প্ল্যাটফর্মকে একটি VIP-এর ব্যান্ডউইথের লিঙ্ক দিন: প্রতিটি VIP-এর পাশে একটি বোতাম, লিঙ্কে VIP-এর ঠিকানা বসিয়ে। এতে শুধু সেই VIP-এর আগত ও বহির্গামী ব্যান্ডউইথ দেখা যায়।", +"emb.locked": "অন্য প্ল্যাটফর্মের লিঙ্ক নিবন্ধিত ইনস্টলেশনের জন্য। বিদ্যমান লিঙ্ক এখনও প্রত্যাহার করা যায়।", +"emb.new": "নতুন লিঙ্ক", +"emb.none": "এখনও কোনো লিঙ্ক নেই।", +"emb.http_warn": "এই মেশিন HTTP-তে ওয়েব UI দেয়; HTTPS প্ল্যাটফর্মে বসাতে এখানেও HTTPS লাগবে, নইলে ব্রাউজার আটকে দেবে।", +"emb.col_name": "নাম", +"emb.col_ranges": "যে VIP দেখানো যাবে", +"emb.col_origins": "অনুমোদিত সাইট", +"emb.col_clients": "দর্শকদের নেটওয়ার্ক", +"emb.col_key": "কী", +"emb.col_used": "শেষ ব্যবহার", +"emb.never": "কখনো নয়", +"emb.any": "যেকোনো", +"emb.hits": "{n}টি অনুরোধ", +"emb.old_until": "পুরোনো কী {t} পর্যন্ত চলবে", +"emb.edit": "সম্পাদনা", +"emb.rotate": "নতুন কী", +"emb.revoke": "প্রত্যাহার", +"emb.rotate_confirm": "“{n}”-কে নতুন কী দেবেন? প্ল্যাটফর্ম বদলানোর জন্য পুরোনোটি আরও ২৪ ঘণ্টা চলবে।", +"emb.revoke_confirm": "“{n}” প্রত্যাহার করবেন? এর লিঙ্ক সঙ্গে সঙ্গে বন্ধ হবে।", +"emb.key_once": "কী শুধু এবারই দেখানো হচ্ছে: এখনই কপি করুন। {VIP}-এর জায়গায় VIP-এর ঠিকানা বসান।", +"emb.link": "লিঙ্ক (যে বোতাম এটি খোলে তার জন্য)", +"emb.frame": "এম্বেড কোড (iframe)", +"emb.copy": "কপি", +"emb.copied": "কপি হয়েছে", +"emb.rate": "প্রতি দর্শক প্রতি মিনিটে সর্বোচ্চ {n}টি অনুরোধ করতে পারেন; বেশি হলে কিছুক্ষণ ফিরিয়ে দেওয়া হয়। পাতা প্রতি মিনিটে নতুন করে আঁকা হয়।", +"emb.f_name": "নাম (যেমন: গ্রাহক A-এর অ্যাসেট প্ল্যাটফর্ম)", +"emb.f_ranges": "যে VIP দেখানো যাবে", +"emb.f_ranges_hint": "প্রতি লাইনে একটি ঠিকানা বা রেঞ্জ, যেমন 203.0.113.0/24। অন্য ঠিকানা ফিরিয়ে দেওয়া হয়।", +"emb.f_origins": "অনুমোদিত সাইট", +"emb.f_origins_hint": "প্রতি লাইনে একটি সাইটের নাম, যেমন assets.example.com; *.example.com সাবডোমেইনসহ; * মানে যেকোনো। শুধু এই সাইটগুলো থেকে খুললে বা এম্বেড করলে পাতা দেখা যায়।", +"emb.f_clients": "দর্শকদের নেটওয়ার্ক (ঐচ্ছিক)", +"emb.f_clients_hint": "প্রতি লাইনে একটি রেঞ্জ। দিলে শুধু এই নেটওয়ার্কের দর্শকরা খুলতে পারবেন।", +"em.sub": "ব্যান্ডউইথ · আগত ও বহির্গামী", +"em.now": "এখন", +"em.peak": "সর্বোচ্চ", +"em.avg": "গড়", +"em.nodata": "এই সময়ে কোনো ট্রাফিক নেই।", +"sl.title": "syslog-এ পাঠান", +"sl.sub": "ফলাফল তৈরি হওয়ামাত্র আপনার SIEM বা লগ প্ল্যাটফর্মে যায় (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "পাঠানো হচ্ছে", +"sl.off": "বন্ধ", +"sl.locked": "syslog-এ পাঠানো নিবন্ধিত ইনস্টলেশনের জন্য। ফলাফল আগের মতোই তৈরি হয় ও এই পাতায় দেখা যায়।", +"sl.enabled": "ফলাফল পাঠান", +"sl.address": "কালেক্টরের ঠিকানা (হোস্ট:পোর্ট)", +"sl.network": "পরিবহন", +"sl.format": "ফরম্যাট", +"sl.fmt_text": "সাধারণ টেক্সট", +"sl.min_sev": "সর্বনিম্ন তীব্রতা", +"sl.base_url": "এই traffic66-এর ঠিকানা (প্রমাণ লিঙ্কের জন্য, ঐচ্ছিক)", +"sl.ascii": "শুধু ASCII (পুরোনো কালেক্টরের জন্য)", +"sl.save": "সংরক্ষণ", +"sl.test": "পরীক্ষা পাঠান", +"sl.saved": "সংরক্ষিত", +"sl.test_ok": "পরীক্ষার বার্তা পাঠানো হয়েছে", +"sl.stats": "{s}টি পাঠানো · {f}টি ব্যর্থ · মিনিটপ্রতি সীমা ছাড়ানোয় {d}টি বাদ", +"sl.last_sent": "শেষ পাঠানো {t}", +"sl.last_err": "শেষ ব্যর্থতা ({t}): {e}", "fd.open": "অমীমাংসিত", "fd.all": "সব", "fd.none": "এই সময়ে কিছু পাওয়া যায়নি।", @@ -543,6 +602,7 @@ "ov.bw_all": "ব্যান্ডউইথ (আগত + বহির্গামী), অ্যাপ্লিকেশন অনুযায়ী", "ov.bw_in": "আগত ব্যান্ডউইথ, অ্যাপ্লিকেশন অনুযায়ী", "ov.bw_out": "বহির্গামী ব্যান্ডউইথ, অ্যাপ্লিকেশন অনুযায়ী", +"ov.dir_empty": "এই সময়ে {if} দিয়ে কোনো {d} ট্রাফিক নেই।", "src.manual": "হাতে ঠিক করা", "src.templates": "পাওয়া টেমপ্লেট (field id; s = scope):", "src.stale": "বন্ধ (ডিভাইস এখন অন্যভাবে পাঠাচ্ছে):", @@ -685,7 +745,9 @@ "ob.copy": "কপি", "nav.help": "সহায়তা", "rk.sub": "ফ্লো পাঠানো প্রতিটি যন্ত্র কোথায় আছে, আর প্রতিটি পোর্ট কোন ক্যারিয়ার ও সার্কিটে যায়", -"rk.hint": "একবার পূরণ করলেই হয়। যন্ত্র ও পোর্ট ফ্লো থেকে আসে; শুধু রুম, র‍্যাক, ক্যারিয়ার ও সার্কিট লিখতে হয়, যা ইনভেন্টরি ফাইলে থাকে।", +"rk.summary": "{r}টি কক্ষ · {k}টি র‍্যাক · {d}টি ডিভাইস · {n}টি পোর্টের {p}টিতে ক্যারিয়ার দেওয়া", +"rk.room_n": "{k}টি র‍্যাক · {d}টি ডিভাইস", +"rk.port": "পোর্ট", "rk.empty": "এখনও কোনো যন্ত্র ফ্লো পাঠায়নি।", "rk.unplaced": "এখনও স্থান ঠিক হয়নি", "rk.no_rack": "র‍্যাক নেই", diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index 3064fb3..383a4d2 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -276,6 +276,65 @@ "sev.high": "Hoch", "sev.medium": "Mittel", "sev.low": "Niedrig", +"emb.title": "Links für andere Plattformen", +"emb.sub": "Geben Sie einer anderen Plattform, etwa einer Asset-Plattform, einen Link zur Bandbreite einer VIP: ein Knopf neben jeder VIP, mit der Adresse im Link. Er zeigt nur die ein- und ausgehende Bandbreite dieser VIP.", +"emb.locked": "Links für andere Plattformen sind registrierten Installationen vorbehalten. Bestehende Links lassen sich weiter zurückziehen.", +"emb.new": "Neuer Link", +"emb.none": "Noch keine Links.", +"emb.http_warn": "Diese Maschine liefert die Web-Oberfläche über HTTP; um in eine HTTPS-Plattform eingebettet zu werden, braucht sie ebenfalls HTTPS, sonst blockiert der Browser.", +"emb.col_name": "Name", +"emb.col_ranges": "Erlaubte VIPs", +"emb.col_origins": "Erlaubte Websites", +"emb.col_clients": "Netze der Betrachter", +"emb.col_key": "Schlüssel", +"emb.col_used": "Zuletzt genutzt", +"emb.never": "Nie", +"emb.any": "Beliebig", +"emb.hits": "{n} Anfragen", +"emb.old_until": "Alter Schlüssel gilt bis {t}", +"emb.edit": "Bearbeiten", +"emb.rotate": "Neuer Schlüssel", +"emb.revoke": "Zurückziehen", +"emb.rotate_confirm": "„{n}“ einen neuen Schlüssel geben? Der alte gilt noch 24 Stunden, damit die Plattform umstellen kann.", +"emb.revoke_confirm": "„{n}“ zurückziehen? Seine Links funktionieren sofort nicht mehr.", +"emb.key_once": "Der Schlüssel wird nur dieses eine Mal gezeigt: jetzt kopieren. Ersetzen Sie {VIP} durch die Adresse der VIP.", +"emb.link": "Link (für einen Knopf, der ihn öffnet)", +"emb.frame": "Einbettungscode (iframe)", +"emb.copy": "Kopieren", +"emb.copied": "Kopiert", +"emb.rate": "Jeder Betrachter darf höchstens {n} Anfragen pro Minute stellen; mehr werden eine Weile abgewiesen. Die Seite wird jede Minute neu gezeichnet.", +"emb.f_name": "Name (z. B. Asset-Plattform Kunde A)", +"emb.f_ranges": "Erlaubte VIPs", +"emb.f_ranges_hint": "Eine Adresse oder ein Bereich pro Zeile, etwa 203.0.113.0/24. Jede andere Adresse wird abgewiesen.", +"emb.f_origins": "Erlaubte Websites", +"emb.f_origins_hint": "Ein Hostname pro Zeile, etwa assets.example.com; *.example.com umfasst Subdomains; * erlaubt alle. Die Seite erscheint nur, wenn sie von diesen Websites geöffnet oder eingebettet wird.", +"emb.f_clients": "Netze der Betrachter (optional)", +"emb.f_clients_hint": "Ein Bereich pro Zeile. Wenn gesetzt, können nur Betrachter aus diesen Netzen sie öffnen.", +"em.sub": "Bandbreite · eingehend und ausgehend", +"em.now": "Aktuell", +"em.peak": "Spitze", +"em.avg": "Durchschnitt", +"em.nodata": "Kein Verkehr in diesem Zeitraum.", +"sl.title": "An Syslog senden", +"sl.sub": "Befunde gehen beim Entstehen an Ihr SIEM oder Ihre Log-Plattform (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Aktiv", +"sl.off": "Aus", +"sl.locked": "Das Senden an Syslog ist registrierten Installationen vorbehalten. Befunde werden weiter erstellt und hier angezeigt.", +"sl.enabled": "Befunde senden", +"sl.address": "Collector-Adresse (Host:Port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Klartext", +"sl.min_sev": "Mindestschwere", +"sl.base_url": "Adresse dieses traffic66 (für den Beleg-Link, optional)", +"sl.ascii": "Nur ASCII (für ältere Collector)", +"sl.save": "Speichern", +"sl.test": "Test senden", +"sl.saved": "Gespeichert", +"sl.test_ok": "Testnachricht gesendet", +"sl.stats": "{s} gesendet · {f} fehlgeschlagen · {d} über dem Minutenlimit verworfen", +"sl.last_sent": "zuletzt gesendet {t}", +"sl.last_err": "Letzter Fehler ({t}): {e}", "fd.open": "Offen", "fd.all": "Alle", "fd.none": "In diesem Zeitraum wurde nichts gefunden.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bandbreite (ein + aus) nach Anwendung", "ov.bw_in": "Eingehende Bandbreite nach Anwendung", "ov.bw_out": "Ausgehende Bandbreite nach Anwendung", +"ov.dir_empty": "Kein Verkehr ({d}) über {if} in diesem Zeitraum.", "src.manual": "manuell gesetzt", "src.templates": "Empfangene Templates (Feld-IDs; s = Scope):", "src.stale": "Gestoppt (das Gerät sendet jetzt anders):", @@ -685,7 +745,9 @@ "ob.copy": "Kopieren", "nav.help": "Hilfe", "rk.sub": "Wo jedes Gerät steht, das Flows sendet, und zu welchem Anbieter und welcher Leitung jeder Port führt", -"rk.hint": "Einmal ausfüllen genügt. Geräte und Ports kommen aus den Flows; eingetragen werden nur Raum, Rack, Anbieter und Leitung, gespeichert in der Inventardatei.", +"rk.summary": "{r} Räume · {k} Racks · {d} Geräte · Provider bei {p} von {n} Ports", +"rk.room_n": "{k} Racks · {d} Geräte", +"rk.port": "Port", "rk.empty": "Noch hat kein Gerät Flows gesendet.", "rk.unplaced": "Noch nicht zugeordnet", "rk.no_rack": "Ohne Rack", diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index a02d94d..0a87fcf 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -276,6 +276,65 @@ "sev.high": "High", "sev.medium": "Medium", "sev.low": "Low", +"emb.title": "Links for other platforms", +"emb.sub": "Give another platform, such as an asset platform, a link to one VIP's bandwidth: a button beside each VIP, with the VIP's address put into the link. It shows that VIP's inbound and outbound bandwidth and nothing else.", +"emb.locked": "Links for other platforms are for registered installations. Existing links can still be withdrawn.", +"emb.new": "New link", +"emb.none": "No links yet.", +"emb.http_warn": "This machine serves the web UI over HTTP; to be framed in an HTTPS platform it needs HTTPS too, or the browser blocks it.", +"emb.col_name": "Name", +"emb.col_ranges": "VIPs it may show", +"emb.col_origins": "Sites allowed", +"emb.col_clients": "Viewer networks", +"emb.col_key": "Key", +"emb.col_used": "Last used", +"emb.never": "Never", +"emb.any": "Any", +"emb.hits": "{n} requests", +"emb.old_until": "Old key works until {t}", +"emb.edit": "Edit", +"emb.rotate": "New key", +"emb.revoke": "Withdraw", +"emb.rotate_confirm": "Give “{n}” a new key? The old one keeps working for 24 hours while the platform changes over.", +"emb.revoke_confirm": "Withdraw “{n}”? Its links stop working at once.", +"emb.key_once": "The key is shown this once: copy it now. Put the VIP's address in place of {VIP}.", +"emb.link": "Link (for a button that opens it)", +"emb.frame": "Embed code (iframe)", +"emb.copy": "Copy", +"emb.copied": "Copied", +"emb.rate": "Each viewer may make at most {n} requests a minute; more are refused for a while. The page redraws every minute.", +"emb.f_name": "Name (for example: Customer A asset platform)", +"emb.f_ranges": "VIPs it may show", +"emb.f_ranges_hint": "One address or range per line, such as 203.0.113.0/24. Any other address is refused.", +"emb.f_origins": "Sites allowed", +"emb.f_origins_hint": "One site name per line, such as assets.example.com; *.example.com covers its subdomains; * allows any. The page shows only when opened or framed from these sites.", +"emb.f_clients": "Viewer networks (optional)", +"emb.f_clients_hint": "One range per line. When set, only viewers on these networks can open it.", +"em.sub": "Bandwidth · in and out", +"em.now": "Now", +"em.peak": "Peak", +"em.avg": "Average", +"em.nodata": "No traffic in this period.", +"sl.title": "Send to syslog", +"sl.sub": "Findings go to your SIEM or log platform as they are made (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Sending", +"sl.off": "Off", +"sl.locked": "Sending to syslog is for registered installations. Findings are still made and shown on this page.", +"sl.enabled": "Send findings", +"sl.address": "Collector address (host:port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Plain text", +"sl.min_sev": "Least severity", +"sl.base_url": "Address of this traffic66 (for the evidence link, optional)", +"sl.ascii": "ASCII only (for older collectors)", +"sl.save": "Save", +"sl.test": "Send a test", +"sl.saved": "Saved", +"sl.test_ok": "Test message sent", +"sl.stats": "{s} sent · {f} failed · {d} dropped over the per-minute ceiling", +"sl.last_sent": "last sent {t}", +"sl.last_err": "Last failure ({t}): {e}", "fd.open": "Open", "fd.all": "All", "fd.none": "Nothing was found in this time range.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bandwidth (in + out), by application", "ov.bw_in": "Inbound bandwidth, by application", "ov.bw_out": "Outbound bandwidth, by application", +"ov.dir_empty": "No {d} traffic through {if} in this period.", "src.manual": "set by hand", "src.templates": "Templates received (field ids; s = scope):", "src.stale": "Stopped (the device now sends otherwise):", @@ -685,7 +745,9 @@ "ob.copy": "Copy", "nav.help": "Help", "rk.sub": "Where each device that sends flows stands, and which carrier and circuit each port goes out to", -"rk.hint": "Fill this in once. Devices and ports come from the flows; only the room, rack, carrier and circuit are typed in, and they are kept in the inventory file.", +"rk.summary": "{r} rooms · {k} racks · {d} devices · carrier set on {p} of {n} ports", +"rk.room_n": "{k} racks · {d} devices", +"rk.port": "Port", "rk.empty": "No device has sent flows yet.", "rk.unplaced": "Not placed yet", "rk.no_rack": "No rack", diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index e3cd217..cfb4d9f 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -276,6 +276,65 @@ "sev.high": "Alta", "sev.medium": "Media", "sev.low": "Baja", +"emb.title": "Enlaces para otras plataformas", +"emb.sub": "Dé a otra plataforma, como una de activos, un enlace al ancho de banda de una VIP: un botón junto a cada VIP, con su dirección en el enlace. Solo muestra el ancho de banda entrante y saliente de esa VIP.", +"emb.locked": "Los enlaces para otras plataformas son para instalaciones registradas. Los existentes se pueden seguir retirando.", +"emb.new": "Nuevo enlace", +"emb.none": "Aún no hay enlaces.", +"emb.http_warn": "Esta máquina sirve la interfaz por HTTP; para integrarse en una plataforma HTTPS también necesita HTTPS, o el navegador la bloquea.", +"emb.col_name": "Nombre", +"emb.col_ranges": "VIP que puede mostrar", +"emb.col_origins": "Sitios permitidos", +"emb.col_clients": "Redes de los visitantes", +"emb.col_key": "Clave", +"emb.col_used": "Último uso", +"emb.never": "Nunca", +"emb.any": "Cualquiera", +"emb.hits": "{n} solicitudes", +"emb.old_until": "La clave anterior vale hasta {t}", +"emb.edit": "Editar", +"emb.rotate": "Nueva clave", +"emb.revoke": "Retirar", +"emb.rotate_confirm": "¿Dar a «{n}» una clave nueva? La anterior sigue valiendo 24 horas mientras la plataforma cambia.", +"emb.revoke_confirm": "¿Retirar «{n}»? Sus enlaces dejan de funcionar al instante.", +"emb.key_once": "La clave solo se muestra esta vez: cópiela ahora. Ponga la dirección de la VIP en lugar de {VIP}.", +"emb.link": "Enlace (para un botón que lo abre)", +"emb.frame": "Código para incrustar (iframe)", +"emb.copy": "Copiar", +"emb.copied": "Copiado", +"emb.rate": "Cada visitante puede hacer como máximo {n} solicitudes por minuto; las demás se rechazan un rato. La página se redibuja cada minuto.", +"emb.f_name": "Nombre (por ejemplo: plataforma de activos del cliente A)", +"emb.f_ranges": "VIP que puede mostrar", +"emb.f_ranges_hint": "Una dirección o rango por línea, como 203.0.113.0/24. Cualquier otra dirección se rechaza.", +"emb.f_origins": "Sitios permitidos", +"emb.f_origins_hint": "Un nombre de sitio por línea, como assets.example.com; *.example.com incluye subdominios; * permite cualquiera. La página solo se muestra si se abre o incrusta desde estos sitios.", +"emb.f_clients": "Redes de los visitantes (opcional)", +"emb.f_clients_hint": "Un rango por línea. Si se indica, solo los visitantes de esas redes pueden abrirla.", +"em.sub": "Ancho de banda · entrante y saliente", +"em.now": "Actual", +"em.peak": "Pico", +"em.avg": "Media", +"em.nodata": "Sin tráfico en este periodo.", +"sl.title": "Enviar a syslog", +"sl.sub": "Los hallazgos van a su SIEM o plataforma de registros en cuanto se generan (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Enviando", +"sl.off": "Desactivado", +"sl.locked": "El envío a syslog es para instalaciones registradas. Los hallazgos se siguen generando y se muestran en esta página.", +"sl.enabled": "Enviar hallazgos", +"sl.address": "Dirección del colector (host:puerto)", +"sl.network": "Transporte", +"sl.format": "Formato", +"sl.fmt_text": "Texto plano", +"sl.min_sev": "Gravedad mínima", +"sl.base_url": "Dirección de este traffic66 (para el enlace de evidencia, opcional)", +"sl.ascii": "Solo ASCII (colectores antiguos)", +"sl.save": "Guardar", +"sl.test": "Enviar una prueba", +"sl.saved": "Guardado", +"sl.test_ok": "Mensaje de prueba enviado", +"sl.stats": "{s} enviados · {f} fallidos · {d} descartados por el límite por minuto", +"sl.last_sent": "último envío {t}", +"sl.last_err": "Último fallo ({t}): {e}", "fd.open": "Pendientes", "fd.all": "Todos", "fd.none": "No se encontró nada en este periodo.", @@ -543,6 +602,7 @@ "ov.bw_all": "Ancho de banda (entrada + salida), por aplicación", "ov.bw_in": "Ancho de banda entrante, por aplicación", "ov.bw_out": "Ancho de banda saliente, por aplicación", +"ov.dir_empty": "Sin tráfico ({d}) por {if} en este periodo.", "src.manual": "fijado a mano", "src.templates": "Plantillas recibidas (ids de campo; s = ámbito):", "src.stale": "Detenidos (el equipo ahora envía de otra forma):", @@ -685,7 +745,9 @@ "ob.copy": "Copiar", "nav.help": "Ayuda", "rk.sub": "Dónde está cada equipo que envía flujos y a qué operador y circuito sale cada puerto", -"rk.hint": "Basta con rellenarlo una vez. Equipos y puertos salen de los flujos; solo se escriben sala, rack, operador y circuito, y se guardan en el archivo de inventario.", +"rk.summary": "{r} salas · {k} racks · {d} equipos · operador indicado en {p} de {n} puertos", +"rk.room_n": "{k} racks · {d} equipos", +"rk.port": "Puerto", "rk.empty": "Ningún equipo ha enviado flujos todavía.", "rk.unplaced": "Sin ubicar", "rk.no_rack": "Sin rack", diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index c5c4466..23e468c 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -276,6 +276,65 @@ "sev.high": "Élevée", "sev.medium": "Moyenne", "sev.low": "Faible", +"emb.title": "Liens pour d’autres plateformes", +"emb.sub": "Donnez à une autre plateforme, par exemple une plateforme d’actifs, un lien vers la bande passante d’une VIP : un bouton à côté de chaque VIP, avec son adresse dans le lien. Il ne montre que la bande passante entrante et sortante de cette VIP.", +"emb.locked": "Les liens pour d’autres plateformes sont réservés aux installations enregistrées. Les liens existants peuvent toujours être retirés.", +"emb.new": "Nouveau lien", +"emb.none": "Aucun lien pour l’instant.", +"emb.http_warn": "Cette machine sert l’interface en HTTP ; pour être intégrée dans une plateforme HTTPS, elle doit aussi passer en HTTPS, sinon le navigateur bloque.", +"emb.col_name": "Nom", +"emb.col_ranges": "VIP visibles", +"emb.col_origins": "Sites autorisés", +"emb.col_clients": "Réseaux des visiteurs", +"emb.col_key": "Clé", +"emb.col_used": "Dernière utilisation", +"emb.never": "Jamais", +"emb.any": "Tous", +"emb.hits": "{n} requêtes", +"emb.old_until": "Ancienne clé valable jusqu’au {t}", +"emb.edit": "Modifier", +"emb.rotate": "Nouvelle clé", +"emb.revoke": "Retirer", +"emb.rotate_confirm": "Donner une nouvelle clé à « {n} » ? L’ancienne reste valable 24 heures, le temps que la plateforme bascule.", +"emb.revoke_confirm": "Retirer « {n} » ? Ses liens cessent de fonctionner immédiatement.", +"emb.key_once": "La clé n’est affichée qu’une fois : copiez-la maintenant. Remplacez {VIP} par l’adresse de la VIP.", +"emb.link": "Lien (pour un bouton qui l’ouvre)", +"emb.frame": "Code d’intégration (iframe)", +"emb.copy": "Copier", +"emb.copied": "Copié", +"emb.rate": "Chaque visiteur peut faire au plus {n} requêtes par minute ; au-delà, elles sont refusées un moment. La page se redessine chaque minute.", +"emb.f_name": "Nom (par exemple : plateforme d’actifs du client A)", +"emb.f_ranges": "VIP visibles", +"emb.f_ranges_hint": "Une adresse ou une plage par ligne, comme 203.0.113.0/24. Toute autre adresse est refusée.", +"emb.f_origins": "Sites autorisés", +"emb.f_origins_hint": "Un nom de site par ligne, comme assets.example.com ; *.example.com couvre ses sous-domaines ; * autorise tout. La page ne s’affiche que si elle est ouverte ou intégrée depuis ces sites.", +"emb.f_clients": "Réseaux des visiteurs (facultatif)", +"emb.f_clients_hint": "Une plage par ligne. Si renseigné, seuls les visiteurs de ces réseaux peuvent l’ouvrir.", +"em.sub": "Bande passante · entrante et sortante", +"em.now": "Actuel", +"em.peak": "Pic", +"em.avg": "Moyenne", +"em.nodata": "Aucun trafic sur cette période.", +"sl.title": "Envoyer vers syslog", +"sl.sub": "Les constats partent vers votre SIEM ou plateforme de journaux dès leur création (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Actif", +"sl.off": "Désactivé", +"sl.locked": "L’envoi vers syslog est réservé aux installations enregistrées. Les constats sont toujours produits et affichés ici.", +"sl.enabled": "Envoyer les constats", +"sl.address": "Adresse du collecteur (hôte:port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Texte brut", +"sl.min_sev": "Gravité minimale", +"sl.base_url": "Adresse de ce traffic66 (pour le lien de preuve, facultatif)", +"sl.ascii": "ASCII uniquement (anciens collecteurs)", +"sl.save": "Enregistrer", +"sl.test": "Envoyer un test", +"sl.saved": "Enregistré", +"sl.test_ok": "Message de test envoyé", +"sl.stats": "{s} envoyés · {f} en échec · {d} écartés au-delà du plafond par minute", +"sl.last_sent": "dernier envoi {t}", +"sl.last_err": "Dernier échec ({t}) : {e}", "fd.open": "À traiter", "fd.all": "Toutes", "fd.none": "Rien n'a été détecté sur cette période.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bande passante (entrant + sortant), par application", "ov.bw_in": "Bande passante entrante, par application", "ov.bw_out": "Bande passante sortante, par application", +"ov.dir_empty": "Aucun trafic ({d}) via {if} sur cette période.", "src.manual": "fixé à la main", "src.templates": "Modèles reçus (ids de champ ; s = portée) :", "src.stale": "Arrêtés (l’équipement envoie désormais autrement) :", @@ -685,7 +745,9 @@ "ob.copy": "Copier", "nav.help": "Aide", "rk.sub": "Où se trouve chaque équipement qui envoie des flux, et vers quel opérateur et quel circuit part chaque port", -"rk.hint": "À remplir une seule fois. Équipements et ports viennent des flux ; seuls la salle, la baie, l’opérateur et le circuit sont saisis, et ils sont gardés dans le fichier d’inventaire.", +"rk.summary": "{r} salles · {k} baies · {d} équipements · opérateur saisi sur {p} ports sur {n}", +"rk.room_n": "{k} baies · {d} équipements", +"rk.port": "Port", "rk.empty": "Aucun équipement n’a encore envoyé de flux.", "rk.unplaced": "Pas encore placé", "rk.no_rack": "Sans baie", diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index d628518..2b252f0 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -276,6 +276,65 @@ "sev.high": "उच्च", "sev.medium": "मध्यम", "sev.low": "निम्न", +"emb.title": "दूसरे प्लैटफ़ॉर्म के लिए लिंक", +"emb.sub": "किसी दूसरे प्लैटफ़ॉर्म, जैसे एसेट प्लैटफ़ॉर्म, को एक VIP की बैंडविड्थ का लिंक दें: हर VIP के बगल में एक बटन, लिंक में VIP का पता डालकर। इसमें सिर्फ़ उस VIP की आने और जाने वाली बैंडविड्थ दिखती है।", +"emb.locked": "दूसरे प्लैटफ़ॉर्म के लिंक पंजीकृत इंस्टॉलेशन के लिए हैं। मौजूदा लिंक अब भी वापस लिए जा सकते हैं।", +"emb.new": "नया लिंक", +"emb.none": "अभी कोई लिंक नहीं।", +"emb.http_warn": "यह मशीन वेब UI HTTP पर देती है; HTTPS प्लैटफ़ॉर्म में एम्बेड करने के लिए यहाँ भी HTTPS चाहिए, नहीं तो ब्राउज़र रोक देगा।", +"emb.col_name": "नाम", +"emb.col_ranges": "कौन से VIP दिखें", +"emb.col_origins": "अनुमत साइटें", +"emb.col_clients": "दर्शकों के नेटवर्क", +"emb.col_key": "कुंजी", +"emb.col_used": "आख़िरी उपयोग", +"emb.never": "कभी नहीं", +"emb.any": "कोई भी", +"emb.hits": "{n} अनुरोध", +"emb.old_until": "पुरानी कुंजी {t} तक चलेगी", +"emb.edit": "बदलें", +"emb.rotate": "नई कुंजी", +"emb.revoke": "वापस लें", +"emb.rotate_confirm": "“{n}” को नई कुंजी दें? प्लैटफ़ॉर्म के बदलने तक पुरानी 24 घंटे और चलेगी।", +"emb.revoke_confirm": "“{n}” वापस लें? इसके लिंक तुरंत बंद हो जाएँगे।", +"emb.key_once": "कुंजी सिर्फ़ इस बार दिख रही है: अभी कॉपी करें। {VIP} की जगह VIP का पता डालें।", +"emb.link": "लिंक (उसे खोलने वाले बटन के लिए)", +"emb.frame": "एम्बेड कोड (iframe)", +"emb.copy": "कॉपी", +"emb.copied": "कॉपी हुआ", +"emb.rate": "हर दर्शक प्रति मिनट अधिकतम {n} अनुरोध कर सकता है; ज़्यादा होने पर कुछ देर मना किया जाता है। पेज हर मिनट दोबारा बनता है।", +"emb.f_name": "नाम (जैसे: ग्राहक A का एसेट प्लैटफ़ॉर्म)", +"emb.f_ranges": "कौन से VIP दिखें", +"emb.f_ranges_hint": "हर पंक्ति में एक पता या रेंज, जैसे 203.0.113.0/24। बाकी पते मना होते हैं।", +"emb.f_origins": "अनुमत साइटें", +"emb.f_origins_hint": "हर पंक्ति में एक साइट नाम, जैसे assets.example.com; *.example.com में सबडोमेन शामिल; * यानी कोई भी। पेज तभी दिखता है जब इन साइटों से खोला या एम्बेड किया जाए।", +"emb.f_clients": "दर्शकों के नेटवर्क (वैकल्पिक)", +"emb.f_clients_hint": "हर पंक्ति में एक रेंज। भरने पर सिर्फ़ इन नेटवर्क के दर्शक इसे खोल सकेंगे।", +"em.sub": "बैंडविड्थ · अंदर और बाहर", +"em.now": "अभी", +"em.peak": "शिखर", +"em.avg": "औसत", +"em.nodata": "इस अवधि में कोई ट्रैफ़िक नहीं।", +"sl.title": "syslog पर भेजें", +"sl.sub": "नतीजे बनते ही आपके SIEM या लॉग प्लैटफ़ॉर्म पर जाते हैं (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "भेजा जा रहा है", +"sl.off": "बंद", +"sl.locked": "syslog पर भेजना पंजीकृत इंस्टॉलेशन के लिए है। नतीजे पहले की तरह बनते हैं और इस पेज पर दिखते हैं।", +"sl.enabled": "नतीजे भेजें", +"sl.address": "कलेक्टर का पता (होस्ट:पोर्ट)", +"sl.network": "ट्रांसपोर्ट", +"sl.format": "फ़ॉर्मैट", +"sl.fmt_text": "सादा टेक्स्ट", +"sl.min_sev": "न्यूनतम गंभीरता", +"sl.base_url": "इस traffic66 का पता (सबूत लिंक के लिए, वैकल्पिक)", +"sl.ascii": "सिर्फ़ ASCII (पुराने कलेक्टरों के लिए)", +"sl.save": "सहेजें", +"sl.test": "टेस्ट भेजें", +"sl.saved": "सहेजा गया", +"sl.test_ok": "टेस्ट संदेश भेजा गया", +"sl.stats": "{s} भेजे · {f} विफल · प्रति मिनट सीमा से ऊपर {d} छोड़े", +"sl.last_sent": "आख़िरी बार भेजा {t}", +"sl.last_err": "आख़िरी विफलता ({t}): {e}", "fd.open": "लंबित", "fd.all": "सभी", "fd.none": "इस अवधि में कुछ नहीं मिला।", @@ -543,6 +602,7 @@ "ov.bw_all": "बैंडविड्थ (आने + जाने वाला), ऐप्लिकेशन के अनुसार", "ov.bw_in": "आने वाली बैंडविड्थ, ऐप्लिकेशन के अनुसार", "ov.bw_out": "जाने वाली बैंडविड्थ, ऐप्लिकेशन के अनुसार", +"ov.dir_empty": "इस अवधि में {if} से कोई {d} ट्रैफ़िक नहीं।", "src.manual": "हाथ से सेट", "src.templates": "मिले टेम्पलेट (field id; s = scope):", "src.stale": "बंद (डिवाइस अब दूसरे तरीके से भेज रहा है):", @@ -685,7 +745,9 @@ "ob.copy": "कॉपी करें", "nav.help": "सहायता", "rk.sub": "फ़्लो भेजने वाला हर डिवाइस कहाँ लगा है, और हर पोर्ट किस कैरियर और सर्किट से जुड़ा है", -"rk.hint": "इसे एक बार भरें। डिवाइस और पोर्ट फ़्लो से आते हैं; केवल कमरा, रैक, कैरियर और सर्किट लिखने होते हैं, जो इन्वेंटरी फ़ाइल में रहते हैं।", +"rk.summary": "{r} कक्ष · {k} रैक · {d} डिवाइस · {n} में से {p} पोर्ट पर कैरियर भरा", +"rk.room_n": "{k} रैक · {d} डिवाइस", +"rk.port": "पोर्ट", "rk.empty": "अभी तक किसी डिवाइस ने फ़्लो नहीं भेजा है।", "rk.unplaced": "अभी स्थान तय नहीं", "rk.no_rack": "कोई रैक नहीं", diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 1bbe015..bc77b01 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -276,6 +276,65 @@ "sev.high": "Tinggi", "sev.medium": "Sedang", "sev.low": "Rendah", +"emb.title": "Tautan untuk platform lain", +"emb.sub": "Beri platform lain, misalnya platform aset, tautan ke bandwidth satu VIP: tombol di samping setiap VIP, dengan alamat VIP dimasukkan ke tautan. Hanya bandwidth masuk dan keluar VIP itu yang terlihat.", +"emb.locked": "Tautan untuk platform lain adalah fitur instalasi terdaftar. Tautan yang ada masih bisa dicabut.", +"emb.new": "Tautan baru", +"emb.none": "Belum ada tautan.", +"emb.http_warn": "Mesin ini menyajikan UI web lewat HTTP; agar bisa disematkan di platform HTTPS, mesin ini juga perlu HTTPS, atau browser akan memblokirnya.", +"emb.col_name": "Nama", +"emb.col_ranges": "VIP yang boleh ditampilkan", +"emb.col_origins": "Situs yang diizinkan", +"emb.col_clients": "Jaringan penonton", +"emb.col_key": "Kunci", +"emb.col_used": "Terakhir dipakai", +"emb.never": "Belum pernah", +"emb.any": "Bebas", +"emb.hits": "{n} permintaan", +"emb.old_until": "Kunci lama berlaku sampai {t}", +"emb.edit": "Ubah", +"emb.rotate": "Kunci baru", +"emb.revoke": "Cabut", +"emb.rotate_confirm": "Beri “{n}” kunci baru? Kunci lama tetap berlaku 24 jam selama platform beralih.", +"emb.revoke_confirm": "Cabut “{n}”? Tautannya langsung berhenti bekerja.", +"emb.key_once": "Kunci hanya ditampilkan kali ini: salin sekarang. Ganti {VIP} dengan alamat VIP.", +"emb.link": "Tautan (untuk tombol yang membukanya)", +"emb.frame": "Kode sematan (iframe)", +"emb.copy": "Salin", +"emb.copied": "Tersalin", +"emb.rate": "Setiap penonton paling banyak {n} permintaan per menit; selebihnya ditolak sementara. Halaman digambar ulang setiap menit.", +"emb.f_name": "Nama (misalnya: platform aset pelanggan A)", +"emb.f_ranges": "VIP yang boleh ditampilkan", +"emb.f_ranges_hint": "Satu alamat atau rentang per baris, misalnya 203.0.113.0/24. Alamat lain ditolak.", +"emb.f_origins": "Situs yang diizinkan", +"emb.f_origins_hint": "Satu nama situs per baris, misalnya assets.example.com; *.example.com mencakup subdomain; * berarti bebas. Halaman hanya tampil bila dibuka atau disematkan dari situs ini.", +"emb.f_clients": "Jaringan penonton (opsional)", +"emb.f_clients_hint": "Satu rentang per baris. Bila diisi, hanya penonton dari jaringan ini yang bisa membukanya.", +"em.sub": "Bandwidth · masuk dan keluar", +"em.now": "Sekarang", +"em.peak": "Puncak", +"em.avg": "Rata-rata", +"em.nodata": "Tidak ada lalu lintas pada periode ini.", +"sl.title": "Kirim ke syslog", +"sl.sub": "Temuan dikirim ke SIEM atau platform log Anda begitu dibuat (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Mengirim", +"sl.off": "Mati", +"sl.locked": "Mengirim ke syslog adalah fitur instalasi terdaftar. Temuan tetap dibuat dan ditampilkan di halaman ini.", +"sl.enabled": "Kirim temuan", +"sl.address": "Alamat kolektor (host:port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Teks biasa", +"sl.min_sev": "Tingkat keparahan minimum", +"sl.base_url": "Alamat traffic66 ini (untuk tautan bukti, opsional)", +"sl.ascii": "Hanya ASCII (untuk kolektor lama)", +"sl.save": "Simpan", +"sl.test": "Kirim uji", +"sl.saved": "Tersimpan", +"sl.test_ok": "Pesan uji terkirim", +"sl.stats": "{s} terkirim · {f} gagal · {d} dibuang melewati batas per menit", +"sl.last_sent": "terakhir dikirim {t}", +"sl.last_err": "Kegagalan terakhir ({t}): {e}", "fd.open": "Belum ditangani", "fd.all": "Semua", "fd.none": "Tidak ada yang ditemukan dalam rentang waktu ini.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bandwidth (masuk + keluar), per aplikasi", "ov.bw_in": "Bandwidth masuk, per aplikasi", "ov.bw_out": "Bandwidth keluar, per aplikasi", +"ov.dir_empty": "Tidak ada lalu lintas {d} melalui {if} pada periode ini.", "src.manual": "diatur manual", "src.templates": "Template yang diterima (id field; s = scope):", "src.stale": "Berhenti (perangkat kini mengirim dengan cara lain):", @@ -685,7 +745,9 @@ "ob.copy": "Salin", "nav.help": "Bantuan", "rk.sub": "Di mana setiap perangkat yang mengirim flow berada, dan ke operator serta sirkuit mana setiap port keluar", -"rk.hint": "Cukup diisi sekali. Perangkat dan port berasal dari flow; hanya ruang, rak, operator, dan sirkuit yang diketik, disimpan di berkas inventaris.", +"rk.summary": "{r} ruang · {k} rak · {d} perangkat · operator diisi pada {p} dari {n} port", +"rk.room_n": "{k} rak · {d} perangkat", +"rk.port": "Port", "rk.empty": "Belum ada perangkat yang mengirim flow.", "rk.unplaced": "Belum ditempatkan", "rk.no_rack": "Tanpa rak", diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index 0c0df1a..73c9a81 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -276,6 +276,65 @@ "sev.high": "高", "sev.medium": "中", "sev.low": "低", +"emb.title": "外部プラットフォーム用リンク", +"emb.sub": "資産管理プラットフォームなどに、VIP ごとの帯域を表示するリンクを渡します。各 VIP の横にボタンを置き、リンクに VIP のアドレスを入れるだけです。表示されるのはその VIP の受信・送信帯域だけです。", +"emb.locked": "外部プラットフォーム用リンクは登録版の機能です。既存のリンクは無効化できます。", +"emb.new": "新しいリンク", +"emb.none": "リンクはまだありません。", +"emb.http_warn": "このマシンは HTTP で Web UI を提供しています。HTTPS のプラットフォームに埋め込むには、こちらも HTTPS が必要です。", +"emb.col_name": "名前", +"emb.col_ranges": "表示できる VIP", +"emb.col_origins": "許可するサイト", +"emb.col_clients": "閲覧者のネットワーク", +"emb.col_key": "キー", +"emb.col_used": "最終利用", +"emb.never": "未使用", +"emb.any": "制限なし", +"emb.hits": "{n} 回", +"emb.old_until": "旧キーは {t} まで有効", +"emb.edit": "編集", +"emb.rotate": "キーを更新", +"emb.revoke": "無効化", +"emb.rotate_confirm": "「{n}」に新しいキーを発行しますか?旧キーは切り替えのため 24 時間有効です。", +"emb.revoke_confirm": "「{n}」を無効化しますか?リンクはすぐに使えなくなります。", +"emb.key_once": "キーは今回だけ表示されます。今コピーしてください。{VIP} を VIP のアドレスに置き換えます。", +"emb.link": "リンク(ボタンから開く)", +"emb.frame": "埋め込みコード(iframe)", +"emb.copy": "コピー", +"emb.copied": "コピーしました", +"emb.rate": "閲覧者ごとに毎分最大 {n} リクエスト。超えると一時的に拒否します。ページは毎分更新されます。", +"emb.f_name": "名前(例:顧客 A 資産プラットフォーム)", +"emb.f_ranges": "表示できる VIP", +"emb.f_ranges_hint": "1 行に 1 つのアドレスまたは範囲(例:203.0.113.0/24)。範囲外は拒否します。", +"emb.f_origins": "許可するサイト", +"emb.f_origins_hint": "1 行に 1 つのドメイン(例:assets.example.com)。*.example.com はサブドメインを含み、* は制限なし。これらのサイトから開く・埋め込む場合のみ表示します。", +"emb.f_clients": "閲覧者のネットワーク(任意)", +"emb.f_clients_hint": "1 行に 1 つの範囲。設定すると、そのネットワークの閲覧者だけが開けます。", +"em.sub": "帯域 · 受信と送信", +"em.now": "現在", +"em.peak": "ピーク", +"em.avg": "平均", +"em.nodata": "この期間のトラフィックはありません。", +"sl.title": "syslog へ送信", +"sl.sub": "検出結果を生成と同時に SIEM やログ基盤へ送ります(Splunk、QRadar、ArcSight、Graylog、Elastic)", +"sl.on": "送信中", +"sl.off": "オフ", +"sl.locked": "syslog への送信は登録版の機能です。検出結果は引き続き生成され、このページに表示されます。", +"sl.enabled": "送信する", +"sl.address": "コレクターのアドレス(ホスト:ポート)", +"sl.network": "転送方式", +"sl.format": "形式", +"sl.fmt_text": "プレーンテキスト", +"sl.min_sev": "最低重大度", +"sl.base_url": "この traffic66 のアドレス(証拠リンク用、省略可)", +"sl.ascii": "ASCII のみ(古いコレクター向け)", +"sl.save": "保存", +"sl.test": "テスト送信", +"sl.saved": "保存しました", +"sl.test_ok": "テストメッセージを送信しました", +"sl.stats": "送信 {s} 件 · 失敗 {f} 件 · 毎分上限超過で破棄 {d} 件", +"sl.last_sent": "最終送信 {t}", +"sl.last_err": "最後の失敗({t}):{e}", "fd.open": "未対応", "fd.all": "すべて", "fd.none": "この期間には何も検知されていません。", @@ -543,6 +602,7 @@ "ov.bw_all": "帯域(受信 + 送信)、アプリ別", "ov.bw_in": "受信帯域、アプリ別", "ov.bw_out": "送信帯域、アプリ別", +"ov.dir_empty": "この期間、{if} の{d}トラフィックはありません。", "src.manual": "手動で指定", "src.templates": "受信したテンプレート(フィールド番号、s はスコープ):", "src.stale": "停止(同じ機器が別の形式で送信中):", @@ -685,7 +745,9 @@ "ob.copy": "コピー", "nav.help": "ヘルプ", "rk.sub": "フローを送る各機器の設置場所と、各ポートがつながる回線事業者と回線", -"rk.hint": "一度入力すれば済みます。機器とポートはフローから自動で並び、入力するのは部屋・ラック・回線事業者・回線 ID だけで、台帳ファイルに保存されます。", +"rk.summary": "{r} 室 · {k} ラック · {d} 台 · {n} ポート中 {p} に回線事業者あり", +"rk.room_n": "{k} ラック · {d} 台", +"rk.port": "ポート", "rk.empty": "まだフローを送った機器はありません。", "rk.unplaced": "設置場所未設定", "rk.no_rack": "ラック未設定", diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index f6ec24d..7248d61 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -276,6 +276,65 @@ "sev.high": "높음", "sev.medium": "중간", "sev.low": "낮음", +"emb.title": "외부 플랫폼용 링크", +"emb.sub": "자산 관리 플랫폼 등에 VIP 하나의 대역폭을 보여 주는 링크를 줍니다. 각 VIP 옆에 버튼을 두고 링크에 VIP 주소만 넣으면 됩니다. 그 VIP의 인바운드·아웃바운드 대역폭만 보입니다.", +"emb.locked": "외부 플랫폼용 링크는 등록판 기능입니다. 기존 링크는 계속 폐기할 수 있습니다.", +"emb.new": "새 링크", +"emb.none": "아직 링크가 없습니다.", +"emb.http_warn": "이 장비는 HTTP로 웹 UI를 제공합니다. HTTPS 플랫폼에 넣으려면 여기도 HTTPS가 필요합니다.", +"emb.col_name": "이름", +"emb.col_ranges": "볼 수 있는 VIP", +"emb.col_origins": "허용 사이트", +"emb.col_clients": "접속자 네트워크", +"emb.col_key": "키", +"emb.col_used": "마지막 사용", +"emb.never": "사용 안 함", +"emb.any": "제한 없음", +"emb.hits": "{n}회", +"emb.old_until": "이전 키 유효 기한 {t}", +"emb.edit": "수정", +"emb.rotate": "키 교체", +"emb.revoke": "폐기", +"emb.rotate_confirm": "‘{n}’의 키를 새로 발급할까요? 이전 키는 전환을 위해 24시간 더 쓸 수 있습니다.", +"emb.revoke_confirm": "‘{n}’을(를) 폐기할까요? 링크가 즉시 동작하지 않습니다.", +"emb.key_once": "키는 이번에만 보입니다. 지금 복사하세요. {VIP} 자리에 VIP 주소를 넣으면 됩니다.", +"emb.link": "링크 (버튼으로 열기)", +"emb.frame": "삽입 코드 (iframe)", +"emb.copy": "복사", +"emb.copied": "복사했습니다", +"emb.rate": "접속자마다 분당 최대 {n}회 요청할 수 있으며, 넘으면 잠시 거부합니다. 페이지는 1분마다 갱신됩니다.", +"emb.f_name": "이름 (예: 고객 A 자산 플랫폼)", +"emb.f_ranges": "볼 수 있는 VIP", +"emb.f_ranges_hint": "한 줄에 주소나 대역 하나 (예: 203.0.113.0/24). 그 밖의 주소는 거부합니다.", +"emb.f_origins": "허용 사이트", +"emb.f_origins_hint": "한 줄에 도메인 하나 (예: assets.example.com). *.example.com은 하위 도메인 포함, *는 제한 없음. 이 사이트에서 열거나 삽입할 때만 보입니다.", +"emb.f_clients": "접속자 네트워크 (선택)", +"emb.f_clients_hint": "한 줄에 대역 하나. 설정하면 이 네트워크의 접속자만 열 수 있습니다.", +"em.sub": "대역폭 · 인바운드와 아웃바운드", +"em.now": "현재", +"em.peak": "최대", +"em.avg": "평균", +"em.nodata": "이 기간에 트래픽이 없습니다.", +"sl.title": "syslog로 보내기", +"sl.sub": "탐지 결과가 생기면 바로 SIEM이나 로그 플랫폼으로 보냅니다 (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "전송 중", +"sl.off": "꺼짐", +"sl.locked": "syslog 전송은 등록판 기능입니다. 탐지 결과는 계속 만들어지고 이 페이지에 표시됩니다.", +"sl.enabled": "전송 켜기", +"sl.address": "수집기 주소 (호스트:포트)", +"sl.network": "전송 방식", +"sl.format": "형식", +"sl.fmt_text": "일반 텍스트", +"sl.min_sev": "최소 심각도", +"sl.base_url": "이 traffic66의 주소 (증거 링크용, 선택)", +"sl.ascii": "ASCII만 (오래된 수집기용)", +"sl.save": "저장", +"sl.test": "테스트 보내기", +"sl.saved": "저장했습니다", +"sl.test_ok": "테스트 메시지를 보냈습니다", +"sl.stats": "보냄 {s}건 · 실패 {f}건 · 분당 한도 초과로 버림 {d}건", +"sl.last_sent": "마지막 전송 {t}", +"sl.last_err": "마지막 실패 ({t}): {e}", "fd.open": "미처리", "fd.all": "전체", "fd.none": "이 기간에는 탐지된 것이 없습니다.", @@ -543,6 +602,7 @@ "ov.bw_all": "대역폭(수신 + 송신), 애플리케이션별", "ov.bw_in": "수신 대역폭, 애플리케이션별", "ov.bw_out": "송신 대역폭, 애플리케이션별", +"ov.dir_empty": "이 기간에 {if}의 {d} 트래픽이 없습니다.", "src.manual": "수동 지정", "src.templates": "받은 템플릿(필드 번호, s는 스코프):", "src.stale": "중지됨(같은 장비가 지금은 다른 방식으로 보냄):", @@ -685,7 +745,9 @@ "ob.copy": "복사", "nav.help": "도움말", "rk.sub": "플로를 보내는 각 장비의 위치와, 각 포트가 연결된 통신사와 회선", -"rk.hint": "한 번만 입력하면 됩니다. 장비와 포트는 플로에서 나오고, 입력하는 것은 전산실·랙·통신사·회선 ID뿐이며 대장 파일에 저장됩니다.", +"rk.summary": "전산실 {r}개 · 랙 {k}개 · 장비 {d}대 · 포트 {n}개 중 {p}개 통신사 입력", +"rk.room_n": "랙 {k}개 · 장비 {d}대", +"rk.port": "포트", "rk.empty": "아직 플로를 보낸 장비가 없습니다.", "rk.unplaced": "위치 미지정", "rk.no_rack": "랙 미지정", diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index 4133229..9c939c4 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -276,6 +276,65 @@ "sev.high": "Alta", "sev.medium": "Média", "sev.low": "Baixa", +"emb.title": "Links para outras plataformas", +"emb.sub": "Dê a outra plataforma, como uma de ativos, um link para a largura de banda de um VIP: um botão ao lado de cada VIP, com o endereço no link. Mostra apenas a largura de banda de entrada e saída desse VIP.", +"emb.locked": "Links para outras plataformas são para instalações registradas. Links existentes ainda podem ser revogados.", +"emb.new": "Novo link", +"emb.none": "Ainda não há links.", +"emb.http_warn": "Esta máquina serve a interface por HTTP; para ser incorporada numa plataforma HTTPS também precisa de HTTPS, ou o navegador bloqueia.", +"emb.col_name": "Nome", +"emb.col_ranges": "VIPs que pode mostrar", +"emb.col_origins": "Sites permitidos", +"emb.col_clients": "Redes dos visitantes", +"emb.col_key": "Chave", +"emb.col_used": "Último uso", +"emb.never": "Nunca", +"emb.any": "Qualquer", +"emb.hits": "{n} pedidos", +"emb.old_until": "A chave antiga vale até {t}", +"emb.edit": "Editar", +"emb.rotate": "Nova chave", +"emb.revoke": "Revogar", +"emb.rotate_confirm": "Dar a “{n}” uma nova chave? A antiga continua valendo por 24 horas enquanto a plataforma troca.", +"emb.revoke_confirm": "Revogar “{n}”? Seus links param de funcionar na hora.", +"emb.key_once": "A chave é mostrada só desta vez: copie agora. Coloque o endereço do VIP no lugar de {VIP}.", +"emb.link": "Link (para um botão que o abre)", +"emb.frame": "Código de incorporação (iframe)", +"emb.copy": "Copiar", +"emb.copied": "Copiado", +"emb.rate": "Cada visitante pode fazer no máximo {n} pedidos por minuto; os demais são recusados por um tempo. A página é redesenhada a cada minuto.", +"emb.f_name": "Nome (por exemplo: plataforma de ativos do cliente A)", +"emb.f_ranges": "VIPs que pode mostrar", +"emb.f_ranges_hint": "Um endereço ou faixa por linha, como 203.0.113.0/24. Qualquer outro endereço é recusado.", +"emb.f_origins": "Sites permitidos", +"emb.f_origins_hint": "Um nome de site por linha, como assets.example.com; *.example.com inclui subdomínios; * permite qualquer um. A página só aparece se aberta ou incorporada a partir desses sites.", +"emb.f_clients": "Redes dos visitantes (opcional)", +"emb.f_clients_hint": "Uma faixa por linha. Se preenchido, só visitantes dessas redes podem abri-la.", +"em.sub": "Largura de banda · entrada e saída", +"em.now": "Atual", +"em.peak": "Pico", +"em.avg": "Média", +"em.nodata": "Sem tráfego neste período.", +"sl.title": "Enviar para syslog", +"sl.sub": "As descobertas vão para o seu SIEM ou plataforma de logs assim que são geradas (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Enviando", +"sl.off": "Desligado", +"sl.locked": "O envio para syslog é para instalações registradas. As descobertas continuam sendo geradas e exibidas nesta página.", +"sl.enabled": "Enviar descobertas", +"sl.address": "Endereço do coletor (host:porta)", +"sl.network": "Transporte", +"sl.format": "Formato", +"sl.fmt_text": "Texto simples", +"sl.min_sev": "Gravidade mínima", +"sl.base_url": "Endereço deste traffic66 (para o link de evidência, opcional)", +"sl.ascii": "Somente ASCII (coletores antigos)", +"sl.save": "Salvar", +"sl.test": "Enviar um teste", +"sl.saved": "Salvo", +"sl.test_ok": "Mensagem de teste enviada", +"sl.stats": "{s} enviadas · {f} com falha · {d} descartadas acima do limite por minuto", +"sl.last_sent": "último envio {t}", +"sl.last_err": "Última falha ({t}): {e}", "fd.open": "Pendentes", "fd.all": "Todas", "fd.none": "Nada foi encontrado neste período.", @@ -543,6 +602,7 @@ "ov.bw_all": "Largura de banda (entrada + saída), por aplicação", "ov.bw_in": "Largura de banda de entrada, por aplicação", "ov.bw_out": "Largura de banda de saída, por aplicação", +"ov.dir_empty": "Sem tráfego ({d}) por {if} neste período.", "src.manual": "definido à mão", "src.templates": "Modelos recebidos (ids de campo; s = escopo):", "src.stale": "Parados (o equipamento agora envia de outra forma):", @@ -685,7 +745,9 @@ "ob.copy": "Copiar", "nav.help": "Ajuda", "rk.sub": "Onde fica cada equipamento que envia fluxos e para qual operadora e circuito sai cada porta", -"rk.hint": "Basta preencher uma vez. Equipamentos e portas vêm dos fluxos; só sala, rack, operadora e circuito são digitados, e ficam no arquivo de inventário.", +"rk.summary": "{r} salas · {k} racks · {d} equipamentos · operadora indicada em {p} de {n} portas", +"rk.room_n": "{k} racks · {d} equipamentos", +"rk.port": "Porta", "rk.empty": "Nenhum equipamento enviou fluxos ainda.", "rk.unplaced": "Ainda sem local", "rk.no_rack": "Sem rack", diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index 69a6741..ab9f25a 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -276,6 +276,65 @@ "sev.high": "Высокая", "sev.medium": "Средняя", "sev.low": "Низкая", +"emb.title": "Ссылки для других платформ", +"emb.sub": "Дайте другой платформе, например платформе учёта активов, ссылку на полосу одного VIP: кнопка рядом с каждым VIP, адрес подставляется в ссылку. Видна только входящая и исходящая полоса этого VIP.", +"emb.locked": "Ссылки для других платформ доступны зарегистрированным установкам. Существующие ссылки по-прежнему можно отозвать.", +"emb.new": "Новая ссылка", +"emb.none": "Ссылок пока нет.", +"emb.http_warn": "Эта машина отдаёт веб-интерфейс по HTTP; чтобы встроиться в платформу на HTTPS, ей тоже нужен HTTPS, иначе браузер заблокирует.", +"emb.col_name": "Название", +"emb.col_ranges": "Какие VIP показывать", +"emb.col_origins": "Разрешённые сайты", +"emb.col_clients": "Сети зрителей", +"emb.col_key": "Ключ", +"emb.col_used": "Последнее использование", +"emb.never": "Никогда", +"emb.any": "Любые", +"emb.hits": "{n} запросов", +"emb.old_until": "Старый ключ действует до {t}", +"emb.edit": "Изменить", +"emb.rotate": "Новый ключ", +"emb.revoke": "Отозвать", +"emb.rotate_confirm": "Выдать «{n}» новый ключ? Старый будет действовать ещё 24 часа, пока платформа переходит.", +"emb.revoke_confirm": "Отозвать «{n}»? Ссылки сразу перестанут работать.", +"emb.key_once": "Ключ показывается только сейчас — скопируйте его. Подставьте адрес VIP вместо {VIP}.", +"emb.link": "Ссылка (для кнопки, которая её открывает)", +"emb.frame": "Код для встраивания (iframe)", +"emb.copy": "Копировать", +"emb.copied": "Скопировано", +"emb.rate": "Каждый зритель может сделать не больше {n} запросов в минуту; лишние на время отклоняются. Страница обновляется раз в минуту.", +"emb.f_name": "Название (например: платформа активов клиента A)", +"emb.f_ranges": "Какие VIP показывать", +"emb.f_ranges_hint": "По одному адресу или диапазону в строке, например 203.0.113.0/24. Остальные адреса отклоняются.", +"emb.f_origins": "Разрешённые сайты", +"emb.f_origins_hint": "По одному имени сайта в строке, например assets.example.com; *.example.com включает поддомены; * — любые. Страница показывается, только если её открыли или встроили с этих сайтов.", +"emb.f_clients": "Сети зрителей (необязательно)", +"emb.f_clients_hint": "По одному диапазону в строке. Если задано, открыть могут только зрители из этих сетей.", +"em.sub": "Полоса · входящая и исходящая", +"em.now": "Сейчас", +"em.peak": "Пик", +"em.avg": "Среднее", +"em.nodata": "За этот период трафика нет.", +"sl.title": "Отправка в syslog", +"sl.sub": "Находки сразу уходят в ваш SIEM или платформу журналов (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Отправляется", +"sl.off": "Выключено", +"sl.locked": "Отправка в syslog доступна зарегистрированным установкам. Находки по-прежнему создаются и показываются на этой странице.", +"sl.enabled": "Отправлять находки", +"sl.address": "Адрес коллектора (хост:порт)", +"sl.network": "Транспорт", +"sl.format": "Формат", +"sl.fmt_text": "Простой текст", +"sl.min_sev": "Минимальная важность", +"sl.base_url": "Адрес этого traffic66 (для ссылки на доказательства, необязательно)", +"sl.ascii": "Только ASCII (для старых коллекторов)", +"sl.save": "Сохранить", +"sl.test": "Отправить тест", +"sl.saved": "Сохранено", +"sl.test_ok": "Тестовое сообщение отправлено", +"sl.stats": "Отправлено: {s} · ошибок: {f} · отброшено сверх лимита в минуту: {d}", +"sl.last_sent": "последняя отправка {t}", +"sl.last_err": "Последняя ошибка ({t}): {e}", "fd.open": "Открытые", "fd.all": "Все", "fd.none": "За этот период ничего не обнаружено.", @@ -543,6 +602,7 @@ "ov.bw_all": "Полоса (вход + выход), по приложениям", "ov.bw_in": "Входящая полоса, по приложениям", "ov.bw_out": "Исходящая полоса, по приложениям", +"ov.dir_empty": "За этот период через {if} нет трафика ({d}).", "src.manual": "задано вручную", "src.templates": "Полученные шаблоны (номера полей; s — область):", "src.stale": "Остановлены (устройство теперь шлёт иначе):", @@ -685,7 +745,9 @@ "ob.copy": "Копировать", "nav.help": "Справка", "rk.sub": "Где стоит каждое устройство, присылающее потоки, и к какому оператору и каналу ведёт каждый порт", -"rk.hint": "Заполняется один раз. Устройства и порты берутся из потоков; вводятся только помещение, стойка, оператор и канал, они хранятся в файле реестра.", +"rk.summary": "Залов: {r} · стоек: {k} · устройств: {d} · оператор указан для {p} из {n} портов", +"rk.room_n": "стоек: {k} · устройств: {d}", +"rk.port": "Порт", "rk.empty": "Ни одно устройство ещё не прислало потоков.", "rk.unplaced": "Место не указано", "rk.no_rack": "Без стойки", diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 829f172..eb8e2ba 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -276,6 +276,65 @@ "sev.high": "زیادہ", "sev.medium": "درمیانہ", "sev.low": "کم", +"emb.title": "دوسرے پلیٹ فارمز کے لیے لنک", +"emb.sub": "کسی دوسرے پلیٹ فارم، جیسے اثاثہ پلیٹ فارم، کو ایک VIP کی بینڈوڈتھ کا لنک دیں: ہر VIP کے ساتھ ایک بٹن، لنک میں VIP کا پتا ڈال کر۔ اس میں صرف اسی VIP کی آنے اور جانے والی بینڈوڈتھ دکھتی ہے۔", +"emb.locked": "دوسرے پلیٹ فارمز کے لنک رجسٹرڈ تنصیبات کے لیے ہیں۔ موجودہ لنک اب بھی واپس لیے جا سکتے ہیں۔", +"emb.new": "نیا لنک", +"emb.none": "ابھی کوئی لنک نہیں۔", +"emb.http_warn": "یہ مشین ویب UI کو HTTP پر دیتی ہے؛ HTTPS پلیٹ فارم میں شامل کرنے کے لیے یہاں بھی HTTPS چاہیے، ورنہ براؤزر روک دے گا۔", +"emb.col_name": "نام", +"emb.col_ranges": "کون سے VIP دکھائے جائیں", +"emb.col_origins": "اجازت یافتہ سائٹس", +"emb.col_clients": "دیکھنے والوں کے نیٹ ورک", +"emb.col_key": "کلید", +"emb.col_used": "آخری استعمال", +"emb.never": "کبھی نہیں", +"emb.any": "کوئی بھی", +"emb.hits": "{n} درخواستیں", +"emb.old_until": "پرانی کلید {t} تک چلے گی", +"emb.edit": "ترمیم", +"emb.rotate": "نئی کلید", +"emb.revoke": "واپس لیں", +"emb.rotate_confirm": "“{n}” کو نئی کلید دیں؟ پلیٹ فارم کے بدلنے تک پرانی 24 گھنٹے مزید چلے گی۔", +"emb.revoke_confirm": "“{n}” واپس لیں؟ اس کے لنک فوراً بند ہو جائیں گے۔", +"emb.key_once": "کلید صرف اسی بار دکھائی جا رہی ہے: ابھی کاپی کریں۔ {VIP} کی جگہ VIP کا پتا ڈالیں۔", +"emb.link": "لنک (اسے کھولنے والے بٹن کے لیے)", +"emb.frame": "ایمبیڈ کوڈ (iframe)", +"emb.copy": "کاپی", +"emb.copied": "کاپی ہو گیا", +"emb.rate": "ہر دیکھنے والا فی منٹ زیادہ سے زیادہ {n} درخواستیں کر سکتا ہے؛ زیادہ ہونے پر کچھ دیر انکار ہوتا ہے۔ صفحہ ہر منٹ دوبارہ بنتا ہے۔", +"emb.f_name": "نام (مثلاً: گاہک A کا اثاثہ پلیٹ فارم)", +"emb.f_ranges": "کون سے VIP دکھائے جائیں", +"emb.f_ranges_hint": "ہر سطر میں ایک پتا یا رینج، جیسے 203.0.113.0/24۔ باقی پتوں سے انکار ہوتا ہے۔", +"emb.f_origins": "اجازت یافتہ سائٹس", +"emb.f_origins_hint": "ہر سطر میں ایک سائٹ کا نام، جیسے assets.example.com؛ *.example.com میں ذیلی ڈومین شامل؛ * یعنی کوئی بھی۔ صفحہ صرف انہی سائٹس سے کھولنے یا شامل کرنے پر دکھتا ہے۔", +"emb.f_clients": "دیکھنے والوں کے نیٹ ورک (اختیاری)", +"emb.f_clients_hint": "ہر سطر میں ایک رینج۔ بھرنے پر صرف انہی نیٹ ورکس کے دیکھنے والے اسے کھول سکیں گے۔", +"em.sub": "بینڈوڈتھ · اندر اور باہر", +"em.now": "ابھی", +"em.peak": "بلند ترین", +"em.avg": "اوسط", +"em.nodata": "اس مدت میں کوئی ٹریفک نہیں۔", +"sl.title": "syslog پر بھیجیں", +"sl.sub": "نتائج بنتے ہی آپ کے SIEM یا لاگ پلیٹ فارم پر جاتے ہیں (Splunk، QRadar، ArcSight، Graylog، Elastic)", +"sl.on": "بھیجا جا رہا ہے", +"sl.off": "بند", +"sl.locked": "syslog پر بھیجنا رجسٹرڈ تنصیبات کے لیے ہے۔ نتائج پہلے کی طرح بنتے اور اس صفحے پر دکھتے ہیں۔", +"sl.enabled": "نتائج بھیجیں", +"sl.address": "کلیکٹر کا پتا (ہوسٹ:پورٹ)", +"sl.network": "ٹرانسپورٹ", +"sl.format": "فارمیٹ", +"sl.fmt_text": "سادہ متن", +"sl.min_sev": "کم از کم شدت", +"sl.base_url": "اس traffic66 کا پتا (ثبوت کے لنک کے لیے، اختیاری)", +"sl.ascii": "صرف ASCII (پرانے کلیکٹرز کے لیے)", +"sl.save": "محفوظ کریں", +"sl.test": "ٹیسٹ بھیجیں", +"sl.saved": "محفوظ ہو گیا", +"sl.test_ok": "ٹیسٹ پیغام بھیج دیا گیا", +"sl.stats": "{s} بھیجے · {f} ناکام · فی منٹ حد سے اوپر {d} چھوڑے", +"sl.last_sent": "آخری بار بھیجا {t}", +"sl.last_err": "آخری ناکامی ({t}): {e}", "fd.open": "زیرِ التوا", "fd.all": "سب", "fd.none": "اس مدت میں کچھ نہیں ملا۔", @@ -543,6 +602,7 @@ "ov.bw_all": "بینڈوڈتھ (آنے + جانے والی)، ایپلیکیشن کے لحاظ سے", "ov.bw_in": "آنے والی بینڈوڈتھ، ایپلیکیشن کے لحاظ سے", "ov.bw_out": "جانے والی بینڈوڈتھ، ایپلیکیشن کے لحاظ سے", +"ov.dir_empty": "اس مدت میں {if} سے کوئی {d} ٹریفک نہیں۔", "src.manual": "ہاتھ سے مقرر", "src.templates": "موصول ٹیمپلیٹ (field id؛ s = scope):", "src.stale": "بند (آلہ اب دوسرے طریقے سے بھیج رہا ہے):", @@ -685,7 +745,9 @@ "ob.copy": "کاپی", "nav.help": "مدد", "rk.sub": "فلو بھیجنے والا ہر آلہ کہاں لگا ہے، اور ہر پورٹ کس کیریئر اور سرکٹ سے جڑا ہے", -"rk.hint": "اسے ایک بار بھریں۔ آلات اور پورٹس فلو سے آتے ہیں؛ صرف کمرہ، ریک، کیریئر اور سرکٹ لکھنے ہوتے ہیں، جو رجسٹر فائل میں رہتے ہیں۔", +"rk.summary": "{r} کمرے · {k} ریک · {d} آلات · {n} میں سے {p} پورٹس پر کیریئر درج", +"rk.room_n": "{k} ریک · {d} آلات", +"rk.port": "پورٹ", "rk.empty": "ابھی تک کسی آلے نے فلو نہیں بھیجا۔", "rk.unplaced": "ابھی جگہ مقرر نہیں", "rk.no_rack": "کوئی ریک نہیں", diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 6fd1e44..70e8a62 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -276,6 +276,65 @@ "sev.high": "Cao", "sev.medium": "Trung bình", "sev.low": "Thấp", +"emb.title": "Liên kết cho nền tảng khác", +"emb.sub": "Cho một nền tảng khác, như nền tảng quản lý tài sản, một liên kết tới băng thông của một VIP: một nút cạnh mỗi VIP, với địa chỉ VIP đặt vào liên kết. Chỉ hiện băng thông vào và ra của VIP đó.", +"emb.locked": "Liên kết cho nền tảng khác là tính năng của bản đã đăng ký. Vẫn có thể thu hồi các liên kết hiện có.", +"emb.new": "Liên kết mới", +"emb.none": "Chưa có liên kết.", +"emb.http_warn": "Máy này phục vụ giao diện qua HTTP; để nhúng vào nền tảng HTTPS thì máy này cũng cần HTTPS, nếu không trình duyệt sẽ chặn.", +"emb.col_name": "Tên", +"emb.col_ranges": "VIP được hiển thị", +"emb.col_origins": "Trang được phép", +"emb.col_clients": "Mạng của người xem", +"emb.col_key": "Khóa", +"emb.col_used": "Dùng lần cuối", +"emb.never": "Chưa dùng", +"emb.any": "Bất kỳ", +"emb.hits": "{n} yêu cầu", +"emb.old_until": "Khóa cũ còn hiệu lực đến {t}", +"emb.edit": "Sửa", +"emb.rotate": "Khóa mới", +"emb.revoke": "Thu hồi", +"emb.rotate_confirm": "Cấp khóa mới cho “{n}”? Khóa cũ vẫn dùng được 24 giờ trong lúc nền tảng chuyển đổi.", +"emb.revoke_confirm": "Thu hồi “{n}”? Các liên kết của nó ngừng hoạt động ngay.", +"emb.key_once": "Khóa chỉ hiện lần này: hãy sao chép ngay. Thay {VIP} bằng địa chỉ VIP.", +"emb.link": "Liên kết (cho nút mở nó)", +"emb.frame": "Mã nhúng (iframe)", +"emb.copy": "Sao chép", +"emb.copied": "Đã sao chép", +"emb.rate": "Mỗi người xem tối đa {n} yêu cầu mỗi phút; vượt quá sẽ bị từ chối một lúc. Trang được vẽ lại mỗi phút.", +"emb.f_name": "Tên (ví dụ: nền tảng tài sản của khách hàng A)", +"emb.f_ranges": "VIP được hiển thị", +"emb.f_ranges_hint": "Mỗi dòng một địa chỉ hoặc dải, ví dụ 203.0.113.0/24. Địa chỉ khác bị từ chối.", +"emb.f_origins": "Trang được phép", +"emb.f_origins_hint": "Mỗi dòng một tên trang, ví dụ assets.example.com; *.example.com gồm cả tên miền con; * là bất kỳ. Trang chỉ hiện khi được mở hoặc nhúng từ các trang này.", +"emb.f_clients": "Mạng của người xem (tùy chọn)", +"emb.f_clients_hint": "Mỗi dòng một dải. Khi điền, chỉ người xem trong các mạng này mới mở được.", +"em.sub": "Băng thông · vào và ra", +"em.now": "Hiện tại", +"em.peak": "Đỉnh", +"em.avg": "Trung bình", +"em.nodata": "Không có lưu lượng trong khoảng này.", +"sl.title": "Gửi tới syslog", +"sl.sub": "Phát hiện được gửi tới SIEM hoặc nền tảng nhật ký của bạn ngay khi tạo ra (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Đang gửi", +"sl.off": "Tắt", +"sl.locked": "Gửi tới syslog là tính năng của bản đã đăng ký. Phát hiện vẫn được tạo và hiển thị trên trang này.", +"sl.enabled": "Gửi phát hiện", +"sl.address": "Địa chỉ bộ thu (máy:cổng)", +"sl.network": "Giao thức", +"sl.format": "Định dạng", +"sl.fmt_text": "Văn bản thuần", +"sl.min_sev": "Mức độ tối thiểu", +"sl.base_url": "Địa chỉ của traffic66 này (cho liên kết bằng chứng, không bắt buộc)", +"sl.ascii": "Chỉ ASCII (cho bộ thu cũ)", +"sl.save": "Lưu", +"sl.test": "Gửi thử", +"sl.saved": "Đã lưu", +"sl.test_ok": "Đã gửi tin nhắn thử", +"sl.stats": "Đã gửi {s} · lỗi {f} · bỏ {d} do vượt giới hạn mỗi phút", +"sl.last_sent": "gửi lần cuối {t}", +"sl.last_err": "Lỗi gần nhất ({t}): {e}", "fd.open": "Đang mở", "fd.all": "Tất cả", "fd.none": "Không phát hiện gì trong khoảng thời gian này.", @@ -543,6 +602,7 @@ "ov.bw_all": "Băng thông (vào + ra), theo ứng dụng", "ov.bw_in": "Băng thông vào, theo ứng dụng", "ov.bw_out": "Băng thông ra, theo ứng dụng", +"ov.dir_empty": "Không có lưu lượng {d} qua {if} trong khoảng này.", "src.manual": "đặt thủ công", "src.templates": "Template đã nhận (field id; s = scope):", "src.stale": "Đã ngừng (thiết bị giờ gửi theo cách khác):", @@ -685,7 +745,9 @@ "ob.copy": "Sao chép", "nav.help": "Trợ giúp", "rk.sub": "Mỗi thiết bị gửi luồng đặt ở đâu, và mỗi cổng đi ra nhà mạng, đường truyền nào", -"rk.hint": "Chỉ cần nhập một lần. Thiết bị và cổng lấy từ luồng; chỉ nhập phòng máy, tủ rack, nhà mạng và mã đường truyền, lưu trong tệp sổ quản lý.", +"rk.summary": "{r} phòng · {k} tủ rack · {d} thiết bị · đã điền nhà mạng cho {p}/{n} cổng", +"rk.room_n": "{k} tủ rack · {d} thiết bị", +"rk.port": "Cổng", "rk.empty": "Chưa có thiết bị nào gửi luồng.", "rk.unplaced": "Chưa xếp chỗ", "rk.no_rack": "Chưa có tủ rack", diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 02c5004..8965489 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -171,8 +171,8 @@ "if.counter": "设备计数器", "if.dev": "偏差 {v}", "if.stat": "统计误差 ±{v}", -"if.in": "入方向", -"if.out": "出方向", +"if.in": "入向", +"if.out": "出向", "if.no_ctr_short": "无计数器", "if.no_counters": "这台设备不发送接口计数器,没有可对比的数据。在「台账」里加一行 snmp 10.0.0.1 public,即可通过 SNMP 读取。", "if.no_flow": "这个接口有计数器数据,但没有收到任何 flow。请检查设备的 flow 导出(是否配置了采样但没有导出?)", @@ -276,6 +276,65 @@ "sev.high": "高", "sev.medium": "中", "sev.low": "低", +"emb.title": "嵌入链接", +"emb.sub": "给其他平台(比如资产平台)一个查看单个 VIP 带宽的链接:在平台里每个 VIP 旁加一个按钮,链接里换上 VIP 地址即可。对方只能看到这个 VIP 的入向、出向带宽。", +"emb.locked": "嵌入链接是注册版功能。已有的链接仍可作废。", +"emb.new": "新建链接", +"emb.none": "还没有链接。", +"emb.http_warn": "本机目前用 HTTP 提供网页;要嵌入到 HTTPS 的平台里,本机也需要启用 HTTPS,否则浏览器会拦截。", +"emb.col_name": "名称", +"emb.col_ranges": "可查看的 VIP", +"emb.col_origins": "允许的来源网站", +"emb.col_clients": "访问者 IP 段", +"emb.col_key": "密钥", +"emb.col_used": "最近使用", +"emb.never": "从未使用", +"emb.any": "不限", +"emb.hits": "{n} 次", +"emb.old_until": "旧密钥有效至 {t}", +"emb.edit": "修改", +"emb.rotate": "换密钥", +"emb.revoke": "作废", +"emb.rotate_confirm": "给「{n}」换一个新密钥?旧密钥还能再用 24 小时,方便平台切换。", +"emb.revoke_confirm": "作废「{n}」?它的链接会立即失效。", +"emb.key_once": "密钥只显示这一次,请现在复制。把 {VIP} 换成 VIP 地址即可。", +"emb.link": "链接(点按钮跳转打开)", +"emb.frame": "嵌入代码(iframe)", +"emb.copy": "复制", +"emb.copied": "已复制", +"emb.rate": "每个访问者每分钟最多 {n} 次请求,超出会暂时拒绝;页面每分钟刷新一次。", +"emb.f_name": "名称(例如:客户 A 资产平台)", +"emb.f_ranges": "可查看的 VIP", +"emb.f_ranges_hint": "每行一个地址或网段,例如 203.0.113.0/24。范围外的地址一律拒绝。", +"emb.f_origins": "允许的来源网站", +"emb.f_origins_hint": "每行一个域名,例如 assets.example.com;*.example.com 包括子域名;填 * 表示不限。只有从这些网站打开或嵌入才会显示。", +"emb.f_clients": "访问者 IP 段(可不填)", +"emb.f_clients_hint": "每行一个网段。填了之后,只有这些网络里的访问者能打开。", +"em.sub": "带宽 · 入向与出向", +"em.now": "当前", +"em.peak": "峰值", +"em.avg": "平均", +"em.nodata": "这段时间没有流量。", +"sl.title": "发送到 SYSLOG", +"sl.sub": "发现产生时同时发到你的 SIEM 或日志平台(Splunk、QRadar、ArcSight、Graylog、Elastic)", +"sl.on": "发送中", +"sl.off": "未启用", +"sl.locked": "发送到 SYSLOG 是注册版功能。发现照常生成,并在本页显示。", +"sl.enabled": "启用", +"sl.address": "采集器地址(主机:端口)", +"sl.network": "连接方式", +"sl.format": "格式", +"sl.fmt_text": "纯文本", +"sl.min_sev": "最低级别", +"sl.base_url": "traffic66 访问地址(用于证据链接,可不填)", +"sl.ascii": "只发 ASCII 字符(较老的采集器需要)", +"sl.save": "保存", +"sl.test": "发一条测试", +"sl.saved": "已保存", +"sl.test_ok": "测试消息已发出", +"sl.stats": "已发送 {s} 条 · 失败 {f} 条 · 超出每分钟上限丢弃 {d} 条", +"sl.last_sent": "最近发送 {t}", +"sl.last_err": "最近一次失败({t}):{e}", "fd.open": "未处理", "fd.all": "全部", "fd.none": "这个时间范围内没有发现。", @@ -541,8 +600,9 @@ "theme.next": "点击换下一个主题", "ov.dir_total": "合计", "ov.bw_all": "带宽(入 + 出),按应用", -"ov.bw_in": "入站带宽,按应用", -"ov.bw_out": "出站带宽,按应用", +"ov.bw_in": "入向带宽,按应用", +"ov.bw_out": "出向带宽,按应用", +"ov.dir_empty": "这段时间 {if} 没有{d}流量。", "src.manual": "手动指定", "src.templates": "收到的模板(字段编号,s 表示作用域):", "src.stale": "已停用(同一设备现在用别的方式在发):", @@ -685,7 +745,9 @@ "ob.copy": "复制", "nav.help": "帮助", "rk.sub": "每台发送流量的设备在哪个机房、哪个机柜,每个接口接的是哪家运营商、哪条线路", -"rk.hint": "录入一次即可。设备和接口来自流量本身,只需填写机房、机柜、运营商和线路 ID,保存在台账文件里。", +"rk.summary": "{r} 个机房 · {k} 个机柜 · {d} 台设备 · {p}/{n} 个接口已填运营商", +"rk.room_n": "{k} 个机柜 · {d} 台设备", +"rk.port": "端口", "rk.empty": "还没有设备发送流量。", "rk.unplaced": "未指定机房", "rk.no_rack": "未指定机柜", diff --git a/internal/web/static/theme.js b/internal/web/static/theme.js index fc1b204..af19dc8 100644 --- a/internal/web/static/theme.js +++ b/internal/web/static/theme.js @@ -1,13 +1,14 @@ // Applies the colour theme before the page is drawn, so it does not flash. // The choice is kept per browser; clicking the theme button goes to the next. +// Black is the default: the pages are mostly charts, often on a wall screen. (function () { var THEMES = ['light', 'black', 'teal', 'orange']; var OLD = {bright: 'light', dim: 'black', dark: 'black', auto: 'light', gray: 'light'}; function get() { - var v = 'light'; - try { v = localStorage.getItem('t66.theme') || 'light'; } catch (e) {} + var v = 'black'; + try { v = localStorage.getItem('t66.theme') || 'black'; } catch (e) {} if (OLD[v]) { v = OLD[v]; try { localStorage.setItem('t66.theme', v); } catch (e) {} } - return THEMES.indexOf(v) < 0 ? 'light' : v; + return THEMES.indexOf(v) < 0 ? 'black' : v; } function apply(x) { if (x === 'light') document.documentElement.removeAttribute('data-theme'); From 19362b51ab4b87c33c7d45d25331f8049e17d0ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:20:48 +0000 Subject: [PATCH 2/4] Release notes --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14278d9..b73c599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,12 @@ heading is the version number. ## 2.5.0 -- Overview: Inbound and Outbound split the interface charted above the way that chart does — what came in through it and what went out through it — so the two charts agree. Telling the direction by address instead left the chart empty wherever the served addresses are public and not listed as your own networks, or private at both ends: by address all of that is transit or internal. Without an interface the address is still what decides. The buttons read 入向 / 出向 in Chinese, like the interface chart, and an empty direction says so instead of saying the data is still arriving. The query filters `iface_in` and `iface_out` (exporter/ifindex) do the same in the API. -- Rooms and racks is redrawn: each rack with its devices as faceplates, each port with its carrier as a coloured tag (one colour per carrier) and its circuit ID beside it, and a count at the top of how many ports have a carrier. One form per device sets its name, room and rack and every port's name, carrier and circuit, saved with one click (`POST /api/racks/device` takes an `ifaces` list). The hint line is gone. -- Pages that are not about the traffic — settings, inventory, rooms and racks, offline analysis, data cleanup, help, account — no longer show the filter chips. -- Black is the default theme. A theme picked before is kept. -- Findings can be sent to syslog from the findings page: collector address, UDP / TCP / TLS, CEF, LEEF, JSON or plain text, the least severity, with a test button and how many messages went, failed or were dropped over the per-minute ceiling. The settings are kept in `/syslog.json` and take effect at once; the `-syslog` flags are the settings until something is saved there. Sending is for registered installations; the licence is checked at each finding, so a feed follows the licence without a restart. -- Links for other platforms (registered installations): `/embed/vip?ip=
&k=` shows one address's bandwidth, in and out, over 1 hour, 24 hours (the default), 7 or 30 days, and nothing else. An asset platform puts the address into one fixed link to give each of its VIPs a traffic button, or frames the page. Each platform has one key, set up on the settings page with the addresses it may show, the sites it may be opened or framed from, and optionally the viewer networks; the key is kept as a hash and shown once, can be replaced (the old one works for 24 more hours) or withdrawn at once, and each viewer may make 60 requests a minute. The links are kept in `/embeds.json`. +- Overview: inbound and outbound follow the interface charted above. +- Rooms and racks: new look; a device and its ports are edited in one form. +- Filter chips only on the traffic pages. +- Black is the default theme. +- Syslog for findings can be set from the findings page. +- Links that show one address's bandwidth on another platform (registered installations). ## 2.4.1 From e0b50e5902fb977835bf4505e86dfbca535004bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:47:35 +0000 Subject: [PATCH 3/4] Build changes --- .github/workflows/ci.yml | 16 ++ cmd/traffic66/main.go | 83 ++---- internal/api/cleanup.go | 70 ----- internal/api/embed.go | 192 ------------- internal/api/embed_test.go | 106 ------- internal/api/server.go | 26 +- internal/api/syslog.go | 95 ------- internal/api/syslog_test.go | 83 ------ internal/embed/embed.go | 424 --------------------------- internal/embed/embed_test.go | 113 -------- internal/store/purge_iface.go | 247 ---------------- internal/store/purge_iface_test.go | 184 ------------ internal/syslogout/event.go | 216 -------------- internal/syslogout/format.go | 441 ----------------------------- internal/syslogout/format_test.go | 385 ------------------------- internal/syslogout/sender.go | 340 ---------------------- internal/web/static/app.js | 4 +- 17 files changed, 43 insertions(+), 2982 deletions(-) delete mode 100644 internal/api/embed.go delete mode 100644 internal/api/embed_test.go delete mode 100644 internal/api/syslog.go delete mode 100644 internal/api/syslog_test.go delete mode 100644 internal/embed/embed.go delete mode 100644 internal/embed/embed_test.go delete mode 100644 internal/store/purge_iface.go delete mode 100644 internal/store/purge_iface_test.go delete mode 100644 internal/syslogout/event.go delete mode 100644 internal/syslogout/format.go delete mode 100644 internal/syslogout/format_test.go delete mode 100644 internal/syslogout/sender.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbc37f1..3ae79d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,25 @@ jobs: container: ${{ matrix.container || '' }} env: GOFLAGS: -buildvcs=false + PRO_TOKEN: ${{ secrets.PRO_TOKEN }} defaults: run: shell: bash steps: - uses: actions/checkout@v5 + - uses: actions/checkout@v5 + if: ${{ env.PRO_TOKEN != '' }} + with: + repository: githubflyideas/traffic66-pro + token: ${{ secrets.PRO_TOKEN }} + path: .pro + - name: Registered features + if: ${{ env.PRO_TOKEN != '' }} + run: | + cp -R .pro/overlay/tree/. . + rm -rf .pro + echo "T66_PRO=1" >> "$GITHUB_ENV" + echo "GOFLAGS=-buildvcs=false -tags=pro" >> "$GITHUB_ENV" - uses: actions/setup-go@v6 with: go-version: "1.26.9" @@ -96,6 +110,7 @@ jobs: # a tag push and a one-click release (a version entered by hand) # are both releases; only the builds of every push stay community kind=community; [[ "$GITHUB_REF" == refs/tags/* || -n "${{ inputs.version }}" ]] && kind=official + if [[ $kind == official && -z "$T66_PRO" ]]; then echo "::error::a release needs the PRO_TOKEN secret"; exit 1; fi ldflags="-s -w -X main.version=${ver} -X main.buildKind=${kind}" tags="" export CGO_ENABLED=1 @@ -114,6 +129,7 @@ jobs: macOS) export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}" ;; esac + [[ -n "$T66_PRO" ]] && tags="${tags:+$tags,}pro" go build -trimpath ${tags:+-tags "$tags"} -ldflags "$ldflags" -o "$out" ./cmd/traffic66 cp README.md "dist/traffic66-${{ matrix.target }}/" cp -r docs "dist/traffic66-${{ matrix.target }}/" diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index b8c0e3a..8ab754a 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -32,7 +32,6 @@ import ( "github.com/githubflyideas/traffic66/internal/collector" "github.com/githubflyideas/traffic66/internal/detect" "github.com/githubflyideas/traffic66/internal/dnsres" - "github.com/githubflyideas/traffic66/internal/embed" "github.com/githubflyideas/traffic66/internal/enrich" "github.com/githubflyideas/traffic66/internal/flow" "github.com/githubflyideas/traffic66/internal/license" @@ -41,7 +40,6 @@ import ( "github.com/githubflyideas/traffic66/internal/sim" "github.com/githubflyideas/traffic66/internal/snmp" "github.com/githubflyideas/traffic66/internal/store" - "github.com/githubflyideas/traffic66/internal/syslogout" "github.com/githubflyideas/traffic66/internal/tlscert" "github.com/githubflyideas/traffic66/internal/tui" "github.com/githubflyideas/traffic66/internal/web" @@ -263,9 +261,19 @@ type serveFlags struct { l2 int hold time.Duration tuiAfter bool - syslog, syslogNet, syslogFmt, syslogCA, syslogBase string - syslogSev int - syslogASCII bool +} + +// Parts compiled in separately add their flags and start with the +// collector, once the licence and the detector exist. +var ( + extFlags []func(fs *flag.FlagSet) + extServe []func(x serveExt) (stop func()) +) + +type serveExt struct { + data string + lic *license.Checker + det *detect.Detector } func serve(args []string, demo bool) { @@ -281,13 +289,9 @@ func serve(args []string, demo bool) { fs.BoolVar(&f.tls, "tls", false, "serve the web UI over HTTPS with a certificate traffic66 keeps in the data directory (registered installations)") fs.StringVar(&f.tlsCert, "tls-cert", "", "serve the web UI over HTTPS with this certificate (registered installations)") fs.StringVar(&f.tlsKey, "tls-key", "", "the private key for -tls-cert") - fs.StringVar(&f.syslog, "syslog", "", "send findings to a syslog collector at host:port (registered installations)") - fs.StringVar(&f.syslogNet, "syslog-transport", "udp", "how to reach the collector: udp, tcp or tls") - fs.StringVar(&f.syslogFmt, "syslog-format", "cef", "message format: cef (ArcSight, QRadar, Splunk, Graylog, Elastic), leef (QRadar), json (Elastic Common Schema), text") - fs.IntVar(&f.syslogSev, "syslog-min-sev", 1, "least severity to send: 1 low, 2 medium, 3 high") - fs.BoolVar(&f.syslogASCII, "syslog-ascii", false, "escape everything outside ASCII, for collectors that mangle UTF-8") - fs.StringVar(&f.syslogCA, "syslog-ca", "", "certificate authority to trust for -syslog-transport tls") - fs.StringVar(&f.syslogBase, "syslog-url", "", "web address of this installation, to build the evidence link with") + for _, x := range extFlags { + x(fs) + } fs.StringVar(&f.listen, "listen", "sflow=:6343,netflow=:2055,ipfix=:4739", "UDP collectors as name=addr, comma separated; every port accepts every protocol; empty disables") fs.StringVar(&f.user, "user", "admin", "login user") fs.StringVar(&f.password, "password", "", "login password for this run instead of the stored one (or set TRAFFIC66_PASSWORD)") @@ -413,15 +417,10 @@ func serve(args []string, demo bool) { } det := detect.New(st, inv, detect.Config{}) - var sender *syslogout.Sender if offline == nil { - sender = startSyslog(f, lic) - defer sender.Close() - // the licence is asked at each finding, so a feed stops when a - // licence runs out and starts when one is added, without a restart - det.OnFinding = func(fi store.Finding) { - if lic != nil && lic.State().Allowed(license.FeatSyslog) { - sender.Send(syslogout.FromFinding(fi, syslogout.PhaseStart, sender.Config().BaseURL)) + for _, x := range extServe { + if stop := x(serveExt{data: f.data, lic: lic, det: det}); stop != nil { + defer stop() } } } @@ -524,15 +523,6 @@ func serve(args []string, demo bool) { Demo: demo, Check: checker.Check, Exists: checker.Exists, Accounts: checker, LocalTok: tok, DataDir: f.data, Started: time.Now(), Listen: listens} srv.SNMP = poller.Status srv.License = lic - srv.Syslog = sender - if offline == nil { - emb, err := embed.Open(filepath.Join(f.data, "embeds.json")) - if err != nil { - fatalf("%v", err) - } - defer emb.Flush() - srv.Embeds = emb - } if lic != nil { go lic.Loop(ctx.Done(), 4*time.Hour) } @@ -994,38 +984,3 @@ func setGoMemoryLimit(frac float64) { debug.SetMemoryLimit(lim) log.Printf("memory: database %d MB and program %d MB (soft), from -memory %.2f", lim>>20, lim>>20, frac) } - -// startSyslog prepares the syslog feed. The settings come from the -// findings page, kept in the data directory; the -syslog flags are the -// settings until something has been saved there. Unlike HTTPS, a feed -// that cannot run is not a reason to stop: findings keep being made and -// keep showing in the web UI, and the one thing lost is that they also go -// somewhere else. -func startSyslog(f serveFlags, lic *license.Checker) *syslogout.Sender { - syslogout.Version = version - cfg, saved, err := syslogout.LoadConfig(filepath.Join(f.data, "syslog.json")) - if err != nil { - log.Printf("%v", err) - } - switch { - case saved && f.syslog != "": - log.Printf("syslog: using the settings saved from the web UI; -syslog %s is not used", f.syslog) - case !saved && f.syslog != "": - cfg = syslogout.Config{ - Enabled: true, Address: f.syslog, Network: f.syslogNet, - Format: syslogout.Format(f.syslogFmt), MinSev: f.syslogSev, - ASCII: f.syslogASCII, CAFile: f.syslogCA, BaseURL: f.syslogBase, - } - if err := cfg.Check(); err != nil { - fatalf("%v", err) - } - } - if cfg.Enabled { - if lic == nil || !lic.State().Allowed(license.FeatSyslog) { - log.Printf("syslog: sending findings to a collector is for registered installations; findings are still made and shown in the web UI") - } else { - log.Printf("syslog: sending findings to %s over %s as %s", cfg.Address, cfg.Network, cfg.Format) - } - } - return syslogout.New(cfg) -} diff --git a/internal/api/cleanup.go b/internal/api/cleanup.go index 6c76dd0..a222559 100644 --- a/internal/api/cleanup.go +++ b/internal/api/cleanup.go @@ -4,11 +4,8 @@ import ( "encoding/json" "errors" "net/http" - "net/netip" - "strconv" "time" - "github.com/githubflyideas/traffic66/internal/license" "github.com/githubflyideas/traffic66/internal/store" ) @@ -78,70 +75,3 @@ func (s *Server) allowed(w http.ResponseWriter, feature string) bool { writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_registered", "feature": feature}) return false } - -// ifaceTarget reads the interface and age a per-interface cleanup names. -// days 0 means the interface's whole history. -func ifaceTarget(exporter, ifindex, days string) (string, uint32, time.Time, error) { - var before time.Time - if _, err := netip.ParseAddr(exporter); err != nil { - return "", 0, before, errors.New("exporter must be an address") - } - idx, err := strconv.ParseUint(ifindex, 10, 32) - if err != nil || idx == 0 { - return "", 0, before, errors.New("ifindex missing") - } - if days != "" { - d, err := strconv.Atoi(days) - if err != nil || d < 0 { - return "", 0, before, errors.New("days must be 0 or more") - } - if d > 0 { - before = time.Now().AddDate(0, 0, -d) - } - } - return exporter, uint32(idx), before, nil -} - -// getCleanupIface shows what deleting one interface's data would remove. -func (s *Server) getCleanupIface(w http.ResponseWriter, r *http.Request) { - if !s.allowed(w, license.FeatPurgeIface) { - return - } - q := r.URL.Query() - exp, idx, before, err := ifaceTarget(q.Get("exporter"), q.Get("ifindex"), q.Get("days")) - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, s.Store.PurgeSizeIface(exp, idx, before)) -} - -// postCleanupIface deletes one interface's flow records and counters. -func (s *Server) postCleanupIface(w http.ResponseWriter, r *http.Request) { - if !s.allowed(w, license.FeatPurgeIface) { - return - } - var in struct { - Exporter string `json:"exporter"` - IfIndex uint32 `json:"ifindex"` - Days int `json:"days"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - exp, idx, before, err := ifaceTarget(in.Exporter, strconv.FormatUint(uint64(in.IfIndex), 10), strconv.Itoa(in.Days)) - if err != nil { - fail(w, err) - return - } - disk := s.Store.Usage().DiskBytes - done, err := s.Store.PurgeIface(exp, idx, before) - if err != nil { - fail(w, err) - return - } - // what the files lost, plus what the database file gave back - done.Bytes += max(0, disk-s.Store.Usage().DiskBytes) - writeJSON(w, http.StatusOK, done) -} diff --git a/internal/api/embed.go b/internal/api/embed.go deleted file mode 100644 index c4bf415..0000000 --- a/internal/api/embed.go +++ /dev/null @@ -1,192 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "fmt" - "html" - "net/http" - "net/netip" - "time" - - "github.com/githubflyideas/traffic66/internal/embed" - "github.com/githubflyideas/traffic66/internal/store" -) - -// Links for other platforms: /embed/vip?ip=
&k= is one -// address's bandwidth, in and out, and nothing else, for a button beside -// that address on the platform. The links are set up on the settings -// page; this is for registered installations. - -// embedRanges are the periods the page offers; a day unless asked. -var embedRanges = map[string]time.Duration{"1h": time.Hour, "24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour} - -func (s *Server) embedRegistered() bool { - return s.License != nil && s.License.State().Registered() -} - -func (s *Server) getEmbeds(w http.ResponseWriter, r *http.Request) { - out := map[string]any{"registered": s.embedRegistered(), "links": []embed.Source{}, "per_minute": embed.PerMinute} - if s.Embeds != nil { - out["links"] = s.Embeds.List() - } - writeJSON(w, http.StatusOK, out) -} - -func (s *Server) putEmbed(w http.ResponseWriter, r *http.Request) { - if !s.embedReady(w) { - return - } - var in embed.Source - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - key, err := s.Embeds.Put(embed.Source{ID: in.ID, Name: in.Name, Ranges: in.Ranges, Origins: in.Origins, Clients: in.Clients}) - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "key": key}) -} - -func (s *Server) rotateEmbed(w http.ResponseWriter, r *http.Request) { - if !s.embedReady(w) { - return - } - var in struct{ ID string } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - key, err := s.Embeds.Rotate(in.ID) - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "key": key}) -} - -func (s *Server) deleteEmbed(w http.ResponseWriter, r *http.Request) { - if s.Embeds == nil { - fail(w, errors.New("links are not available here")) - return - } - // withdrawing is allowed without a licence: a link must be stoppable - // whatever happened to the licence since it was made - if err := s.Embeds.Delete(r.URL.Query().Get("id")); err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// embedReady answers the request unless links can be made here. -func (s *Server) embedReady(w http.ResponseWriter) bool { - if s.Embeds == nil { - fail(w, errors.New("links are not available here")) - return false - } - if !s.embedRegistered() { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_registered"}) - return false - } - return true -} - -// embedCheck decides one request to the page or its data. It answers the -// request itself when refusing, and says why in plain words: the person -// who set the platform up needs to tell a typo from a block. -func (s *Server) embedCheck(w http.ResponseWriter, r *http.Request, page bool) (netip.Addr, embed.Source, bool) { - refuse := func(code int, err error) (netip.Addr, embed.Source, bool) { - if page { - embedRefusal(w, code, err.Error()) - } else { - writeJSON(w, code, map[string]string{"error": err.Error()}) - } - return netip.Addr{}, embed.Source{}, false - } - if s.Embeds == nil || !s.embedRegistered() { - return refuse(http.StatusForbidden, errors.New("links to traffic are for registered installations")) - } - q := r.URL.Query() - a, err := netip.ParseAddr(q.Get("ip")) - if err != nil { - return refuse(http.StatusBadRequest, errors.New("the link has no address to show (ip=)")) - } - viewer, _ := netip.ParseAddr(clientIP(r)) - src, err := s.Embeds.Check(embed.Request{Key: q.Get("k"), Addr: a.Unmap(), Viewer: viewer, Referer: r.Referer(), Page: page}) - switch { - case errors.Is(err, embed.ErrRate): - return refuse(http.StatusTooManyRequests, err) - case err != nil: - return refuse(http.StatusForbidden, err) - } - return a.Unmap(), src, true -} - -// embedPage is the page a platform opens or frames. -func (s *Server) embedPage(w http.ResponseWriter, r *http.Request) { - a, src, ok := s.embedCheck(w, r, true) - if !ok { - return - } - h := w.Header() - h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; "+embed.FrameAncestors(src.Origins)) - h.Set("Content-Type", "text/html; charset=utf-8") - h.Set("Cache-Control", "no-store") - h.Set("Referrer-Policy", "no-referrer") - h.Set("X-Content-Type-Options", "nosniff") - fmt.Fprintf(w, ` -%s · traffic66 -
`, html.EscapeString(a.String()), html.EscapeString(a.String())) -} - -// embedData is the bandwidth behind the page: bits per second to the -// address and from it, over the period asked. -func (s *Server) embedData(w http.ResponseWriter, r *http.Request) { - a, _, ok := s.embedCheck(w, r, false) - if !ok { - return - } - d, ok := embedRanges[r.URL.Query().Get("range")] - if !ok { - d = 24 * time.Hour - } - now := time.Now().UTC() - q := store.Query{From: now.Add(-d), To: now, ZoneOffset: zoneOffset(r.URL.Query().Get("tz"))} - if d > longRange { - q.From = q.From.Truncate(time.Hour) - } - series := func(field string) (*store.Series, error) { - qq := q - qq.Filters = []store.Filter{{Field: field, Value: a.String()}} - return s.Store.TotalSeries(qq) - } - in, err := series("dst") - if err != nil { - fail(w, err) - return - } - out, err := series("src") - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "ip": a.String(), "step": in.Step, "times": in.Times, - "in": in.Values[0], "out": out.Values[0], "settled_to": in.SettledTo, "data_from": in.DataFrom, - }) -} - -// embedRefusal is the page shown instead, small enough to sit in a frame. -func embedRefusal(w http.ResponseWriter, code int, why string) { - h := w.Header() - h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") - h.Set("Content-Type", "text/html; charset=utf-8") - h.Set("Cache-Control", "no-store") - w.WriteHeader(code) - fmt.Fprintf(w, `traffic66 - -

traffic66

%s

`, html.EscapeString(why)) -} diff --git a/internal/api/embed_test.go b/internal/api/embed_test.go deleted file mode 100644 index 8c6d1ea..0000000 --- a/internal/api/embed_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/githubflyideas/traffic66/internal/embed" - "github.com/githubflyideas/traffic66/internal/license" - "github.com/githubflyideas/traffic66/internal/store" -) - -// A platform's button opens one VIP's bandwidth: in to it and out of it, -// for the addresses its key covers, from the sites it names. -func TestEmbedLinks(t *testing.T) { - dir := t.TempDir() - st, err := store.Open(store.Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer st.Close() - ts := time.Now().UTC().Add(-20 * time.Minute) - vip, user := "203.0.113.10", "198.51.100.7" - base := store.Row{TS: ts, Client: user, Server: vip, Dir: store.DirTransit, Pkts: 1, Flows: 1, Exporter: "10.0.0.1"} - req, resp := base, base - req.Src, req.Dst, req.Wire = user, vip, 60_000 - resp.Src, resp.Dst, resp.Wire = vip, user, 600_000 - if err := st.AppendRows([]store.Row{req, resp}); err != nil { - t.Fatal(err) - } - emb, _ := embed.Open(filepath.Join(dir, "embeds.json")) - c, priv := signed(t, dir, license.File{}) - s := &Server{Store: st, License: c, Embeds: emb} - - page := func(url, referer string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", url, nil) - r.Header.Set("Referer", referer) - s.embedPage(w, r) - return w - } - - // links are for registered installations - w := httptest.NewRecorder() - s.putEmbed(w, httptest.NewRequest("POST", "/api/embeds", strings.NewReader(`{"name":"Assets","ranges":["203.0.113.0/24"],"origins":["assets.example.com"]}`))) - if w.Code != 403 { - t.Fatalf("unregistered: %d", w.Code) - } - install(t, s, sign(priv, license.File{Customer: "Example KK", Product: "traffic66", - License: license.TypeSubscription, Tier: license.TierStandard, MaxUsers: 25, Features: []string{license.FeatSyslog}})) - w = httptest.NewRecorder() - s.putEmbed(w, httptest.NewRequest("POST", "/api/embeds", strings.NewReader(`{"name":"Assets","ranges":["203.0.113.0/24"],"origins":["assets.example.com"]}`))) - var made struct{ Key string } - json.Unmarshal(w.Body.Bytes(), &made) - if w.Code != 200 || made.Key == "" { - t.Fatalf("make: %d %s", w.Code, w.Body) - } - - link := "/embed/vip?ip=" + vip + "&k=" + made.Key - from := "https://assets.example.com/vip/42" - if w := page(link, from); w.Code != 200 || !strings.Contains(w.Header().Get("Content-Security-Policy"), "frame-ancestors assets.example.com") { - t.Fatalf("page: %d %q", w.Code, w.Header().Get("Content-Security-Policy")) - } - if w := page(link, "https://elsewhere.example.org/"); w.Code != 403 { - t.Errorf("another site: %d", w.Code) - } - if w := page("/embed/vip?ip=198.51.100.7&k="+made.Key, from); w.Code != 403 { - t.Errorf("an address outside the key: %d", w.Code) - } - if w := page("/embed/vip?ip="+vip+"&k=t66e_wrong", from); w.Code != 403 { - t.Errorf("a wrong key: %d", w.Code) - } - - w = httptest.NewRecorder() - s.embedData(w, httptest.NewRequest("GET", link+"&range=1h", nil)) - var out struct { - IP string - In, Out []float64 - } - if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 { - t.Fatalf("data: %d %s", w.Code, w.Body) - } - sum := func(v []float64) (n float64) { - for _, x := range v { - n += x - } - return - } - // in to the VIP and out of it, the download ten times the request - if in, o := sum(out.In), sum(out.Out); out.IP != vip || in <= 0 || o/in < 9.99 || o/in > 10.01 { - t.Fatalf("in %v out %v", sum(out.In), sum(out.Out)) - } - // nothing but the bandwidth - var raw map[string]any - json.Unmarshal(w.Body.Bytes(), &raw) - for k := range raw { - switch k { - case "ip", "step", "times", "in", "out", "settled_to", "data_from": - default: - t.Errorf("the data carries %q", k) - } - } -} diff --git a/internal/api/server.go b/internal/api/server.go index e85b920..e284653 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,15 +22,16 @@ import ( "github.com/githubflyideas/traffic66/internal/collector" "github.com/githubflyideas/traffic66/internal/detect" "github.com/githubflyideas/traffic66/internal/dnsres" - "github.com/githubflyideas/traffic66/internal/embed" "github.com/githubflyideas/traffic66/internal/enrich" "github.com/githubflyideas/traffic66/internal/license" "github.com/githubflyideas/traffic66/internal/pipeline" "github.com/githubflyideas/traffic66/internal/sandbox" "github.com/githubflyideas/traffic66/internal/store" - "github.com/githubflyideas/traffic66/internal/syslogout" ) +// extRoutes add routes from parts that are compiled in separately. +var extRoutes []func(s *Server, mux *http.ServeMux, api, adminAPI func(string, http.HandlerFunc)) + // Server wires the API together. type Server struct { Store *store.Store @@ -67,12 +68,6 @@ type Server struct { DataDir string // Listen is the UDP collectors, for the first-run guide on Overview. Listen []ListenInfo - // Syslog sends findings to the customer's collector; nil where there - // is none to configure (tests, the offline sandbox). - Syslog *syslogout.Sender - // Embeds are the links other platforms open to show one address's - // bandwidth; nil where there are none (tests, the offline sandbox). - Embeds *embed.Store mu sync.Mutex sessions map[string]session @@ -138,9 +133,6 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /logo", s.logo) mux.HandleFunc("GET /api/loginhint", s.loginHint) mux.HandleFunc("GET /tls.crt", s.certDownload) - // the links other platforms open: no session, the key in the link - mux.HandleFunc("GET /embed/vip", s.embedPage) - mux.HandleFunc("GET /embed/vip/data", s.embedData) api := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, s.auth(h)) } // adminAPI is api plus the administrator check. The permission belongs // in this table rather than inside each handler: a new route's gate is @@ -175,13 +167,6 @@ func (s *Server) Handler() http.Handler { adminAPI("POST /api/iface", s.putIface) api("GET /api/racks", s.getRacks) adminAPI("POST /api/threats/lists", s.putThreatList) - api("GET /api/syslog", s.getSyslog) - adminAPI("POST /api/syslog", s.putSyslog) - adminAPI("POST /api/syslog/test", s.testSyslog) - adminAPI("GET /api/embeds", s.getEmbeds) - adminAPI("POST /api/embeds", s.putEmbed) - adminAPI("POST /api/embeds/rotate", s.rotateEmbed) - adminAPI("DELETE /api/embeds", s.deleteEmbed) adminAPI("DELETE /api/threats/lists", s.deleteThreatList) adminAPI("POST /api/racks/device", s.putRackDevice) adminAPI("POST /api/racks/iface", s.putRackIface) @@ -189,8 +174,6 @@ func (s *Server) Handler() http.Handler { api("POST /api/findings", s.data((*Server).setFindings)) api("GET /api/cleanup", s.getCleanup) adminAPI("POST /api/cleanup", s.postCleanup) - api("GET /api/cleanup/iface", s.getCleanupIface) - adminAPI("POST /api/cleanup/iface", s.postCleanupIface) api("GET /api/sandbox", s.getSandbox) api("POST /api/sandbox/files", s.putSandboxFile) adminAPI("DELETE /api/sandbox/files", s.deleteSandbox) @@ -202,6 +185,9 @@ func (s *Server) Handler() http.Handler { adminAPI("POST /api/geo", s.putGeo) adminAPI("DELETE /api/geo", s.deleteGeo) adminAPI("POST /api/geo/dbip", s.updateDBIP) + for _, f := range extRoutes { + f(s, mux, api, adminAPI) + } static := http.FileServer(http.FS(s.Static)) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/internal/api/syslog.go b/internal/api/syslog.go deleted file mode 100644 index 297ac53..0000000 --- a/internal/api/syslog.go +++ /dev/null @@ -1,95 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - "path/filepath" - - "github.com/githubflyideas/traffic66/internal/license" - "github.com/githubflyideas/traffic66/internal/syslogout" -) - -// The syslog feed, set from the findings page: where findings are sent, -// in which format, and how it is going. Sending is for registered -// installations; reading the settings is not, so the page can show what -// would be sent and why it is not. - -// syslogFile is where the settings are kept. -func (s *Server) syslogFile() string { return filepath.Join(s.DataDir, "syslog.json") } - -func (s *Server) syslogAllowed() bool { - return s.License != nil && s.License.State().Allowed(license.FeatSyslog) -} - -func (s *Server) getSyslog(w http.ResponseWriter, r *http.Request) { - if s.Syslog == nil { - writeJSON(w, http.StatusOK, map[string]any{"available": false}) - return - } - cfg := s.Syslog.Config() - cfg.CAFile = filepath.Base(cfg.CAFile) // the name, not where it lives - if cfg.CAFile == "." { - cfg.CAFile = "" - } - writeJSON(w, http.StatusOK, map[string]any{ - "available": true, "registered": s.syslogAllowed(), "admin": s.isAdmin(r), - "config": cfg, "stats": s.Syslog.Stats(), "formats": syslogout.Formats, - }) -} - -func (s *Server) putSyslog(w http.ResponseWriter, r *http.Request) { - if s.Syslog == nil { - fail(w, errors.New("syslog is not available here")) - return - } - var in struct { - Enabled bool `json:"enabled"` - Address string `json:"address"` - Network string `json:"network"` - Format syslogout.Format `json:"format"` - MinSev int `json:"min_sev"` - ASCII bool `json:"ascii"` - Insecure bool `json:"insecure"` - BaseURL string `json:"base_url"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - if in.Enabled && !s.allowed(w, license.FeatSyslog) { - return - } - // what the page does not set (facility, ceiling, a CA file given on - // the command line) stays as it was - cfg := s.Syslog.Config() - cfg.Enabled, cfg.Address, cfg.Network, cfg.Format = in.Enabled, clip(in.Address, 255), in.Network, in.Format - cfg.MinSev, cfg.ASCII, cfg.Insecure, cfg.BaseURL = in.MinSev, in.ASCII, in.Insecure, clip(in.BaseURL, 255) - if err := cfg.Check(); err != nil { - fail(w, err) - return - } - if s.DataDir != "" { - if err := syslogout.SaveConfig(s.syslogFile(), cfg); err != nil { - fail(w, err) - return - } - } - s.Syslog.SetConfig(cfg) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) testSyslog(w http.ResponseWriter, r *http.Request) { - if s.Syslog == nil { - fail(w, errors.New("syslog is not available here")) - return - } - if !s.allowed(w, license.FeatSyslog) { - return - } - if err := s.Syslog.Test(); err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} diff --git a/internal/api/syslog_test.go b/internal/api/syslog_test.go deleted file mode 100644 index 1fe5af5..0000000 --- a/internal/api/syslog_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package api - -import ( - "encoding/json" - "net" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/githubflyideas/traffic66/internal/license" - "github.com/githubflyideas/traffic66/internal/syslogout" -) - -// The feed is set from the findings page and in force at once; turning it -// on is for registered installations, reading or turning it off is not. -func TestSyslogSettings(t *testing.T) { - dir := t.TempDir() - c, priv := signed(t, dir, license.File{}) - s := &Server{License: c, DataDir: dir, Syslog: syslogout.New(syslogout.Config{})} - - udp, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer udp.Close() - on := `{"enabled":true,"address":"` + udp.LocalAddr().String() + `","network":"udp","format":"json","min_sev":2}` - post := func(body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - s.putSyslog(w, httptest.NewRequest("POST", "/api/syslog", strings.NewReader(body))) - return w - } - test := func() *httptest.ResponseRecorder { - w := httptest.NewRecorder() - s.testSyslog(w, httptest.NewRequest("POST", "/api/syslog/test", nil)) - return w - } - - if w := post(on); w.Code != 403 || !strings.Contains(w.Body.String(), "not_registered") { - t.Fatalf("unregistered, switching on: %d %s", w.Code, w.Body) - } - if w := post(`{"enabled":false,"address":"","network":"udp","format":"cef","min_sev":1}`); w.Code != 200 { - t.Fatalf("unregistered, switching off: %d %s", w.Code, w.Body) - } - if w := test(); w.Code != 403 { - t.Fatalf("unregistered test: %d", w.Code) - } - - install(t, s, sign(priv, license.File{Customer: "Example KK", Product: "traffic66", - License: license.TypeSubscription, Tier: license.TierStandard, MaxUsers: 25, Features: []string{license.FeatSyslog}})) - if w := post(`{"enabled":true,"address":"no port","network":"udp","format":"json","min_sev":1}`); w.Code != 400 { - t.Fatalf("an address without a port was accepted: %d", w.Code) - } - if w := post(on); w.Code != 200 { - t.Fatalf("registered, switching on: %d %s", w.Code, w.Body) - } - if w := test(); w.Code != 200 { - t.Fatalf("test: %d %s", w.Code, w.Body) - } - buf := make([]byte, 4096) - udp.SetReadDeadline(time.Now().Add(3 * time.Second)) - n, _, err := udp.ReadFrom(buf) - if err != nil || !strings.Contains(string(buf[:n]), "test") { - t.Fatalf("the collector got %q, %v", buf[:n], err) - } - - // kept for the next start, and shown with how the feed is going - saved, ok, err := syslogout.LoadConfig(s.syslogFile()) - if err != nil || !ok || saved.Format != "json" || saved.MinSev != 2 { - t.Fatalf("saved %+v %v %v", saved, ok, err) - } - w := httptest.NewRecorder() - s.getSyslog(w, httptest.NewRequest("GET", "/api/syslog", nil)) - var out struct { - Registered bool `json:"registered"` - Stats syslogout.Stats `json:"stats"` - Config syslogout.Config `json:"config"` - } - json.Unmarshal(w.Body.Bytes(), &out) - if !out.Registered || out.Stats.Sent != 1 || out.Config.Address != udp.LocalAddr().String() { - t.Fatalf("status %s", w.Body) - } -} diff --git a/internal/embed/embed.go b/internal/embed/embed.go deleted file mode 100644 index 97078e0..0000000 --- a/internal/embed/embed.go +++ /dev/null @@ -1,424 +0,0 @@ -// Package embed keeps the links other platforms use to show one address's -// traffic: an asset platform puts a "traffic" button beside each of its -// service addresses (VIPs), and the button opens, or frames, a page here -// with that address's bandwidth and nothing else. -// -// One key covers a whole platform, so its links are built by putting the -// address into a fixed URL rather than by keeping one secret per address. -// What keeps that safe is everything around the key: it opens only the -// addresses in its ranges, only from the sites it names, optionally only -// for viewers on given networks, and only so many times a minute. It is -// stored as a hash, shown once when it is made, and can be replaced (the -// old one keeps working for a day, while the platform changes over) or -// withdrawn at once. -package embed - -import ( - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/netip" - "net/url" - "os" - "slices" - "strings" - "sync" - "time" -) - -const ( - // KeyPrefix starts every key, so one pasted somewhere it should not be - // is recognisable for what it is. - KeyPrefix = "t66e_" - // Grace is how long a replaced key keeps working. - Grace = 24 * time.Hour - // PerMinute is how many requests one key may make in a minute from - // one viewer: enough for a page that redraws every minute to be open - // in several tabs, and nowhere near enough to read the data out fast. - PerMinute = 60 - // MaxSources keeps the file a size a person can read. - MaxSources = 200 -) - -// Errors a request can be refused with. The page says which, so whoever -// set the platform up can tell a typo from a block. -var ( - ErrKey = errors.New("unknown or withdrawn key") - ErrScope = errors.New("this address is not one the key may show") - ErrOrigin = errors.New("not opened from a site the key allows") - ErrViewer = errors.New("not opened from a network the key allows") - ErrRate = errors.New("too many requests; try again in a minute") - ErrUnknown = errors.New("no such link") -) - -// Source is one platform allowed to show traffic. -type Source struct { - ID string `json:"id"` - Name string `json:"name"` - Ranges []string `json:"ranges"` // addresses and prefixes it may show - Origins []string `json:"origins"` // sites it may be opened or framed from; "*" for any - Clients []string `json:"clients,omitempty"` // viewer networks; empty for any - - Prefix string `json:"prefix"` // the start of the key, to tell keys apart - Hash string `json:"hash"` - OldHash string `json:"old_hash,omitempty"` - OldUntil time.Time `json:"old_until,omitzero"` - Created time.Time `json:"created"` - LastUsed time.Time `json:"last_used,omitzero"` - Hits int64 `json:"hits"` -} - -// Store is the list of sources, kept in one file. -type Store struct { - mu sync.Mutex - path string - list []*Source - saved time.Time - rate map[string]*window - now func() time.Time -} - -type window struct { - min time.Time - n int -} - -// Open reads the list; a missing file is an empty one. -func Open(path string) (*Store, error) { - s := &Store{path: path, rate: map[string]*window{}, now: time.Now} - b, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return s, nil - } - if err != nil { - return nil, err - } - if err := json.Unmarshal(b, &s.list); err != nil { - return nil, fmt.Errorf("embed: %s: %w", path, err) - } - return s, nil -} - -// List returns the sources without their key hashes. -func (s *Store) List() []Source { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]Source, 0, len(s.list)) - for _, x := range s.list { - c := *x - c.Hash, c.OldHash = "", "" - if !c.OldUntil.After(s.now()) { - c.OldUntil = time.Time{} - } - out = append(out, c) - } - return out -} - -// Put adds a source, or changes the name and limits of one. A new source -// gets a key, returned this once; changing one keeps its key. -func (s *Store) Put(in Source) (key string, err error) { - if err := in.clean(); err != nil { - return "", err - } - s.mu.Lock() - defer s.mu.Unlock() - if in.ID == "" { - if len(s.list) >= MaxSources { - return "", fmt.Errorf("at most %d links", MaxSources) - } - key = newKey() - x := &Source{ID: randHex(6), Name: in.Name, Ranges: in.Ranges, Origins: in.Origins, Clients: in.Clients, - Prefix: key[:len(KeyPrefix)+4], Hash: hash(key), Created: s.now().UTC()} - s.list = append(s.list, x) - return key, s.save() - } - x := s.find(in.ID) - if x == nil { - return "", ErrUnknown - } - x.Name, x.Ranges, x.Origins, x.Clients = in.Name, in.Ranges, in.Origins, in.Clients - return "", s.save() -} - -// Rotate gives a source a new key. The old one works for Grace more, so -// the platform can change over without its buttons failing meanwhile. -func (s *Store) Rotate(id string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - x := s.find(id) - if x == nil { - return "", ErrUnknown - } - key := newKey() - x.OldHash, x.OldUntil = x.Hash, s.now().UTC().Add(Grace) - x.Hash, x.Prefix = hash(key), key[:len(KeyPrefix)+4] - return key, s.save() -} - -// Delete withdraws a source: its key stops working at once. -func (s *Store) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - i := slices.IndexFunc(s.list, func(x *Source) bool { return x.ID == id }) - if i < 0 { - return ErrUnknown - } - s.list = slices.Delete(s.list, i, i+1) - return s.save() -} - -// Request is what one page or data request carries. -type Request struct { - Key string - Addr netip.Addr // the address to show - Viewer netip.Addr // who is asking - Referer string // the page it was opened from; checked when set to - Page bool // the page itself, which is what a site opens or frames -} - -// Check decides one request, and counts it. The page is checked against -// the sites the key allows; the data it then fetches comes from this -// server's own page, so for that the key, the address and the viewer are -// what is checked. -func (s *Store) Check(r Request) (Source, error) { - h := hash(r.Key) - s.mu.Lock() - defer s.mu.Unlock() - now := s.now() - var x *Source - for _, c := range s.list { - if eq(c.Hash, h) || (c.OldHash != "" && now.Before(c.OldUntil) && eq(c.OldHash, h)) { - x = c - } - } - if x == nil || !strings.HasPrefix(r.Key, KeyPrefix) { - return Source{}, ErrKey - } - if !s.allow(x.ID+"|"+r.Viewer.String(), now) { - return Source{}, ErrRate - } - if !inAny(r.Addr, x.Ranges) { - return Source{}, ErrScope - } - if len(x.Clients) > 0 && !inAny(r.Viewer, x.Clients) { - return Source{}, ErrViewer - } - if r.Page && !originOK(r.Referer, x.Origins) { - return Source{}, ErrOrigin - } - x.Hits++ - x.LastUsed = now.UTC() - // counts are kept, but not by writing the file at every request - if now.Sub(s.saved) > time.Minute { - s.save() - } - c := *x - c.Hash, c.OldHash = "", "" - return c, nil -} - -// Flush writes the counts kept since the last save. -func (s *Store) Flush() error { - s.mu.Lock() - defer s.mu.Unlock() - return s.save() -} - -func (s *Store) allow(who string, now time.Time) bool { - m := now.Truncate(time.Minute) - if len(s.rate) > 10000 { - for k, w := range s.rate { - if w.min != m { - delete(s.rate, k) - } - } - } - w := s.rate[who] - if w == nil || w.min != m { - w = &window{min: m} - s.rate[who] = w - } - if w.n >= PerMinute { - return false - } - w.n++ - return true -} - -func (s *Store) find(id string) *Source { - for _, x := range s.list { - if x.ID == id { - return x - } - } - return nil -} - -func (s *Store) save() error { - s.saved = s.now() - if s.path == "" { - return nil - } - b, err := json.MarshalIndent(s.list, "", " ") - if err != nil { - return err - } - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { - return err - } - return os.Rename(tmp, s.path) -} - -// clean checks a source as typed into the settings page and puts it in -// one canonical form. -func (in *Source) clean() error { - in.Name = strings.TrimSpace(in.Name) - if in.Name == "" || len([]rune(in.Name)) > 80 { - return errors.New("a name of up to 80 characters is needed") - } - var err error - if in.Ranges, err = prefixes(in.Ranges); err != nil { - return err - } - if len(in.Ranges) == 0 { - return errors.New("at least one address or range to show is needed") - } - if in.Clients, err = prefixes(in.Clients); err != nil { - return err - } - var origins []string - for _, o := range in.Origins { - o = strings.ToLower(strings.TrimSpace(o)) - if o == "" { - continue - } - // a pasted URL is taken for its host - if u, err := url.Parse(o); err == nil && u.Host != "" { - o = u.Hostname() - } - if o != "*" && !validHost(strings.TrimPrefix(o, "*.")) { - return fmt.Errorf("%q is not a site name", o) - } - if !slices.Contains(origins, o) { - origins = append(origins, o) - } - } - if len(origins) == 0 { - return errors.New("at least one site the link may be opened from is needed, or * for any") - } - in.Origins = origins - return nil -} - -func prefixes(ss []string) ([]string, error) { - var out []string - for _, x := range ss { - x = strings.TrimSpace(x) - if x == "" { - continue - } - p, err := netip.ParsePrefix(x) - if err != nil { - a, aerr := netip.ParseAddr(x) - if aerr != nil { - return nil, fmt.Errorf("%q is neither an address nor a range", x) - } - p = netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()) - } - s := p.Masked().String() - if !slices.Contains(out, s) { - out = append(out, s) - } - } - if len(out) > 1000 { - return nil, errors.New("at most 1000 ranges") - } - return out, nil -} - -func inAny(a netip.Addr, ranges []string) bool { - if !a.IsValid() { - return false - } - a = a.Unmap() - for _, r := range ranges { - if p, err := netip.ParsePrefix(r); err == nil && p.Contains(a) { - return true - } - } - return false -} - -// originOK: the page was opened from, or framed by, one of the sites. -// "*.example.com" covers example.com's subdomains. -func originOK(referer string, origins []string) bool { - if slices.Contains(origins, "*") { - return true - } - u, err := url.Parse(referer) - if err != nil || u.Host == "" { - return false - } - h := strings.ToLower(u.Hostname()) - for _, o := range origins { - if h == o || (strings.HasPrefix(o, "*.") && strings.HasSuffix(h, o[1:])) { - return true - } - } - return false -} - -// FrameAncestors is the Content-Security-Policy value that lets only the -// source's sites frame the page. -func FrameAncestors(origins []string) string { - if slices.Contains(origins, "*") { - return "frame-ancestors *" - } - return "frame-ancestors " + strings.Join(origins, " ") -} - -func validHost(h string) bool { - if h == "" || len(h) > 253 { - return false - } - if _, err := netip.ParseAddr(h); err == nil { - return true - } - for _, l := range strings.Split(h, ".") { - if l == "" || len(l) > 63 { - return false - } - for _, c := range l { - if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { - return false - } - } - } - return true -} - -func newKey() string { - b := make([]byte, 24) - rand.Read(b) - return KeyPrefix + base64.RawURLEncoding.EncodeToString(b) -} - -func randHex(n int) string { - b := make([]byte, n) - rand.Read(b) - return hex.EncodeToString(b) -} - -func hash(key string) string { - h := sha256.Sum256([]byte(key)) - return hex.EncodeToString(h[:]) -} - -func eq(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } diff --git a/internal/embed/embed_test.go b/internal/embed/embed_test.go deleted file mode 100644 index e8bfa53..0000000 --- a/internal/embed/embed_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package embed - -import ( - "net/netip" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestLinks(t *testing.T) { - path := filepath.Join(t.TempDir(), "embeds.json") - s, _ := Open(path) - now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) - s.now = func() time.Time { return now } - - key, err := s.Put(Source{Name: "Asset platform", Ranges: []string{"203.0.113.0/24", "2001:db8::10"}, - Origins: []string{"https://assets.example.com/vips", "*.portal.example.net"}, Clients: nil}) - if err != nil || !strings.HasPrefix(key, KeyPrefix) { - t.Fatalf("put: %q %v", key, err) - } - vip, from := netip.MustParseAddr("203.0.113.10"), "https://assets.example.com/vip/42" - ok := func(r Request) error { _, err := s.Check(r); return err } - req := Request{Key: key, Addr: vip, Viewer: netip.MustParseAddr("192.0.2.1"), Referer: from, Page: true} - if err := ok(req); err != nil { - t.Fatalf("the platform's own link: %v", err) - } - for name, c := range map[string]struct { - r Request - want error - }{ - "another address": {Request{Key: key, Addr: netip.MustParseAddr("198.51.100.1"), Referer: from, Page: true}, ErrScope}, - "another site": {Request{Key: key, Addr: vip, Referer: "https://evil.example.org/", Page: true}, ErrOrigin}, - "no referer": {Request{Key: key, Addr: vip, Page: true}, ErrOrigin}, - "a made-up key": {Request{Key: KeyPrefix + "guess", Addr: vip, Referer: from, Page: true}, ErrKey}, - "subdomain allowed": {Request{Key: key, Addr: vip, Referer: "https://a.portal.example.net/x", Page: true}, nil}, - "IPv6 single": {Request{Key: key, Addr: netip.MustParseAddr("2001:db8::10"), Referer: from, Page: true}, nil}, - "data, not the page": {Request{Key: key, Addr: vip}, nil}, - } { - if err := ok(c.r); err != c.want { - t.Errorf("%s: %v, want %v", name, err, c.want) - } - } - if list := s.List(); len(list) != 1 || list[0].Hash != "" || list[0].Hits == 0 { - t.Fatalf("list %+v", list) - } - - // viewers limited to the platform's office - id := s.List()[0].ID - if _, err := s.Put(Source{ID: id, Name: "Asset platform", Ranges: []string{"203.0.113.0/24"}, Origins: []string{"*"}, Clients: []string{"192.0.2.0/24"}}); err != nil { - t.Fatal(err) - } - if err := ok(Request{Key: key, Addr: vip, Viewer: netip.MustParseAddr("198.51.100.9")}); err != ErrViewer { - t.Errorf("a viewer outside: %v", err) - } - - // a new key: the old one keeps working for a day, then stops - key2, err := s.Rotate(id) - if err != nil { - t.Fatal(err) - } - req = Request{Key: key, Addr: vip, Viewer: netip.MustParseAddr("192.0.2.1"), Page: true} - if err := ok(req); err != nil { - t.Errorf("old key during the changeover: %v", err) - } - now = now.Add(Grace + time.Minute) - if err := ok(req); err != ErrKey { - t.Errorf("old key after the changeover: %v", err) - } - req.Key = key2 - if err := ok(req); err != nil { - t.Errorf("new key: %v", err) - } - - // so many a minute, then a pause - var last error - for range PerMinute + 1 { - last = ok(req) - } - if last != ErrRate { - t.Errorf("over the ceiling: %v", last) - } - - // kept on disk without the key itself - again, err := Open(path) - if err != nil || len(again.list) != 1 || again.list[0].Hash == "" || strings.Contains(again.list[0].Hash, key2) { - t.Fatalf("reopened %+v %v", again.list, err) - } - - // withdrawn: stops at once - if err := s.Delete(id); err != nil { - t.Fatal(err) - } - now = now.Add(time.Minute) - if err := ok(req); err != ErrKey { - t.Errorf("withdrawn: %v", err) - } -} - -func TestSourceChecks(t *testing.T) { - s, _ := Open("") - for _, bad := range []Source{ - {Name: "", Ranges: []string{"10.0.0.1"}, Origins: []string{"*"}}, - {Name: "x", Origins: []string{"*"}}, - {Name: "x", Ranges: []string{"not an address"}, Origins: []string{"*"}}, - {Name: "x", Ranges: []string{"10.0.0.1"}}, - {Name: "x", Ranges: []string{"10.0.0.1"}, Origins: []string{"bad host!"}}, - } { - if _, err := s.Put(bad); err == nil { - t.Errorf("accepted %+v", bad) - } - } -} diff --git a/internal/store/purge_iface.go b/internal/store/purge_iface.go deleted file mode 100644 index fcce233..0000000 --- a/internal/store/purge_iface.go +++ /dev/null @@ -1,247 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "time" -) - -// IfacePurge is what deleting one interface's data would remove, or did. -// -// Bytes is what the flow files lose. The database file is not counted: -// most of it is the summaries, which stay, so its share of one interface -// cannot be worked out — PurgeIface reports what was actually freed. -// -// Rows covers the flow records: those are the only thing stored per -// interface. The hourly and daily summaries (and the findings) record -// traffic by application, host and dimension, not by interface, so an -// interface cannot be subtracted from them; Summaries says whether any are -// held for the range, so the page can say so rather than imply the -// interface is gone from everything. -type IfacePurge struct { - Exporter string `json:"exporter"` - IfIndex uint32 `json:"ifindex"` - Files int `json:"files"` // hourly flow files touched - Bytes int64 `json:"bytes"` - Rows int64 `json:"rows"` - Counters int64 `json:"counters"` // interface counter samples - Summaries int64 `json:"summaries"` // summary rows that stay, by hour -} - -// ifaceWhere matches the flow records that went in or out of one interface -// of one exporter, optionally only those older than a time. -// -// A flow record names the interface it arrived on and the one it left by, -// so a record matched here is also part of the other interface's traffic: -// deleting it takes those flows out of that interface's view too. There is -// no way around that at the record level, and blanking the interface -// instead would leave traffic that can no longer be attributed, so the -// record goes and the pages say as much. -func ifaceWhere(exporter string, ifindex uint32, before time.Time) (string, []any) { - w := "exporter = ? AND (in_if = ? OR out_if = ?)" - args := []any{exporter, ifindex, ifindex} - if !before.IsZero() { - w += " AND ts < ?" - args = append(args, before.UTC()) - } - return w, args -} - -// PurgeSizeIface estimates PurgeIface. A zero time means the interface's -// whole history. -func (s *Store) PurgeSizeIface(exporter string, ifindex uint32, before time.Time) IfacePurge { - p := IfacePurge{Exporter: exporter, IfIndex: ifindex} - where, args := ifaceWhere(exporter, ifindex, before) - - var hot int64 - s.DB.QueryRow(`SELECT count(*) FROM hot WHERE `+where, args...).Scan(&hot) - p.Rows = hot - - for _, c := range s.segmentCounts(where, args) { - if c.match == 0 { - continue - } - p.Files++ - p.Rows += c.match - if fi, err := os.Stat(c.path); err == nil && c.total > 0 { - p.Bytes += fi.Size() * c.match / c.total - } - } - cw, cargs := "exporter = ? AND ifindex = ?", []any{exporter, ifindex} - if !before.IsZero() { - cw += " AND ts < ?" - cargs = append(cargs, before.UTC()) - } - s.DB.QueryRow(`SELECT count(*) FROM ifc WHERE `+cw, cargs...).Scan(&p.Counters) - - sw, sargs := "true", []any{} - if !before.IsZero() { - sw, sargs = "ts < ?", []any{before.UTC()} - } - s.DB.QueryRow(`SELECT count(*) FROM r_ts WHERE `+sw, sargs...).Scan(&p.Summaries) - return p -} - -// PurgeIface deletes one interface's flow records and counter samples, -// either all of them or only those older than a time. Flow files that hold -// nothing else are deleted; the rest are rewritten without those records. -// -// The summaries are left alone: see IfacePurge. This is deliberate — an -// interface's share of an hourly total is not recorded, so removing it -// would mean either dropping other interfaces' traffic with it or -// inventing a number. -func (s *Store) PurgeIface(exporter string, ifindex uint32, before time.Time) (IfacePurge, error) { - done := IfacePurge{Exporter: exporter, IfIndex: ifindex} - if exporter == "" || ifindex == 0 { - return done, fmt.Errorf("store: no interface given") - } - where, args := ifaceWhere(exporter, ifindex, before) - - for _, c := range s.segmentCounts(where, args) { - if c.match == 0 { - continue - } - fi, _ := os.Stat(c.path) - size := int64(0) - if fi != nil { - size = fi.Size() - } - if c.match >= c.total { - if err := os.Remove(c.path); err != nil && !os.IsNotExist(err) { - log.Printf("store: purge %s: %v", c.path, err) - continue - } - s.DB.Exec(`DELETE FROM segments WHERE path = ?`, c.path) - os.Remove(filepath.Dir(c.path)) // the day's directory, once empty - done.Files++ - done.Rows += c.total - done.Bytes += size - continue - } - n, err := s.rewriteSegment(c.path, where, args) - if err != nil { - log.Printf("store: purge %s: %v", c.path, err) - continue - } - done.Files++ - done.Rows += n - if fi2, err := os.Stat(c.path); err == nil { - done.Bytes += max(0, size-fi2.Size()) - } - } - if err := s.loadSegments(); err != nil { - return done, err - } - - s.wmu.Lock() - defer s.wmu.Unlock() - if err := s.appHot.Flush(); err != nil { - return done, err - } - if err := s.appIfc.Flush(); err != nil { - return done, err - } - if r, err := s.DB.Exec(`DELETE FROM hot WHERE `+where, args...); err == nil { - n, _ := r.RowsAffected() - done.Rows += n - } else { - return done, err - } - cw, cargs := "exporter = ? AND ifindex = ?", []any{exporter, ifindex} - if !before.IsZero() { - cw += " AND ts < ?" - cargs = append(cargs, before.UTC()) - } - if r, err := s.DB.Exec(`DELETE FROM ifc WHERE `+cw, cargs...); err == nil { - n, _ := r.RowsAffected() - done.Counters += n - } else { - return done, err - } - s.DB.Exec(`CHECKPOINT`) - log.Printf("store: purged %d flow records and %d counter samples of %s/%d", done.Rows, done.Counters, exporter, ifindex) - return done, nil -} - -// segCount is how much of one flow file a condition matches. -type segCount struct { - path string - match, total int64 -} - -// segmentCounts counts the rows matching a condition in every flow file, -// in one pass. A file DuckDB reports under a path the segment table does -// not know is ignored rather than guessed at. -func (s *Store) segmentCounts(where string, args []any) []segCount { - s.segMu.RLock() - files := make([]string, 0, len(s.segCache)) - known := make(map[string]string, len(s.segCache)) - for _, g := range s.segCache { - files = append(files, "'"+sqlPath(g.path)+"'") - known[sqlPath(g.path)] = g.path - } - s.segMu.RUnlock() - if len(files) == 0 { - return nil - } - q := fmt.Sprintf(`SELECT filename, count(*) FILTER (WHERE %s), count(*) - FROM read_parquet([%s], filename = true) GROUP BY 1`, where, strings.Join(files, ",")) - rows, err := s.DB.Query(q, args...) - if err != nil { - log.Printf("store: counting flow files: %v", err) - return nil - } - defer rows.Close() - var out []segCount - for rows.Next() { - var name string - var c segCount - if err := rows.Scan(&name, &c.match, &c.total); err != nil { - log.Printf("store: counting flow files: %v", err) - return out - } - p, ok := known[sqlPath(name)] - if !ok { - continue - } - c.path = p - out = append(out, c) - } - return out -} - -// rewriteSegment rewrites a flow file without the rows the drop condition -// matches and returns how many it dropped. The file's recorded range and -// row count are updated, because dropping rows from the middle of a file -// can move either end. -func (s *Store) rewriteSegment(path, drop string, args []any) (int64, error) { - var total, kept int64 - var mn, mx sql.NullTime - src := "'" + sqlPath(path) + "'" - // the condition is evaluated once, in the subquery, so its parameters - // are bound once however many aggregates read the result - q := fmt.Sprintf(`SELECT count(*), count(*) FILTER (WHERE keep), min(ts) FILTER (WHERE keep), max(ts) FILTER (WHERE keep) - FROM (SELECT ts, NOT (%s) AS keep FROM read_parquet(%s))`, drop, src) - if err := s.DB.QueryRow(q, args...).Scan(&total, &kept, &mn, &mx); err != nil { - return 0, err - } - tmp := path + ".trim" - cp := fmt.Sprintf(`COPY (SELECT * FROM read_parquet(%s) WHERE NOT (%s) ORDER BY ts, client) TO '%s' (FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 122880)`, - src, drop, sqlPath(tmp)) - if _, err := s.DB.Exec(cp, args...); err != nil { - os.Remove(tmp) - return 0, err - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return 0, err - } - if _, err := s.DB.Exec(`UPDATE segments SET min_ts = ?, max_ts = ?, rows = ? WHERE path = ?`, mn.Time, mx.Time, kept, path); err != nil { - return 0, err - } - return total - kept, nil -} diff --git a/internal/store/purge_iface_test.go b/internal/store/purge_iface_test.go deleted file mode 100644 index fc6b7ef..0000000 --- a/internal/store/purge_iface_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package store - -import ( - "testing" - "time" -) - -// Deleting one interface's data takes its flow records out of the hot -// table and out of the flow files — rewriting a file it shares with -// another interface, deleting one it does not — and takes its counter -// samples with it. Everything else stays. -func TestPurgeIface(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - // Half past the hour, not the minute the test happens to run in: Seal - // rounds down to the hour after subtracting five minutes, so a base time - // in the first five minutes of an hour put the first Seal's boundary an - // hour earlier than this test needs and sealed nothing. That made the - // test fail for five minutes in every sixty with nothing wrong. - now := time.Now().UTC().Truncate(time.Hour).Add(-30 * time.Minute) - old := now.Add(-3 * time.Hour) - row := func(ts time.Time, in, out uint32, exp string) Row { - return Row{TS: ts, Src: "10.0.0.1", Dst: "10.0.0.2", Client: "10.0.0.1", Server: "10.0.0.2", - Exporter: exp, InIf: in, OutIf: out, Bytes: 100, Wire: 100, Pkts: 1, Flows: 1} - } - // a file of its own, then a file shared with interface 7 - if err := s.AppendRows([]Row{row(old, 3, 0, "127.0.0.1"), row(old, 0, 3, "127.0.0.1")}); err != nil { - t.Fatal(err) - } - if err := s.Seal(old.Add(time.Hour)); err != nil { - t.Fatal(err) - } - mid := now.Add(-2 * time.Hour) - if err := s.AppendRows([]Row{row(mid, 3, 0, "127.0.0.1"), row(mid, 7, 0, "127.0.0.1"), row(mid, 3, 0, "127.0.0.2")}); err != nil { - t.Fatal(err) - } - if err := s.Seal(mid.Add(time.Hour)); err != nil { - t.Fatal(err) - } - // and rows still in the hot table - if err := s.AppendRows([]Row{row(now, 3, 0, "127.0.0.1"), row(now, 7, 0, "127.0.0.1")}); err != nil { - t.Fatal(err) - } - for _, ts := range []time.Time{old, mid, now} { - if err := s.AppendCounter(ts, "127.0.0.1", 3, 1e9, 10, 10, 1, 1); err != nil { - t.Fatal(err) - } - if err := s.AppendCounter(ts, "127.0.0.1", 7, 1e9, 10, 10, 1, 1); err != nil { - t.Fatal(err) - } - } - - est := s.PurgeSizeIface("127.0.0.1", 3, time.Time{}) - if est.Rows != 4 { // two in the first file, one in the second, one hot - t.Fatalf("estimated %d rows, want 4", est.Rows) - } - if est.Counters != 3 { - t.Fatalf("estimated %d counter samples, want 3", est.Counters) - } - if est.Files != 2 { - t.Fatalf("estimated %d files, want 2", est.Files) - } - - done, err := s.PurgeIface("127.0.0.1", 3, time.Time{}) - if err != nil { - t.Fatal(err) - } - if done.Rows != 4 { - t.Fatalf("deleted %d rows, want 4", done.Rows) - } - if done.Counters != 3 { - t.Fatalf("deleted %d counter samples, want 3", done.Counters) - } - - from, to := old.Add(-time.Hour), now.Add(time.Hour) - var left int - if err := s.DB.QueryRow(`SELECT count(*) FROM ` + s.Source(from, to) + - ` WHERE exporter = '127.0.0.1' AND (in_if = 3 OR out_if = 3)`).Scan(&left); err != nil { - t.Fatal(err) - } - if left != 0 { - t.Fatalf("%d records of the interface are left", left) - } - // interface 7 of the same exporter, and interface 3 of another, stay - for _, c := range []struct { - where string - want int - }{ - {"exporter = '127.0.0.1' AND in_if = 7", 2}, - {"exporter = '127.0.0.2' AND in_if = 3", 1}, - } { - var n int - if err := s.DB.QueryRow(`SELECT count(*) FROM ` + s.Source(from, to) + ` WHERE ` + c.where).Scan(&n); err != nil { - t.Fatal(err) - } - if n != c.want { - t.Fatalf("%s: %d records, want %d", c.where, n, c.want) - } - } - var ctr int - s.DB.QueryRow(`SELECT count(*) FROM ifc`).Scan(&ctr) - if ctr != 3 { - t.Fatalf("%d counter samples left, want 3 (interface 7's)", ctr) - } - // the rewritten file's recorded range and row count must match it - rows, err := s.DB.Query(`SELECT path, min_ts, max_ts, rows FROM segments`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - files := 0 - for rows.Next() { - var path string - var mn, mx time.Time - var n int64 - if err := rows.Scan(&path, &mn, &mx, &n); err != nil { - t.Fatal(err) - } - files++ - var real int64 - var rmn, rmx time.Time - if err := s.DB.QueryRow(`SELECT count(*), min(ts), max(ts) FROM read_parquet('`+sqlPath(path)+`')`).Scan(&real, &rmn, &rmx); err != nil { - t.Fatal(err) - } - if n != real || !mn.Equal(rmn) || !mx.Equal(rmx) { - t.Fatalf("%s: recorded %d rows %v..%v, file has %d rows %v..%v", path, n, mn, mx, real, rmn, rmx) - } - } - if files != 1 { // the interface's own file is gone, the shared one is not - t.Fatalf("%d flow files left, want 1", files) - } -} - -// Deleting by age as well as by interface leaves the interface's newer -// records alone. -func TestPurgeIfaceBefore(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - now := time.Now().UTC().Truncate(time.Minute) - row := func(ts time.Time) Row { - return Row{TS: ts, Src: "10.0.0.1", Dst: "10.0.0.2", Client: "10.0.0.1", Server: "10.0.0.2", - Exporter: "127.0.0.1", InIf: 3, Bytes: 100, Wire: 100, Pkts: 1, Flows: 1} - } - if err := s.AppendRows([]Row{row(now.AddDate(0, 0, -10)), row(now)}); err != nil { - t.Fatal(err) - } - done, err := s.PurgeIface("127.0.0.1", 3, now.AddDate(0, 0, -5)) - if err != nil { - t.Fatal(err) - } - if done.Rows != 1 { - t.Fatalf("deleted %d rows, want 1", done.Rows) - } - var n int - s.DB.QueryRow(`SELECT count(*) FROM hot`).Scan(&n) - if n != 1 { - t.Fatalf("%d rows left, want 1", n) - } -} - -// An interface that was never seen is not an error and deletes nothing. -func TestPurgeIfaceUnknown(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - done, err := s.PurgeIface("127.0.0.1", 99, time.Time{}) - if err != nil { - t.Fatal(err) - } - if done.Rows != 0 || done.Files != 0 { - t.Fatalf("deleted %+v", done) - } - if _, err := s.PurgeIface("", 1, time.Time{}); err == nil { - t.Fatal("an empty exporter was accepted") - } -} diff --git a/internal/syslogout/event.go b/internal/syslogout/event.go deleted file mode 100644 index 256d42a..0000000 --- a/internal/syslogout/event.go +++ /dev/null @@ -1,216 +0,0 @@ -// Package syslogout sends findings to a syslog collector. -// -// What makes this worth having is not that it can send a line of text: it -// is that the line can be parsed, counted and summed by whatever the -// customer already runs. Three things decide that, and they are the -// reason this package exists rather than a fmt.Fprintf somewhere: -// -// - The event type is a stable word (scan, brute, exfil …) that never -// changes with the wording, the translation or the version, so a -// dashboard grouped by it does not break when a sentence is improved. -// - A finding is one event with one id, sent when it starts and when it -// ends, not once per detection run. Otherwise one night of password -// guessing is counted as forty incidents and somebody turns the feed -// off. -// - The sampling rate travels with every event. traffic66 detects on -// sampled data, so the byte and packet counts are estimates; a -// collector that sums them without knowing that produces a number -// nobody can defend. -package syslogout - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - "time" - - "github.com/githubflyideas/traffic66/internal/store" -) - -// Phase says what happened to a finding, which is what decides whether -// anything is sent at all. -type Phase string - -const ( - PhaseStart Phase = "start" // first seen - PhaseEnd Phase = "end" // closed, by a person or by time - PhaseTest Phase = "test" // the "send test" button -) - -// Event is one thing to report, already flattened out of a finding so the -// formats do not each have to understand the store. -type Event struct { - Phase Phase - ID string // stable for the life of one finding - Kind string // scan, portscan, brute, lateral, exfil, flood, threat - Severity int // 1 low, 2 medium, 3 high - Text string // one line a person can read - - Src, Dst string - Port string - Proto string - - First, Last time.Time - Hits int // detection runs that saw it - - Bytes, Packets uint64 // what the source sent; estimates when Sampling is set - Down uint64 // what came back, where the rule counted it - Sampling string // "1:4096", empty when the data was not sampled - Exporter string - IfIndex uint32 - Country string - ASN string - Org string - Evidence string // link to the finding, empty when there is none - - Extra map[string]string // whatever else the rule recorded -} - -// FromFinding flattens a finding. base is the address the web UI is -// reachable at, used to build the evidence link; empty leaves it out. -func FromFinding(f store.Finding, phase Phase, base string) Event { - e := Event{ - Phase: phase, ID: strconv.FormatInt(f.ID, 10), Kind: f.Kind, - Severity: f.Sev, Src: f.Src, Dst: f.Dst, Port: f.Port, - First: f.First, Last: f.Last, Hits: f.Hits, - Extra: map[string]string{}, - } - if host, proto, ok := strings.Cut(f.Port, "/"); ok { - e.Port, e.Proto = host, strings.ToUpper(proto) - } - if base != "" && f.ID != 0 { - e.Evidence = strings.TrimRight(base, "/") + "/#v=findings&finding=" + e.ID - } - for k, v := range f.Ev { - switch k { - // The rules record what they actually counted: "up" is what the - // source sent, which is the number that matters for an upload to - // somewhere new, and "down" is what came back. - case "up": - e.Bytes = asUint(v) - case "down": - e.Down = asUint(v) - case "pkts": - e.Packets = asUint(v) - case "sampling": - if n := asUint(v); n > 1 { - e.Sampling = "1:" + strconv.FormatUint(n, 10) - } - case "cc": - e.Country = asString(v) - case "org": - e.Org = asString(v) - case "asn": - e.ASN = asString(v) - case "examples": - // a list of sample addresses; useful to a person, noise in a - // field a collector has to index - default: - if s := asString(v); s != "" { - e.Extra[k] = s - } - } - } - e.Text = Describe(e) - return e -} - -// Describe writes the one line a person reads, in English: a collector is -// indexed and searched in one language, and the translated wording lives -// in the web UI where a person is actually reading it. -func Describe(e Event) string { - who := e.Src - if who == "" { - who = "an unknown source" - } - target := e.Dst - if target != "" && e.Port != "" { - target += ":" + e.Port - } - switch e.Kind { - case "scan": - return fmt.Sprintf("%s scanned the network on port %s", who, e.Port) - case "portscan": - return fmt.Sprintf("%s probed many ports of %s", who, e.Dst) - case "brute": - return fmt.Sprintf("%s tried to log in to %s again and again", who, target) - case "lateral": - return fmt.Sprintf("%s reached %s, a service never offered there before", who, target) - case "exfil": - return fmt.Sprintf("%s uploaded %s to %s, a destination it had not used before", who, bytes(e.Bytes), e.Dst) - case "flood": - return fmt.Sprintf("%s was flooded with packets", e.Dst) - case "threat": - return fmt.Sprintf("%s communicated with %s, which is on a threat list", who, e.Dst) - case "test": - return "test message from traffic66" - } - return fmt.Sprintf("%s: %s to %s", e.Kind, who, target) -} - -func bytes(n uint64) string { - switch { - case n >= 1<<30: - return fmt.Sprintf("%.1f GB", float64(n)/(1<<30)) - case n >= 1<<20: - return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) - case n >= 1<<10: - return fmt.Sprintf("%.1f kB", float64(n)/(1<<10)) - } - return fmt.Sprintf("%d B", n) -} - -func asUint(v any) uint64 { - switch n := v.(type) { - case float64: - if n > 0 { - return uint64(n) - } - case int: - if n > 0 { - return uint64(n) - } - case int64: - if n > 0 { - return uint64(n) - } - case uint64: - return n - case json.Number: - if f, err := n.Float64(); err == nil && f > 0 { - return uint64(f) - } - } - return 0 -} - -func asString(v any) string { - switch s := v.(type) { - case string: - return s - case float64: - return strconv.FormatFloat(s, 'f', -1, 64) - case int: - return strconv.Itoa(s) - case int64: - return strconv.FormatInt(s, 10) - case bool: - return strconv.FormatBool(s) - case nil: - return "" - } - return fmt.Sprint(v) -} - -// extraKeys lists the extra fields in a fixed order, so two messages about -// the same thing look the same and a collector's field list stays stable. -func (e Event) extraKeys() []string { - out := make([]string, 0, len(e.Extra)) - for k := range e.Extra { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/internal/syslogout/format.go b/internal/syslogout/format.go deleted file mode 100644 index dd72e88..0000000 --- a/internal/syslogout/format.go +++ /dev/null @@ -1,441 +0,0 @@ -package syslogout - -import ( - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - "time" -) - -// Formats of the message body. The envelope is RFC 5424 either way; this -// is what goes in it, and it is a choice because which one parses by -// itself depends entirely on what the customer already runs. -type Format string - -const ( - // FormatCEF is ArcSight's Common Event Format, and the one with the - // widest out-of-the-box support: ArcSight, QRadar, Splunk, Graylog, - // Elastic and Wazuh all read it without anyone writing a parser. - FormatCEF Format = "cef" - // FormatLEEF is IBM's, read natively by QRadar. - FormatLEEF Format = "leef" - // FormatJSON uses Elastic Common Schema field names, so an Elastic or - // Splunk pipeline needs no mapping written for it. - FormatJSON Format = "json" - // FormatText is for a person reading the file. - FormatText Format = "text" -) - -// Formats lists what can be chosen, for the settings page and the flag. -var Formats = []Format{FormatCEF, FormatLEEF, FormatJSON, FormatText} - -// Valid reports whether f is a format this knows. -func (f Format) Valid() bool { - for _, k := range Formats { - if f == k { - return true - } - } - return false -} - -// Vendor, Product and the enterprise number identify traffic66 in the -// formats that ask. 32473 is the number IANA reserves for examples and -// private use, which is honest: we have not registered one. -const ( - Vendor = "traffic66" - Product = "traffic66" - Enterprise = "32473" -) - -// severity turns our three levels into the 0–10 the formats expect. -func severity(sev int) int { - switch sev { - case 3: - return 8 - case 2: - return 5 - case 1: - return 3 - } - return 1 -} - -// priority is the syslog PRI: facility times eight plus the level. High -// findings go out as warnings, the rest as notices, because a finding is -// something to look at, not something that is on fire. -func priority(facility, sev int) int { - level := 5 // notice - if sev >= 3 { - level = 4 // warning - } - return facility*8 + level -} - -// Message builds the whole line, envelope and body. -func Message(cfg Config, e Event, now time.Time) string { - host := cfg.Hostname - if host == "" { - host, _ = os.Hostname() - } - if host == "" { - host = "-" - } - body := body(cfg.Format, e) - if cfg.ASCII { - body, host = ascii(body), ascii(host) - } - // RFC 5424: 1 TIMESTAMP HOSTNAME APP PROCID MSGID [SD] MSG - sd := structured(e) - return fmt.Sprintf("<%d>1 %s %s %s %d %s %s %s", - priority(cfg.Facility, e.Severity), - now.UTC().Format("2006-01-02T15:04:05.000Z"), - nilable(host), Product, os.Getpid(), nilable(e.Kind), sd, body) -} - -// structured is the RFC 5424 structured data: the few fields worth having -// even when the body is plain text, so a collector that understands only -// the envelope still gets the identity of the event and the sampling rate. -func structured(e Event) string { - var b strings.Builder - fmt.Fprintf(&b, `[traffic66@%s id="%s" kind="%s" phase="%s"`, Enterprise, sdEsc(e.ID), sdEsc(e.Kind), sdEsc(string(e.Phase))) - if e.Sampling != "" { - fmt.Fprintf(&b, ` sampling="%s"`, sdEsc(e.Sampling)) - } - b.WriteString("]") - return b.String() -} - -func body(f Format, e Event) string { - switch f { - case FormatLEEF: - return leef(e) - case FormatJSON: - return asJSON(e) - case FormatText: - return text(e) - } - return cef(e) -} - -// cef writes ArcSight's Common Event Format. The header's seven fields are -// pipe separated; everything else is key=value. -func cef(e Event) string { - var b strings.Builder - fmt.Fprintf(&b, "CEF:0|%s|%s|%s|%s|%s|%d|", Vendor, Product, version(), cefHdr(e.Kind), cefHdr(e.Text), severity(e.Severity)) - kv := func(k, v string) { - if v != "" { - fmt.Fprintf(&b, "%s=%s ", k, cefVal(v)) - } - } - kv("src", e.Src) - kv("dst", e.Dst) - kv("dpt", e.Port) - kv("proto", e.Proto) - if e.Bytes > 0 { - kv("out", strconv.FormatUint(e.Bytes, 10)) - } - if e.Down > 0 { - kv("in", strconv.FormatUint(e.Down, 10)) - } - if e.Packets > 0 { - kv("cn1Label", "packets") - kv("cn1", strconv.FormatUint(e.Packets, 10)) - } - if e.Hits > 0 { - kv("cnt", strconv.Itoa(e.Hits)) - } - if !e.First.IsZero() { - kv("start", strconv.FormatInt(e.First.UnixMilli(), 10)) - } - if !e.Last.IsZero() { - kv("end", strconv.FormatInt(e.Last.UnixMilli(), 10)) - } - kv("externalId", e.ID) - kv("act", string(e.Phase)) - label(&b, "cs1", "sampling", e.Sampling) - label(&b, "cs2", "findingUrl", e.Evidence) - label(&b, "cs3", "exporter", exporter(e)) - label(&b, "cs4", "peerOrg", strings.TrimSpace(e.Org+" "+e.ASN)) - if e.Country != "" { - kv("cs5Label", "peerCountry") - kv("cs5", e.Country) - } - for _, k := range e.extraKeys() { - kv("traffic66"+title(k), e.Extra[k]) - } - return strings.TrimRight(b.String(), " ") -} - -func label(b *strings.Builder, key, name, v string) { - if v == "" { - return - } - fmt.Fprintf(b, "%sLabel=%s %s=%s ", key, name, key, cefVal(v)) -} - -// leef writes IBM's format, which QRadar reads natively. Version 2.0 names -// its own delimiter; a tab keeps values readable and never appears in one. -func leef(e Event) string { - var b strings.Builder - fmt.Fprintf(&b, "LEEF:2.0|%s|%s|%s|%s|\t|", Vendor, Product, version(), leefEsc(e.Kind)) - kv := func(k, v string) { - if v != "" { - fmt.Fprintf(&b, "%s=%s\t", k, leefEsc(v)) - } - } - kv("cat", e.Kind) - kv("sev", strconv.Itoa(severity(e.Severity))) - kv("src", e.Src) - kv("dst", e.Dst) - kv("dstPort", e.Port) - kv("proto", e.Proto) - if e.Bytes > 0 { - kv("srcBytes", strconv.FormatUint(e.Bytes, 10)) - } - if e.Down > 0 { - kv("dstBytes", strconv.FormatUint(e.Down, 10)) - } - if e.Packets > 0 { - kv("dstPackets", strconv.FormatUint(e.Packets, 10)) - } - if !e.First.IsZero() { - kv("devTimeFormat", "MMM dd yyyy HH:mm:ss") - kv("devTime", e.First.UTC().Format("Jan 02 2006 15:04:05")) - } - kv("identSrc", e.Src) - kv("findingId", e.ID) - kv("phase", string(e.Phase)) - kv("hits", nonZero(e.Hits)) - kv("sampling", e.Sampling) - kv("exporter", exporter(e)) - kv("peerCountry", e.Country) - kv("peerOrg", strings.TrimSpace(e.Org+" "+e.ASN)) - kv("findingUrl", e.Evidence) - kv("msg", e.Text) - for _, k := range e.extraKeys() { - kv(k, e.Extra[k]) - } - return strings.TrimRight(b.String(), "\t") -} - -// asJSON writes Elastic Common Schema names, so an Elastic or Splunk -// pipeline needs no mapping written for it and the SIEM views recognise -// the event on their own. -func asJSON(e Event) string { - m := map[string]any{ - "@timestamp": e.Last.UTC().Format(time.RFC3339Nano), - "message": e.Text, - "event": map[string]any{ - "kind": "alert", - "category": []string{"network", "intrusion_detection"}, - "action": e.Kind, - "id": e.ID, - "severity": severity(e.Severity), - "count": e.Hits, - "start": rfc(e.First), - "end": rfc(e.Last), - "module": Product, - "provider": Vendor, - }, - "rule": map[string]any{"name": e.Kind, "id": e.Kind}, - "observer": observer(e), - } - if e.Evidence != "" { - m["event"].(map[string]any)["reference"] = e.Evidence - } - if e.Src != "" { - src := map[string]any{"ip": e.Src} - if e.Bytes > 0 { - src["bytes"] = e.Bytes - } - if e.Packets > 0 { - src["packets"] = e.Packets - } - m["source"] = src - } - dst := map[string]any{} - if e.Dst != "" { - dst["ip"] = e.Dst - } - if p, err := strconv.Atoi(e.Port); err == nil { - dst["port"] = p - } - if e.Down > 0 { - dst["bytes"] = e.Down - } - if e.Country != "" { - dst["geo"] = map[string]any{"country_iso_code": e.Country} - } - if e.ASN != "" || e.Org != "" { - as := map[string]any{} - if n, err := strconv.Atoi(strings.TrimPrefix(e.ASN, "AS")); err == nil { - as["number"] = n - } - if e.Org != "" { - as["organization"] = map[string]any{"name": e.Org} - } - dst["as"] = as - } - if len(dst) > 0 { - m["destination"] = dst - } - if e.Proto != "" { - m["network"] = map[string]any{"transport": strings.ToLower(e.Proto), "bytes": e.Bytes + e.Down} - } - var related []string - for _, ip := range []string{e.Src, e.Dst} { - if ip != "" { - related = append(related, ip) - } - } - if len(related) > 0 { - m["related"] = map[string]any{"ip": related} - } - // What a collector cannot work out for itself, under our own name so - // it never collides with a schema field. - own := map[string]any{"phase": string(e.Phase)} - if e.Sampling != "" { - own["sampling"] = e.Sampling - own["estimated"] = true // the byte and packet counts are weighted - } - for k, v := range e.Extra { - own[k] = v - } - m["traffic66"] = own - b, err := json.Marshal(m) - if err != nil { - return `{"message":"traffic66: could not encode this finding"}` - } - return string(b) -} - -func observer(e Event) map[string]any { - o := map[string]any{"vendor": Vendor, "product": Product, "type": "flow"} - if e.Exporter != "" { - o["ip"] = e.Exporter - } - if e.IfIndex != 0 { - o["ingress"] = map[string]any{"interface": map[string]any{"id": strconv.FormatUint(uint64(e.IfIndex), 10)}} - } - return o -} - -// text is for a person: everything that matters on one line, no escaping -// games, no schema. -func text(e Event) string { - var parts []string - add := func(f string, a ...any) { parts = append(parts, fmt.Sprintf(f, a...)) } - add("%s", e.Text) - add("kind=%s", e.Kind) - add("severity=%d", e.Severity) - add("id=%s", e.ID) - add("phase=%s", e.Phase) - if e.Sampling != "" { - add("sampling=%s (counts are estimates)", e.Sampling) - } - if ex := exporter(e); ex != "" { - add("exporter=%s", ex) - } - if e.Evidence != "" { - add("%s", e.Evidence) - } - return strings.Join(parts, " · ") -} - -func exporter(e Event) string { - switch { - case e.Exporter != "" && e.IfIndex != 0: - return fmt.Sprintf("%s/%d", e.Exporter, e.IfIndex) - case e.Exporter != "": - return e.Exporter - } - return "" -} - -func rfc(t time.Time) any { - if t.IsZero() { - return nil - } - return t.UTC().Format(time.RFC3339Nano) -} - -func nonZero(n int) string { - if n == 0 { - return "" - } - return strconv.Itoa(n) -} - -func nilable(s string) string { - if s == "" { - return "-" - } - return s -} - -// Version is the traffic66 version put in the message headers; main sets -// it at startup. -var Version = "0.0.0" - -func version() string { return cefHdr(Version) } - -// cefHdr escapes a CEF header field, where a pipe would end it. -func cefHdr(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, "|", `\|`) - return oneLine(s) -} - -// cefVal escapes a CEF extension value. The specification asks only for -// the backslash and the equals sign, but parsers differ on a pipe or a tab -// inside a value — some split the header on every pipe they see — so both -// are escaped too. Nothing is lost by being stricter than the format -// requires, and a field that swallows the rest of an event is the kind of -// bug that is only ever found in production. -func cefVal(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, "=", `\=`) - s = strings.ReplaceAll(s, "|", `\|`) - s = strings.ReplaceAll(s, "\t", `\t`) - return oneLine(s) -} - -// leefEsc escapes the tab that separates LEEF fields. -func leefEsc(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, "\t", `\t`) - return oneLine(s) -} - -// sdEsc escapes an RFC 5424 structured data value. -func sdEsc(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - s = strings.ReplaceAll(s, "]", `\]`) - return oneLine(s) -} - -// oneLine keeps a message to one line: a newline in the middle would look -// like the start of another event to every collector there is. -func oneLine(s string) string { - s = strings.ReplaceAll(s, "\r", " ") - return strings.ReplaceAll(s, "\n", " ") -} - -// ascii replaces anything outside ASCII. Plenty of older collectors -// mangle UTF-8, and a Japanese host name should not be the reason a feed -// stops being readable. -func ascii(s string) string { - var b strings.Builder - for _, r := range s { - if r < 0x20 || r > 0x7e { - fmt.Fprintf(&b, "\\u%04x", r) - continue - } - b.WriteRune(r) - } - return b.String() -} diff --git a/internal/syslogout/format_test.go b/internal/syslogout/format_test.go deleted file mode 100644 index 92e3ccb..0000000 --- a/internal/syslogout/format_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package syslogout - -import ( - "encoding/json" - "net" - "strconv" - "strings" - "testing" - "time" - - "github.com/githubflyideas/traffic66/internal/store" -) - -var when = time.Date(2026, 10, 14, 2, 59, 0, 0, time.UTC) - -func sample() Event { - e := Event{ - Phase: PhaseStart, ID: "41", Kind: "brute", Severity: 3, - Src: "10.10.3.27", Dst: "10.20.0.15", Port: "3389", Proto: "TCP", - First: when, Last: when.Add(15 * time.Minute), Hits: 22, - Bytes: 3740, Packets: 90112, Sampling: "1:4096", - Exporter: "192.0.2.1", IfIndex: 3, Country: "JP", ASN: "AS64500", Org: "Example Telecom", - Evidence: "https://traffic66.example.com/#v=findings&finding=41", - Extra: map[string]string{"conns": "22"}, - } - e.Text = Describe(e) - return e -} - -func lineFor(t *testing.T, f Format, e Event) string { - t.Helper() - cfg := Config{Enabled: true, Address: "collector:514", Format: f, Hostname: "sensor1"} - cfg.defaults() - return Message(cfg, e, when) -} - -// Whatever the body, the envelope carries the identity of the event and -// the sampling rate, so a collector that understands only RFC 5424 still -// gets the two things it cannot work out for itself. -func TestEnvelope(t *testing.T) { - for _, f := range Formats { - line := lineFor(t, f, sample()) - if !strings.HasPrefix(line, "<108>1 2026-10-14T02:59:00.000Z sensor1 traffic66 ") { - t.Fatalf("%s: envelope is %.60q", f, line) - } - for _, want := range []string{`traffic66@32473`, `id="41"`, `kind="brute"`, `phase="start"`, `sampling="1:4096"`} { - if !strings.Contains(line, want) { - t.Errorf("%s: missing %s", f, want) - } - } - if strings.Count(line, "\n") != 0 { - t.Errorf("%s: a message must be one line", f) - } - } -} - -func TestCEF(t *testing.T) { - line := lineFor(t, FormatCEF, sample()) - body := line[strings.Index(line, "CEF:"):] - head := strings.SplitN(body, "|", 8) - if len(head) != 8 { - t.Fatalf("CEF header has %d fields: %q", len(head), body) - } - if head[0] != "CEF:0" || head[1] != "traffic66" || head[4] != "brute" { - t.Fatalf("header %v", head[:5]) - } - if head[6] != "8" { - t.Fatalf("a high finding should be severity 8, got %s", head[6]) - } - for _, want := range []string{ - "src=10.10.3.27", "dst=10.20.0.15", "dpt=3389", "proto=TCP", - "out=3740", "cnt=22", "externalId=41", "act=start", - "start=" + strconv.FormatInt(when.UnixMilli(), 10), - "end=" + strconv.FormatInt(when.Add(15*time.Minute).UnixMilli(), 10), - "cs1Label=sampling cs1=1:4096", - "cs3Label=exporter cs3=192.0.2.1/3", - "cs5Label=peerCountry cs5=JP", - } { - if !strings.Contains(body, want) { - t.Errorf("missing %q in %s", want, body) - } - } -} - -func TestLEEF(t *testing.T) { - line := lineFor(t, FormatLEEF, sample()) - body := line[strings.Index(line, "LEEF:"):] - if !strings.HasPrefix(body, "LEEF:2.0|traffic66|traffic66|") { - t.Fatalf("header %.40q", body) - } - head := strings.SplitN(body, "|", 7) - if head[4] != "brute" { - t.Fatalf("event id %q", head[4]) - } - if head[5] != "\t" { - t.Fatalf("version 2.0 names its delimiter, got %q", head[5]) - } - for _, want := range []string{"src=10.10.3.27", "dstPort=3389", "sev=8", "findingId=41", "sampling=1:4096"} { - if !strings.Contains(body, want) { - t.Errorf("missing %q", want) - } - } -} - -func TestJSONIsECS(t *testing.T) { - line := lineFor(t, FormatJSON, sample()) - body := line[strings.Index(line, "{"):] - var m map[string]any - if err := json.Unmarshal([]byte(body), &m); err != nil { - t.Fatalf("%v in %s", err, body) - } - ev := m["event"].(map[string]any) - if ev["kind"] != "alert" || ev["action"] != "brute" { - t.Fatalf("event %v", ev) - } - if m["source"].(map[string]any)["ip"] != "10.10.3.27" { - t.Fatal("source.ip") - } - dst := m["destination"].(map[string]any) - if dst["port"].(float64) != 3389 { - t.Fatalf("destination %v", dst) - } - if m["source"].(map[string]any)["bytes"].(float64) != 3740 { - t.Fatalf("what the source sent belongs on the source: %v", m["source"]) - } - if dst["geo"].(map[string]any)["country_iso_code"] != "JP" { - t.Fatal("destination.geo") - } - if dst["as"].(map[string]any)["number"].(float64) != 64500 { - t.Fatalf("destination.as %v", dst["as"]) - } - own := m["traffic66"].(map[string]any) - if own["sampling"] != "1:4096" || own["estimated"] != true { - t.Fatalf("sampled counts must be marked as estimates: %v", own) - } - obs := m["observer"].(map[string]any) - if obs["ip"] != "192.0.2.1" { - t.Fatalf("observer %v", obs) - } -} - -// Someone reading the file should see the sampling warning in words, not -// have to know what 1:4096 means for the numbers next to it. -func TestTextSaysTheCountsAreEstimates(t *testing.T) { - line := lineFor(t, FormatText, sample()) - if !strings.Contains(line, "counts are estimates") { - t.Fatalf("got %s", line) - } -} - -// A value carrying the separator of its own format would split one event -// into two, or swallow the rest of it. -func TestSeparatorsAreEscaped(t *testing.T) { - e := sample() - e.Org = "Pipe | Equals = Back\\slash" - e.Text = "a|b=c" - e.Extra = map[string]string{"note": "tab\there"} - - cef := lineFor(t, FormatCEF, e) - if strings.Contains(cef, "Pipe | Equals = ") { - t.Errorf("CEF did not escape: %s", cef) - } - if n := unescapedPipes(cef[strings.Index(cef, "CEF:"):]); n != 7 { - t.Errorf("CEF should have exactly 7 separating pipes, got %d in %s", n, cef) - } - leef := lineFor(t, FormatLEEF, e) - fields := strings.Split(leef[strings.Index(leef, "LEEF:"):], "\t") - for _, f := range fields[1:] { - if f != "" && !strings.Contains(f, "=") { - t.Errorf("LEEF field %q has no key, so a tab leaked through", f) - } - } -} - -// Older collectors mangle UTF-8, and a Japanese host name should not be -// the reason a feed stops being readable. -// unescapedPipes counts the pipes that separate CEF header fields: one -// written in a value is escaped and does not. -func unescapedPipes(s string) int { - n := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\\' { - i++ - continue - } - if s[i] == '|' { - n++ - } - } - return n -} - -func TestASCIIOption(t *testing.T) { - e := sample() - e.Text = "株式会社サンプル" - cfg := Config{Enabled: true, Address: "c:514", Format: FormatCEF, Hostname: "センサー", ASCII: true} - cfg.defaults() - line := Message(cfg, e, when) - for _, r := range line { - if r > 0x7e { - t.Fatalf("%q is not ASCII: %s", r, line) - } - } - if !strings.Contains(line, "\\u682a") { - t.Fatalf("non-ASCII should be escaped, not dropped: %s", line) - } -} - -func TestSeverityAndPriority(t *testing.T) { - for _, c := range []struct{ sev, cef, pri int }{{3, 8, 108}, {2, 5, 109}, {1, 3, 109}} { - if got := severity(c.sev); got != c.cef { - t.Errorf("severity(%d) = %d, want %d", c.sev, got, c.cef) - } - if got := priority(13, c.sev); got != c.pri { - t.Errorf("priority(13, %d) = %d, want %d", c.sev, got, c.pri) - } - } -} - -// The event type is what a dashboard groups by, so it must not change -// with the wording. -func TestKindIsTheStableKey(t *testing.T) { - e := sample() - e.Text = "完全に違う文章" - line := lineFor(t, FormatCEF, e) - if !strings.Contains(line, `kind="brute"`) || !strings.Contains(line, "|brute|") { - t.Fatalf("the event type should survive any wording: %s", line) - } -} - -func TestFromFinding(t *testing.T) { - f := store.Finding{ - ID: 41, Kind: "exfil", Sev: 3, Src: "10.10.3.27", Dst: "203.0.113.200", - Port: "443/tcp", First: when, Last: when.Add(time.Hour), Hits: 4, - Ev: map[string]any{"up": float64(11_900_000_000), "down": float64(372_000_000), "sampling": float64(1000), "cc": "RU", "org": "Demo Hosting", "conns": float64(7)}, - } - e := FromFinding(f, PhaseStart, "https://traffic66.example.com/") - if e.Port != "443" || e.Proto != "TCP" { - t.Fatalf("port %q proto %q", e.Port, e.Proto) - } - if e.Sampling != "1:1000" { - t.Fatalf("sampling %q", e.Sampling) - } - if e.Bytes != 11_900_000_000 || e.Down != 372_000_000 || e.Country != "RU" || e.Org != "Demo Hosting" { - t.Fatalf("%+v", e) - } - if e.Extra["conns"] != "7" { - t.Fatalf("extra %v", e.Extra) - } - if !strings.Contains(e.Evidence, "finding=41") { - t.Fatalf("evidence %q", e.Evidence) - } - if !strings.Contains(e.Text, "11.1 GB") { - t.Fatalf("text %q", e.Text) - } -} - -// Sampling of 1 is not sampling, and saying "1:1" would make a reader -// think the counts were weighted. -func TestUnsampledSaysNothing(t *testing.T) { - f := store.Finding{ID: 1, Kind: "scan", Sev: 1, Ev: map[string]any{"sampling": float64(1)}} - if e := FromFinding(f, PhaseStart, ""); e.Sampling != "" { - t.Fatalf("sampling %q", e.Sampling) - } -} - -func TestConfigCheck(t *testing.T) { - ok := Config{Enabled: true, Address: "siem.example.com:514", Network: "tls", Format: FormatLEEF, MinSev: 2} - if err := ok.Check(); err != nil { - t.Fatal(err) - } - for _, c := range []struct { - what string - cfg Config - }{ - {"no address", Config{Enabled: true}}, - {"no port", Config{Enabled: true, Address: "siem.example.com"}}, - {"unknown transport", Config{Enabled: true, Address: "a:514", Network: "carrier-pigeon"}}, - {"unknown format", Config{Enabled: true, Address: "a:514", Format: "xml"}}, - {"severity out of range", Config{Enabled: true, Address: "a:514", MinSev: 9}}, - } { - if err := c.cfg.Check(); err == nil { - t.Errorf("%s was accepted", c.what) - } - } - // Switched off, nothing else matters. - if err := (Config{Address: "nonsense"}).Check(); err != nil { - t.Fatalf("a feed that is off should not complain: %v", err) - } -} - -// A flood of findings must not become a flood of syslog, or the feed gets -// turned off and nobody hears about the next one either. -func TestCeiling(t *testing.T) { - s := New(Config{Enabled: true, Address: "203.0.113.9:514", PerMin: 3}) - now := time.Now() - for i := 0; i < 5; i++ { - s.mu.Lock() - got := s.allow(now) - s.mu.Unlock() - if want := i < 3; got != want { - t.Fatalf("message %d: allowed=%v, want %v", i+1, got, want) - } - } - s.mu.Lock() - next := s.allow(now.Add(time.Minute)) - s.mu.Unlock() - if !next { - t.Fatal("the next minute starts again") - } -} - -// It has to actually reach a collector. -func TestSendsOverUDP(t *testing.T) { - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer pc.Close() - s := New(Config{Enabled: true, Address: pc.LocalAddr().String(), Format: FormatCEF}) - defer s.Close() - s.Send(sample()) - - buf := make([]byte, 4096) - pc.SetReadDeadline(time.Now().Add(3 * time.Second)) - n, _, err := pc.ReadFrom(buf) - if err != nil { - t.Fatal(err) - } - if got := string(buf[:n]); !strings.Contains(got, "CEF:0|traffic66") || !strings.Contains(got, "src=10.10.3.27") { - t.Fatalf("got %s", got) - } -} - -func TestBelowSeverityIsNotSent(t *testing.T) { - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer pc.Close() - s := New(Config{Enabled: true, Address: pc.LocalAddr().String(), MinSev: 3}) - defer s.Close() - low := sample() - low.Severity = 1 - s.Send(low) - pc.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) - if _, _, err := pc.ReadFrom(make([]byte, 512)); err == nil { - t.Fatal("a finding below the chosen severity was sent anyway") - } -} - -// A stream needs to know where one message ends. -func TestTCPIsFramed(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - got := make(chan string, 1) - go func() { - c, err := ln.Accept() - if err != nil { - return - } - defer c.Close() - b := make([]byte, 4096) - n, _ := c.Read(b) - got <- string(b[:n]) - }() - s := New(Config{Enabled: true, Address: ln.Addr().String(), Network: "tcp", Format: FormatText}) - defer s.Close() - s.Send(sample()) - select { - case line := <-got: - size, rest, ok := strings.Cut(line, " ") - if !ok { - t.Fatalf("no octet count: %q", line) - } - if len(rest) == 0 || size == "" { - t.Fatalf("framing %q", line) - } - case <-time.After(3 * time.Second): - t.Fatal("nothing arrived") - } -} diff --git a/internal/syslogout/sender.go b/internal/syslogout/sender.go deleted file mode 100644 index e76f555..0000000 --- a/internal/syslogout/sender.go +++ /dev/null @@ -1,340 +0,0 @@ -package syslogout - -import ( - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "log" - "net" - "os" - "strings" - "sync" - "time" -) - -// Config is what a customer sets. It is deliberately small: an address, a -// format, and the two knobs that stop a feed from being turned off again -// (what is worth sending, and how much of it at most). -type Config struct { - Enabled bool `json:"enabled"` - Address string `json:"address"` // host:port - Network string `json:"network"` // udp, tcp, tls - Format Format `json:"format"` // cef, leef, json, text - MinSev int `json:"min_sev"` // 1 low, 2 medium, 3 high - Facility int `json:"facility"` // syslog facility, default 13 (log audit) - Hostname string `json:"hostname"` // what to put in the header; empty is this machine's - ASCII bool `json:"ascii"` // escape everything outside ASCII - PerMin int `json:"per_minute"` // hard ceiling, 0 takes the default - CAFile string `json:"ca_file"` // trust for a TLS collector with its own CA - Insecure bool `json:"insecure"` // accept any certificate; for a lab, not for a customer - BaseURL string `json:"base_url"` // where the web UI is reachable, for the evidence link -} - -// Defaults for anything left empty. -const ( - DefaultFacility = 13 // log audit - DefaultPerMin = 60 - dialTimeout = 5 * time.Second - writeTimeout = 5 * time.Second -) - -func (c *Config) defaults() { - if c.Network == "" { - c.Network = "udp" - } - if c.Format == "" { - c.Format = FormatCEF - } - if c.Facility == 0 { - c.Facility = DefaultFacility - } - if c.PerMin == 0 { - c.PerMin = DefaultPerMin - } - if c.MinSev == 0 { - c.MinSev = 1 - } -} - -// Check reports what is wrong with a configuration, so the settings page -// can say so before anything is saved. -func (c Config) Check() error { - c.defaults() - if !c.Enabled { - return nil - } - if c.Address == "" { - return errors.New("syslog: no collector address") - } - if _, _, err := net.SplitHostPort(c.Address); err != nil { - return fmt.Errorf("syslog: address should be host:port, not %q", c.Address) - } - switch c.Network { - case "udp", "tcp", "tls": - default: - return fmt.Errorf("syslog: %q is not udp, tcp or tls", c.Network) - } - if !c.Format.Valid() { - return fmt.Errorf("syslog: %q is not one of %v", c.Format, Formats) - } - if c.MinSev < 1 || c.MinSev > 3 { - return fmt.Errorf("syslog: the least severity to send is 1, 2 or 3, not %d", c.MinSev) - } - return nil -} - -// Sender holds the connection and the throttle. -type Sender struct { - mu sync.Mutex - cfg Config - conn net.Conn - - minute time.Time - sent int - dropped int - - st Stats -} - -// Stats is what the settings page shows about the feed: whether messages -// are going out, and if not, why. -type Stats struct { - Sent int64 `json:"sent"` - Failed int64 `json:"failed"` - Dropped int `json:"dropped"` - LastSent time.Time `json:"last_sent,omitzero"` - LastError string `json:"last_error,omitempty"` - LastErrAt time.Time `json:"last_error_at,omitzero"` -} - -// Stats returns the counts since this process started. -func (s *Sender) Stats() Stats { - s.mu.Lock() - defer s.mu.Unlock() - st := s.st - st.Dropped = s.dropped - return st -} - -// note records how one message went. -func (s *Sender) note(err error) { - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now() - if err != nil { - s.st.Failed++ - s.st.LastError, s.st.LastErrAt = err.Error(), now - return - } - s.st.Sent++ - s.st.LastSent = now -} - -// New prepares a sender. It does not connect: a collector that is down -// when traffic66 starts is not a reason for traffic66 not to start. -func New(cfg Config) *Sender { - cfg.defaults() - return &Sender{cfg: cfg} -} - -// Config returns the configuration in use. -func (s *Sender) Config() Config { - s.mu.Lock() - defer s.mu.Unlock() - return s.cfg -} - -// SetConfig swaps the configuration, closing any connection to the old -// collector. -func (s *Sender) SetConfig(cfg Config) { - cfg.defaults() - s.mu.Lock() - defer s.mu.Unlock() - s.cfg = cfg - s.close() -} - -// Dropped is how many messages the ceiling has thrown away, for the -// settings page: a feed that is silently losing events is worse than one -// that says so. -func (s *Sender) Dropped() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.dropped -} - -// Send reports one event. Anything below the configured severity, and -// anything over the ceiling, is dropped here rather than at the collector. -func (s *Sender) Send(e Event) { - s.mu.Lock() - cfg := s.cfg - if !cfg.Enabled || cfg.Address == "" { - s.mu.Unlock() - return - } - if e.Phase != PhaseTest && e.Severity < cfg.MinSev { - s.mu.Unlock() - return - } - if !s.allow(time.Now()) { - s.dropped++ - s.mu.Unlock() - return - } - s.mu.Unlock() - - err := s.write(Message(cfg, e, time.Now())) - s.note(err) - if err != nil { - log.Printf("syslog: %v", err) - } -} - -// Test sends one message and reports whether it went, for the button that -// says so on the settings page. -func (s *Sender) Test() error { - cfg := s.Config() - if err := cfg.Check(); err != nil { - return err - } - if !cfg.Enabled { - return errors.New("syslog: not switched on") - } - now := time.Now() - e := Event{ - Phase: PhaseTest, ID: "0", Kind: "test", Severity: 1, - First: now, Last: now, Extra: map[string]string{}, - } - e.Text = Describe(e) - err := s.write(Message(cfg, e, now)) - s.note(err) - return err -} - -// allow is the ceiling: a flood of findings must not become a flood of -// syslog, or the customer turns the feed off and nobody hears about the -// next one either. -func (s *Sender) allow(now time.Time) bool { - m := now.Truncate(time.Minute) - if m != s.minute { - s.minute, s.sent = m, 0 - } - if s.sent >= s.cfg.PerMin { - return false - } - s.sent++ - return true -} - -// write sends one line, reconnecting once if the connection has gone. A -// collector that restarts should cost one message at most, not the feed. -func (s *Sender) write(msg string) error { - s.mu.Lock() - defer s.mu.Unlock() - for attempt := 0; attempt < 2; attempt++ { - if s.conn == nil { - c, err := s.dial() - if err != nil { - return err - } - s.conn = c - } - s.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) - var err error - if s.cfg.Network == "udp" { - _, err = s.conn.Write([]byte(msg)) - } else { - // Octet counting (RFC 6587): a stream needs to know where one - // message ends, and a length is the framing collectors agree on. - _, err = fmt.Fprintf(s.conn, "%d %s", len(msg), msg) - } - if err == nil { - return nil - } - s.close() - if attempt == 1 { - return fmt.Errorf("sending to %s: %w", s.cfg.Address, err) - } - } - return nil -} - -func (s *Sender) dial() (net.Conn, error) { - switch s.cfg.Network { - case "tls": - cfg := &tls.Config{InsecureSkipVerify: s.cfg.Insecure, MinVersion: tls.VersionTLS12} - if s.cfg.CAFile != "" { - pem, err := os.ReadFile(s.cfg.CAFile) - if err != nil { - return nil, fmt.Errorf("syslog: %w", err) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("syslog: %s holds no certificate", s.cfg.CAFile) - } - cfg.RootCAs = pool - } - if host, _, err := net.SplitHostPort(s.cfg.Address); err == nil { - cfg.ServerName = host - } - return tls.DialWithDialer(&net.Dialer{Timeout: dialTimeout}, "tcp", s.cfg.Address, cfg) - case "tcp": - return net.DialTimeout("tcp", s.cfg.Address, dialTimeout) - } - return net.DialTimeout("udp", s.cfg.Address, dialTimeout) -} - -// close drops the connection; the next write dials again. -func (s *Sender) close() { - if s.conn != nil { - s.conn.Close() - s.conn = nil - } -} - -// Close stops using the collector. -func (s *Sender) Close() { - s.mu.Lock() - defer s.mu.Unlock() - s.close() -} - -// title upper-cases the first letter of a field name for CEF, where the -// custom field names are conventionally camel case. -func title(s string) string { - if s == "" { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} - -// LoadConfig reads the configuration saved from the settings page. ok is -// false when nothing has been saved yet. -func LoadConfig(path string) (cfg Config, ok bool, err error) { - b, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return Config{}, false, nil - } - if err != nil { - return Config{}, false, err - } - if err := json.Unmarshal(b, &cfg); err != nil { - return Config{}, false, fmt.Errorf("syslog: %s: %w", path, err) - } - return cfg, true, nil -} - -// SaveConfig writes the configuration so it outlives a restart. -func SaveConfig(path string, cfg Config) error { - b, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { - return err - } - return os.Rename(tmp, path) -} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index ce62f7a..b10e2e6 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -1731,7 +1731,7 @@ async function ifaceCleanup(el) { est.textContent = t('cl.if_estimating'); const res = await fetch(`/api/cleanup/iface?exporter=${encodeURIComponent(tg.exporter)}&ifindex=${tg.ifindex}&days=${tg.days}`); const j = await res.json().catch(() => ({})); - if (!res.ok) { est.textContent = j.error === 'not_registered' ? t('cl.if_locked') : (j.error || res.statusText); return; } + if (!res.ok) { est.textContent = (j.error === 'not_registered' || res.status === 404) ? t('cl.if_locked') : (j.error || res.statusText); return; } const parts = [t('cl.rows', {n: nf(j.rows || 0)}), t('cl.if_counters', {n: nf(j.counters || 0)})]; if (j.bytes) parts.push(t('cl.if_frees', {s: fmtBytes(j.bytes)})); est.textContent = parts.join(' · ') + (j.summaries ? ' — ' + t('cl.if_keep', {n: nf(j.summaries)}) : ''); @@ -1745,7 +1745,7 @@ async function ifaceCleanup(el) { run.disabled = true; const res = await fetch('/api/cleanup/iface', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(tg)}); const j = await res.json().catch(() => ({})); - if (!res.ok) { m.style.color = 'var(--crit)'; m.textContent = j.error === 'not_registered' ? t('cl.if_locked') : (j.error || res.statusText); return; } + if (!res.ok) { m.style.color = 'var(--crit)'; m.textContent = (j.error === 'not_registered' || res.status === 404) ? t('cl.if_locked') : (j.error || res.statusText); return; } m.style.color = 'var(--good)'; m.textContent = t('cl.if_done', {i: name, n: nf(j.rows || 0), c: nf(j.counters || 0), s: fmtBytes(j.bytes || 0)}); loadStatus(); From 34999e1d153324fee037b5756cc8315a2733d992 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 12:14:25 +0000 Subject: [PATCH 4/4] Build changes --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae79d0..9bba201 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,19 +42,21 @@ jobs: env: GOFLAGS: -buildvcs=false PRO_TOKEN: ${{ secrets.PRO_TOKEN }} + PRO_SSH_KEY: ${{ secrets.PRO_SSH_KEY }} defaults: run: shell: bash steps: - uses: actions/checkout@v5 - uses: actions/checkout@v5 - if: ${{ env.PRO_TOKEN != '' }} + if: ${{ env.PRO_TOKEN != '' || env.PRO_SSH_KEY != '' }} with: repository: githubflyideas/traffic66-pro - token: ${{ secrets.PRO_TOKEN }} + token: ${{ secrets.PRO_TOKEN || github.token }} + ssh-key: ${{ secrets.PRO_SSH_KEY }} path: .pro - name: Registered features - if: ${{ env.PRO_TOKEN != '' }} + if: ${{ env.PRO_TOKEN != '' || env.PRO_SSH_KEY != '' }} run: | cp -R .pro/overlay/tree/. . rm -rf .pro @@ -110,7 +112,7 @@ jobs: # a tag push and a one-click release (a version entered by hand) # are both releases; only the builds of every push stay community kind=community; [[ "$GITHUB_REF" == refs/tags/* || -n "${{ inputs.version }}" ]] && kind=official - if [[ $kind == official && -z "$T66_PRO" ]]; then echo "::error::a release needs the PRO_TOKEN secret"; exit 1; fi + if [[ $kind == official && -z "$T66_PRO" ]]; then echo "::error::a release needs the PRO_SSH_KEY or PRO_TOKEN secret"; exit 1; fi ldflags="-s -w -X main.version=${ver} -X main.buildKind=${kind}" tags="" export CGO_ENABLED=1
${t('blk.col_addr')}${t('blk.col_since')}${t('blk.col_dropped')}${t('blk.col_note')}
${esc(b.address)} ${b.since ? esc(new Intl.DateTimeFormat(LANG, {dateStyle: 'short', timeStyle: 'short'}).format(new Date(b.since))) : ''}