From 4400372f37ac9cf1ecb70cd3d31f7aa0bc445ad2 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:50:17 +0900 Subject: [PATCH 01/36] [client] Forward non-address DNS record types through route forwarders (#6455) --- client/internal/dns/resutil/resolve.go | 230 ++++++++++++++- client/internal/dns/resutil/resolve_test.go | 159 +++++++++++ client/internal/dnsfwd/forwarder.go | 70 ++++- client/internal/dnsfwd/forwarder_test.go | 267 ++++++++++++++++-- .../routemanager/dnsinterceptor/handler.go | 24 +- 5 files changed, 703 insertions(+), 47 deletions(-) diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go index a2599aee7..931938755 100644 --- a/client/internal/dns/resutil/resolve.go +++ b/client/internal/dns/resutil/resolve.go @@ -8,6 +8,7 @@ import ( "errors" "net" "net/netip" + "slices" "strings" "github.com/miekg/dns" @@ -167,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina case dns.TypeA: alternativeNetwork = "ip6" default: - return dns.RcodeNameError + // Non-address types reach LookupIP only unexpectedly; without an + // address pair to probe we cannot prove the name is absent, so answer + // NODATA rather than a poisoning NXDOMAIN. + return dns.RcodeSuccess } if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil { @@ -184,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina return dns.RcodeSuccess } +// RecordResolver is the host resolver surface used to forward non-address +// record queries. net.DefaultResolver satisfies it. +type RecordResolver interface { + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) +} + +// LookupRecords resolves a non-address DNS record type through the host +// resolver and returns the resource records and the DNS rcode. Types the host +// resolver cannot answer (anything not covered by the net.Resolver Lookup* +// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN +// for an unsupported type. +func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) { + fqdn := dns.Fqdn(name) + + switch qtype { + case dns.TypeMX: + return lookupMX(ctx, r, name, fqdn, ttl) + case dns.TypeTXT: + return lookupTXT(ctx, r, name, fqdn, ttl) + case dns.TypeNS: + return lookupNS(ctx, r, name, fqdn, ttl) + case dns.TypeSRV: + return lookupSRV(ctx, r, name, fqdn, ttl) + case dns.TypeCNAME: + return lookupCNAME(ctx, r, name, fqdn, ttl) + case dns.TypePTR: + return lookupPTR(ctx, r, name, fqdn, ttl) + default: + return nil, dns.RcodeSuccess + } +} + +func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header { + return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl} +} + +func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupMX(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, mx := range recs { + rrs = append(rrs, &dns.MX{ + Hdr: recordHeader(fqdn, dns.TypeMX, ttl), + Preference: mx.Pref, + Mx: dns.Fqdn(mx.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupTXT(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, txt := range recs { + rrs = append(rrs, &dns.TXT{ + Hdr: recordHeader(fqdn, dns.TypeTXT, ttl), + Txt: chunkTXT(txt), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupNS(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, ns := range recs { + rrs = append(rrs, &dns.NS{ + Hdr: recordHeader(fqdn, dns.TypeNS, ttl), + Ns: dns.Fqdn(ns.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + _, recs, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, srv := range recs { + rrs = append(rrs, &dns.SRV{ + Hdr: recordHeader(fqdn, dns.TypeSRV, ttl), + Priority: srv.Priority, + Weight: srv.Weight, + Port: srv.Port, + Target: dns.Fqdn(srv.Target), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + cname, err := r.LookupCNAME(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + // LookupCNAME returns the queried name itself when the name resolves but + // has no CNAME record; that is a NODATA result, not a CNAME. + if strings.EqualFold(dns.Fqdn(cname), fqdn) { + return nil, dns.RcodeSuccess + } + return []dns.RR{&dns.CNAME{ + Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl), + Target: dns.Fqdn(cname), + }}, dns.RcodeSuccess +} + +func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + addr, ok := ptrQueryAddr(name) + if !ok { + return nil, dns.RcodeSuccess + } + names, err := r.LookupAddr(ctx, addr) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(names)) + for _, n := range names { + rrs = append(rrs, &dns.PTR{ + Hdr: recordHeader(fqdn, dns.TypePTR, ttl), + Ptr: dns.Fqdn(n), + }) + } + return rrs, dns.RcodeSuccess +} + +// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa) +// into the address string expected by net.Resolver.LookupAddr. It reports false +// when the name is not a well-formed reverse name. +func ptrQueryAddr(qname string) (string, bool) { + name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".") + + switch { + case strings.HasSuffix(name, ".in-addr.arpa"): + return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa")) + case strings.HasSuffix(name, ".ip6.arpa"): + return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa")) + default: + return "", false + } +} + +// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4 +// address string, reporting false when it is not a well-formed reverse name. +func parseInAddrArpa(labelPart string) (string, bool) { + labels := strings.Split(labelPart, ".") + if len(labels) != 4 { + return "", false + } + slices.Reverse(labels) + addr, err := netip.ParseAddr(strings.Join(labels, ".")) + if err != nil || !addr.Is4() { + return "", false + } + return addr.String(), true +} + +// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6 +// address string, reporting false when it is not a well-formed reverse name. +func parseIP6Arpa(nibblePart string) (string, bool) { + nibbles := strings.Split(nibblePart, ".") + if len(nibbles) != 32 { + return "", false + } + slices.Reverse(nibbles) + var sb strings.Builder + for i, n := range nibbles { + if i > 0 && i%4 == 0 { + sb.WriteByte(':') + } + sb.WriteString(n) + } + addr, err := netip.ParseAddr(sb.String()) + if err != nil || !addr.Is6() { + return "", false + } + return addr.String(), true +} + +// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A +// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound +// does not distinguish a missing name from a name that exists only with records +// of other types, so the name cannot be proven absent and must not be poisoned. +func rcodeForRecordError(err error) int { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return dns.RcodeSuccess + } + return dns.RcodeServerFailure +} + +// chunkTXT splits a TXT string into character-strings no longer than 255 bytes +// so the record can be packed. The chunks form one TXT resource record. +func chunkTXT(s string) []string { + const maxLen = 255 + if len(s) <= maxLen { + return []string{s} + } + + var chunks []string + for len(s) > maxLen { + chunks = append(chunks, s[:maxLen]) + s = s[maxLen:] + } + if len(s) > 0 { + chunks = append(chunks, s) + } + return chunks +} + // FormatAnswers formats DNS resource records for logging. func FormatAnswers(answers []dns.RR) string { if len(answers) == 0 { diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go index e6a8cc6a5..f51092a83 100644 --- a/client/internal/dns/resutil/resolve_test.go +++ b/client/internal/dns/resutil/resolve_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/netip" + "strings" "testing" "github.com/miekg/dns" @@ -121,6 +122,164 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) { assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL") } +func TestPtrQueryAddr(t *testing.T) { + tests := []struct { + name string + qname string + want string + wantOK bool + }{ + {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true}, + {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true}, + { + name: "ipv6", + qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + want: "2001:db8::1", + wantOK: true, + }, + {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false}, + {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false}, + {name: "not a reverse name", qname: "example.com.", wantOK: false}, + {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ptrQueryAddr(tt.qname) + assert.Equal(t, tt.wantOK, ok, "parse success mismatch") + if tt.wantOK { + assert.Equal(t, tt.want, got, "parsed address mismatch") + } + }) + } +} + +type mockRecordResolver struct { + mx []*net.MX + txt []string + ns []*net.NS + srv []*net.SRV + cname string + ptr []string + err error +} + +func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) { + return m.mx, m.err +} +func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) { + return m.txt, m.err +} +func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) { + return m.ns, m.err +} +func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) { + return "", m.srv, m.err +} +func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) { + return m.cname, m.err +} +func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) { + return m.ptr, m.err +} + +func TestLookupRecords(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com."} + + t.Run("MX success", func(t *testing.T) { + r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx) + }) + + t.Run("TXT short string is one character-string", func(t *testing.T) { + r := &mockRecordResolver{txt: []string{"v=spf1 -all"}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt) + }) + + t.Run("TXT chunks long strings", func(t *testing.T) { + long := strings.Repeat("a", 300) + r := &mockRecordResolver{txt: []string{long}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + txt := rrs[0].(*dns.TXT).Txt + require.Len(t, txt, 2, "300-byte string should split into two character-strings") + assert.Equal(t, 255, len(txt[0])) + assert.Equal(t, 45, len(txt[1])) + }) + + t.Run("NS success", func(t *testing.T) { + r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns) + }) + + t.Run("SRV success", func(t *testing.T) { + r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}} + rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port) + }) + + t.Run("CNAME success", func(t *testing.T) { + r := &mockRecordResolver{cname: "target.example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target) + }) + + t.Run("CNAME equal to name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{cname: "example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs, "self-referential CNAME is NODATA") + }) + + t.Run("PTR success", func(t *testing.T) { + r := &mockRecordResolver{ptr: []string{"host.example.com."}} + rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr) + }) + + t.Run("PTR malformed name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) + + t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) { + r := &mockRecordResolver{err: notFound} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name") + }) + + t.Run("server failure maps to SERVFAIL", func(t *testing.T) { + r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeServerFailure, rcode) + }) + + t.Run("unsupported type is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) +} + func TestStripOPT(t *testing.T) { rm := &dns.Msg{ Extra: []dns.RR{ diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index c15a8520f..b7e5a10e3 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -37,6 +37,12 @@ const ( type resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) } type firewaller interface { @@ -210,12 +216,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := query.SetReply(query) - network := resutil.NetworkForQtype(question.Qtype) - if network == "" { - resp.Rcode = dns.RcodeNotImplemented - f.writeResponse(logger, w, resp, qname, startTime) - return - } mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, ".")) if mostSpecificResId == "" { @@ -227,9 +227,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout) defer cancel() + reqHasEdns := query.IsEdns0() != nil + + switch question.Qtype { + case dns.TypeA, dns.TypeAAAA: + f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime) + case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR: + f.handleRecordQuery(ctx, logger, w, resp, startTime) + default: + // The domain is routed here, so any other type is answered NODATA + // (NOERROR, empty answer) rather than falling back to a resolver that + // would poison the name with NXDOMAIN. The Extended DNS Error lets a + // client tell this capability-driven NODATA apart from an + // authoritative one. The OPT pseudo-record must not appear unless the + // query advertised EDNS0. + if reqHasEdns { + attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type") + } + f.writeResponse(logger, w, resp, qname, startTime) + } +} + +// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and +// resolved-IP state, and caches the answer for resilience on upstream failure. +func (f *DNSForwarder) handleAddressQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + mostSpecificResId route.ResID, + matchingEntries []*ForwarderEntry, + reqHasEdns bool, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + network := resutil.NetworkForQtype(question.Qtype) result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype) if result.Err != nil { - f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime) + f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime) return } @@ -240,6 +277,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q f.writeResponse(logger, w, resp, qname, startTime) } +// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV, +// CNAME, PTR) through the host resolver. Missing records are answered NODATA so +// the routed name is never poisoned with NXDOMAIN. +func (f *DNSForwarder) handleRecordQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl) + resp.Rcode = rcode + resp.Answer = append(resp.Answer, records...) + f.writeResponse(logger, w, resp, qname, startTime) +} + func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) { if err := w.WriteMsg(resp); err != nil { logger.Errorf("failed to write DNS response: %v", err) diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 046595473..c69a9166e 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -133,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([ return args.Get(0).([]netip.Addr), args.Error(1) } +func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.MX) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.NS) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + args := m.Called(ctx, service, proto, name) + recs, _ := args.Get(1).([]*net.SRV) + return args.String(0), recs, args.Error(2) +} + +func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) { + args := m.Called(ctx, host) + return args.String(0), args.Error(1) +} + +func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + args := m.Called(ctx, addr) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) { tests := []struct { name string @@ -545,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) { } func TestDNSForwarder_ResponseCodes(t *testing.T) { + // A type with no net.Resolver Lookup method (CAA) must answer NODATA + // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name. tests := []struct { name string queryType uint16 queryDomain string configured string expectedCode int + expectEDE bool description string }{ { @@ -562,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { description: "RFC compliant REFUSED for unauthorized queries", }, { - name: "unsupported query type returns NOTIMP", - queryType: dns.TypeMX, + name: "unsupported query type returns NODATA", + queryType: dns.TypeCAA, queryDomain: "example.com", configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "RFC compliant NOTIMP for unsupported types", - }, - { - name: "CNAME query returns NOTIMP", - queryType: dns.TypeCNAME, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "CNAME queries not supported", - }, - { - name: "TXT query returns NOTIMP", - queryType: dns.TypeTXT, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "TXT queries not supported", + expectedCode: dns.RcodeSuccess, + expectEDE: true, + description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP", }, } @@ -599,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { query := &dns.Msg{} query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType) + query.SetEdns0(dns.DefaultMsgSize, false) // Capture the written response var writtenResp *dns.Msg @@ -614,10 +638,213 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { // Check the response written to the writer require.NotNil(t, writtenResp, "Expected response to be written") assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description) + assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers") + + if tt.expectEDE { + require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply") + assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported), + "unsupported type NODATA should carry EDE Not Supported") + } }) } } +func hasEDE(m *dns.Msg, code uint16) bool { + opt := m.IsEdns0() + if opt == nil { + return false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code { + return true + } + } + return false +} + +func TestDNSForwarder_RecordQueries(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com"} + + t.Run("MX records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + mx, ok := resp.Answer[0].(*dns.MX) + require.True(t, ok, "answer should be an MX record") + assert.Equal(t, uint16(10), mx.Preference) + assert.Equal(t, "mail.example.com.", mx.Mx) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // A not-found cannot prove the name is absent (it may exist with only + // other record types), so it must answer NODATA, never NXDOMAIN. + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA") + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("NS records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ns, ok := resp.Answer[0].(*dns.NS) + require.True(t, ok, "answer should be an NS record") + assert.Equal(t, "ns1.example.com.", ns.Ns) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing NS is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("SRV records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + srv, ok := resp.Answer[0].(*dns.SRV) + require.True(t, ok, "answer should be an SRV record") + assert.Equal(t, "sip.example.com.", srv.Target) + assert.Equal(t, uint16(5060), srv.Port) + assert.Equal(t, uint16(10), srv.Priority) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing SRV is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("TXT records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupTXT", mock.Anything, "example.com."). + Return([]string{"v=spf1 -all"}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + txt, ok := resp.Answer[0].(*dns.TXT) + require.True(t, ok, "answer should be a TXT record") + assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com") + + mockResolver.On("LookupCNAME", mock.Anything, "www.example.com."). + Return("target.example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok, "answer should be a CNAME record") + assert.Equal(t, "target.example.com.", cname.Target) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME equal to the name is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // No CNAME exists: LookupCNAME echoes the queried name back. + mockResolver.On("LookupCNAME", mock.Anything, "example.com."). + Return("example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record") + mockResolver.AssertExpectations(t) + }) + + t.Run("PTR record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa") + + // The reverse name is parsed back to the address LookupAddr expects. + mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4"). + Return([]string{"host.example.com."}, nil).Once() + + resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ptr, ok := resp.Answer[0].(*dns.PTR) + require.True(t, ok, "answer should be a PTR record") + assert.Equal(t, "host.example.com.", ptr.Ptr) + mockResolver.AssertExpectations(t) + }) +} + +func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder { + t.Helper() + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = r + + d, err := domain.FromString(configured) + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + return forwarder +} + +func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg { + t.Helper() + query := &dns.Msg{} + query.SetQuestion(dns.Fqdn(qname), qtype) + + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + + resp := mockWriter.GetLastResponse() + require.NotNil(t, resp, "expected response to be written") + return resp +} + func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) { tests := []struct { name string diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 22f3355c8..b784cc274 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - // pass if non A/AAAA query - if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA { - d.continueToNextHandler(w, r, logger, "non A/AAAA query") - return - } - + // All query types for an intercepted domain are forwarded to the peer's + // DNS forwarder, which owns the name. Falling through to the system + // resolver would let it answer NXDOMAIN for a name it isn't authoritative + // for, poisoning the whole name (including the A/AAAA records the route + // does serve). The forwarder answers NODATA for types it cannot resolve. d.mu.RLock() peerKey := d.currentPeerKey d.mu.RUnlock() @@ -293,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger } } -// continueToNextHandler signals the handler chain to try the next handler -func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) { - logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason) - - resp := new(dns.Msg) - resp.SetRcode(r, dns.RcodeNameError) - // Set Zero bit to signal handler chain to continue - resp.MsgHdr.Zero = true - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed writing DNS continue response: %v", err) - } -} - func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) { peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey) if !exists { From 1409a1325a805d70f456244fde999bbb09bb06b6 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 29 Jun 2026 09:19:01 +0200 Subject: [PATCH 02/36] [misc] Update careers page link (#6538) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9a51b6f1..40c6b9ed5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@

- 🚀 We are hiring! Join us at careers.netbird.io + 🚀 We are hiring! Join us at https://netbird.io/careers

From 5711f0e38c69ddbbfb5604a44a5098a6a04d7dcb Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:02:02 +0200 Subject: [PATCH 03/36] [client] add per-phase timing metrics for sync processing (#6533) * Adds metrics sync phases time split to monitor costs * Address review fixes * Increment README.md with description on usage with debug bundles --- client/internal/engine.go | 119 +++++--- client/internal/metrics/influxdb.go | 24 ++ client/internal/metrics/infra/README.md | 69 ++++- .../dashboards/json/netbird-sync-phases.json | 259 ++++++++++++++++++ client/internal/metrics/infra/ingest/main.go | 13 + client/internal/metrics/metrics.go | 15 + client/internal/metrics/push_test.go | 3 + 7 files changed, 468 insertions(+), 34 deletions(-) create mode 100644 client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json diff --git a/client/internal/engine.go b/client/internal/engine.go index e7f1c0501..f7c7e1862 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -895,6 +895,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate) } +// phase times a sync sub-phase: it returns a function that records the elapsed +// duration when called. Starting the timer at the call site keeps inter-phase +// glue code out of the measurement. +func (e *Engine) phase(name string) func() { + start := time.Now() + return func() { + e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start)) + } +} + func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { started := time.Now() defer func() { @@ -914,7 +924,10 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) } - if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil { + done := e.phase("netbird_config") + err := e.updateNetbirdConfig(update.GetNetbirdConfig()) + done() + if err != nil { return err } @@ -928,11 +941,16 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } - if err := e.updateChecksIfNew(update.Checks); err != nil { + done = e.phase("checks") + err = e.updateChecksIfNew(update.Checks) + done() + if err != nil { return err } + done = e.phase("persist") e.persistSyncResponse(update) + done() // only apply new changes and ignore old ones if err := e.updateNetworkMap(nm); err != nil { @@ -1371,13 +1389,16 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address()) + done := e.phase("dns_server") if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil { log.Errorf("failed to update dns server, err: %v", err) } + done() e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort) // apply routes first, route related actions might depend on routing being enabled + done = e.phase("routes_classify") routes := toRoutes(networkMap.GetRoutes()) serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes) @@ -1386,29 +1407,60 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.connMgr.UpdateRouteHAMap(clientRoutes) log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes)) } + done() + done = e.phase("routes_apply") dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap) if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil { log.Errorf("failed to update routes: %v", err) } + done() + done = e.phase("filtering") if e.acl != nil { e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag) } + done() + done = e.phase("dns_forwarder") fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes) e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries) + done() // Ingress forward rules + done = e.phase("forward_rules") forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) if err != nil { log.Errorf("failed to update forward rules, err: %v", err) } + done() log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers())) + done = e.phase("offline_peers") e.updateOfflinePeers(networkMap.GetOfflinePeers()) + done() + remotePeers, err := e.reconcilePeers(networkMap) + if err != nil { + return err + } + + // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store + done = e.phase("lazy_exclude") + excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) + e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + done() + + e.networkSerial = serial + + return nil +} + +// reconcilePeers applies the remote peer list from the network map (removing, +// modifying and adding peers, then updating SSH config) and returns the remote +// peers with our own peer filtered out, for use by later sync steps. +func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) { // Filter out own peer from the remote peers list localPubKey := e.config.WgPrivateKey.PublicKey().String() remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers())) @@ -1423,42 +1475,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { err := e.removeAllPeers() e.statusRecorder.FinishPeerListModifications() if err != nil { - return err + return nil, err } - } else { - err := e.removePeers(remotePeers) - if err != nil { - return err - } - - err = e.modifyPeers(remotePeers) - if err != nil { - return err - } - - err = e.addNewPeers(remotePeers) - if err != nil { - return err - } - - e.statusRecorder.FinishPeerListModifications() - - e.updatePeerSSHHostKeys(remotePeers) - - if err := e.updateSSHClientConfig(remotePeers); err != nil { - log.Warnf("failed to update SSH client config: %v", err) - } - - e.updateSSHServerAuth(networkMap.GetSshAuth()) + return remotePeers, nil } - // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store - excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) - e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + done := e.phase("removed_peers") + err := e.removePeers(remotePeers) + done() + if err != nil { + return nil, err + } - e.networkSerial = serial + done = e.phase("modified_peers") + err = e.modifyPeers(remotePeers) + done() + if err != nil { + return nil, err + } - return nil + done = e.phase("added_peers") + err = e.addNewPeers(remotePeers) + done() + if err != nil { + return nil, err + } + + e.statusRecorder.FinishPeerListModifications() + + e.updatePeerSSHHostKeys(remotePeers) + + if err := e.updateSSHClientConfig(remotePeers); err != nil { + log.Warnf("failed to update SSH client config: %v", err) + } + + e.updateSSHServerAuth(networkMap.GetSshAuth()) + + return remotePeers, nil } func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool { diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go index 531f6a986..4ba14bf44 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -120,6 +120,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI m.trimLocked() } +func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s", + agentInfo.DeploymentType.String(), + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + phase, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_sync_phase", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + m.trimLocked() +} + func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { result := "success" if !success { diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md index 5a93dbd87..7941a30cf 100644 --- a/client/internal/metrics/infra/README.md +++ b/client/internal/metrics/infra/README.md @@ -78,6 +78,25 @@ Tags: - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) +### Sync Phase Timing + +Measurement: `netbird_sync_phase` + +Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates. + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time spent in one sub-phase of sync processing | + +Tags: +- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude` +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work. + ### Login Duration Measurement: `netbird_login` @@ -191,4 +210,52 @@ docker compose exec influxdb influx query \ # Check ingest server health curl http://localhost:8087/health -``` \ No newline at end of file +``` + +## Analyzing a Debug Bundle + +Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client. + +The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough. + +### 1. Start the stack + +```bash +# From this directory (client/internal/metrics/infra) +INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \ + docker compose up -d +``` + +(`admin123` are throwaway local credentials — fine for offline analysis.) + +### 2. Clear any previous data + +So you only see this bundle: + +```bash +docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \ + --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z +``` + +### 3. Import the bundle's metrics.txt + +InfluxDB is not exposed on the host, so import inside the container: + +```bash +docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt +docker exec influxdb influx write --org netbird --bucket metrics --precision ns \ + --token admin123 --file /tmp/m.txt +``` + +Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites). + +### 4. View the dashboards + +Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned: + +- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes +- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics + +**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty. + +Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side. \ No newline at end of file diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json new file mode 100644 index 000000000..69dbac0ae --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json @@ -0,0 +1,259 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "netbird", + "sync" + ], + "templating": { + "list": [ + { + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")", + "includeAll": true, + "label": "version", + "multi": true, + "name": "version", + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")", + "refresh": 2, + "type": "query", + "allValue": ".*" + } + ] + }, + "time": { + "from": "now-2d", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "NetBird Sync Phases (where time goes)", + "uid": "netbird-sync-phases", + "version": 1, + "panels": [ + { + "id": 1, + "title": "Time per phase over time (stacked, ms)", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "drawStyle": "bars", + "stacking": { + "mode": "normal", + "group": "A" + }, + "fillOpacity": 80, + "lineWidth": 0 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "max", + "mean" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])" + } + ] + }, + { + "id": 2, + "title": "p95 per phase (ms)", + "type": "bargauge", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 10 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "color": { + "mode": "continuous-GrYlRd" + } + }, + "overrides": [] + }, + "options": { + "displayMode": "gradient", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)" + } + ] + }, + { + "id": 3, + "title": "Per-phase stats (ms): mean / p95 / max", + "type": "table", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 10 + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "max", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + } + ], + "targets": [ + { + "refId": "mean", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})" + }, + { + "refId": "p95", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})" + }, + { + "refId": "max", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})" + } + ] + }, + { + "id": 4, + "title": "Total sync duration (netbird_sync, ms) \u2014 reference", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "max", + "mean" + ] + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])" + } + ] + } + ] +} \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index a5031a873..623a17e4d 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -59,6 +59,19 @@ var allowedMeasurements = map[string]measurementSpec{ "peer_id": true, }, }, + "netbird_sync_phase": { + allowedFields: map[string]bool{ + "duration_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + "phase": true, + }, + }, "netbird_login": { allowedFields: map[string]bool{ "duration_seconds": true, diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index 4ebb43496..f18082995 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -56,6 +56,9 @@ type metricsImplementation interface { // RecordSyncDuration records how long it took to process a sync message RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) + // RecordSyncPhase records how long a single sub-phase of sync processing took + RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) + // RecordLoginDuration records how long the login to management took RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) @@ -127,6 +130,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du c.impl.RecordSyncDuration(ctx, agentInfo, duration) } +// RecordSyncPhase records the duration of a single sub-phase of sync processing +func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + // RecordLoginDuration records how long the login to management server took func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) { if c == nil { diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go index 20a509da1..43c1b2c06 100644 --- a/client/internal/metrics/push_test.go +++ b/client/internal/metrics/push_test.go @@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) { } +func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) { +} + func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) { } From deff8af59f13222d245abfdfc7e2e700c74dd8c7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 29 Jun 2026 11:24:25 +0200 Subject: [PATCH 04/36] [client] Wait for signal receive watchdog to stop before reconnect (#6574) * [client] Wait for signal receive watchdog to stop before reconnect The per-stream watchReceiveStream goroutine was started fire-and-forget and never joined. On reconnect a lingering watchdog could still flip shared client state (receiveStalled, the disconnect notifier) on the freshly established stream, since cancelStream only cancels its own stream context. Track the watchdog with a WaitGroup and wait for it to exit (after cancelling its stream) before the operation returns, so each reconnect starts with no stale watchdog. * [client] Bind signal receive probe to the stream context The watchdog probe reused the generic Send, which derives its per-attempt timeouts from the long-lived client context, so cancelStream could not interrupt an in-flight probe. After joining the watchdog on reconnect, watchdogWg.Wait() could then block for the full send-attempt chain. Split Send into a context-aware send and pass the stream context down through sendReceiveProbe, so cancelStream aborts any in-flight probe and the watchdog exits promptly. --- shared/signal/client/grpc.go | 30 ++++++++++++++++++++------- shared/signal/client/watchdog_test.go | 2 +- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index 611ab0c45..7e8e551b6 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -85,6 +85,7 @@ type GrpcClient struct { // receive backpressure as a dead stream: reconnecting cannot help, since the // new stream feeds the same worker, and only triggers a reconnect storm. receiveHandoffBlocked atomic.Bool + watchdogWg sync.WaitGroup } // NewClient creates a new Signal client @@ -200,10 +201,18 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // Guard the receive direction: the transport can stay healthy while the // server stops delivering messages. The watchdog reconnects via cancelStream. c.markReceived() - go c.watchReceiveStream(streamCtx, cancelStream) + c.watchdogWg.Add(1) + go func() { + defer c.watchdogWg.Done() + c.watchReceiveStream(streamCtx, cancelStream) + }() // start receiving messages from the Signal stream (from other peers through signal) err = c.receive(stream) + + cancelStream() + c.watchdogWg.Wait() + if err != nil { // Check the parent context, not streamCtx: a watchdog-triggered // cancelStream must reconnect, only a parent cancel is shutdown. @@ -400,7 +409,12 @@ func (c *GrpcClient) encryptMessage(msg *proto.Message) (*proto.EncryptedMessage // Send sends a message to the remote Peer through the Signal Exchange. func (c *GrpcClient) Send(msg *proto.Message) error { + return c.send(c.ctx, msg) +} +// send delivers a message deriving per-attempt timeouts from parentCtx, so a +// caller can abort an in-flight send by cancelling that context. +func (c *GrpcClient) send(parentCtx context.Context, msg *proto.Message) error { if !c.Ready() { return fmt.Errorf("no connection to signal") } @@ -416,7 +430,7 @@ func (c *GrpcClient) Send(msg *proto.Message) error { if attempt > 1 { attemptTimeout = time.Duration(attempt) * 5 * time.Second } - ctx, cancel := context.WithTimeout(c.ctx, attemptTimeout) + ctx, cancel := context.WithTimeout(parentCtx, attemptTimeout) _, err = c.realClient.Send(ctx, encryptedMessage) @@ -486,7 +500,7 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex } if probeSentAt.IsZero() { - if err := c.sendReceiveProbe(); err != nil { + if err := c.sendReceiveProbe(ctx); err != nil { log.Debugf("failed to send signal receive probe: %v", err) } probeSentAt = time.Now() @@ -495,11 +509,13 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex } } -// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it -// back to this client, exercising the exact receive path the watchdog guards. -func (c *GrpcClient) sendReceiveProbe() error { +// sendReceiveProbe sends a self-addressed heartbeat bound to ctx, so cancelStream +// aborts an in-flight probe instead of leaving the watchdog blocked on send timeouts. +// The Signal server routes it back to this client, exercising the exact receive +// path the watchdog guards. +func (c *GrpcClient) sendReceiveProbe(ctx context.Context) error { self := c.key.PublicKey().String() - return c.Send(&proto.Message{ + return c.send(ctx, &proto.Message{ Key: self, RemoteKey: self, Body: &proto.Body{Type: proto.Body_HEARTBEAT}, diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go index bc6b5520b..eeb9aec30 100644 --- a/shared/signal/client/watchdog_test.go +++ b/shared/signal/client/watchdog_test.go @@ -74,7 +74,7 @@ func TestReceiveProbeRoundTrips(t *testing.T) { t.Fatal("signal stream did not connect within timeout") } - require.NoError(t, client.sendReceiveProbe()) + require.NoError(t, client.sendReceiveProbe(ctx)) select { case <-received: From 0b594c639a75dc96af7893e85d87729966eec681 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 29 Jun 2026 11:28:58 +0200 Subject: [PATCH 05/36] [client] report management unhealthy while Sync stream is failing (#6575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mgm): report management unhealthy while Sync stream is failing The health probe (IsHealthy) only checked the gRPC transport and a GetServerKey call. GetServerKey succeeds even when the peer cannot sync (e.g. the server returns "settings not found"), so the probe kept marking management Connected while the Sync stream failed in a tight retry loop — pinning the status to "Connected" forever despite no sync ever succeeding. Track the last Sync stream error and have IsHealthy consult it, so a healthy transport is no longer enough to report the connection healthy. * fix(mgm): record disconnected state when sync stream setup fails The connectToSyncStream failure path in handleSyncStream returned early without updating syncStreamErr, so the client could still report healthy even when stream setup failed. Mirror the receiveUpdatesEvents error path by calling notifyDisconnected and setSyncStreamDisconnected. --- shared/management/client/grpc.go | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 016cde68a..6f5172376 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -55,6 +55,14 @@ type GrpcClient struct { connStateCallback ConnStateNotifier connStateCallbackLock sync.RWMutex serverURL string + + // syncStreamErr holds the last Sync stream error, or nil while the stream + // is established and healthy. GetServerKey succeeds even when the peer + // cannot sync (e.g. the server returns "settings not found"), so the + // health probe must consult this to avoid reporting a healthy management + // connection while the Sync stream keeps failing. + syncStreamMu sync.RWMutex + syncStreamErr error } type ExposeRequest struct { @@ -364,6 +372,8 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. stream, err := c.connectToSyncStream(ctx, serverPubKey, sysInfo) if err != nil { log.Debugf("failed to open Management Service stream: %s", err) + c.notifyDisconnected(err) + c.setSyncStreamDisconnected(err) if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer } @@ -372,11 +382,13 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. log.Infof("connected to the Management Service stream") c.notifyConnected() + c.setSyncStreamConnected() // blocking until error err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler) if err != nil { c.notifyDisconnected(err) + c.setSyncStreamDisconnected(err) if ctx.Err() != nil { log.Debugf("management connection context has been canceled, this usually indicates shutdown") return nil @@ -530,6 +542,13 @@ func (c *GrpcClient) IsHealthy() bool { log.Warnf("health check returned: %s", err) return false } + + if syncErr := c.syncStreamError(); syncErr != nil { + c.notifyDisconnected(syncErr) + log.Warnf("management transport is up but the Sync stream is unhealthy: %s", syncErr) + return false + } + c.notifyConnected() return true } @@ -771,6 +790,24 @@ func (c *GrpcClient) SyncMeta(sysInfo *system.Info) error { return err } +func (c *GrpcClient) setSyncStreamConnected() { + c.syncStreamMu.Lock() + defer c.syncStreamMu.Unlock() + c.syncStreamErr = nil +} + +func (c *GrpcClient) setSyncStreamDisconnected(err error) { + c.syncStreamMu.Lock() + defer c.syncStreamMu.Unlock() + c.syncStreamErr = err +} + +func (c *GrpcClient) syncStreamError() error { + c.syncStreamMu.RLock() + defer c.syncStreamMu.RUnlock() + return c.syncStreamErr +} + func (c *GrpcClient) notifyDisconnected(err error) { c.connStateCallbackLock.RLock() defer c.connStateCallbackLock.RUnlock() From b434cda0627a01538bf977f89b8e4834d95d2fed Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:16:47 +0900 Subject: [PATCH 06/36] [client] Refresh signal receive liveness when worker handoff drains (#6594) --- shared/signal/client/grpc.go | 3 ++ shared/signal/client/watchdog_test.go | 70 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index 7e8e551b6..a07867263 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -557,6 +557,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er if err := c.decryptionWorker.AddMsg(c.ctx, msg); err != nil { log.Errorf("failed to add message to decryption worker: %v", err) } + // Refresh liveness before clearing the flag so the window between here and + // the next Recv does not read a stale timestamp as a dead stream. + c.markReceived() c.receiveHandoffBlocked.Store(false) } } diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go index eeb9aec30..a8bbafa29 100644 --- a/shared/signal/client/watchdog_test.go +++ b/shared/signal/client/watchdog_test.go @@ -2,6 +2,7 @@ package client import ( "context" + "io" "net" "testing" "time" @@ -106,3 +107,72 @@ func TestReceiveAliveTreatsHandoffBlockAsLiveness(t *testing.T) { c.markReceived() require.True(t, c.receiveAlive(), "a freshly received frame must keep the stream alive") } + +// fakeRecvStream feeds the receive loop frames from a channel and reports EOF +// once the channel is closed. Only Recv is exercised by the loop. +type fakeRecvStream struct { + sigProto.SignalExchange_ConnectStreamClient + frames chan *sigProto.EncryptedMessage +} + +func (s *fakeRecvStream) Recv() (*sigProto.EncryptedMessage, error) { + msg, ok := <-s.frames + if !ok { + return nil, io.EOF + } + return msg, nil +} + +// TestReceiveLoopRefreshesLivenessAfterBlockedHandoff drives the real receive +// loop into a handoff that blocks past the inactivity threshold, then checks the +// window after the handoff drains but before the next Recv. The loop must have +// refreshed the timestamp on unblocking, otherwise that window reads the stale +// pre-handoff timestamp as a dead stream and the watchdog tears down a healthy +// connection. +func TestReceiveLoopRefreshesLivenessAfterBlockedHandoff(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + c := &GrpcClient{ctx: ctx} + + handling := make(chan struct{}, 8) + gate := make(chan struct{}) + decrypt := func(*sigProto.EncryptedMessage) (*sigProto.Message, error) { return &sigProto.Message{}, nil } + handler := func(*sigProto.Message) error { + handling <- struct{}{} + <-gate + return nil + } + c.decryptionWorker = NewWorker(decrypt, handler) + workerCtx, workerCancel := context.WithCancel(context.Background()) + go c.decryptionWorker.Work(workerCtx) + t.Cleanup(workerCancel) + + frames := make(chan *sigProto.EncryptedMessage) + t.Cleanup(func() { close(frames) }) + go func() { _ = c.receive(&fakeRecvStream{frames: frames}) }() + + // First frame: the worker drains it and parks in the blocking handler. + frames <- &sigProto.EncryptedMessage{} + <-handling + // Second frame fills the worker's single-slot pool. + frames <- &sigProto.EncryptedMessage{} + // Third frame: the pool is full, so the loop parks on the handoff. + frames <- &sigProto.EncryptedMessage{} + + require.Eventually(t, c.receiveHandoffBlocked.Load, time.Second, time.Millisecond, + "receive loop should park on the worker handoff") + + // Simulate the handoff having blocked past the inactivity threshold. + c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano()) + require.True(t, c.receiveAlive(), "a loop parked on the handoff must stay alive") + + // Drain the worker so the handoff returns and the loop resumes reading. + close(gate) + + // Once the handoff clears, the loop is parked on the next Recv with no frame + // pending. The stream must still read as alive in that window. + require.Eventually(t, func() bool { return !c.receiveHandoffBlocked.Load() }, time.Second, time.Millisecond, + "handoff should drain once the worker is released") + require.True(t, c.receiveAlive(), + "the loop must refresh liveness when the handoff drains, before the next Recv") +} From 3f1fb3b52d010d07247c46bf2a9fbf483563b0a9 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 29 Jun 2026 19:51:25 +0200 Subject: [PATCH 07/36] [ingest] raise duration validation limit to 24 hours (#6598) Peer connection timing fields (signaling_to_connection_seconds) can legitimately exceed 5 minutes during long reconnections; the previous 300 s cap caused valid data points to be rejected. --- client/internal/metrics/infra/ingest/main.go | 2 +- client/internal/metrics/infra/ingest/main_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index 623a17e4d..91405b85f 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -19,7 +19,7 @@ const ( defaultListenAddr = ":8087" defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns" maxBodySize = 50 * 1024 * 1024 // 50 MB max request body - maxDurationSeconds = 300.0 // reject any duration field > 5 minutes + maxDurationSeconds = 86400.0 // reject any duration field > 24 hours peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars maxTagValueLength = 64 // reject tag values longer than this ) diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go index bacaa4588..96287813e 100644 --- a/client/internal/metrics/infra/ingest/main_test.go +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -53,14 +53,14 @@ func TestValidateLine_NegativeValue(t *testing.T) { } func TestValidateLine_DurationTooLarge(t *testing.T) { - line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890` + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=100000 1234567890` err := validateLine(line) require.Error(t, err) assert.Contains(t, err.Error(), "too large") } func TestValidateLine_TotalSecondsTooLarge(t *testing.T) { - line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890` + line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=100000 1234567890` err := validateLine(line) require.Error(t, err) assert.Contains(t, err.Error(), "too large") From 04c3d19032dd1f3361168e0d1fc706b1e6cbfb42 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 29 Jun 2026 19:51:50 +0200 Subject: [PATCH 08/36] [client] Skip firewall ruleset rebuild when config is unchanged (#6508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Skip firewall ruleset rebuild when config is unchanged ApplyFiltering rebuilt every peer and route ACL and flushed the firewall on every sync, with no guard for an unchanged configuration. Management re-sends the same network map far more often than it actually changes (account-wide updates, peer meta churn), so on busy accounts this is the dominant client-side cost of redundant syncs — especially with a large route set and a userspace firewall. Hash the inputs ApplyFiltering consumes (peer rules, route rules, the empty flag and the dns-route feature flag) and skip the rebuild + flush when the hash matches the last successfully applied update. Mirrors the guard the DNS server already uses (previousConfigHash). The hash is only recorded after apply and flush both succeed, so a failed update is not skipped on the next (possibly identical) sync and gets a chance to reconcile the firewall state. * [client] Include config hash in ACL skip debug log * [client] Include RoutesFirewallRulesIsEmpty in firewall config hash * [client] Add benchmarks for firewall config hash computation --- client/internal/acl/manager.go | 74 ++++++++++++-- client/internal/acl/manager_test.go | 147 ++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 9 deletions(-) diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index c54a3e897..d9b179457 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -11,6 +11,7 @@ import ( "time" "github.com/hashicorp/go-multierror" + "github.com/mitchellh/hashstructure/v2" log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" @@ -30,11 +31,13 @@ type Manager interface { // DefaultManager uses firewall manager to handle type DefaultManager struct { - firewall firewall.Manager - ipsetCounter int - peerRulesPairs map[id.RuleID][]firewall.Rule - routeRules map[id.RuleID]struct{} - mutex sync.Mutex + firewall firewall.Manager + ipsetCounter int + peerRulesPairs map[id.RuleID][]firewall.Rule + routeRules map[id.RuleID]struct{} + previousConfigHash uint64 + hasAppliedConfig bool + mutex sync.Mutex } func NewDefaultManager(fm firewall.Manager) *DefaultManager { @@ -57,6 +60,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout return } + // Skip the full rebuild + flush when the inputs that drive the firewall + // state are byte-for-byte identical to the last successfully applied + // update. Management re-sends the same network map far more often than it + // actually changes (account-wide updates, peer meta churn), and rebuilding + // every peer/route ACL and flushing the firewall on every such sync is the + // dominant client-side cost when nothing changed. Mirrors the same guard the + // DNS server already uses (previousConfigHash). Only the fields ApplyFiltering + // consumes participate in the hash, so an unrelated map change cannot mask a + // real ACL change. + hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag) + if err != nil { + log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err) + } else if d.hasAppliedConfig && d.previousConfigHash == hash { + log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash) + return + } + start := time.Now() defer func() { total := 0 @@ -70,13 +90,49 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout d.applyPeerACLs(networkMap) - if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil { - log.Errorf("Failed to apply route ACLs: %v", err) + routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag) + if routeErr != nil { + log.Errorf("Failed to apply route ACLs: %v", routeErr) } - if err := d.firewall.Flush(); err != nil { - log.Error("failed to flush firewall rules: ", err) + flushErr := d.firewall.Flush() + if flushErr != nil { + log.Error("failed to flush firewall rules: ", flushErr) } + + // Only remember the hash once the firewall actually reflects this config. + // If applying or flushing failed, leave the previous hash untouched so the + // next (possibly identical) update is not skipped and gets a chance to + // reconcile the firewall state. + if err == nil && routeErr == nil && flushErr == nil { + d.previousConfigHash = hash + d.hasAppliedConfig = true + } else { + d.hasAppliedConfig = false + } +} + +// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the +// firewall state, so an identical hash means an identical resulting ruleset. +func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) { + return hashstructure.Hash(struct { + PeerRules []*mgmProto.FirewallRule + PeerRulesIsEmpty bool + RouteRules []*mgmProto.RouteFirewallRule + RouteRulesIsEmpty bool + DNSRouteFeatureFlag bool + }{ + PeerRules: networkMap.GetFirewallRules(), + PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(), + RouteRules: networkMap.GetRoutesFirewallRules(), + RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(), + DNSRouteFeatureFlag: dnsRouteFeatureFlag, + }, hashstructure.FormatV2, &hashstructure.HashOptions{ + ZeroNil: true, + IgnoreZeroValue: true, + SlicesAsSets: true, + UseStringer: true, + }) } func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 408ed992f..968654ae9 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -1,6 +1,7 @@ package acl import ( + "fmt" "net/netip" "testing" @@ -485,3 +486,149 @@ func TestPortInfoEmpty(t *testing.T) { }) } } + +// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map +// re-applied is recognized as a no-op (hash unchanged), while a real change to +// any firewall-relevant input forces a re-apply (hash changes). This is the +// guard that prevents a full ruleset rebuild + flush on every redundant sync. +func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{ + IP: network.Addr(), + Network: network, + }).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, fw.Close(nil)) + }() + + acl := NewDefaultManager(fw) + + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, + } + + acl.ApplyFiltering(networkMap, false) + require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply") + firstHash := acl.previousConfigHash + require.NotZero(t, firstHash) + + // Re-applying the identical map must not change the recorded hash: the + // expensive rebuild path was skipped. + acl.ApplyFiltering(networkMap, false) + assert.Equal(t, firstHash, acl.previousConfigHash, + "identical re-apply must be a no-op (hash unchanged)") + + // A real change must produce a different hash and re-apply. + networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP + acl.ApplyFiltering(networkMap, false) + assert.NotEqual(t, firstHash, acl.previousConfigHash, + "changing a rule's action must force a re-apply (hash changed)") + + // The dnsRouteFeatureFlag also participates in the hash. + changedHash := acl.previousConfigHash + acl.ApplyFiltering(networkMap, true) + assert.NotEqual(t, changedHash, acl.previousConfigHash, + "flipping dnsRouteFeatureFlag must force a re-apply (hash changed)") +} + +func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap { + nm := &mgmProto.NetworkMap{ + FirewallRulesIsEmpty: peerRules == 0, + RoutesFirewallRulesIsEmpty: routeRules == 0, + } + for i := range peerRules { + nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{ + PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: fmt.Sprintf("%d", 1024+i%64511), + }) + } + for i := range routeRules { + nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{ + Destination: fmt.Sprintf("192.168.%d.0/24", i%256), + SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)}, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_ALL, + }) + } + return nm +} + +func BenchmarkFirewallConfigHash_Small(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(10, 5) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +func BenchmarkFirewallConfigHash_Medium(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(100, 50) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +func BenchmarkFirewallConfigHash_Large(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(1000, 200) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +// TestFirewallConfigHashDeterministic verifies the hash is stable for equal +// inputs and order-independent for the rule slices (management does not +// guarantee rule order). +func TestFirewallConfigHashDeterministic(t *testing.T) { + d := &DefaultManager{} + + nm1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + {PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"}, + {PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"}, + }, + } + // Same rules, reversed order. + nm2 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + nm1.FirewallRules[1], + nm1.FirewallRules[0], + }, + } + + h1, err := d.firewallConfigHash(nm1, false) + require.NoError(t, err) + h2, err := d.firewallConfigHash(nm2, false) + require.NoError(t, err) + assert.Equal(t, h1, h2, "hash must be order-independent for rule slices") +} From 3de889d529a366441788886cfc46970096fd28fd Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:18:51 +0200 Subject: [PATCH 09/36] [client] bound system info / posture-check gathering with a timeout to prevent sync-loop freeze (#6512) * Wraps syestem info / posture checks into a goroutine with timeout e.checks = checks is set before doing the SyncMeta, so if it fails next time isCheckEquals compares true and bypasses the update. This is to avoid another repeating the 15 seconds hang. The checks will be synced on reconnect or posture checks changes push from mgmt. * Propagate context to OS calls that can leverage its cancellation / timeout * Distinguish timeout from cancellation in logs * Dont log twice * Block on timeout failure and reapply the exclude_ips * Refactor for complexity --- client/internal/engine.go | 59 ++++++++++++++++------------------- client/system/info.go | 44 +++++++++++++++++++++++++- client/system/info_android.go | 2 +- client/system/info_darwin.go | 2 +- client/system/info_ios.go | 2 +- client/system/info_js.go | 2 +- client/system/info_test.go | 15 +++++++++ client/system/process.go | 21 +++++++++---- client/system/process_test.go | 31 ++++++++++++++++-- 9 files changed, 133 insertions(+), 45 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index f7c7e1862..de151592d 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -82,6 +82,12 @@ const ( PeerConnectionTimeoutMax = 45000 // ms PeerConnectionTimeoutMin = 30000 // ms disableAutoUpdate = "disabled" + + // systemInfoTimeout bounds how long the sync loop waits for system info / posture + // check gathering. The gathering runs uncancellable system calls (process scan, + // exec, os.Stat); without this bound a single stuck call freezes handleSync, and + // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes). + systemInfoTimeout = 15 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") @@ -1084,11 +1090,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { } e.checks = checks - info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...) - if err != nil { - log.Warnf("failed to get system info with checks: %v", err) - info = system.GetInfo(e.ctx) + info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) + if !ok { + // Gathering timed out; skip the meta sync this cycle rather than blocking the + // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry. + return nil } + e.applyInfoFlags(info) + + if err := e.mgmClient.SyncMeta(info); err != nil { + return fmt.Errorf("could not sync meta: error %s", err) + } + return nil +} + +// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info. +func (e *Engine) applyInfoFlags(info *system.Info) { info.SetFlags( e.config.RosenpassEnabled, e.config.RosenpassPermissive, @@ -1107,12 +1124,6 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { e.config.EnableSSHRemotePortForwarding, e.config.DisableSSHAuth, ) - - if err := e.mgmClient.SyncMeta(info); err != nil { - log.Errorf("could not sync meta: error %s", err) - return err - } - return nil } // overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it @@ -1272,31 +1283,15 @@ func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...) - if err != nil { - log.Warnf("failed to get system info with checks: %v", err) + info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) + if !ok { + // Gathering timed out; connect the stream with base info so management + // connectivity still comes up rather than blocking here. info = system.GetInfo(e.ctx) } - info.SetFlags( - e.config.RosenpassEnabled, - e.config.RosenpassPermissive, - &e.config.ServerSSHAllowed, - e.config.DisableClientRoutes, - e.config.DisableServerRoutes, - e.config.DisableDNS, - e.config.DisableFirewall, - e.config.BlockLANAccess, - e.config.BlockInbound, - e.config.DisableIPv6, - e.config.LazyConnectionEnabled, - e.config.EnableSSHRoot, - e.config.EnableSSHSFTP, - e.config.EnableSSHLocalPortForwarding, - e.config.EnableSSHRemotePortForwarding, - e.config.DisableSSHAuth, - ) + e.applyInfoFlags(info) - err = e.mgmClient.Sync(e.ctx, info, e.handleSync) + err := e.mgmClient.Sync(e.ctx, info, e.handleSync) if err != nil { // happens if management is unavailable for a long time. // We want to cancel the operation of the whole client diff --git a/client/system/info.go b/client/system/info.go index 27588859e..496b478a3 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -2,9 +2,11 @@ package system import ( "context" + "errors" "net/netip" "slices" "strings" + "time" log "github.com/sirupsen/logrus" "google.golang.org/grpc/metadata" @@ -174,7 +176,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs . processCheckPaths = append(processCheckPaths, check.GetFiles()...) } - files, err := checkFileAndProcess(processCheckPaths) + files, err := checkFileAndProcess(ctx, processCheckPaths) if err != nil { return nil, err } @@ -187,3 +189,43 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs . log.Debugf("all system information gathered successfully") return info, nil } + +// GetInfoWithChecksTimeout is GetInfoWithChecks bounded by timeout. Posture-check gathering +// runs uncancellable system calls (process enumeration, os.Stat), so calling it inline can +// block the caller for as long as such a call hangs. It runs in a goroutine instead: if it +// does not return within timeout the caller gets (nil, false) and should proceed with +// degraded behavior rather than block. On a gathering error it falls back to base GetInfo. +// +// The buffered channel lets the abandoned goroutine finish and exit once its blocking call +// returns, so it does not leak beyond the duration of that call. +func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + infoCh := make(chan *Info, 1) + go func() { + info, err := GetInfoWithChecks(ctx, checks, excludeIPs...) + if err != nil { + if ctx.Err() != nil { + return + } + log.Warnf("failed to get system info with checks: %v", err) + info = GetInfo(ctx) + info.removeAddresses(excludeIPs...) + } + infoCh <- info + }() + + select { + case info := <-infoCh: + return info, true + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + log.Warnf("gathering system info with checks timed out after %s", timeout) + } else { + // Parent context canceled (e.g. shutdown), not a timeout. + log.Warnf("gathering system info with checks canceled: %v", ctx.Err()) + } + return nil, false + } +} diff --git a/client/system/info_android.go b/client/system/info_android.go index 794ff15ed..3c71573bb 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -50,7 +50,7 @@ func GetInfo(ctx context.Context) *Info { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go index 4a31920ec..e7bf367f6 100644 --- a/client/system/info_darwin.go +++ b/client/system/info_darwin.go @@ -32,7 +32,7 @@ func GetInfo(ctx context.Context) *Info { sysName := string(bytes.Split(utsname.Sysname[:], []byte{0})[0]) machine := string(bytes.Split(utsname.Machine[:], []byte{0})[0]) release := string(bytes.Split(utsname.Release[:], []byte{0})[0]) - swVersion, err := exec.Command("sw_vers", "-productVersion").Output() + swVersion, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() if err != nil { log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err) swVersion = []byte(release) diff --git a/client/system/info_ios.go b/client/system/info_ios.go index ad42b1edf..1b0c084b3 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -105,7 +105,7 @@ func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_js.go b/client/system/info_js.go index 994d439a7..f32532881 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -103,7 +103,7 @@ func collectLocationInfo(info *Info) { } } -func checkFileAndProcess(_ []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_test.go b/client/system/info_test.go index dcda18e61..a7fa02197 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -4,6 +4,7 @@ import ( "context" "net/netip" "testing" + "time" "github.com/stretchr/testify/assert" "google.golang.org/grpc/metadata" @@ -35,6 +36,20 @@ func Test_CustomHostname(t *testing.T) { assert.Equal(t, want, got.Hostname) } +func TestGetInfoWithChecksTimeout_Success(t *testing.T) { + info, ok := GetInfoWithChecksTimeout(context.Background(), 30*time.Second, nil) + assert.True(t, ok, "expected gathering to complete within the timeout") + assert.NotNil(t, info) +} + +func TestGetInfoWithChecksTimeout_Timeout(t *testing.T) { + // A 1ns budget expires before the (real) system-info gathering can finish, so the + // caller must get (nil, false) instead of blocking on the in-flight goroutine. + info, ok := GetInfoWithChecksTimeout(context.Background(), time.Nanosecond, nil) + assert.False(t, ok, "expected timeout to be reported") + assert.Nil(t, info) +} + func Test_NetAddresses(t *testing.T) { addr, err := networkAddresses() if err != nil { diff --git a/client/system/process.go b/client/system/process.go index 87e21eb9d..07f69a212 100644 --- a/client/system/process.go +++ b/client/system/process.go @@ -3,24 +3,30 @@ package system import ( + "context" "os" "slices" "github.com/shirou/gopsutil/v3/process" ) -// getRunningProcesses returns a list of running process paths. -func getRunningProcesses() ([]string, error) { - processIDs, err := process.Pids() +// getRunningProcesses returns a list of running process paths. The context bounds the work: +// the per-PID loop bails as soon as ctx is done, and the gopsutil calls honor it where they +// can, so a stuck enumeration cannot run unbounded. +func getRunningProcesses(ctx context.Context) ([]string, error) { + processIDs, err := process.PidsWithContext(ctx) if err != nil { return nil, err } processMap := make(map[string]bool) for _, pID := range processIDs { + if err := ctx.Err(); err != nil { + return nil, err + } p := &process.Process{Pid: pID} - path, _ := p.Exe() + path, _ := p.ExeWithContext(ctx) if path != "" { processMap[path] = false } @@ -35,18 +41,21 @@ func getRunningProcesses() ([]string, error) { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(ctx context.Context, paths []string) ([]File, error) { files := make([]File, len(paths)) if len(paths) == 0 { return files, nil } - runningProcesses, err := getRunningProcesses() + runningProcesses, err := getRunningProcesses(ctx) if err != nil { return nil, err } for i, path := range paths { + if err := ctx.Err(); err != nil { + return nil, err + } file := File{Path: path} _, err := os.Stat(path) diff --git a/client/system/process_test.go b/client/system/process_test.go index 505808a9e..44a1c8ba0 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,6 +1,7 @@ package system import ( + "context" "testing" "github.com/shirou/gopsutil/v3/process" @@ -9,7 +10,7 @@ import ( func Benchmark_getRunningProcesses(b *testing.B) { b.Run("getRunningProcesses new", func(b *testing.B) { for i := 0; i < b.N; i++ { - ps, err := getRunningProcesses() + ps, err := getRunningProcesses(context.Background()) if err != nil { b.Fatalf("unexpected error: %v", err) } @@ -29,12 +30,38 @@ func Benchmark_getRunningProcesses(b *testing.B) { } } }) - s, _ := getRunningProcesses() + s, _ := getRunningProcesses(context.Background()) b.Logf("getRunningProcesses returned %d processes", len(s)) s, _ = getRunningProcessesOld() b.Logf("getRunningProcessesOld returned %d processes", len(s)) } +func TestCheckFileAndProcess_ContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // With a canceled context and non-empty paths the gathering must bail with an error + // instead of running the (potentially blocking) process scan / stat loop. + if _, err := checkFileAndProcess(ctx, []string{"/does/not/exist"}); err == nil { + t.Fatal("expected error on canceled context, got nil") + } +} + +func TestCheckFileAndProcess_EmptyPaths(t *testing.T) { + // No check paths means no work to do: it must return immediately with no error, + // even on a canceled context (nothing to scan or stat). + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + files, err := checkFileAndProcess(ctx, nil) + if err != nil { + t.Fatalf("unexpected error for empty paths: %v", err) + } + if len(files) != 0 { + t.Fatalf("expected no files, got %d", len(files)) + } +} + func getRunningProcessesOld() ([]string, error) { processes, err := process.Processes() if err != nil { From 5b5f11740acdc82d8c7b80b314194bbb7420988a Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 30 Jun 2026 11:34:23 +0300 Subject: [PATCH 10/36] [misc] Require on-premise EULA acceptance in enterprise scripts (#6596) --- .../getting-started-enterprise.sh | 47 +++++++++++++++++++ infrastructure_files/migrate-to-enterprise.sh | 46 ++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 5d2341cbe..135440180 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -9,6 +9,8 @@ set -o pipefail SED_STRIP_PADDING='s/=//g' +NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" + check_docker_compose() { if command -v docker-compose &> /dev/null; then echo "docker-compose" @@ -139,6 +141,43 @@ read_yes_no() { esac } +# Gate the install on explicit acceptance of the NetBird On-Premise EULA. +require_eula_acceptance() { + cat > /dev/stderr < /dev/stderr + return 0 + fi + + local ans="" + echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr + read -r ans < /dev/tty + if [[ "$ans" != "accept" ]]; then + echo "" > /dev/stderr + echo "EULA not accepted. Aborting installation." > /dev/stderr + exit 1 + fi + echo "" > /dev/stderr +} + wait_postgres() { set +e echo -n "Waiting for postgres to become ready" @@ -174,6 +213,9 @@ init_environment() { exit 1 fi + require_eula_acceptance + NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + echo "NetBird Enterprise bootstrap" echo "" echo "Traffic flow:" @@ -260,6 +302,11 @@ render_env() { # Generated by getting-started-enterprise.sh # Holds all configuration and secrets for the stack. Mode 600. +# NetBird On-Premise EULA acceptance +NETBIRD_EULA_ACCEPTED=yes +NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT} +NETBIRD_EULA_URL=${NETBIRD_EULA_URL} + # Features (set by the script; don't edit without re-running) NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW} diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index e8a3ad515..8e8a41114 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -25,6 +25,8 @@ set -o pipefail OVERRIDE_FILE="docker-compose.override.yml" ENTERPRISE_CONFIG_FILE="config.yaml.enterprise" +NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" + check_docker_compose() { if command -v docker-compose &> /dev/null; then echo "docker-compose" @@ -115,6 +117,43 @@ read_yes_no() { esac } +# Gate the migration on explicit acceptance of the NetBird On-Premise EULA. +require_eula_acceptance() { + cat > /dev/stderr < /dev/stderr + return 0 + fi + + local ans="" + echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr + read -r ans < /dev/tty + if [[ "$ans" != "accept" ]]; then + echo "" > /dev/stderr + echo "EULA not accepted. Aborting migration." > /dev/stderr + exit 1 + fi + echo "" > /dev/stderr +} + # --------------------------------------------------------------------------- # Detection — read the operator's existing compose to find service names and # paths we need to override. Bail loudly if shape isn't recognised. @@ -436,6 +475,9 @@ init_migration() { echo " Network: $COMPOSE_NETWORK" echo "" + require_eula_acceptance + NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + local proceed proceed=$(read_yes_no "Proceed with migration?" "y") if [[ "$proceed" != "yes" ]]; then @@ -529,6 +571,10 @@ apply_changes() { { echo "" echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "# NetBird On-Premise EULA accepted at install time" + echo "NETBIRD_EULA_ACCEPTED=yes" + echo "NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT}" + echo "NETBIRD_EULA_URL=${NETBIRD_EULA_URL}" echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}" if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}" From 4ef65294e970b8559cc222c7f184313fae22f272 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:22:25 +0900 Subject: [PATCH 11/36] [client] Reinject captured first packet on lazy connection activation (#6572) --- client/iface/wgproxy/bind/proxy.go | 5 ++ client/iface/wgproxy/ebpf/wrapper.go | 11 ++++ client/iface/wgproxy/proxy.go | 5 ++ client/iface/wgproxy/udp/proxy.go | 11 ++++ client/internal/engine_test.go | 4 ++ client/internal/iface_common.go | 1 + .../lazyconn/activity/listener_bind.go | 5 ++ .../lazyconn/activity/listener_bind_test.go | 17 ++++--- .../lazyconn/activity/listener_udp.go | 28 +++++++--- client/internal/lazyconn/activity/manager.go | 20 +++++--- .../lazyconn/activity/manager_test.go | 19 ++++--- client/internal/lazyconn/manager/manager.go | 12 ++--- client/internal/lazyconn/wgiface.go | 1 + client/internal/peer/conn.go | 51 +++++++++++++++++++ client/internal/peerstore/store.go | 15 +++++- 15 files changed, 169 insertions(+), 36 deletions(-) diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go index be6f3806e..be690ed4f 100644 --- a/client/iface/wgproxy/bind/proxy.go +++ b/client/iface/wgproxy/bind/proxy.go @@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error { return p.close() } +// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only. +func (p *ProxyBind) InjectPacket(_ []byte) error { + return nil +} + func (p *ProxyBind) close() error { if p.remoteConn == nil { return nil diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go index 6e80945c4..a6156a661 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/ebpf/wrapper.go @@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) { p.pausedCond.L.Unlock() } +// InjectPacket writes b to the remote peer over the underlying transport. +func (p *ProxyWrapper) InjectPacket(b []byte) error { + if p.remoteConn == nil { + return errors.New("proxy not started") + } + if _, err := p.remoteConn.Write(b); err != nil { + return err + } + return nil +} + // CloseConn close the remoteConn and automatically remove the conn instance from the map func (p *ProxyWrapper) CloseConn() error { if p.cancel == nil { diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go index 3c8dfd30e..40346bc15 100644 --- a/client/iface/wgproxy/proxy.go +++ b/client/iface/wgproxy/proxy.go @@ -18,4 +18,9 @@ type Proxy interface { RedirectAs(endpoint *net.UDPAddr) CloseConn() error SetDisconnectListener(disconnected func()) + + // InjectPacket writes a raw packet directly to the remote peer over the underlying transport, + // bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the + // kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only. + InjectPacket(b []byte) error } diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go index 6069d1960..783843aba 100644 --- a/client/iface/wgproxy/udp/proxy.go +++ b/client/iface/wgproxy/udp/proxy.go @@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) { p.sendPkg = p.srcFakerConn.SendPkg } +// InjectPacket writes b to the remote peer over the underlying transport. +func (p *WGUDPProxy) InjectPacket(b []byte) error { + if p.remoteConn == nil { + return errors.New("proxy not started") + } + if _, err := p.remoteConn.Write(b); err != nil { + return err + } + return nil +} + // CloseConn close the localConn func (p *WGUDPProxy) CloseConn() error { if p.cancel == nil { diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 1ac9ceff7..fbd47ed74 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -178,6 +178,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time { return nil } +func (m *MockWGIface) MTU() uint16 { + return 1280 +} + func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { return nil } diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 2eeac1954..8ffa0b102 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -44,4 +44,5 @@ type wgIfaceBase interface { FullStats() (*configurer.Stats, error) LastActivities() map[string]monotime.Time SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error + MTU() uint16 } diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go index 666c3bc28..72a0cfc76 100644 --- a/client/internal/lazyconn/activity/listener_bind.go +++ b/client/internal/lazyconn/activity/listener_bind.go @@ -124,6 +124,11 @@ func (d *BindListener) ReadPackets() { d.done.Done() } +// CapturedPacket is unused in userspace bind mode: first-packet reinjection is kernel-only. +func (d *BindListener) CapturedPacket() []byte { + return nil +} + // Close stops the listener and cleans up resources. func (d *BindListener) Close() { d.peerCfg.Log.Infof("closing activity listener (LazyConn)") diff --git a/client/internal/lazyconn/activity/listener_bind_test.go b/client/internal/lazyconn/activity/listener_bind_test.go index 1baaae6be..7026a9c97 100644 --- a/client/internal/lazyconn/activity/listener_bind_test.go +++ b/client/internal/lazyconn/activity/listener_bind_test.go @@ -45,10 +45,6 @@ type MockWGIfaceBind struct { endpointMgr *mockEndpointManager } -func (m *MockWGIfaceBind) RemovePeer(string) error { - return nil -} - func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { return nil } @@ -68,6 +64,10 @@ func (m *MockWGIfaceBind) GetBind() device.EndpointManager { return m.endpointMgr } +func (m *MockWGIfaceBind) MTU() uint16 { + return 1280 +} + func TestBindListener_Creation(t *testing.T) { mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} @@ -207,8 +207,9 @@ func TestManager_BindMode(t *testing.T) { require.NoError(t, err) select { - case peerConnID := <-mgr.OnActivityChan: - assert.Equal(t, cfg.PeerConnID, peerConnID, "Received peer connection ID should match") + case ev := <-mgr.OnActivityChan: + assert.Equal(t, cfg.PeerConnID, ev.PeerConnID, "Received peer connection ID should match") + assert.Nil(t, ev.FirstPacket, "Bind mode does not capture packets: reinjection is kernel-only") case <-time.After(2 * time.Second): t.Fatal("timeout waiting for activity notification") } @@ -266,8 +267,8 @@ func TestManager_BindMode_MultiplePeers(t *testing.T) { receivedPeers := make(map[peerid.ConnID]bool) for i := 0; i < 2; i++ { select { - case peerConnID := <-mgr.OnActivityChan: - receivedPeers[peerConnID] = true + case ev := <-mgr.OnActivityChan: + receivedPeers[ev.PeerConnID] = true case <-time.After(2 * time.Second): t.Fatal("timeout waiting for activity notifications") } diff --git a/client/internal/lazyconn/activity/listener_udp.go b/client/internal/lazyconn/activity/listener_udp.go index e0b09be6c..4b7e0ddf7 100644 --- a/client/internal/lazyconn/activity/listener_udp.go +++ b/client/internal/lazyconn/activity/listener_udp.go @@ -3,11 +3,13 @@ package activity import ( "fmt" "net" + "slices" "sync" "sync/atomic" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface/bufsize" "github.com/netbirdio/netbird/client/internal/lazyconn" ) @@ -20,6 +22,8 @@ type UDPListener struct { done sync.Mutex isClosed atomic.Bool + + capturedPacket []byte } // NewUDPListener creates a listener that detects activity via UDP socket reads. @@ -46,9 +50,13 @@ func NewUDPListener(wgIface WgInterface, cfg lazyconn.PeerConfig) (*UDPListener, } // ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed. +// The first packet that triggers activity is captured so it can be reinjected through the real +// transport once it is established. Without this, kernel WireGuard's handshake initiation would be +// dropped and WG would only retry after REKEY_TIMEOUT. func (d *UDPListener) ReadPackets() { for { - n, remoteAddr, err := d.conn.ReadFromUDP(make([]byte, 1)) + buf := make([]byte, int(d.wgIface.MTU())+bufsize.WGBufferOverhead) + n, remoteAddr, err := d.conn.ReadFromUDP(buf) if err != nil { if d.isClosed.Load() { d.peerCfg.Log.Infof("exit from activity listener") @@ -62,20 +70,24 @@ func (d *UDPListener) ReadPackets() { d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr) continue } - d.peerCfg.Log.Infof("activity detected") + d.capturedPacket = slices.Clone(buf[:n]) + d.peerCfg.Log.Infof("activity detected, captured %d bytes for reinjection", n) break } - d.peerCfg.Log.Debugf("removing lazy endpoint: %s", d.endpoint.String()) - if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil { - d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err) - } - - // Ignore close error as it may return "use of closed network connection" if already closed. + // Leave the peer in place. ConfigureWGEndpoint will UpdatePeer with the real endpoint; + // removing the peer here wipes kernel WG's staged queue and drops the user packet that + // triggered activation. _ = d.conn.Close() d.done.Unlock() } +// CapturedPacket returns the first packet that triggered activity, or nil if none was captured. +// Safe to call after ReadPackets returns. +func (d *UDPListener) CapturedPacket() []byte { + return d.capturedPacket +} + // Close stops the listener and cleans up resources. func (d *UDPListener) Close() { d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String()) diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go index cccc0669f..9de8c0fa7 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -19,17 +19,25 @@ import ( type listener interface { ReadPackets() Close() + CapturedPacket() []byte +} + +// Event reports activity on a managed peer. FirstPacket is the bytes that triggered activation, +// captured for reinjection through the real transport. +type Event struct { + PeerConnID peerid.ConnID + FirstPacket []byte } type WgInterface interface { - RemovePeer(peerKey string) error UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error IsUserspaceBind() bool Address() wgaddr.Address + MTU() uint16 } type Manager struct { - OnActivityChan chan peerid.ConnID + OnActivityChan chan Event wgIface WgInterface @@ -41,7 +49,7 @@ type Manager struct { func NewManager(wgIface WgInterface) *Manager { m := &Manager{ - OnActivityChan: make(chan peerid.ConnID, 1), + OnActivityChan: make(chan Event, 1), wgIface: wgIface, peers: make(map[peerid.ConnID]listener), done: make(chan struct{}), @@ -116,12 +124,12 @@ func (m *Manager) waitForTraffic(l listener, peerConnID peerid.ConnID) { delete(m.peers, peerConnID) m.mu.Unlock() - m.notify(peerConnID) + m.notify(Event{PeerConnID: peerConnID, FirstPacket: l.CapturedPacket()}) } -func (m *Manager) notify(peerConnID peerid.ConnID) { +func (m *Manager) notify(ev Event) { select { case <-m.done: - case m.OnActivityChan <- peerConnID: + case m.OnActivityChan <- ev: } } diff --git a/client/internal/lazyconn/activity/manager_test.go b/client/internal/lazyconn/activity/manager_test.go index 0768d9219..07dd8d84c 100644 --- a/client/internal/lazyconn/activity/manager_test.go +++ b/client/internal/lazyconn/activity/manager_test.go @@ -1,6 +1,7 @@ package activity import ( + "bytes" "net" "net/netip" "testing" @@ -25,10 +26,6 @@ func (m *MocPeer) ConnID() peerid.ConnID { type MocWGIface struct { } -func (m MocWGIface) RemovePeer(string) error { - return nil -} - func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { return nil } @@ -44,6 +41,10 @@ func (m MocWGIface) Address() wgaddr.Address { } } +func (m MocWGIface) MTU() uint16 { + return 1280 +} + // GetPeerListener is a test helper to access listeners func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) { m.mu.Lock() @@ -86,11 +87,15 @@ func TestManager_MonitorPeerActivity(t *testing.T) { } select { - case peerConnID := <-mgr.OnActivityChan: - if peerConnID != peerCfg1.PeerConnID { - t.Fatalf("unexpected peerConnID: %v", peerConnID) + case ev := <-mgr.OnActivityChan: + if ev.PeerConnID != peerCfg1.PeerConnID { + t.Fatalf("unexpected peerConnID: %v", ev.PeerConnID) + } + if !bytes.Equal(ev.FirstPacket, []byte{0x01, 0x02, 0x03, 0x04, 0x05}) { + t.Fatalf("unexpected first packet: %v", ev.FirstPacket) } case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for activity") } } diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index fc47bda39..3868e37e8 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -130,8 +130,8 @@ func (m *Manager) Start(ctx context.Context) { select { case <-ctx.Done(): return - case peerConnID := <-m.activityManager.OnActivityChan: - m.onPeerActivity(peerConnID) + case ev := <-m.activityManager.OnActivityChan: + m.onPeerActivity(ev) case peerIDs := <-m.inactivityManager.InactivePeersChan(): m.onPeerInactivityTimedOut(peerIDs) } @@ -513,13 +513,13 @@ func (m *Manager) checkHaGroupActivity(haGroup route.HAUniqueID, peerID string, return false } -func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) { +func (m *Manager) onPeerActivity(ev activity.Event) { m.managedPeersMu.Lock() defer m.managedPeersMu.Unlock() - mp, ok := m.managedPeersByConnID[peerConnID] + mp, ok := m.managedPeersByConnID[ev.PeerConnID] if !ok { - log.Errorf("peer not found by conn id: %v", peerConnID) + log.Errorf("peer not found by conn id: %v", ev.PeerConnID) return } @@ -536,7 +536,7 @@ func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) { m.activateHAGroupPeers(mp.peerCfg) - m.peerStore.PeerConnOpen(m.engineCtx, mp.peerCfg.PublicKey) + m.peerStore.PeerConnOpenWithFirstPacket(m.engineCtx, mp.peerCfg.PublicKey, ev.FirstPacket) } func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { diff --git a/client/internal/lazyconn/wgiface.go b/client/internal/lazyconn/wgiface.go index 0626c1815..f003ab3cf 100644 --- a/client/internal/lazyconn/wgiface.go +++ b/client/internal/lazyconn/wgiface.go @@ -17,4 +17,5 @@ type WGIface interface { IsUserspaceBind() bool Address() wgaddr.Address LastActivities() map[string]monotime.Time + MTU() uint16 } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 79a513956..85e54ba5f 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "slices" "sync" "time" @@ -136,6 +137,39 @@ type Conn struct { // Connection stage timestamps for metrics metricsRecorder MetricsRecorder metricsStages *MetricsStages + + // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real + // transport is up. + pendingFirstPacket []byte +} + +// injectPendingFirstPacket replays the captured handshake through the proxy if present, else +// directly through the ICE conn. The packet is cleared only after a successful write, so a failed +// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu. +func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) { + pkt := conn.pendingFirstPacket + if len(pkt) == 0 { + return + } + + switch { + case proxy != nil: + if err := proxy.InjectPacket(pkt); err != nil { + conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err) + return + } + case directConn != nil: + if _, err := directConn.Write(pkt); err != nil { + conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err) + return + } + default: + conn.Log.Debugf("no transport available to reinject captured first packet") + return + } + + conn.pendingFirstPacket = nil + conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt)) } // NewConn creates a new not opened Conn to the remote peer. @@ -172,6 +206,16 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { // It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will // be used. func (conn *Conn) Open(engineCtx context.Context) error { + return conn.open(engineCtx, nil) +} + +// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once +// the real transport is established. The packet is retained only on a successful open. +func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error { + return conn.open(engineCtx, firstPacket) +} + +func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error { conn.mu.Lock() defer conn.mu.Unlock() @@ -227,6 +271,9 @@ func (conn *Conn) Open(engineCtx context.Context) error { defer conn.wg.Done() conn.guard.Start(conn.ctx, conn.onGuardEvent) }() + if len(firstPacket) > 0 { + conn.pendingFirstPacket = slices.Clone(firstPacket) + } conn.opened = true return nil } @@ -423,6 +470,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.wgProxyRelay.RedirectAs(ep) } + conn.injectPendingFirstPacket(wgProxy, iceConnInfo.RemoteConn) + conn.currentConnPriority = priority conn.statusICE.SetConnected() conn.updateIceState(iceConnInfo, updateTime) @@ -546,6 +595,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { wgConfigWorkaround() + conn.injectPendingFirstPacket(wgProxy, nil) + conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() diff --git a/client/internal/peerstore/store.go b/client/internal/peerstore/store.go index 099fe4528..112caa101 100644 --- a/client/internal/peerstore/store.go +++ b/client/internal/peerstore/store.go @@ -88,11 +88,24 @@ func (s *Store) PeerConnOpen(ctx context.Context, pubKey string) { if !ok { return } - // this can be blocked because of the connect open limiter semaphore if err := p.Open(ctx); err != nil { p.Log.Errorf("failed to open peer connection: %v", err) } +} +// PeerConnOpenWithFirstPacket opens the peer connection and stashes a first packet to be +// reinjected once the real transport is established. +func (s *Store) PeerConnOpenWithFirstPacket(ctx context.Context, pubKey string, firstPacket []byte) { + s.peerConnsMu.RLock() + defer s.peerConnsMu.RUnlock() + + p, ok := s.peerConns[pubKey] + if !ok { + return + } + if err := p.OpenWithFirstPacket(ctx, firstPacket); err != nil { + p.Log.Errorf("failed to open peer connection: %v", err) + } } func (s *Store) PeerConnIdle(pubKey string) { From 3be90f06b25d2e7ff044263f39e01a13e854a65a Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Wed, 1 Jul 2026 12:31:46 +0300 Subject: [PATCH 12/36] [management] Add peer expiration reason to activity meta (#6619) --- management/server/account.go | 6 +++--- management/server/peer.go | 11 ++++++++++- management/server/user.go | 8 +++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 34220ed3f..2c57c4637 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -689,7 +689,7 @@ func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, acc log.WithContext(ctx).Debugf("discovered %d peers to expire for account %s", len(peerIDs), accountID) - if err := am.expireAndUpdatePeers(ctx, accountID, expiredPeers); err != nil { + if err := am.expireAndUpdatePeers(ctx, accountID, expiredPeers, peerExpirationSessionExpired); err != nil { log.WithContext(ctx).Errorf("failed updating account peers while expiring peers for account %s", accountID) return peerSchedulerRetryInterval, true } @@ -724,7 +724,7 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context log.Debugf("discovered %d peers to expire for account %s", len(peerIDs), accountID) - if err := am.expireAndUpdatePeers(ctx, accountID, inactivePeers); err != nil { + if err := am.expireAndUpdatePeers(ctx, accountID, inactivePeers, peerExpirationInactivity); err != nil { log.Errorf("failed updating account peers while expiring peers for account %s", accountID) return peerSchedulerRetryInterval, true } @@ -1949,7 +1949,7 @@ func (am *DefaultAccountManager) onPeersInvalidated(ctx context.Context, account } } if len(peers) > 0 { - err := am.expireAndUpdatePeers(ctx, accountID, peers) + err := am.expireAndUpdatePeers(ctx, accountID, peers, peerExpirationValidationFailed) if err != nil { log.WithContext(ctx).Errorf("failed to expire and update invalidated peers for account %s: %v", accountID, err) return diff --git a/management/server/peer.go b/management/server/peer.go index 440e90044..32bf9feea 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -34,7 +34,16 @@ import ( "github.com/netbirdio/netbird/version" ) -const remoteJobsMinVer = "0.64.0" +type peerExpirationReason string + +const ( + remoteJobsMinVer = "0.64.0" + + peerExpirationSessionExpired peerExpirationReason = "session expiration" + peerExpirationInactivity peerExpirationReason = "inactivity timeout" + peerExpirationValidationFailed peerExpirationReason = "failed integration validation" + peerExpirationUserBlocked peerExpirationReason = "blocked owner account" +) // GetPeers returns peers visible to the user within an account. // Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings. diff --git a/management/server/user.go b/management/server/user.go index 666d6d178..b4b9ebe01 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -675,7 +675,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, } if len(peersToExpire) > 0 { - if err := am.expireAndUpdatePeers(ctx, accountID, peersToExpire); err != nil { + if err := am.expireAndUpdatePeers(ctx, accountID, peersToExpire, peerExpirationUserBlocked); err != nil { log.WithContext(ctx).Errorf("failed update expired peers: %s", err) return nil, err } @@ -1118,7 +1118,7 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a } // expireAndUpdatePeers expires all peers of the given user and updates them in the account -func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accountID string, peers []*nbpeer.Peer) error { +func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accountID string, peers []*nbpeer.Peer, reason peerExpirationReason) error { log.WithContext(ctx).Debugf("Expiring %d peers for account %s", len(peers), accountID) settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -1145,10 +1145,12 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou if err := am.Store.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil { return err } + meta := peer.EventMeta(dnsDomain) + meta["reason"] = string(reason) am.StoreEvent( ctx, peer.UserID, peer.ID, accountID, - activity.PeerLoginExpired, peer.EventMeta(dnsDomain), + activity.PeerLoginExpired, meta, ) } From 92a66cdd202407e4114c64ebfea42b6ed50c5377 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 1 Jul 2026 12:45:14 +0200 Subject: [PATCH 13/36] [management,proxy,client] 0.74.0 version (#6563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555) * [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly. * Update getting started to point to rc when agent network enabled * Add a reference to a commercial license * Fix docs localhost link * Fix docs localhost link * Add private services domain note * [management] Add agent-network telemetry metrics (#6561) Surface agent-network adoption and usage in the self-hosted metrics worker: distinct accounts, providers, policies, budget rules, accounts with log collection enabled, and aggregated input/output tokens plus cost. Tokens and cost are summed from agent_network_request_usage (the always-written per-request ledger) so the figures are accurate regardless of the log-collection toggle and carry no double-counting. All values come from a handful of indexed aggregate queries run only on the worker's periodic tick. Adds store.AgentNetworkMetrics with GetAgentNetworkMetrics on the Store interface, the SqlStore implementation, and a zero-valued FileStore stub. * Update NetBird server and proxy image versions to 0.74.0-rc.2 * [management,proxy] Reduce agent-network cognitive complexity (#6566) Address the SonarCloud quality-gate findings in new agent-network code by extracting focused helpers. No behavior change. - synthesizer.go: split buildIdentityInjectConfigJSON into per-shape rule builders; extract mergeGuardrail from mergeGuardrails to cut nesting depth. - llm_identity_inject: extract injectionEmitsAnything validation predicate from New. - llm_response_parser/streaming.go: extract applyOpenAIStreamUsage and applyAnthropicStreamUsage (via a named anthropicStreamUsage type) and simplify the OpenAI scanner loop. - reverseproxy.go: decompose ServeHTTP into serveRouteError, buildTargetContext, serveDirect, serveWithChain, captureRequestForChain, serveDeny, newResponseWriter, observeResponse, and forwardUpstream, preserving the defer ordering so response observation still reads the captured writer before it is released. * [management] Move agent-network access-log ingest into the agentnetwork module (#6568) The agent-network access-log ingest path (metaKey wire contract, flatten, usage derivation, and the dual-write of the usage ledger + settings-gated full row) lived in the reverseproxy accesslogs manager, even though the agentnetwork module already owns the rest of that domain — types, read (ListAccessLogs / GetUsageOverview), the budget-counter writes, and retention cleanup. Move it next to the rest: a stateless agentnetwork.IngestAccessLog(ctx, store, entry) that the reverseproxy SaveAccessLog delegates to when the entry is agent-network. Removes the agentNetworkTypes import from the reverseproxy manager. No behavior change; the write/read table separation is unchanged. Adds real-store coverage for the disable->enable log-collection toggle (usage ledger always written, full row gated) plus the metadata parse and group-dedup helpers, which previously had no dedicated tests. * Add session view support in the access log * [management,proxy] Container-based agent-network e2e harness (#6577) * [e2e] Add container-based agent-network e2e harness (Pillar 1) Introduce a self-contained, OIDC-free e2e harness that stands up NetBird in containers, so suites no longer depend on the hand-maintained Tilt stack or a real IdP. - harness brings up the combined server (management + signal + relay + STUN + embedded IdP) in a single container built from combined/Dockerfile.multistage, and mints an admin PAT through the unauthenticated /api/setup bootstrap (NB_SETUP_PAT_ENABLED). API access goes through the existing shared/management/client/rest typed client. - the image is built via the docker CLI (BuildKit) so the Dockerfile's cache mounts are honored; testcontainers then runs the tagged image. - everything is behind the `e2e` build tag so normal builds and unit tests never pull in testcontainers. Adds BuildKit cache mounts to combined/Dockerfile.multistage so source changes recompile incrementally rather than from scratch. Pillar 1 proven by TestCombinedBootstrap: server builds, boots, mints a PAT, and the PAT authenticates a real management API call. * [e2e] Add management-side agent-network scenarios (Pillar 2) Port the API-driven agent-network scenarios from the bash suites to Go, sharing one combined server per package run (TestMain) with each test owning its resource cleanup. Drives the /api/agent-network/* endpoints through the shared REST client's NewRequest primitive with the generated api types. Scenarios: - provider lifecycle (create/get/list/delete + 404 after delete) - provider validation (missing api_key, unknown catalog id → 4xx) - settings collection-toggle round-trip with cluster/subdomain immutability - policy window floor (reject <60s enabled limit, accept at 60s) - consumption read endpoint returns an array All deterministic and dependency-free (dummy provider keys; no upstream calls), so they run headless in CI. * [e2e] Add live chat-through-proxy scenario (Pillar 3) Stand up the full agent-network data path in containers and drive a real chat-completion through the gateway: - harness: a shared docker network (combined server reachable by alias), a proxy container built from the published reverse-proxy image (NB_PROXY_PRIVATE, NB_PROXY_ALLOW_INSECURE, NB_RELAY_TRANSPORT=ws to match the combined server's WS-multiplexed relay) with a generated self-signed wildcard cert, and a netbird client container that joins via a setup key. - the combined image, proxy image, and client image default to the published rc.2 releases (overridable via NB_E2E_*_IMAGE; a bare local tag is built from source instead). Geolocation download is disabled so the server starts without external fetches. - one shared domain is used for the management exposed address, the proxy domain, and the agent-network cluster; the proxy token is minted via the server CLI (global) to match the manual install. TestChatCompletionThroughProxy provisions provider+policy+group+setup key, runs proxy+client, drives an OpenAI chat-completion through the tunnel, and asserts a 200 plus the ingested access-log row. Requires OPENAI_TOKEN (skips otherwise). The provider must be created with enabled=true explicitly — the create default is false despite the API doc. * [e2e] Run the live chat scenario across a provider matrix Replace the single-provider chat test with a data-driven matrix that runs the same scenario through every provider whose credentials are present in the environment (keys/URLs sourced from ~/.llm-keys locally, Actions secrets in CI): - OpenAI (chat), Anthropic (messages), Vercel, OpenRouter, Cloudflare (OpenAI-compatible gateways), and Bedrock (path-routed, bearer, via the messages shape) — covering both wire shapes and the gateway routing. - all providers are created enabled with a unique model string so the proxy's connect-time snapshot carries them all and model->provider routing is unambiguous (provider toggles after connect don't reconcile to a connected proxy). - the client supports both wire shapes (/v1/chat/completions and /v1/messages); Cloudflare gets the openai provider segment appended to its gateway URL. Each provider must return 200 through the tunnel and produce an ingested access-log row. Vertex is intentionally excluded from the uniform matrix: it needs a bespoke rawPredict request shape rather than the shared chat/messages path, so it warrants a dedicated scenario. * [ci] Add manual workflow for the agent-network e2e suite The e2e suite (build tag `e2e`) stands up the combined server + proxy + client in Docker and drives live chat-completions, so it is slow and needs provider credentials. Gate it out of normal CI (it already is, via the build tag) and run it on demand via workflow_dispatch. Provider scenarios skip when their secret is unset, so it degrades gracefully. * [e2e] Add Vertex to the provider matrix; run e2e on ubuntu-latest Vertex (Anthropic-on-Vertex) doesn't share the chat/messages wire shapes: the model travels in a rawPredict path and the proxy mints the service account's OAuth token. Add a Vertex client method that posts /v1/projects//locations//publishers/anthropic/models/:rawPredict with the Vertex anthropic_version body, and wire it into the matrix as a path-routed provider (created without a models array). It is keyed off GOOGLE_VERTEX_SA_BASE64 + GOOGLE_VERTEX_PROJECT (region defaults to "global", model to a pinned claude snapshot, both overridable). Also bump the e2e workflow runner to ubuntu-latest and add the Vertex secrets. * Add docker/docker and docker/go-connections as direct dependencies in go.mod * [ci] Trigger agent-network e2e workflow on push to main and pull requests * [e2e] Fix proxy cert permission denied on Linux CI runners The proxy bind-mounts a temp dir of self-signed certs. MkdirTemp creates it 0700 and the key was 0600, which Docker Desktop on macOS ignores but a non-root proxy container on Linux runners cannot traverse/read, so the cert watcher failed with "open /certs/tls.crt: permission denied" and the container exited. Widen the cert dir to 0755 and write the throwaway key 0644 so the proxy uid can read the bind-mounted material. * [e2e] Build images from source by default instead of pulling rc.2 The agent-network code under test lives in this branch, so the e2e should exercise it rather than a frozen published release. Flip the harness default: combined/proxy/client are now built from their in-repo Dockerfiles (combined/Dockerfile.multistage, proxy/Dockerfile.multistage, e2e/harness/Dockerfile.client) under local tags. Pulling a published image stays available by setting NB_E2E_*_IMAGE to a registry reference. Builds now go through buildx --load so the Dockerfile cache mounts are honored and the result is loaded for testcontainers. The CI workflow adds a container-driver builder and a local layer cache (NB_E2E_BUILDX_CACHE) persisted via actions/cache, which caches the base/apt/dep-download layers across runs. The Go compile still re-runs each time, as BuildKit mount caches cannot be exported to the GitHub cache. * [e2e] Cover real providers in lifecycle + assert real consumption metering - TestProviderLifecycle now runs per available real provider (create → get → list → delete → 404) instead of a single dummy provider, exercising each catalog's create and field round-trip. Create is offline, so it stays fast and burns no provider quota; falls back to a synthetic OpenAI provider when no keys are set. - TestProvidersMatrix attaches a token limit (high caps, 60s window) to its policy, which switches on usage metering, and asserts consumption rows are recorded with positive token counts after the live traffic. Consumption is account-scoped (keyed by source group / user and window, not per provider), so the assertion is aggregate. - TestProviderValidation gains invalid-upstream and blank-name cases. Create validation is uniform across catalogs (no per-provider required-field rules), so per-provider rejection cases would be redundant. * [e2e] Assert session id propagates per provider Each matrix request now sends a unique session id as the universal x-session-id header and asserts it round-trips into that provider's access-log row. This guards the session-grouping contract end to end for every provider (header extraction runs in llm_request_parser ahead of the parser-specific body extraction, so it is provider-agnostic). * [e2e] Drop accidentally committed sync-phases dashboard netbird-sync-phases.json was swept into the Pillar 1 commit by a broad git add; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Remove it from the branch so the PR diff is scoped to the e2e changes. * [e2e] Revert accidentally committed sync-phase ingest spec The netbird_sync_phase measurement spec in metrics ingest was swept into the Pillar 1 commit; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Its emission side never landed here, so the spec was orphaned anyway. Restore ingest/main.go to its origin/main state. * Fix golint issues * Fix sonar * Add access log session test * Fix access log tests --------- Co-authored-by: braginini Co-authored-by: Zoltan Papp --- .github/workflows/agent-network-e2e.yml | 69 + .github/workflows/golangci-lint.yml | 2 +- combined/Dockerfile.multistage | 10 +- docs/agent-networks/00-overview.md | 109 + docs/agent-networks/01-end-to-end-flows.md | 217 ++ docs/agent-networks/README.md | 66 + docs/agent-networks/modules/10-shared-api.md | 105 + .../modules/20-management-store.md | 112 + .../modules/21-management-agentnetwork.md | 225 ++ .../modules/22-management-handlers-wiring.md | 203 ++ .../modules/30-proxy-middleware-framework.md | 215 ++ .../modules/31-proxy-middleware-builtin.md | 365 +++ .../modules/32-proxy-llm-parsers.md | 392 ++++ .../modules/33-proxy-runtime.md | 194 ++ docs/agent-networks/modules/40-dashboard.md | 228 ++ .../modules/50-path-routed-providers.md | 251 ++ e2e/agentnetwork/bootstrap_test.go | 30 + e2e/agentnetwork/chat_test.go | 281 +++ e2e/agentnetwork/main_test.go | 46 + e2e/agentnetwork/management_test.go | 221 ++ e2e/harness/Dockerfile.client | 24 + e2e/harness/agentnetwork.go | 130 ++ e2e/harness/bootstrap.go | 47 + e2e/harness/cert.go | 66 + e2e/harness/client.go | 256 ++ e2e/harness/combined.go | 243 ++ e2e/harness/config.go | 26 + e2e/harness/doc.go | 13 + e2e/harness/paths.go | 29 + e2e/harness/proxy.go | 122 + go.mod | 6 +- .../network_map/controller/controller.go | 36 +- .../network_map/controller/repository.go | 10 + .../modules/agentnetwork/accesslog_ingest.go | 215 ++ .../accesslog_ingest_realstore_test.go | 124 + .../accesslog_sessions_realstore_test.go | 343 +++ .../agentnetwork/affectedpeers_hook.go | 15 + .../modules/agentnetwork/catalog/catalog.go | 749 ++++++ .../handlers/access_log_handler.go | 134 ++ .../agentnetwork/handlers/budget_handler.go | 172 ++ .../handlers/budget_handler_test.go | 131 ++ .../handlers/consumption_handler.go | 53 + .../handlers/guardrails_handler.go | 171 ++ .../agentnetwork/handlers/handlers_test.go | 256 ++ .../agentnetwork/handlers/policies_handler.go | 228 ++ .../handlers/providers_handler.go | 217 ++ .../agentnetwork/handlers/settings_handler.go | 74 + .../modules/agentnetwork/labelgen/labelgen.go | 66 + .../agentnetwork/labelgen/labelgen_test.go | 101 + .../modules/agentnetwork/labelgen/words.go | 136 ++ .../internals/modules/agentnetwork/manager.go | 911 ++++++++ .../modules/agentnetwork/policyselect.go | 660 ++++++ .../policyselect_account_realstore_test.go | 181 ++ .../policyselect_realstore_test.go | 214 ++ .../modules/agentnetwork/policyselect_test.go | 641 +++++ .../modules/agentnetwork/reconcile.go | 131 ++ .../modules/agentnetwork/reconcile_test.go | 232 ++ .../modules/agentnetwork/synthesizer.go | 1083 +++++++++ .../synthesizer_guardrail_realstore_test.go | 178 ++ ...nthesizer_log_collection_realstore_test.go | 70 + ...ynthesizer_parser_redact_realstore_test.go | 145 ++ .../synthesizer_realstore_test.go | 174 ++ .../modules/agentnetwork/synthesizer_test.go | 1098 +++++++++ .../modules/agentnetwork/types/accesslog.go | 289 +++ .../agentnetwork/types/accesslogfilter.go | 249 ++ .../modules/agentnetwork/types/budgetrule.go | 106 + .../modules/agentnetwork/types/consumption.go | 69 + .../agentnetwork/types/consumption_test.go | 141 ++ .../modules/agentnetwork/types/guardrail.go | 120 + .../modules/agentnetwork/types/policy.go | 192 ++ .../modules/agentnetwork/types/provider.go | 252 ++ .../modules/agentnetwork/types/settings.go | 78 + .../modules/agentnetwork/types/usage.go | 47 + .../agentnetwork/types/usageoverview.go | 96 + .../modules/agentnetwork/wire_shape_test.go | 109 + management/internals/modules/peers/manager.go | 56 +- .../reverseproxy/accesslogs/accesslogentry.go | 5 + .../accesslogs/manager/manager.go | 9 +- .../modules/reverseproxy/service/service.go | 109 +- management/internals/server/boot.go | 28 +- management/internals/server/modules.go | 19 + management/internals/shared/grpc/proxy.go | 200 +- management/server/activity/codes.go | 49 + .../server/affectedpeers/proxy_synth_test.go | 95 + management/server/affectedpeers/resolver.go | 37 +- .../agentnetwork_budgetrule_realstack_test.go | 126 + .../agentnetwork_proxypeer_restart_test.go | 199 ++ .../server/agentnetwork_realstack_test.go | 212 ++ management/server/http/handler.go | 7 +- .../testing/testing_tools/channel/channel.go | 4 +- management/server/metrics/selfhosted.go | 16 + management/server/metrics/selfhosted_test.go | 15 + .../server/permissions/modules/module.go | 2 + management/server/store/file_store.go | 6 + management/server/store/sql_store.go | 339 +++ .../server/store/sql_store_agentnetwork.go | 664 ++++++ .../sql_store_agentnetwork_accesslog_test.go | 302 +++ .../sql_store_agentnetwork_budgetrule_test.go | 112 + management/server/store/store.go | 66 + .../server/store/store_mock_agentnetwork.go | 495 ++++ proxy/inbound.go | 9 +- proxy/inbound_test.go | 122 + proxy/internal/accesslog/logger.go | 50 + proxy/internal/accesslog/middleware.go | 14 +- proxy/internal/accesslog/middleware_test.go | 185 ++ proxy/internal/auth/middleware_test.go | 119 +- proxy/internal/llm/anthropic.go | 196 ++ proxy/internal/llm/anthropic_test.go | 169 ++ proxy/internal/llm/bedrock.go | 189 ++ proxy/internal/llm/bedrock_test.go | 65 + proxy/internal/llm/errors.go | 31 + .../llm/fixtures/anthropic_messages.json | 17 + .../llm/fixtures/anthropic_stream.txt | 21 + .../llm/fixtures/openai_chat_completion.json | 21 + .../llm/fixtures/openai_responses.json | 24 + .../llm/fixtures/openai_responses_stream.txt | 24 + proxy/internal/llm/fixtures/openai_stream.txt | 8 + proxy/internal/llm/fixtures/pricing.yaml | 59 + proxy/internal/llm/openai.go | 412 ++++ proxy/internal/llm/openai_test.go | 255 ++ proxy/internal/llm/parser.go | 112 + proxy/internal/llm/parser_test.go | 54 + .../llm/pricing/defaults_coverage_test.go | 65 + .../llm/pricing/defaults_pricing.yaml | 264 +++ proxy/internal/llm/pricing/pricing.go | 449 ++++ proxy/internal/llm/pricing/pricing_other.go | 20 + proxy/internal/llm/pricing/pricing_test.go | 432 ++++ proxy/internal/llm/pricing/pricing_unix.go | 68 + proxy/internal/llm/sse.go | 117 + proxy/internal/llm/sse_test.go | 175 ++ proxy/internal/metrics/metrics.go | 9 + proxy/internal/middleware/bodypolicy.go | 63 + proxy/internal/middleware/bodytap/request.go | 344 +++ proxy/internal/middleware/bodytap/response.go | 189 ++ .../middleware/bodytap/routing_scan_test.go | 86 + .../agentnetwork_chain_integration_test.go | 318 +++ proxy/internal/middleware/builtin/all_test.go | 40 + proxy/internal/middleware/builtin/builtin.go | 93 + .../middleware/builtin/cost_meter/factory.go | 88 + .../builtin/cost_meter/middleware.go | 193 ++ .../builtin/cost_meter/middleware_test.go | 459 ++++ .../builtin/llm_guardrail/factory.go | 82 + .../builtin/llm_guardrail/middleware.go | 183 ++ .../builtin/llm_guardrail/middleware_test.go | 219 ++ .../builtin/llm_guardrail/redact.go | 75 + .../builtin/llm_guardrail/redact_test.go | 217 ++ .../builtin/llm_identity_inject/factory.go | 108 + .../builtin/llm_identity_inject/middleware.go | 439 ++++ .../llm_identity_inject/middleware_test.go | 666 ++++++ .../builtin/llm_limit_check/factory.go | 38 + .../builtin/llm_limit_check/middleware.go | 196 ++ .../llm_limit_check/middleware_test.go | 186 ++ .../builtin/llm_limit_record/factory.go | 35 + .../builtin/llm_limit_record/middleware.go | 144 ++ .../llm_limit_record/middleware_test.go | 191 ++ .../llm_request_parser/bedrock_test.go | 55 + .../builtin/llm_request_parser/factory.go | 71 + .../builtin/llm_request_parser/middleware.go | 453 ++++ .../llm_request_parser/middleware_test.go | 418 ++++ .../builtin/llm_response_parser/factory.go | 43 + .../builtin/llm_response_parser/gzip_test.go | 133 ++ .../builtin/llm_response_parser/middleware.go | 339 +++ .../llm_response_parser/middleware_test.go | 433 ++++ .../responses_stream_test.go | 69 + .../builtin/llm_response_parser/streaming.go | 270 +++ .../llm_response_parser/streaming_bedrock.go | 110 + .../streaming_bedrock_test.go | 74 + .../llm_response_parser/streaming_test.go | 169 ++ .../middleware/builtin/llm_router/factory.go | 106 + .../builtin/llm_router/middleware.go | 793 +++++++ .../builtin/llm_router/middleware_test.go | 840 +++++++ .../builtin/llm_router/path_routed_test.go | 159 ++ proxy/internal/middleware/chain.go | 320 +++ proxy/internal/middleware/chain_test.go | 370 +++ proxy/internal/middleware/decision.go | 81 + proxy/internal/middleware/dispatcher.go | 189 ++ proxy/internal/middleware/headerpolicy.go | 99 + proxy/internal/middleware/keys.go | 86 + proxy/internal/middleware/manager.go | 412 ++++ proxy/internal/middleware/metadata.go | 99 + proxy/internal/middleware/metrics.go | 171 ++ proxy/internal/middleware/middleware.go | 47 + proxy/internal/middleware/redaction.go | 79 + proxy/internal/middleware/registry.go | 121 + proxy/internal/middleware/spec.go | 44 + proxy/internal/middleware/types.go | 253 ++ .../agent_network_chain_realstack_test.go | 321 +++ proxy/internal/proxy/context.go | 43 +- proxy/internal/proxy/reverseproxy.go | 456 +++- proxy/internal/proxy/reverseproxy_test.go | 43 + proxy/internal/proxy/servicemapping.go | 16 + proxy/internal/proxy/strip_prefix_test.go | 30 + proxy/internal/roundtrip/netbird.go | 74 +- proxy/internal/tcp/accept.go | 85 + proxy/internal/tcp/accept_test.go | 142 ++ proxy/internal/tcp/router.go | 15 +- proxy/internal/tcp/router_test.go | 129 ++ proxy/middleware_register.go | 16 + proxy/middleware_translate.go | 165 ++ proxy/middleware_translate_test.go | 246 ++ proxy/server.go | 173 +- shared/management/http/api/openapi.yml | 2061 +++++++++++++++++ shared/management/http/api/types.gen.go | 954 ++++++++ shared/management/proto/management.pb.go | 24 +- shared/management/proto/proxy_service.pb.go | 1934 +++++++++++----- shared/management/proto/proxy_service.proto | 125 + .../management/proto/proxy_service_grpc.pb.go | 88 + shared/management/status/error.go | 20 + 208 files changed, 39957 insertions(+), 688 deletions(-) create mode 100644 .github/workflows/agent-network-e2e.yml create mode 100644 docs/agent-networks/00-overview.md create mode 100644 docs/agent-networks/01-end-to-end-flows.md create mode 100644 docs/agent-networks/README.md create mode 100644 docs/agent-networks/modules/10-shared-api.md create mode 100644 docs/agent-networks/modules/20-management-store.md create mode 100644 docs/agent-networks/modules/21-management-agentnetwork.md create mode 100644 docs/agent-networks/modules/22-management-handlers-wiring.md create mode 100644 docs/agent-networks/modules/30-proxy-middleware-framework.md create mode 100644 docs/agent-networks/modules/31-proxy-middleware-builtin.md create mode 100644 docs/agent-networks/modules/32-proxy-llm-parsers.md create mode 100644 docs/agent-networks/modules/33-proxy-runtime.md create mode 100644 docs/agent-networks/modules/40-dashboard.md create mode 100644 docs/agent-networks/modules/50-path-routed-providers.md create mode 100644 e2e/agentnetwork/bootstrap_test.go create mode 100644 e2e/agentnetwork/chat_test.go create mode 100644 e2e/agentnetwork/main_test.go create mode 100644 e2e/agentnetwork/management_test.go create mode 100644 e2e/harness/Dockerfile.client create mode 100644 e2e/harness/agentnetwork.go create mode 100644 e2e/harness/bootstrap.go create mode 100644 e2e/harness/cert.go create mode 100644 e2e/harness/client.go create mode 100644 e2e/harness/combined.go create mode 100644 e2e/harness/config.go create mode 100644 e2e/harness/doc.go create mode 100644 e2e/harness/paths.go create mode 100644 e2e/harness/proxy.go create mode 100644 management/internals/modules/agentnetwork/accesslog_ingest.go create mode 100644 management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/affectedpeers_hook.go create mode 100644 management/internals/modules/agentnetwork/catalog/catalog.go create mode 100644 management/internals/modules/agentnetwork/handlers/access_log_handler.go create mode 100644 management/internals/modules/agentnetwork/handlers/budget_handler.go create mode 100644 management/internals/modules/agentnetwork/handlers/budget_handler_test.go create mode 100644 management/internals/modules/agentnetwork/handlers/consumption_handler.go create mode 100644 management/internals/modules/agentnetwork/handlers/guardrails_handler.go create mode 100644 management/internals/modules/agentnetwork/handlers/handlers_test.go create mode 100644 management/internals/modules/agentnetwork/handlers/policies_handler.go create mode 100644 management/internals/modules/agentnetwork/handlers/providers_handler.go create mode 100644 management/internals/modules/agentnetwork/handlers/settings_handler.go create mode 100644 management/internals/modules/agentnetwork/labelgen/labelgen.go create mode 100644 management/internals/modules/agentnetwork/labelgen/labelgen_test.go create mode 100644 management/internals/modules/agentnetwork/labelgen/words.go create mode 100644 management/internals/modules/agentnetwork/manager.go create mode 100644 management/internals/modules/agentnetwork/policyselect.go create mode 100644 management/internals/modules/agentnetwork/policyselect_account_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/policyselect_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/policyselect_test.go create mode 100644 management/internals/modules/agentnetwork/reconcile.go create mode 100644 management/internals/modules/agentnetwork/reconcile_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/synthesizer_test.go create mode 100644 management/internals/modules/agentnetwork/types/accesslog.go create mode 100644 management/internals/modules/agentnetwork/types/accesslogfilter.go create mode 100644 management/internals/modules/agentnetwork/types/budgetrule.go create mode 100644 management/internals/modules/agentnetwork/types/consumption.go create mode 100644 management/internals/modules/agentnetwork/types/consumption_test.go create mode 100644 management/internals/modules/agentnetwork/types/guardrail.go create mode 100644 management/internals/modules/agentnetwork/types/policy.go create mode 100644 management/internals/modules/agentnetwork/types/provider.go create mode 100644 management/internals/modules/agentnetwork/types/settings.go create mode 100644 management/internals/modules/agentnetwork/types/usage.go create mode 100644 management/internals/modules/agentnetwork/types/usageoverview.go create mode 100644 management/internals/modules/agentnetwork/wire_shape_test.go create mode 100644 management/server/affectedpeers/proxy_synth_test.go create mode 100644 management/server/agentnetwork_budgetrule_realstack_test.go create mode 100644 management/server/agentnetwork_proxypeer_restart_test.go create mode 100644 management/server/agentnetwork_realstack_test.go create mode 100644 management/server/store/sql_store_agentnetwork.go create mode 100644 management/server/store/sql_store_agentnetwork_accesslog_test.go create mode 100644 management/server/store/sql_store_agentnetwork_budgetrule_test.go create mode 100644 management/server/store/store_mock_agentnetwork.go create mode 100644 proxy/internal/accesslog/middleware_test.go create mode 100644 proxy/internal/llm/anthropic.go create mode 100644 proxy/internal/llm/anthropic_test.go create mode 100644 proxy/internal/llm/bedrock.go create mode 100644 proxy/internal/llm/bedrock_test.go create mode 100644 proxy/internal/llm/errors.go create mode 100644 proxy/internal/llm/fixtures/anthropic_messages.json create mode 100644 proxy/internal/llm/fixtures/anthropic_stream.txt create mode 100644 proxy/internal/llm/fixtures/openai_chat_completion.json create mode 100644 proxy/internal/llm/fixtures/openai_responses.json create mode 100644 proxy/internal/llm/fixtures/openai_responses_stream.txt create mode 100644 proxy/internal/llm/fixtures/openai_stream.txt create mode 100644 proxy/internal/llm/fixtures/pricing.yaml create mode 100644 proxy/internal/llm/openai.go create mode 100644 proxy/internal/llm/openai_test.go create mode 100644 proxy/internal/llm/parser.go create mode 100644 proxy/internal/llm/parser_test.go create mode 100644 proxy/internal/llm/pricing/defaults_coverage_test.go create mode 100644 proxy/internal/llm/pricing/defaults_pricing.yaml create mode 100644 proxy/internal/llm/pricing/pricing.go create mode 100644 proxy/internal/llm/pricing/pricing_other.go create mode 100644 proxy/internal/llm/pricing/pricing_test.go create mode 100644 proxy/internal/llm/pricing/pricing_unix.go create mode 100644 proxy/internal/llm/sse.go create mode 100644 proxy/internal/llm/sse_test.go create mode 100644 proxy/internal/middleware/bodypolicy.go create mode 100644 proxy/internal/middleware/bodytap/request.go create mode 100644 proxy/internal/middleware/bodytap/response.go create mode 100644 proxy/internal/middleware/bodytap/routing_scan_test.go create mode 100644 proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go create mode 100644 proxy/internal/middleware/builtin/all_test.go create mode 100644 proxy/internal/middleware/builtin/builtin.go create mode 100644 proxy/internal/middleware/builtin/cost_meter/factory.go create mode 100644 proxy/internal/middleware/builtin/cost_meter/middleware.go create mode 100644 proxy/internal/middleware/builtin/cost_meter/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_guardrail/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_guardrail/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_guardrail/redact.go create mode 100644 proxy/internal/middleware/builtin/llm_guardrail/redact_test.go create mode 100644 proxy/internal/middleware/builtin/llm_identity_inject/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_identity_inject/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_limit_check/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_limit_check/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_limit_record/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_limit_record/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go create mode 100644 proxy/internal/middleware/builtin/llm_request_parser/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_request_parser/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/streaming.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go create mode 100644 proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go create mode 100644 proxy/internal/middleware/builtin/llm_router/factory.go create mode 100644 proxy/internal/middleware/builtin/llm_router/middleware.go create mode 100644 proxy/internal/middleware/builtin/llm_router/middleware_test.go create mode 100644 proxy/internal/middleware/builtin/llm_router/path_routed_test.go create mode 100644 proxy/internal/middleware/chain.go create mode 100644 proxy/internal/middleware/chain_test.go create mode 100644 proxy/internal/middleware/decision.go create mode 100644 proxy/internal/middleware/dispatcher.go create mode 100644 proxy/internal/middleware/headerpolicy.go create mode 100644 proxy/internal/middleware/keys.go create mode 100644 proxy/internal/middleware/manager.go create mode 100644 proxy/internal/middleware/metadata.go create mode 100644 proxy/internal/middleware/metrics.go create mode 100644 proxy/internal/middleware/middleware.go create mode 100644 proxy/internal/middleware/redaction.go create mode 100644 proxy/internal/middleware/registry.go create mode 100644 proxy/internal/middleware/spec.go create mode 100644 proxy/internal/middleware/types.go create mode 100644 proxy/internal/proxy/agent_network_chain_realstack_test.go create mode 100644 proxy/internal/proxy/strip_prefix_test.go create mode 100644 proxy/internal/tcp/accept.go create mode 100644 proxy/internal/tcp/accept_test.go create mode 100644 proxy/middleware_register.go create mode 100644 proxy/middleware_translate.go create mode 100644 proxy/middleware_translate_test.go diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml new file mode 100644 index 000000000..c041bfbfa --- /dev/null +++ b/.github/workflows/agent-network-e2e.yml @@ -0,0 +1,69 @@ +name: Agent Network E2E + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Agent Network E2E + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: "go.mod" + + # Container-driver builder so the harness can build the combined/proxy/ + # client images from source with a local layer cache. + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Persist the Docker layer cache across runs. This caches the base, apt, + # and go-mod-download layers; the Go compile still re-runs, as BuildKit + # mount caches cannot be exported to the GitHub cache. + - name: Cache Docker layers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client') }} + restore-keys: | + ${{ runner.os }}-anet-e2e-buildx- + + - name: Run agent-network e2e + env: + # Build the images from source (this branch's code) with the shared + # local layer cache. + NB_E2E_BUILDX_CACHE: /tmp/.buildx-cache + # Provider credentials. Each provider scenario skips if its + # token (and URL, for gateways) is unset, so partial coverage is fine. + OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} + ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} + VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} + OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} + OPENROUTER_TOKEN: ${{ secrets.E2E_OPENROUTER_TOKEN }} + CLOUDFLARE_URL: ${{ secrets.E2E_CLOUDFLARE_URL }} + CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }} + AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }} + AWS_REGION: ${{ secrets.E2E_AWS_REGION }} + # Vertex (Anthropic-on-Vertex): SA + project required; region defaults + # to "global", model to a pinned claude snapshot. + GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }} + GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }} + GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }} + GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }} + run: go test -tags e2e -timeout 40m -v ./e2e/... diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 5d26d678d..511e54b62 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,7 +21,7 @@ jobs: - name: codespell uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 with: - ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals + ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable skip: go.mod,go.sum,**/proxy/web/** golangci: strategy: diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage index ef3d68c6e..79746819d 100644 --- a/combined/Dockerfile.multistage +++ b/combined/Dockerfile.multistage @@ -5,12 +5,16 @@ WORKDIR /app RUN apt-get update && apt-get install -y gcc libc6-dev git && rm -rf /var/lib/apt/lists/* COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY . . -# Build with version info from git (matching goreleaser ldflags) -RUN CGO_ENABLED=1 GOOS=linux go build \ +# Build with version info from git (matching goreleaser ldflags). +# BuildKit cache mounts persist the module + build caches across image builds, +# so a source change recompiles incrementally instead of from scratch. +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=1 GOOS=linux go build \ -ldflags="-s -w \ -X github.com/netbirdio/netbird/version.version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev') \ -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown') \ diff --git a/docs/agent-networks/00-overview.md b/docs/agent-networks/00-overview.md new file mode 100644 index 000000000..0d76e44a0 --- /dev/null +++ b/docs/agent-networks/00-overview.md @@ -0,0 +1,109 @@ +# Agent Networks — overview + +Single-entry point. Feature scope, the module map, and the cross-cutting +topics worth keeping in mind, with links into every per-module guide. + +## TL;DR + +Agent Networks introduces an **LLM-aware reverse-proxy middleware system** +plus **account-level controls** (budget rules, log collection toggles, +PII redaction). The management server synthesises a per-peer middleware +chain that the proxy executes on every LLM request; the chain enforces +quotas, injects identity, redacts PII, parses tokens/cost, and emits +access-log entries. The dashboard exposes the surface as a single **AI +Observability** page with four tabs. + +- **Backend** lives in this repo, primarily under + `management/server/agentnetwork`, `proxy/internal/middleware`, and + `proxy/internal/llm`, with wire contracts in `shared/management`. +- **Dashboard** lives in the dashboard repo under + `src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`. + +## Reading order + +| # | Doc | Why | +|---|-----|-----| +| 1 | [01-end-to-end-flows.md](01-end-to-end-flows.md) | Get the three big diagrams in your head first. | +| 2 | [modules/10-shared-api.md](modules/10-shared-api.md) | Wire contracts — every other module either produces or consumes these. | +| 3 | [modules/21-management-agentnetwork.md](modules/21-management-agentnetwork.md) | The largest module; everything the proxy executes originates here. | +| 4 | [modules/30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md) | The generic plugin system on the proxy side. | +| 5 | [modules/31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md) | The 8 LLM middlewares that ride on the framework. | +| 6 | Everything else in any order. | | + +## Module map + +11 modules. Each is described in detail in its own file under +[`modules/`](modules/). + +| # | Module | Risk | BC impact | +|---|--------|------|-----------| +| 10 | [shared/api](modules/10-shared-api.md) — proto + OpenAPI | Low | Additive only | +| 20 | [management/store](modules/20-management-store.md) — SQL persistence | Medium | Auto-migrate (additive) | +| 21 | [management/agentnetwork](modules/21-management-agentnetwork.md) — domain layer + synthesizer | **High** | Additive | +| 22 | [management/handlers + wiring](modules/22-management-handlers-wiring.md) — HTTP API + gRPC delivery | Medium | Additive | +| 30 | [proxy/middleware-framework](modules/30-proxy-middleware-framework.md) — generic plugin system | High | Additive | +| 31 | [proxy/middleware-builtin](modules/31-proxy-middleware-builtin.md) — 8 LLM middlewares | High | Additive | +| 32 | [proxy/llm-parsers](modules/32-proxy-llm-parsers.md) — SDK adapters + pricing | Medium | Additive | +| 33 | [proxy/runtime](modules/33-proxy-runtime.md) — translate + serve + access-log | High | Additive (touches hot path) | +| 40 | [dashboard](modules/40-dashboard.md) — UI for everything above | Medium | Sidebar reshape | +| 50 | [path-routed-providers](modules/50-path-routed-providers.md) — Vertex AI + Bedrock | Medium | Additive (new catalog entries) | + +The largest and highest-risk module is `management/agentnetwork`: it is +the single writer of the middleware chain the proxy executes. + +## Cross-cutting topics + +These are the items most likely to bite production. Each is fully +documented in the linked module guide. + +1. **Capture-pointer semantics** (`*bool` for `capture_prompt` and + `capture_completion`): nil = legacy emit, false = suppress, true = + emit. nil-vs-false must be handled at every JSON hop. See + [21-management-agentnetwork.md](modules/21-management-agentnetwork.md) + and [31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md). +2. **`ProxyMapping.Private` preservation** on per-proxy live updates. + Failure mode: `auth` skips `ValidateTunnelPeer` → + `CapturedData.UserGroups` empty → `llm_router` denies. See + [33-proxy-runtime.md](modules/33-proxy-runtime.md). +3. **respInput carrying `UserEmail`/`UserGroups`/`UserGroupNames` onto + the response leg** in `reverseproxy.go`. Load-bearing wire that lets + `llm_limit_record` ship non-empty `group_ids` on `RecordLLMUsage`. See + [33-proxy-runtime.md](modules/33-proxy-runtime.md). +4. **Min-wins all-must-pass budget rule semantics**. Every matching + rule's remaining quota must be > 0 for the request to proceed; one + exhausted rule blocks the whole call. Documented in + [21-management-agentnetwork.md](modules/21-management-agentnetwork.md) + and the `llm_limit_check` middleware in + [31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md). +5. **body-tap memory bounds**: per-direction 1 MiB cap, shared 256 MiB + budget, `LimitReader(r.Body, limit+1)` for truncation detection with + `replayReadCloser` fallback so upstream still sees the full body. + `cloneInputFor` deep-copies the body up to 16 times per chain — a + perf hot-spot. See + [30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md). +6. **UpstreamRewrite.AuthHeader bypasses the header denylist** + deliberately. The runtime consumer only unpacks it via the + trusted upstream-build path. See + [30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md). +7. **`disable_access_log` default-false semantics**: the synth target + sets it true, all other targets leave it false. See + [10-shared-api.md](modules/10-shared-api.md). +8. **String-typed `decision` / `deny_code`** on + `CheckLLMPolicyLimitsResponse` — would benefit from enum pinning + before external consumers integrate. See + [10-shared-api.md](modules/10-shared-api.md). + +## Explicit non-goals + +- **Reaper / GC pass over stale synth services** — designed but cut from + scope. +- **URL-sync for tab state on AI Observability** — read path is wired + (`?tab=`) but write path isn't. Future work. +- **CI golden-file regen-and-diff for `types.gen.go` / + `proxy_service.pb.go`** — would catch codegen drift; not yet in place. + +## Where to read the code + +Per-module file scopes are listed in each module guide. Behaviour is +covered by Go tests co-located with each package (and an end-to-end +chain integration test under `proxy/internal/proxy`). diff --git a/docs/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md new file mode 100644 index 000000000..7264f3768 --- /dev/null +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -0,0 +1,217 @@ +# End-to-end flows + +Three cross-module mermaid diagrams. Each per-module guide repeats the +slice that's relevant to its own scope — these are the canonical +top-down views. + +- [Flow A — Config → runtime (synth + deliver)](#flow-a--config--runtime-synth--deliver) +- [Flow B — Request lifecycle through the LLM chain](#flow-b--request-lifecycle-through-the-llm-chain) +- [Flow C — Budget rule feedback loop](#flow-c--budget-rule-feedback-loop) + +--- + +## Flow A — Config → runtime (synth + deliver) + +How an operator's change to a Provider, Policy, Guardrail, Budget Rule, +or Settings record ends up as live middleware on a peer's proxy. + +```mermaid +sequenceDiagram + autonumber + actor Op as Operator + participant UI as Dashboard + participant HTTP as management/handlers + participant Mgr as agentnetwork.Manager + participant Store as management/store (SQL) + participant Ctl as network_map.Controller + participant Synth as agentnetwork.SynthesizeServices + participant Grpc as management gRPC + participant Proxy as netbird-proxy + participant Xlate as middleware_translate + participant Chain as middleware.Chain + + Op->>UI: edit provider/policy/budget/settings + UI->>HTTP: REST PUT/POST /api/agent-network/* + HTTP->>Mgr: SaveProvider / SavePolicy / SaveBudgetRule / SaveSettings + Mgr->>Store: persist (gorm) + Mgr-->>Ctl: account change event (Network-Map dirty) + loop per connected peer + Ctl->>Synth: SynthesizeServices(ctx, store, accountID) + Synth->>Store: load providers, policies, guardrails, budget rules, settings + Synth-->>Synth: build per-peer Service list + Note over Synth: each Service has a middleware
chain with capture_prompt /
capture_completion / redact_pii
baked from account settings + Synth-->>Ctl: []rpservice.Service + Ctl->>Grpc: NetworkMap push (services + middleware configs) + end + Grpc-->>Proxy: NetworkMap stream + Proxy->>Xlate: translate proto MiddlewareConfig → runtime Spec + Xlate->>Chain: register / replace per-service chain + Note over Chain: chain replacement is live
(no proxy restart, in-flight
requests unaffected) +``` + +**Notes on the diagram** + +- The `network_map.Controller` synthesises on every push, not on a + timer. A single config change costs O(connected peers × policies × + providers) per push. See [`modules/22-management-handlers-wiring.md`](modules/22-management-handlers-wiring.md). +- `SynthesizeServices` is the single source of truth for the wire + format the proxy executes. Anything the proxy does that the + synthesiser didn't request is a bug. See + [`modules/21-management-agentnetwork.md`](modules/21-management-agentnetwork.md). +- The translate step (step 13) is the only place that knows the + middleware-ID strings on the proxy side. It must reject unknown IDs; + silently dropping middlewares would create a security gap (e.g. + missing `llm_limit_check` ⇒ unbounded spend). See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). + +--- + +## Flow B — Request lifecycle through the LLM chain + +What happens when an agent on the client peer sends a chat-completion / +messages request through the synthesised reverse-proxy. + +```mermaid +sequenceDiagram + autonumber + actor Agent as Agent (local) + participant Px as netbird-proxy + participant Auth as auth middleware + participant Map as service-mapping + participant Req as llm_request_parser + participant Rt as llm_router + participant Chk as llm_limit_check + participant Inj as llm_identity_inject + participant Grd as llm_guardrail + participant Up as upstream LLM + participant Resp as llm_response_parser + participant Cost as cost_meter + participant Rec as llm_limit_record + participant Log as access-log + participant MgmtGrpc as management gRPC + + Agent->>Px: POST /v1/chat/completions (OpenAI / Anthropic) + Px->>Auth: identify peer (user, groups) + Auth->>Map: resolve service from Host + path + Map-->>Req: dispatch chain in slot order + + Req->>Req: parse body → provider, model, prompt, token estimate + Note over Req: capture_prompt gates raw_prompt
capture (nil = legacy emit,
false = drop, true = emit) + Req->>Rt: pass metadata + Rt->>Chk: route to upstream candidate + + Chk->>MgmtGrpc: CheckLLMPolicyLimits(provider, model, est_tokens, groups, user) + MgmtGrpc-->>Chk: decision = allow / deny + deny_code + alt decision == deny + Chk-->>Log: emit access-log with deny_code
(if EnableLogCollection) + Chk-->>Agent: 429 (or 403 per deny_code) + else decision == allow + Chk->>Inj: continue + Inj->>Inj: inject NetBird identity headers per provider config + Inj->>Grd: continue + Grd->>Grd: enforce model allowlist + Grd->>Up: forward (over WireGuard) + Up-->>Resp: response (JSON or SSE stream) + Resp->>Resp: parse usage tokens, completion + Note over Resp: capture_completion gates raw
completion capture + Resp->>Cost: tokens + Cost->>Cost: lookup pricing.yaml + compute cost + Cost->>Rec: tokens + cost + Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user) + Rec-->>Log: emit access-log entry
(if EnableLogCollection) + Log-->>Agent: 200 + body (streamed if SSE) + end +``` + +**Notes on the diagram** + +- The chain runs in synth-defined order. Re-ordering middlewares + changes invariants — `llm_limit_check` must precede `llm_router` so + a denied request never hits upstream, and `llm_limit_record` must + pair with `llm_limit_check` so a successful check is always recorded + (or the rate-limit semantics break). See + [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- `llm_guardrail` is also where PII redaction happens + (`redact_pii = settings.RedactPii`). Phones, emails, credit cards, + PII names — see `redact.go` for the full set. See + [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- SSE streaming requires special handling on the response side; the + parser must handle partial chunks without buffering the whole + stream. See [`modules/32-proxy-llm-parsers.md`](modules/32-proxy-llm-parsers.md). +- Access-log emission is gated on `settings.EnableLogCollection`. With + it OFF, neither the deny nor the allow leg writes an entry — the + chain still runs (budget rules are still enforced) but no audit trail + is kept. See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). + +--- + +## Flow C — Budget rule feedback loop + +How an account's budget rules tighten ceilings on every request and how +consumption flows back into the dashboard. + +```mermaid +flowchart LR + subgraph Operator + DashBud[Dashboard Budget Settings tab] + end + subgraph Mgmt[Management] + Save[POST/PUT /api/agent-network/budget-rules] + Store[(SQL store)] + Synth[SynthesizeServices] + Check[CheckLLMPolicyLimits RPC] + Rec[RecordLLMUsage RPC] + Cons[/api/agent-network/consumption] + end + subgraph Proxy[Proxy] + Chk[llm_limit_check] + RecMw[llm_limit_record] + end + subgraph DashView[Dashboard Budget Dashboard tab] + Panel[AgentConsumptionPanel] + end + + DashBud -->|create / update rules| Save + Save --> Store + Store --> Synth + Synth -->|push synth-services to peer| Proxy + + Chk -->|per request| Check + Check -->|aggregate matching rules
min-wins all-must-pass| Store + Check -->|allow / deny| Chk + + RecMw -->|post-response| Rec + Rec -->|tokens + cost + groups + user| Store + + Store -->|read counters| Cons + Cons --> Panel +``` + +**Notes on the diagram** + +- **min-wins all-must-pass** is the core semantic. A budget rule binds + to (group set, user set) with a (window, ceiling). At check time, + every rule that matches the caller is evaluated; if ANY rule has + zero remaining quota the request is denied. This is the most + surprising semantic for operators — see the invariants section of + [`modules/21-management-agentnetwork.md`](modules/21-management-agentnetwork.md). +- The proxy never makes its own budget decisions. It always asks + management via `CheckLLMPolicyLimits` and reports back via + `RecordLLMUsage`. This keeps account-wide accounting in one place + and avoids per-proxy drift. +- `RecordLLMUsage` must carry `group_ids` and `user_id` so the + decrement hits the right rule(s). The wire that carries those + fields onto the response leg is `respInput` in `reverseproxy.go`. See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). +- The dashboard's Budget Dashboard tab polls + `/api/agent-network/consumption` — not gRPC, not WebSocket. Poll + interval lives in `AgentConsumptionPanel.tsx`. See + [`modules/40-dashboard.md`](modules/40-dashboard.md). + +--- + +## Cross-references + +- Per-module guides: [`modules/`](modules/) +- Overview + module map: [`00-overview.md`](00-overview.md) diff --git a/docs/agent-networks/README.md b/docs/agent-networks/README.md new file mode 100644 index 000000000..a7d2d2ab5 --- /dev/null +++ b/docs/agent-networks/README.md @@ -0,0 +1,66 @@ +# Agent Networks — architecture documentation + +A self-contained set of documents describing the agent-networks feature: +an LLM-aware reverse-proxy middleware system plus account-level controls +(budget rules, log collection toggles, PII redaction). The management +server synthesises a per-peer middleware chain that the proxy executes on +every LLM request. + +## What to read first + +1. **[00-overview.md](00-overview.md)** — the single entry point. Feature + scope, the module map, and the cross-cutting topics worth keeping in + mind, with links to every per-module guide. +2. **[01-end-to-end-flows.md](01-end-to-end-flows.md)** — three + high-level mermaid diagrams: config-to-runtime synth/delivery, + per-request lifecycle through the LLM chain, and the budget-rule + feedback loop. +3. **Per-module guides** under `modules/` — one file per package. Each + describes the module boundary, the file-level layout, its own flow + diagrams, the public contracts, the invariants it relies on, and the + areas worth the closest attention. + +## Directory layout + +``` +docs/agent-networks/ +├── README.md # you are here +├── 00-overview.md # feature summary + module map +├── 01-end-to-end-flows.md # cross-module mermaid diagrams +└── modules/ + ├── 10-shared-api.md # proto + OpenAPI wire contracts + ├── 20-management-store.md # SQL persistence layer + ├── 21-management-agentnetwork.md # domain layer + synthesizer (largest) + ├── 22-management-handlers-wiring.md # HTTP API + gRPC delivery + ├── 30-proxy-middleware-framework.md # generic plugin system + ├── 31-proxy-middleware-builtin.md # 8 LLM-aware middlewares + ├── 32-proxy-llm-parsers.md # OpenAI/Anthropic/Bedrock SDKs + pricing + ├── 33-proxy-runtime.md # translate + serve + access-log + ├── 40-dashboard.md # UI for everything above (lives in the dashboard repo) + └── 50-path-routed-providers.md # Vertex AI + Bedrock (path-routed, keyfile:: creds, /bedrock prefix) +``` + +The `40-dashboard.md` module documents code that lives in the **dashboard +repo**, not in this repo. The guide is co-located here so backend readers +see the full picture in one place. + +## How the per-module guides are structured + +Every `modules/*.md` follows the same template so the docs are easy to +scan: + +- **Module boundary** — what this package owns; where it sits in the stack. +- **Files** — path / role. +- **Architecture & flow** — one or more mermaid diagrams. +- **Public contracts** — function signatures, gRPC messages, JSON shapes. +- **Invariants** — semantic guarantees the module relies on or enforces. +- **Things to scrutinize** — split by correctness / security / + concurrency / backward-compat / performance / observability. +- **Test coverage** — the test files that lock down behaviour in this + module. +- **Known limitations / non-goals** — what is intentionally out of scope. +- **Cross-references** — upstream/downstream module links + the + end-to-end flow + the overview. + +See [00-overview.md](00-overview.md) for the module map and the +cross-cutting topics. diff --git a/docs/agent-networks/modules/10-shared-api.md b/docs/agent-networks/modules/10-shared-api.md new file mode 100644 index 000000000..532927b90 --- /dev/null +++ b/docs/agent-networks/modules/10-shared-api.md @@ -0,0 +1,105 @@ +# shared/api — wire contracts (proto + OpenAPI) + +> **Risk level:** Medium — wire-format surface that every other module pins against; backward-compat hinges on field-number discipline more than on logic correctness. +> **Backward-compat impact:** Additive only (new proto fields use unallocated numbers, new RPCs default to `Unimplemented`, new OpenAPI schemas/paths are append-only; no existing field/RPC/schema removed or renumbered). + +## Module boundary +This module owns the cross-process contract surface between management, proxy, and dashboard. Two artefacts: `shared/management/proto/proxy_service.proto` (management↔proxy gRPC) and `shared/management/http/api/openapi.yml` (dashboard/CLI↔management REST). Both have generated companions checked in (`proxy_service.pb.go`, `proxy_service_grpc.pb.go`, `types.gen.go`) which must travel in lockstep with their sources. `shared/management/status/error.go` is in scope only for the four new typed `NotFound` constructors that the new HTTP handlers return. + +Everything downstream — `management/agentnetwork`, `management/server/http/handlers/*`, `proxy/internal/*`, the dashboard SDK — consumes these types verbatim. The concern here is wire stability and codegen reproducibility, not behaviour: behaviour is covered in the management and proxy module guides. + +`management.proto` and `signalexchange.proto` are unchanged. `status/error.go` only receives four additive constructors (lines 208-227); no existing error types are reshaped. + +## Files +| Path | Role | +| ---- | ---- | +| `shared/management/proto/proxy_service.proto` | Source of truth: 2 new RPCs, 1 new message group (`MiddlewareConfig` + slot enum), additive fields on `PathTargetOptions`, `AccessLog`, `RecordLLMUsageRequest` | +| `shared/management/proto/proxy_service.pb.go` | Generated (protoc-gen-go) | +| `shared/management/proto/proxy_service_grpc.pb.go` | Generated; adds `CheckLLMPolicyLimits` + `RecordLLMUsage` client/server stubs and `UnimplementedProxyServiceServer` defaults | +| `shared/management/http/api/openapi.yml` | 15 new `AgentNetwork*` schemas, 9 new path groups under `/api/agent-network/*` | +| `shared/management/http/api/types.gen.go` | Generated (oapi-codegen; see codegen note below) | +| `shared/management/status/error.go` | Four `NotFound` constructors for the new resource kinds (lines 208-227) | + +## Architecture & flow +```mermaid +sequenceDiagram + participant Dash as Dashboard / CLI + participant Mgmt as management (HTTP+gRPC) + participant Px as proxy + + Note over Dash,Mgmt: REST (OpenAPI / types.gen.go) + Dash->>Mgmt: PUT /api/agent-network/providers (AgentNetworkProviderRequest) + Dash->>Mgmt: PUT /api/agent-network/settings (AgentNetworkSettingsRequest) + Dash->>Mgmt: GET /api/agent-network/consumption -> [AgentNetworkConsumption] + + Note over Mgmt,Px: gRPC ProxyService (proxy_service.proto) + Mgmt-->>Px: SyncMappingsResponse{ ProxyMapping.path[*].options.middlewares,
agent_network, disable_access_log, capture_* } + Px->>Mgmt: CheckLLMPolicyLimits(account, user, groups, provider, model) + Mgmt-->>Px: decision=allow|deny + selected_policy_id + attribution_group_id + window_seconds + Px->>Mgmt: RecordLLMUsage(account, user, group_id, group_ids, window_seconds, tokens, cost) + Px->>Mgmt: SendAccessLog(AccessLog{ agent_network=true }) +``` + +The proto changes split into three independent slices: (1) **mapping enrichment** — `PathTargetOptions` grows fields 8-13 so management can ship middleware configs, capture limits, and the agent-network / log-suppression flags down to the proxy without a second RPC; (2) **two new request/response RPCs** (`CheckLLMPolicyLimits`, `RecordLLMUsage`) for per-LLM-request budget arbitration; (3) **observability tag** — `AccessLog.agent_network` so management can route logs to the right surface. + +The OpenAPI side is a thin CRUD surface — every resource (`Provider`, `Policy`, `Guardrail`, `BudgetRule`, `Settings`) follows the same `GET-list / POST / GET / PUT / DELETE` pattern, plus a read-only `/consumption` listing and a catalog endpoint. The `*Request` variants drop server-controlled fields (id, timestamps). `AgentNetworkBudgetRule` deliberately reuses `AgentNetworkPolicyLimits` to keep wire-shape parity with policies. + +## Public contracts added +- gRPC RPCs (`proxy_service.proto:52-57`): `CheckLLMPolicyLimits(CheckLLMPolicyLimitsRequest) → CheckLLMPolicyLimitsResponse`, `RecordLLMUsage(RecordLLMUsageRequest) → RecordLLMUsageResponse`. Both unary; default `UnimplementedProxyServiceServer` returns `codes.Unimplemented` (`proxy_service_grpc.pb.go:283-289`). +- New messages (`proxy_service.proto:145-175,448-502`): `MiddlewareConfig`, `MiddlewareSlot` enum, `CheckLLMPolicyLimitsRequest`/`Response`, `RecordLLMUsageRequest`/`Response`. +- New `PathTargetOptions` fields 8-13 (`proxy_service.proto:124-140`): `capture_max_request_bytes`, `capture_max_response_bytes`, `capture_content_types`, `middlewares`, `agent_network`, `disable_access_log`. All default-false / zero; pre-existing fields 1-7 byte-for-byte unchanged. +- `AccessLog.agent_network = 18` (`proxy_service.proto:258-261`). +- `RecordLLMUsageRequest.group_ids = 8` (`proxy_service.proto:496-498`) — so the record path can fan out to every applicable budget rule's window without a re-lookup. +- 15 new OpenAPI component schemas (`openapi.yml:5072-5829`): `AgentNetworkProvider[Request|Model]`, `AgentNetworkCatalog{Model,Provider,IdentityInjection,HeaderPairInjection,JSONMetadataInjection,ExtraHeader}`, `AgentNetworkPolicy[Request|TokenLimit|BudgetLimit|Limits]`, `AgentNetworkGuardrail[Checks|Request]`, `AgentNetworkConsumption`, `AgentNetworkSettings[Request]`, `AgentNetworkBudgetRule[Request]`. +- 9 new path groups (`openapi.yml:12797-13460`): `/api/agent-network/{consumption,settings,budget-rules,budget-rules/{ruleId},catalog/providers,providers,providers/{providerId},policies,policies/{policyId},guardrails,guardrails/{guardrailId}}`. +- Four typed NotFound errors (`shared/management/status/error.go:208-227`). + +## Invariants +- **Field-number monotonicity.** Every new proto field uses a previously-unallocated number in its message: `PathTargetOptions` 8-13 (was 1-7), `AccessLog` 18 (was 1-17), `RecordLLMUsageRequest` 8. `SendStatusUpdateRequest.inbound_listener = 50` (pre-existing) reserves 50+ for observability extensions, so 8 on `RecordLLMUsageRequest` doesn't conflict. +- **Old proxies stay compatible.** Old management never sends `disable_access_log`/`middlewares`/`agent_network` (zero value → existing behaviour); old proxies that don't decode these fields just drop them silently (proto3 unknown-field semantics) — log emission stays on. No pre-existing field number changed: the proto change is insertions only. +- **Old management stays compatible.** The two new RPCs are registered on the same `management.ProxyService` descriptor; old proxies hitting them get `codes.Unimplemented` from the unimplemented embed (`proxy_service_grpc.pb.go:283-289`), which is the same fallback pattern `SyncMappings` already documents (`proxy_service.proto:20-21`). +- **OpenAPI shapes are append-only.** New schemas are placed at the end of `components.schemas` (line 5072+); new paths at the end of `paths` (line 12797+). No existing schema's `required` list, enum, or property type was changed. +- **`*Request` vs response asymmetry.** Read shapes (`AgentNetworkProvider`, `AgentNetworkPolicy`, `AgentNetworkGuardrail`, `AgentNetworkSettings`, `AgentNetworkBudgetRule`) require `created_at`/`updated_at`; the matching `*Request` shapes do not — server fills them. `AgentNetworkProviderRequest.api_key` is write-only (`openapi.yml:5158-5161` "never returned in responses"); reviewers should confirm the response schema (5072-5138) actually omits `api_key`. + +## Things to scrutinize +### Correctness +- `RecordLLMUsageRequest` carries both `group_id` (singular, the attribution group — field 3) and `group_ids` (plural, full membership — field 8). `b22d5a181` adds field 8 to drive account-budget fan-out; double-check that consumers can't accidentally key counters on the wrong one. Field comments at `proxy_service.proto:489-491` and `496-498` distinguish them but it's the kind of subtle thing a follow-up commit might collapse. +- `PathTargetOptions.disable_access_log` is the only field whose default-false meaning **changes semantics** on the proxy side: false → log (status quo), true → suppress. Synthesizer sets `DisableAccessLog = !settings.EnableLogCollection`, so a missing/default settings row yields `EnableLogCollection=false → DisableAccessLog=true → suppressed`. Worth confirming downstream (`agentnetwork.synthesizer`) that operator-defined private services never inherit this flag — the proto field default protects them, but only if synth code is explicit. +- `CheckLLMPolicyLimitsResponse.decision` is a free-form `string` (`proxy_service.proto:471`) rather than an enum. Only documented values are "allow" / "deny". An enum would prevent typo drift; consider before this RPC ships to external consumers. +- `deny_code` (`proxy_service.proto:478-481`) is documented as "a stable label" but is also a free string. Pin the allowed set somewhere observable to the proxy. + +### Security +- `AgentNetworkProvider.api_key` MUST be write-only. Schema split (request has it at line 5158; response omits it) looks correct, but a regression here leaks the upstream provider credential to every dashboard reader. Check that the handler explicitly zeros it on the response path. +- `extra_values` / `identity_header_*` headers on `AgentNetworkProvider` get stamped onto upstream requests. Description at `openapi.yml:5099` says "values not declared by the catalog are ignored at synth time" — a contract this module documents but the synthesizer must enforce. Confirm the synth module honours it. +- Cluster + subdomain on `AgentNetworkSettings` are documented immutable (`openapi.yml:5686-5694`) and the `AgentNetworkSettingsRequest` (lines 5733-5752) doesn't accept them. Verify the `PUT /api/agent-network/settings` handler can't be tricked by extra JSON keys (oapi-codegen's `additionalProperties: false` is not declared here; spec defaults to permissive). + +### Backward compatibility +- The proto change is field-number additive: every previously numbered field keeps the same name + type, and the change is insertions only (no deletions in `proxy_service.proto`), so this holds at the source-text level. +- `proxy_service_grpc.pb.go` adds two RPC handlers and registers them in `ProxyService_ServiceDesc.Methods` (lines 543-552). The existing entries are unchanged and order-preserving — gRPC method dispatch is name-keyed, so order doesn't matter, but reviewing the diff (no method renamed/dropped) is still worth a glance. +- OpenAPI 3.0 doesn't have a built-in deprecation flow for paths; if any client tooling iterates `paths.*`, the additive routes shouldn't break it, but generated SDKs (especially the dashboard's) need a regen to gain access to `AgentNetwork*`. + +### Codegen pinning +- `generate.sh` (`shared/management/http/api/generate.sh:14`) installs `oapi-codegen@latest` rather than a pinned version. **This is a reproducibility gap** — re-running the script later may produce a different `types.gen.go`. Either pin the version in `generate.sh` (e.g. `@v2.7.0`) or document the pin in a `tools.go`. +- proto codegen has the protoc / protoc-gen-go version stamped in the generated file header (`proxy_service.pb.go:3-4`). +- Regenerate locally and confirm zero diff against the committed `types.gen.go` / `proxy_service.pb.go`. + +## Test coverage +| Test file | Locks down | +| --------- | ---------- | +| None in this scope | The proto and OpenAPI sources are tested transitively by the handler tests (`shared/management/http/handlers/agentnetwork/...`) and by the synthesizer/manager tests (`management/server/agentnetwork/...`). No round-trip serialisation test exists in the `proto/` or `api/` packages themselves. | +| `shared/management/proto/*_test.go` | (absent) | +| `shared/management/http/api/*_test.go` | (absent) | + +Acceptable for codegen artefacts, but a single golden-file test that re-runs `oapi-codegen` and `protoc` in CI and diffs against the checked-in files would close the reproducibility gap noted above. + +## Known limitations / explicit non-goals +- **No deprecation surface.** Old fields/RPCs are kept silently; there is no `[deprecated = true]` annotation on anything. Acceptable here because nothing is being removed. +- **No proto-side validation.** Numeric ranges (e.g. `window_seconds >= 60`, `cost_usd >= 0`, capture-byte clamps) are enforced in the OpenAPI schema via `minimum:` and inside Go code by the proxy/management, but `proto3` itself can't express them; downstream is expected to validate every message. +- **`MiddlewareConfig.config_json` is `bytes`** (`proxy_service.proto:163`) — opaque to the proto layer. Schema validity is the middleware factory's problem. This is a deliberate tradeoff (per the comment at 161-162) but worth flagging: a corrupted/malicious config_json can only fail at proxy apply time, not at the wire-decode step. +- **No catalog endpoint schema for the catalog itself** — the catalog data ships as a `GET /api/agent-network/catalog/providers` returning `[AgentNetworkCatalogProvider]` (`openapi.yml:13024`), but the catalog source-of-truth lives in `management/server/agentnetwork/catalog`, not here. +- The reaper / GC design was cut from scope; no reaper-related types appear here. + +## Cross-references +- Downstream: [management/store](20-management-store.md), [management/agentnetwork](21-management-agentnetwork.md), [management/handlers + wiring](22-management-handlers-wiring.md), [proxy/runtime](33-proxy-runtime.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/20-management-store.md b/docs/agent-networks/modules/20-management-store.md new file mode 100644 index 000000000..1acc12611 --- /dev/null +++ b/docs/agent-networks/modules/20-management-store.md @@ -0,0 +1,112 @@ +# management/store — persistence for agent-network entities + +> **Risk level:** Medium — six brand-new tables behind AutoMigrate, one upsert-counter table that runs on the request hot path, and one column carrying an encrypted secret. +> **Backward-compat impact:** Additive (six new tables created by AutoMigrate; the `Store` interface gains 23 methods, but no existing column/index is touched). + +## Module boundary + +This module is the persistence layer for the Agent Network feature. Everything the management server stores about LLM proxying — providers, policies, guardrails, the per-account settings row, a usage-counter table written on every proxied LLM request, and the account-budget rules — flows through the methods added to `store.Store`. The module owns six tables, six entity types from `management/server/agentnetwork/types`, and a single hot-path upsert (`IncrementAgentNetworkConsumption`) consumed by the proxy fleet. + +Out of scope here: the catalog of provider definitions (compiled-in, no DB), the synthesizer/manager built on top of these CRUDs (covered in [21-management-agentnetwork.md](21-management-agentnetwork.md)), and the HTTP handlers that translate API requests into Save/Delete calls. + +## Files + +| Path | Role | +| ---- | ---- | +| `management/server/store/sql_store_agentnetwork.go` | gorm implementations of all 23 store methods | +| `management/server/store/sql_store_agentnetwork_budgetrule_test.go` | round-trip + account-scoping coverage against a real sqlite store | +| `management/server/store/sql_store.go` | one import, six entities appended to the `AutoMigrate` slice (sql_store.go:40, sql_store.go:141-142) | +| `management/server/store/store.go` | 23 methods added to the `Store` interface (store.go:328-354) | +| `management/server/store/store_mock_agentnetwork.go` | mockgen output for the new interface surface | + +## Tables added / migrations + +All six tables are created by `db.AutoMigrate` invoked from `NewSqlStore` at sql_store.go:133-143. There is no hand-rolled SQL migration script — the schema is whatever GORM derives from the struct tags. + +- `agent_network_providers` — `Provider.TableName()` at provider.go:76. PK `id`, index on `account_id`, named index `idx_agent_network_provider` on `provider_id`. Carries an at-rest-encrypted `api_key` and ed25519 `session_private_key` (provider.go:35,56). `extra_values` and `models` are JSON blobs (`serializer:json`). +- `agent_network_policies` — `Policy.TableName()` at policy.go:70. PK `id`, index on `account_id`. JSON columns: `source_groups`, `destination_provider_ids`, `guardrail_ids`, `limits`. +- `agent_network_guardrails` — `Guardrail.TableName()` at guardrail.go:41. PK `id`, index on `account_id`. JSON `checks`. +- `agent_network_settings` — `Settings.TableName()` at settings.go:33. PK `account_id` (one row per account), named index `idx_agent_network_settings_cluster_subdomain` on `subdomain` only — the index name implies a composite, but only one column is tagged. +- `agent_network_consumption` — `Consumption.TableName()` at consumption.go:46. Composite PK across `(account_id, dim_kind, dim_id, window_seconds, window_start_utc)` — the same tuple the upsert keys on. +- `agent_network_budget_rules` — `AccountBudgetRule.TableName()` at budgetrule.go:35. PK `id`, index on `account_id`. JSON `target_groups`, `target_users`, `limits`. + +## CRUD surface added + +Provider, Policy, Guardrail, BudgetRule follow the same pattern: `GetByID`, `GetAccount` (list), `Save` (upsert), `Delete`, with account-scoping enforced by the existing `accountAndIDQueryCondition` / `accountIDCondition` constants (sql_store.go:59-62). Provider additionally exposes `GetAllAgentNetworkProviders` (cross-account, used by the synthesizer). Settings exposes `Get`/`GetByCluster`/`Save` (no delete — one row per account, created on first save). Consumption exposes the upsert `Increment`, a point `Get`, and a cross-window `List`. + +## Architecture & flow + +```mermaid +flowchart LR + handlers["HTTP handlers
(management/server/agentnetwork)"] -->|Save/Delete| iface["Store interface
store.go:328-354"] + manager["agentnetwork.Manager"] -->|Get*| iface + synth["synthesizer
(global)"] -->|GetAllAgentNetworkProviders| iface + proxy["proxy fleet
(hot path)"] -->|IncrementAgentNetworkConsumption| iface + iface --> sql["SqlStore methods
sql_store_agentnetwork.go"] + iface -.gomock.-> mock["MockStore
store_mock_agentnetwork.go"] + sql --> gorm["gorm.DB"] + gorm --> tables[("6 tables
agent_network_*")] + sql --> enc["crypt.FieldEncrypt
(provider only)"] +``` + +Reads decrypt provider secrets in-place; writes do `provider.Copy().EncryptSensitiveData(...)` before `db.Save` so the caller's in-memory object keeps the plaintext `api_key` (sql_store_agentnetwork.go:88-102). Every list/get takes a `LockingStrength` and applies `clause.Locking{Strength: ...}` when non-`None` — matching the rest of the store. The upsert path uses `clause.OnConflict` with `gorm.Expr` server-side increments so concurrent proxy nodes converge without read-modify-write races (sql_store_agentnetwork.go:321-335). + +## Invariants enforced at the store layer + +- **Account scoping.** Every entity-by-ID method keys on `account_id = ? and id = ?`; no cross-tenant leak path through the API is reachable as long as callers always pass the auth'd `accountID` (sql_store_agentnetwork.go:70,141,201,429). +- **NotFound mapping.** `gorm.ErrRecordNotFound` is translated to typed `status.NewAgentNetwork*NotFoundError`; `Delete*` returns NotFound when `RowsAffected == 0` (sql_store_agentnetwork.go:111-113,171-173,231-233,461-463). +- **Provider secret encryption at rest.** `SaveAgentNetworkProvider` always encrypts before persist; `Get*` always decrypts after read. The plaintext `api_key` never reaches the DB through this layer (sql_store_agentnetwork.go:31,54,80,90). +- **Consumption monotonicity.** The upsert only ever issues `col = col + ?` for the three counter columns — no decrement path exists (sql_store_agentnetwork.go:330-332). +- **Window alignment is the caller's responsibility.** The store stamps `WindowStartUTC` as-passed; alignment to epoch happens in `types.WindowStart` at consumption.go:51-58. +- **Settings has no Delete.** Intentional — one row per account, created on first save; the row sticks around for the account lifetime. + +## Things to scrutinize + +### Correctness +- `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch. +- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects the `OnConflict` clause — GORM emits engine-specific SQL but `ON DUPLICATE KEY UPDATE` (mysql) vs `ON CONFLICT (...)` (sqlite/postgres) need their unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction. +- `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field. +- `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check. + +### Concurrency / transactions +- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer. +- `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging. +- `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation. + +### Migration safety +- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default. +- The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading. +- The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store. + +### Backward compatibility +- Net additive. No removed methods, no renamed columns, no schema change to existing tables. Existing deployments running a prior binary continue to work; the first boot of the new binary creates the six tables. +- The `Store` interface grows by 23 methods (store.go:330-354); any non-mock external implementer of `store.Store` will fail to compile. The repo only has `SqlStore` + `MockStore`, both updated. + +### Performance (indexes, N+1) +- All by-account list queries hit the `idx_account_id` per-table index. No N+1: list methods return the full slice in one query. +- `GetAgentNetworkSettingsByCluster` (sql_store_agentnetwork.go:263-277) does a tablescan on `cluster` — no index. Tolerable for the bootstrap label generator (one-shot at provisioning) but worth noting if the call moves onto a hot path. +- `ListAgentNetworkConsumption` returns every row ever recorded for the account (sql_store_agentnetwork.go:382-400) — unbounded growth, no `LIMIT`, no time filter. With one row per (dim, window) per request burst, this table grows fastest of the six; a retention job + a paginated list method are obvious follow-ups. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_RoundTrip` | full save → reload of `AccountBudgetRule` including the JSON-serialised `PolicyLimits`, target slices, double-delete returns NotFound (lines 18-59) | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_ScopedByAccount` | cross-account isolation for budget rules (lines 63-78) | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip` | collection toggles default off, survive save/reload at the set values (lines 83-112) | + +Gap: there is no store-level test for providers (encryption round-trip), policies, guardrails, or `IncrementAgentNetworkConsumption` (concurrent upsert, window-key uniqueness). The consumption upsert is the most performance-sensitive method in this module and the only one without a real-sqlite test. + +## Known limitations / explicit non-goals + +- No retention / GC for `agent_network_consumption`. +- No `Delete` for `Settings` (one row per account, cleared with the account). +- No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres. +- Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design. +- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional. + +## Cross-references + +- Upstream: [shared/api](10-shared-api.md), [management/agentnetwork](21-management-agentnetwork.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md new file mode 100644 index 000000000..b64c1ba20 --- /dev/null +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -0,0 +1,225 @@ +# management/agentnetwork — domain layer + synth pipeline + +> **Risk level:** High — central business logic + budget enforcement + the source of every middleware-chain change the proxy executes. +> **Backward-compat impact:** Additive within the agent-network surface; one **behavioural difference for opted-out accounts** in parser capture (the capture flag is stamped explicitly false instead of being absent — see capture-pointer semantics below). Non-agent-network proxy services are untouched (the synth chain only ships on `agent-net-svc-*` targets). + +## Module boundary + +`management/server/agentnetwork` owns every agent-network entity (providers, policies, guardrails, account budget rules, per-account settings, consumption rows) and **translates them into the in-memory `*rpservice.Service` that the reverse-proxy controller turns into `proto.ProxyMapping`s and pushes to clusters**. It is the *only* writer of the agent-network middleware chain. + +Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthesizer.go` walks settings + providers + policies + guardrails and emits the per-account service plus every middleware's JSON config; `policyselect.go` runs per-request attribution (min-wins account ceiling, then "drain bigger pool first"); `reconcile.go` diffs successive synth outputs and emits precise Create/Update/Delete proxy-mapping updates plus a peer-map refresh. `labelgen/` mints DNS-safe subdomain labels; `catalog/` is the static provider catalogue; `types/` carries gorm entity structs. The `_realstack_test.go` files in the parent `management/server/` directory exercise the manager + network-map controller end-to-end with no mocks. + +## Files + +| Path | Role | +| ---- | ---- | +| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger | +| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain | +| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) | +| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete | +| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) | +| `agentnetwork/labelgen/{labelgen,words}.go` | DNS-safe subdomain picker + curated wordlist | +| `agentnetwork/types/provider.go` | Provider entity + APIKey + Models + ExtraValues + SessionKeys | +| `agentnetwork/types/policy.go` | Policy entity + `PolicyLimits` (token + budget) | +| `agentnetwork/types/guardrail.go` | Guardrail entity (`ModelAllowlist`, `PromptCapture`) | +| `agentnetwork/types/budgetrule.go` | `AccountBudgetRule` (reuses `PolicyLimits`) | +| `agentnetwork/types/settings.go` | Per-account `Settings` (Cluster, Subdomain, 3 toggles) | +| `agentnetwork/types/consumption.go` | `Consumption` row + `WindowStart` aligner | +| `agentnetwork/{synthesizer,policyselect,reconcile,wire_shape}_*test.go` | See test coverage table | +| `agentnetwork/types/consumption_test.go` | `WindowStart` alignment proofs | +| `agentnetwork/labelgen/labelgen_test.go` | Deterministic picks + exhaustion + fallback | +| `management/server/agentnetwork_realstack_test.go` | No-mock provider CRUD → network-map fan-out | +| `management/server/agentnetwork_budgetrule_realstack_test.go` | No-mock budget-rule CRUD + settings preserve-immutable | + +## Architecture & flow + +### Synthesis (settings/policy → wire format) + +```mermaid +flowchart TD + A[Mutation: provider/policy/guardrail/settings] --> B[managerImpl.reconcile accountID] + B --> C{proxyController nil?} + C -- yes --> D[accountManager.UpdateAccountPeers only] + C -- no --> E[SynthesizeServices] + E --> F[loadSettings — NotFound returns ok=false, no synth] + F --> G[filterEnabledProviders sorted by CreatedAt] + G --> H[filterEnabledPolicies] + H --> I[backfillProviderSessionKeys if missing] + I --> J[indexProviderGroups: providerID -> sorted source groups] + J --> K[buildRouterConfigJSON drops orphan providers] + J --> L[buildIdentityInjectConfigJSON per catalog entry] + H --> M[mergeGuardrails: union allowlist, OR redact] + M --> N[applyAccountCollectionControls account toggle = SOLE capture control] + N --> O[marshalGuardrailConfig] + K --> P[buildMiddlewareChain 8 middleware entries] + L --> P + O --> P + P --> Q[buildAccountService: AccessGroups=union source groups, noop.invalid target] + Q --> R[reconcile.diffMappings vs cache] + R --> S[SendServiceUpdateToCluster CREATE/MODIFY/REMOVE] + R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map] +``` + +### Budget rule resolution (min-wins, group+user bound) + +```mermaid +flowchart TD + A[SelectPolicyForRequest in] --> B[checkAccountBudget — runs FIRST, independent of policies] + B --> C[GetAccountAgentNetworkBudgetRules] + C --> D{for each enabled rule} + D --> E{budgetRuleApplies?} + E -- no --> D + E -- yes --> F[attrGroup = lowestIntersect TargetGroups, in.GroupIDs] + F --> G{Token cap enabled?} + G -- yes --> H[evalTokenCap user dim + group dim] + H --> I{exhausted?} + I -- yes --> J[DENY: llm_account.token_cap_exceeded - STOP] + I -- no --> K{Budget cap enabled?} + G -- no --> K + K -- yes --> L[evalBudgetCap user dim + group dim] + L --> M{exhausted?} + M -- yes --> N[DENY: llm_account.budget_cap_exceeded - STOP] + M -- no --> D + K -- no --> D + D --> O[All rules passed -> fall through to per-policy selection] +``` + +Key invariant: **rules are checked sequentially and ANY exhausted rule denies (all-must-pass / min-wins).** Untargeted rules (`len(TargetGroups)==0 && len(TargetUsers)==0`) apply to every caller (`policyselect.go:393`). + +### Policy selection (per-peer, per-request) + +```mermaid +flowchart TD + A[Account-budget gate passed] --> B[GetAccountAgentNetworkPolicies] + B --> C[filterApplicablePolicies enabled + provider match + group intersect] + C --> D{candidates empty?} + D -- yes --> E[Allow, empty SelectedPolicyID] + D -- no --> F[scoreCandidates -> scoreOne per policy] + F --> G[scoreOne: attrGroup + window] + G --> H{any cap exhausted?} + H -- yes --> I[Drop policy; record last deny code] + H -- no --> K[Keep as live candidate] + F --> L{live candidates exist?} + L -- no --> M[Deny with last exhaustion code] + L -- yes --> N[Sort: uncapped wins -> larger group token -> group budget -> user token -> user budget -> oldest CreatedAt] + N --> O[winner = scored 0] + O --> P[Allow + SelectedPolicyID + AttributionGroupID + WindowSeconds] +``` + +End-to-end: a mutation calls `managerImpl.reconcile(ctx, accountID)` (`manager.go:205,239,...`). Reconcile defers an `accountManager.UpdateAccountPeers` so the network-map controller re-runs and `injectAllProxyPolicies` picks up the new access groups; with a `proxyController` wired, it re-synthesizes the service, diffs against `reconcileCache[accountID]` (guarded by `reconcileMu`), and emits proto mappings to the cluster derived from the mapping's domain (`reconcile.go:120`). Synthesis is stateless and idempotent. Sole persistent side effect: `backfillProviderSessionKeys` (`synthesizer.go:249`) mints ed25519 keys on legacy provider rows and writes them back. + +At request time the path is independent: the proxy calls `SelectPolicyForRequest` (`policyselect.go:56`); account-budget ceiling first, then per-policy scoring. Token + budget caps share `evalTokenCap` / `evalBudgetCap` — same primitive for account rules and policy limits, `label` differentiates the deny reason. After a served request, `RecordAccountBudgetUsage` (`policyselect.go:415`) fans deltas to every applicable rule's distinct `(dim_kind, dim_id, window)` tuple, deduplicating to prevent double-count when two rules share target+window. + +## Public contracts + +- **Manager interface** (`manager.go:48-80`): CRUD for `Providers/Policies/Guardrails/BudgetRules`; `GetSettings/UpdateSettings` (cluster + subdomain immutable, only the three toggles mutate); `ListConsumption/RecordConsumption(account, kind, dimID, windowSec, in, out, USD)`; `RecordAccountBudgetUsage(account, user, groups, in, out, USD)`; `SelectPolicyForRequest(ctx, PolicySelectionInput) → *PolicySelectionResult{Allow, SelectedPolicyID, AttributionGroupID, WindowSeconds, DenyCode, DenyReason}`. +- **`PolicySelectionInput`** (`manager.go:85-90`): `{AccountID, UserID, GroupIDs, ProviderID}` — populated by the proxy from CapturedData + `llm_router` resolution. +- **Synthesized middleware chain** (`synthesizer.go:576-657`), order load-bearing — response slot runs reverse-of-slice: + + | Slot | Idx | ID | ConfigJSON shape | CanMutate | + | --- | --- | --- | --- | --- | + | on_request | 0 | `llm_request_parser` | `{"capture_prompt": , "redact_pii"?: true}` | – | + | on_request | 1 | `llm_router` | `{"providers":[{id, models[], upstream_*, auth_header_*, allowed_group_ids[]}]}` | **true** | + | on_request | 2 | `llm_limit_check` | `{}` | – | + | on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** | + | on_request | 4 | `llm_guardrail` | `{"model_allowlist"?, "prompt_capture":{enabled,redact_pii}}` | – | + | on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – | + | on_response | 6 | `cost_meter` | `{}` | – | + | on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – | +- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=.`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`. + +## Invariants + +- **Min-wins / all-must-pass for account budget rules** (`checkAccountBudget`, `policyselect.go:353`): every applicable enabled rule is checked; first exhausted cap denies. Untargeted rules bind every caller. +- **Account toggle is the SOLE control for capture enablement.** `applyAccountCollectionControls` (`synthesizer.go:701`) sets `merged.PromptCapture.Enabled = settings.EnablePromptCollection` *unconditionally*. +- **Capture-pointer semantics on parser configs** — see "Things to scrutinize" below. +- **`EnableLogCollection` ↔ `DisableAccessLog` is the only access-log toggle** (`synthesizer.go:770`). Default off ⇒ access log suppressed. +- **`RedactPii` flows verbatim to BOTH parsers** (`synthesizer.go:584-585`) and is OR'd into the merged guardrail (`synthesizer.go:706`). +- **Cluster and Subdomain are immutable on Settings.** `UpdateSettings` reloads existing row and overlays only the three toggles (`manager.go:558-561`). +- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry. +- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`). +- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced. + +## Things to scrutinize + +### Correctness + +- **Capture-pointer semantics — `*bool` vs `bool`.** Three states, owned by separate sides: + - **Wire JSON this module emits:** `buildParserConfigJSON` (`synthesizer.go:678-693`) *always* stamps the capture field. Agent-network targets ship `"capture_prompt": false` or `"capture_prompt": true` — never absent. Same for `"capture_completion"`. The happy-path test pins `{"capture_prompt":false}` (`synthesizer_test.go:174`). + - **Proxy-side parser config (consumer):** parsers decode into `*bool`. Matrix: + - `nil` (field absent) → **legacy default = emit**. Preserved for non-agent-network callers and pre-existing tests (the backward-compat hook). + - `false` (field present, value false) → **suppress emission entirely**. The behaviour for opted-out agent-network accounts. Without this, `enable_log_collection=true` + `enable_prompt_collection=false` would leak raw user input AND raw model output to the access log. + - `true` → emit normally. + - **Why the synth always stamps a value:** an agent-network mapping omitting the field would hit legacy "always emit" and re-introduce the leak. The `json.Marshal` error fallback at `synthesizer.go:687` degrades to `{}` — comment-claimed unreachable, but if ever fired re-introduces the leak. Consider fail-closed (return literal `{"capture_prompt":false}`) instead. +- **`scoreCandidates` non-cumulative deny code.** Only the *last* exhausted policy's deny code survives (`policyselect.go:188-190`). Iteration order is store's natural order. Auth signal is `len(scored)==0`, so this is informational only — verify no UI depends on "first exhausted policy" semantics. +- **`effectiveWindowSeconds` token-wins tiebreak.** When both halves are enabled with different windows, token's window wins (`policyselect.go:482`). Verify `RecordLLMUsage` increments against the winning window only. +- **`RecordAccountBudgetUsage` dedup.** Two rules with the same `(kind, dim_id, window)` would double-count without the `tuples` map (`policyselect.go:434-449`). Key includes all three dimensions — correct. +- **Fail-closed on bad provider:** unknown catalog id (`synthesizer.go:794-796`) or empty API key (`synthesizer.go:801-803`) drops the **entire** account's synth, not just the bad provider. Confirm matches operator UX. + +### Security + +- **Redact OR-merge:** merged `RedactPii` = account OR guardrail (`synthesizer.go:706`). **Parser-side flag is `settings.RedactPii` only, NOT the OR** — a guardrail-only opt-in does not propagate to parsers. Correct because the account toggle gates capture, but worth noting on the proxy side. +- **Group resolution must not leak across accounts.** Every store call carries `accountID` (`policyselect.go:73, 286, 298, 322, 334, 354`); `lowestIntersect` uses caller's claimed groups only (`policyselect.go:494`). Risk surface is upstream (handler populates `in.GroupIDs`). +- **`UpdateSettings` preserves immutable Cluster + Subdomain** (`manager.go:558`). A client can't rebind the cluster. +- **Provider session keypair backfill writes through `SaveAgentNetworkProvider`** (`synthesizer.go:256`) from a read-shaped call. Idempotent → worst case is a wasted write under concurrent reconcile + snapshot. + +### Concurrency + +- **`reconcileMu`** guards `reconcileCache`. Lock window is narrow — compute diff inside, send outside (`reconcile.go:56-68`). +- **`labelRngMu`** guards `labelRng` because `math/rand.Source` is unsafe for concurrent use (`manager.go:638-640`). +- **Real-store tests** use `store.NewTestStoreFromSQL` with `t.TempDir()` per test — no shared state, no `t.Parallel()`. +- **`RecordAccountBudgetUsage` dedup `tuples` map is per-call;** concurrent calls fan out fully — correct (each request's tokens book once per applicable rule). +- **Deferred `UpdateAccountPeers` runs inline after the proxy push** (`reconcile.go:28-35`); a slow call stretches CRUD response time. + +### Backward compatibility + +- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value. +- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating. +- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out. + +### Performance + +- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions. +- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant. +- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect. + +### Observability + +- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume). +- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`). +- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `synthesizer_test.go` | Mock-store: `HappyPath` (8-mw chain ordering, `{"capture_prompt":false}` baseline); `No{Settings,Providers}`; `Disabled{Provider,Policy}_NoService`; `RouterConfigOrdering`; `PolicyCheckConfig_UnionsSourceGroups`; `OrphanProvider_HasEmptyAllowedGroups`; identity-inject for LiteLLM / Bifrost (overrides + partial disable) / Cloudflare / Portkey / Vercel / OpenRouter / generic non-customizable; `GuardrailMerge_AllowlistUnion_LimitsRestrictive`; `BackfillsMissingSessionKeys`; `HTTPUpstream_KeepsExplicitPort`; `UpstreamURLPath_FlowsToRouter`; `UnknownProviderID_FailsClosed`; `EmptyAPIKey_FailsClosed`. | +| `synthesizer_realstore_test.go` | Real-sqlite: `SurvivesStatusToggle` reproduces the disable/re-enable 403 regression; `Reconcile_RealStore_PushesPrivateAfterStatusToggle` extends through reconcile push. | +| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. | +| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. | +| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. | +| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. | +| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. | +| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. | +| `reconcile_test.go` | `FirstSynth_EmitsCreate`; `NoChange_EmitsNothingExtra` (re-push as Modified — verify desired); `PolicyRemoved_EmitsDelete`; `NilProxyController_NoOp`; `EmptyAccountID_NoOp`; `ClusterFromMapping`. | +| `wire_shape_test.go` | `TestSynthesizedService_WireShape` — proto-shape lockdown via `ToProtoMapping`. Catches "service not matching" (mapping reaches proxy but no SNI/HTTP route). Asserts ID, Domain, Mode, AuthToken, `Private`, `Auth.Oidc=false`, one path `/` + `https://noop.invalid/`, 8 middlewares with correct slot enums, router config `auth_header_value="Bearer sk-test-key"`. | +| `labelgen/labelgen_test.go` | `PickUnique_{DeterministicWithSeededRng,AvoidsTakenWordsWhenMostAreReserved,FallsBackWhenAllReserved}`; `UniqueWords_DropsDuplicates`. | +| `types/consumption_test.go` | `WindowStart_{AlignedToUnixEpoch,WithinWindowConverges,AcrossWindowsDiverges,DifferentWindowsHaveDifferentBuckets,SubMinuteAndMinuteAlignment,ZeroWindowReturnsInputUTC}`. Bucket alignment so multi-node reads converge. | +| `agentnetwork_realstack_test.go` | `ProviderCRUD_FansOutToProxyAndClientPeers` — no-mock end-to-end through real account manager + network-map + agentnetwork: provider create propagates the updated map to both proxy peer and client peer with the synth DNS surface. | +| `agentnetwork_budgetrule_realstack_test.go` | `BudgetRuleCRUD_RealManager`; `UpdateSettings_PreservesImmutableAndTogglesCollection`. | + +## Known limitations / explicit non-goals + +- **`MergedGuardrails.TokenLimits/Budget/Retention` emit at zero** (`synthesizer.go:940-948`); real enforcement is `Policy.Limits` via `llm_limit_check`. Future cleanup implied. +- **Session keys picked from first enabled provider by created_at** (`pickServiceSessionKeys`, `synthesizer.go:270`). Existing session cookies survive provider edits only while the first-by-CreatedAt provider stays in place. Document for operators. +- **Reconcile failures silently swallowed** (`reconcile.go:42-44`). Persistent failures keep the proxy out of sync until the next reconcile. +- **`scoreCandidates` exposes only the LAST exhaustion's deny code** when multiple policies are exhausted. +- **`bootstrapSettingsIfNeeded` failure is non-fatal to provider create** (`manager.go:200`): provider lands, synth is no-op until the next provider create retries the bootstrap. +- **Budget rules do not trigger a reconcile** (`manager.go:476-477`). Request-time evaluation only; new rules take effect on the next request without a proxy push. + +## Cross-references + +- **Upstream:** [shared/api](10-shared-api.md), [management/store](20-management-store.md), reverseproxy `service`/`proxy`/`sessionkey` packages, `management/server/permissions` + `activity`. +- **Downstream:** [management/handlers (HTTP wiring)](22-management-handlers-wiring.md), [proxy/middleware-builtin](31-proxy-middleware-builtin.md), network-map controller (`injectAllProxyPolicies` fan-out). +- **End-to-end flow:** [../01-end-to-end-flows.md](../01-end-to-end-flows.md) — "Provider create → reconcile → proxy push → peer map refresh" and "request → policy select → record" diagrams. +- **Top-level:** [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/22-management-handlers-wiring.md b/docs/agent-networks/modules/22-management-handlers-wiring.md new file mode 100644 index 000000000..9b8a47445 --- /dev/null +++ b/docs/agent-networks/modules/22-management-handlers-wiring.md @@ -0,0 +1,203 @@ +# management/handlers + wiring — HTTP API + gRPC delivery + +> **Risk level:** Medium — the surface is mostly additive, but two changes are load-bearing: `injectAllProxyPolicies` runs on every per-peer compute, and `shallowCloneMapping` must round-trip `Private` (a missed field silently breaks every MODIFIED). +> **Backward-compat impact:** Additive on the wire (new routes, new RPCs, new proto fields, new gorm column on `AccessLogEntry`). One management-internal break: `nbhttp.NewAPIHandler` gains a trailing `agentNetworkManager` parameter; `nil` is tolerated and silently skips route registration. + +## Module boundary + +This module is the seam between the public Agent Network HTTP API and the proxy fleet that serves agent traffic. North side: a `/api/agent-network/*` surface (providers, policies, guardrails, budget rules, settings, consumption) on the existing gorilla router, delegating to `agentnetwork.Manager`. Handlers are thin — they translate `api.*` ↔ `types.*`, validate shape, forward. RBAC and event emission stay inside the manager (`manager.go:680-682`). + +South side: `ProxyServiceServer` (`proxy.go`) learns to (a) ship synth services to a proxy on initial snapshot, (b) resolve agent-network domains in `getServiceByDomain` for OIDC/session/tunnel-peer flows, (c) gate LLM requests via `CheckLLMPolicyLimits` + `RecordLLMUsage`, (d) preserve `Private` through `shallowCloneMapping` so per-proxy live updates don't silently flip services public. The network_map controller prepends synth services to `account.Services` on every per-peer compute; `accesslogentry.go` gains an indexed `AgentNetwork` column so the dashboard can filter cheaply. + +## Files + +| Path | Role | +| ---- | ---- | +| `handlers/agentnetwork/providers_handler.go` | Catalog + provider CRUD + central `AddEndpoints` | +| `handlers/agentnetwork/policies_handler.go` | Policy CRUD + shared `validatePolicy*` | +| `handlers/agentnetwork/guardrails_handler.go` | Guardrail CRUD | +| `handlers/agentnetwork/budget_handler.go` | Account-level budget rule CRUD | +| `handlers/agentnetwork/settings_handler.go` | GET (200+`null` if unbootstrapped) + PUT toggles | +| `handlers/agentnetwork/consumption_handler.go` | Read-only consumption rows | +| `handlers/agentnetwork/handlers_test.go` | Real-store fixture; wire round-trip + validation | +| `handlers/agentnetwork/budget_handler_test.go` | Budget-rule + settings toggles | +| `server/http/handler.go` | New `agentNetworkManager` arg; conditional `AddEndpoints` | +| `server/permissions/modules/module.go` | New `AgentNetwork` module key | +| `internals/server/boot.go` | Wires synthesiser adapter + limits service into proxy server | +| `internals/server/modules.go` | `AgentNetworkManager()` lazy-create node | +| `internals/controllers/network_map/controller/controller.go` | `injectAllProxyPolicies` replaces 4 `InjectProxyPolicies` calls | +| `internals/controllers/network_map/controller/repository.go` | `SynthesizeAgentNetworkServices` repo method | +| `internals/modules/reverseproxy/service/service.go` | `MiddlewareConfig`, capture limits, `AgentNetwork`, `DisableAccessLog` + proto | +| `internals/modules/reverseproxy/accesslogs/accesslogentry.go` | Indexed `AgentNetwork bool` from proto | +| `internals/shared/grpc/proxy.go` | Synth wiring, 2 RPCs, domain fallback, `Private` in clone | +| `internals/shared/grpc/proxy_clone_test.go` | Locks every `ProxyMapping` field minus `AuthToken` | +| `server/activity/codes.go` | 13 new activity codes (125-137) | + +## HTTP routes added + +All routes inherit the platform's auth middleware. Perms enforced inside `agentnetwork.Manager.requirePermission` (`manager.go:680-682`) on `modules.AgentNetwork`. Permission column shows the `op` passed to `requirePermission` — read = `Read`, etc. + +| Method | Path | Perm | Handler | +| ------ | ---- | ---- | ------- | +| GET | `/agent-network/catalog/providers` | authn only | `providers_handler.go:43` | +| GET | `/agent-network/providers` | read | `providers_handler.go:57` | +| POST | `/agent-network/providers` | create | `providers_handler.go:97` | +| GET | `/agent-network/providers/{providerId}` | read | `providers_handler.go:77` | +| PUT | `/agent-network/providers/{providerId}` | update | `providers_handler.go:132` | +| DELETE | `/agent-network/providers/{providerId}` | delete | `providers_handler.go:172` | +| GET | `/agent-network/policies` | read | `policies_handler.go:32` | +| POST | `/agent-network/policies` | create | `policies_handler.go:72` | +| GET | `/agent-network/policies/{policyId}` | read | `policies_handler.go:52` | +| PUT | `/agent-network/policies/{policyId}` | update | `policies_handler.go:102` | +| DELETE | `/agent-network/policies/{policyId}` | delete | `policies_handler.go:142` | +| GET | `/agent-network/guardrails` | read | `guardrails_handler.go:25` | +| POST | `/agent-network/guardrails` | create | `guardrails_handler.go:65` | +| GET | `/agent-network/guardrails/{guardrailId}` | read | `guardrails_handler.go:45` | +| PUT | `/agent-network/guardrails/{guardrailId}` | update | `guardrails_handler.go:95` | +| DELETE | `/agent-network/guardrails/{guardrailId}` | delete | `guardrails_handler.go:135` | +| GET | `/agent-network/budget-rules` | read | `budget_handler.go:24` | +| POST | `/agent-network/budget-rules` | create | `budget_handler.go:64` | +| GET | `/agent-network/budget-rules/{ruleId}` | read | `budget_handler.go:44` | +| PUT | `/agent-network/budget-rules/{ruleId}` | update | `budget_handler.go:95` | +| DELETE | `/agent-network/budget-rules/{ruleId}` | delete | `budget_handler.go:135` | +| GET | `/agent-network/settings` | read | `settings_handler.go:53` (200+`null` if no row) | +| PUT | `/agent-network/settings` | update | `settings_handler.go:27` | +| GET | `/agent-network/consumption` | read | `consumption_handler.go:21` | + +## gRPC RPCs added (or modified) + +| RPC | Direction | Trigger | +| --- | --------- | ------- | +| `CheckLLMPolicyLimits` | proxy→mgmt unary | Pre-flight gate; returns allow/deny, selected policy, attribution group, window, deny code+reason (`proxy.go:259-301`). `Unimplemented` when limits service is nil. | +| `RecordLLMUsage` | proxy→mgmt unary | Post-flight write of tokens+cost against policy-window dimensions + every applicable account budget rule (`proxy.go:303-349`). `window_seconds==0` ⇒ no policy cap, only account fan-out runs. | +| `GetMappingUpdate`/`SendServiceUpdate` (stream) | mgmt→proxy | Snapshot (`proxy.go:752-780`) now appends `SynthesizeServicesForCluster`. Live updates use `SendServiceUpdateToCluster` + `shallowCloneMapping`. | + +## Architecture & flow + +### HTTP request lifecycle + +```mermaid +sequenceDiagram + participant DB as Dashboard + participant R as gorilla.Router (/api) + participant H as handler (agentnetwork) + participant M as agentnetwork.Manager + participant S as store.Store + participant AM as accountManager (StoreEvent) + + DB->>R: POST /api/agent-network/providers + R->>H: createProvider (auth mw sets UserAuth) + H->>H: GetUserAuthFromContext + validate(req) + H->>M: CreateProvider(userID, provider, bootstrapCluster) + M->>M: requirePermission(AgentNetwork, Create) + M->>S: SaveAgentNetworkProvider + M->>AM: StoreEvent(AgentNetworkProviderCreated) + M-->>H: created provider + H-->>DB: 200 + api.AgentNetworkProvider JSON +``` + +### Synth-service delivery via gRPC + +```mermaid +sequenceDiagram + participant P as Proxy + participant G as ProxyServiceServer + participant SM as service.Manager (persisted) + participant SA as synthesizerAdapter + participant AN as SynthesizeServicesForCluster + participant ST as store.Store + + Note over P,G: Initial snapshot + P->>G: GetMappingUpdate (stream open) + G->>SM: GetServicesForCluster(conn.address) + SM-->>G: persisted []*Service + G->>SA: SynthesizeServicesForCluster(conn.address) + SA->>AN: SynthesizeServicesForCluster(store, clusterAddr) + AN->>ST: walk every account; read providers/policies/settings + AN-->>SA: in-memory []*Service + SA-->>G: []*Service + G->>P: response (persisted + synth) + + Note over G,P: Per-request live update + G->>G: SendServiceUpdateToCluster(update, clusterAddr) + G->>G: shallowCloneMapping(update) %% Private MUST survive + G->>P: response with single mapping +``` + +End-to-end: HTTP write persists rows and emits an activity event; the manager then triggers `proxyController.SendServiceUpdate` so proxies re-render. **The snapshot path is the only one that calls into the synthesiser** — on stream open it pulls persisted services then appends synth services for the cluster. Synth services are never persisted. For OIDC/session/tunnel-peer flows, `getServiceByDomain` falls back to `SynthesizeServicesForCluster(clusterFromDomain(domain))` when persisted lookup misses (`proxy.go:1763-1793`). The network_map contribution is orthogonal: per-peer compute prepends the same synth services to `account.Services` before `InjectProxyPolicies`. + +## Permissions model added + +- `permissions/modules/module.go:22` adds `AgentNetwork Module = "agent_network"`, registered in `All` (`module.go:42`). Standard `operations.{Read,Create,Update,Delete}` matrix. +- Handlers don't call `permissionsManager` directly — they extract `UserAuth` and delegate to `agentnetwork.Manager`, which gates every mutation through `requirePermission` (`manager.go:168, 308, 549`, etc.). Confirm your role-set provider has `agent_network` rows for owner/admin/user/billing-admin before merging. +- `getCatalogProviders` (`providers_handler.go:43`) intentionally skips RBAC — catalog is global static data. + +## Activity codes added + +`activity/codes.go:244-274` adds Activities 125-137 + string/code mappings (`codes.go:428-444`), following `..` (e.g., `agent_network.provider.create`). Audit-log exporters / SIEM forwarders need to know the new codes. + +## Invariants + +- **Synth services are never persisted.** Snapshot appends after `serviceManager.GetServicesForCluster` (`proxy.go:761-770`); network_map prepends before `InjectProxyPolicies` (`controller.go:117-126`). +- **`shallowCloneMapping` must round-trip every `ProxyMapping` field except `AuthToken`** — `proxy_clone_test.go:50-58` enforces via `gproto.Equal`. The bug it guards: a missing `Private` made every MODIFIED arrive `private=false`, the proxy skipped `ValidateTunnelPeer`, `UserGroups` stayed empty, `llm_router` denied `no_authorised_provider`; a restart "fixed" it because the snapshot uses the original mapping. +- **Limit-window floor is 60s** (`policies_handler.go:189-220`); enabled cap with both per-group and per-user at zero is rejected. Budget rules reuse the same validator (`budget_handler.go:170`). +- **Manager is optional at boot.** `NewAPIHandler` registers routes only when non-nil (`handler.go:129`); `ProxyServiceServer` returns `Unimplemented` from both RPCs when limits service is unwired (`proxy.go:262-265, 306-309`). +- **Settings GET on an unbootstrapped account returns 200 + `null`** (`settings_handler.go:65-72`) — not 404. + +## Things to scrutinize + +### Correctness +- **`injectAllProxyPolicies` runs on every per-peer compute**: `controller.go:163, 309, 415, 681`. `sendUpdateAccountPeers` is the target of the buffered fan-out — synth runs once per debounced account-update tick **and** once per direct `UpdateAccountPeer`. Cost is O(providers + policies × users-per-group) per account under `LockingStrengthNone`. No per-account synth cache — verify it fits the buffer interval for your largest tenant. +- **`clusterFromDomain` strips at the first `.`** (`proxy.go:1784-1792`). A zero-dot domain returns `""` and the synth call walks every account. Confirm no path reaches this with a malformed/internal domain. +- **Account-budget `RecordConsumption` fans out even when `window_seconds == 0`** (`proxy.go:341-348`) — intentional. Verify the proxy never sends `RecordLLMUsage` for a request that wasn't actually allowed. + +### Security +- Every handler extracts `UserAuth` via `nbcontext.GetUserAuthFromContext` before any work. Routes live behind the standard `/api` mux; bypass list is not extended. +- `CheckLLMPolicyLimits` / `RecordLLMUsage` ride the existing **proxy → mgmt** gRPC connection auth. No additional token check inside the RPCs — they trust the connection. Confirm the proxy-side token-verification interceptor in this package gates both. +- `RecordLLMUsage` only validates `account_id != ""` (`proxy.go:317-319`). A compromised proxy can attribute cost to any account in its cluster — was already true for prior RPCs but is louder now that data drives denials. + +### Concurrency +- `SetAgentNetworkSynthesizer` / `SetAgentNetworkLimitsService` write under `s.mu.Lock`; read paths copy the interface under read lock (`proxy.go:236-247, 260-263, 304-307`). Same pattern as existing `serviceManager`/`proxyController` setters. +- Manager writes use `LockingStrengthUpdate`; synth reads use `LockingStrengthNone` — read-after-write via the proxy snapshot can observe a stale view by up to one fan-out tick. +- Network_map controller is single-threaded per account; cross-account is parallel. + +### Backward compatibility +- `proxy_clone_test.go` is the regression net; any new `ProxyMapping` field must be cloned or explicitly nulled in the test. +- `AccessLogEntry` adds indexed `AgentNetwork bool` — implicit AutoMigrate; deploy story must handle table-rewrite cost on high-volume access-log tables. +- `TargetOptions` gains seven `omitempty` JSON fields (`service.go:69-94`); on-wire shape stays compatible. `targetOptionsToProto` tests all fields when deciding nil (`service.go:551-556`). +- `NewAPIHandler` signature changes — every caller must pass `agentNetworkManager`; `nil` is supported. + +### Observability +- 13 new activity codes via `accountManager.StoreEvent` in the manager — confirm dashboard's audit-log UI maps them. +- `AccessLogEntry.AgentNetwork` is indexed for the dashboard's agent-network log filter. +- New RPCs log at error level on store/selector failures (`proxy.go:284, 327, 332, 348`). Snapshot synth failures degrade to warnings — stream is not aborted (`proxy.go:765`). + +## Test coverage + +| Test | Locks down | +| ---- | ---------- | +| `handlers_test.go::TestPolicyHandler_WindowSecondsRoundTrip` | GET carries `window_seconds`; legacy `window_hours`/`window_days` absent. | +| `handlers_test.go::TestPolicyHandler_RejectsSubMinuteWindow` | POST `<60s` returns 4xx. | +| `handlers_test.go::TestConsumptionHandler_EmptyAccountReturnsArray` | `/consumption` returns `[]` — never null. | +| `handlers_test.go::TestConsumptionHandler_PopulatedAccountListsRows` | RecordConsumption×2 surfaces both with correct tokens/cost/window. | +| `budget_handler_test.go::TestBudgetRuleHandler_RoundTrip` | Targets + PolicyLimits shape round-trip. | +| `budget_handler_test.go::TestBudgetRuleHandler_ListReturnsArray` | Empty-list shape. | +| `budget_handler_test.go::TestBudgetRuleHandler_{RejectsMissingName,RejectsSubMinuteWindow}` | Validation rejections are 4xx. | +| `budget_handler_test.go::TestSettingsHandler_GetExposesCollectionToggles` | All four toggles + computed `Endpoint`. | +| `proxy_clone_test.go::TestShallowCloneMapping_PreservesAllFieldsExceptAuthToken` | Future-proofs clone; every field round-trips, `AuthToken` dropped. | + +Handler tests use a real sqlite store + real manager + always-allow permissions mock (`handlers_test.go:53-75`). Create/update/delete success paths flow through `accountManager.StoreEvent` which the fixture doesn't wire — covered by manager-level no-mock tests outside this module. + +## Known limitations / explicit non-goals + +- No pagination on any list endpoint; no bulk endpoints. +- Synth result is not cached — every snapshot and every per-peer compute repeats the store walk. +- `getSettings` returning `200 + null` is a deliberate dashboard concession. +- No rate-limiting beyond the global `/api` rate limiter. + +## Cross-references + +- Upstream: [shared/api](10-shared-api.md), [management/agentnetwork](21-management-agentnetwork.md), [management/store](20-management-store.md) +- Downstream: [proxy/runtime](33-proxy-runtime.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/30-proxy-middleware-framework.md b/docs/agent-networks/modules/30-proxy-middleware-framework.md new file mode 100644 index 000000000..39322fdce --- /dev/null +++ b/docs/agent-networks/modules/30-proxy-middleware-framework.md @@ -0,0 +1,215 @@ +# proxy/middleware-framework — generic plugin system + +> **Risk level:** **High** — every proxied request transits this chain. Budget exhaustion, panic recovery, or chain-close bugs hit the hot path for all targets, not just agent-network ones. +> **Backward-compat impact:** Additive at the proxy. The `middleware` and `bodytap` packages are new (`proxy/internal/middleware/middleware.go:1`, `proxy/internal/middleware/bodytap/request.go:13`); existing proxy targets keep working until a chain is bound to them via `Manager.Rebuild`. + +This module is the **framework only** — no LLM/agent-network domain knowledge is required, since every example built into it is generic. + +## Module boundary + +This module is the **framework only**: slots, chains, registry, dispatcher, accumulator, body-tap, output filters. No middleware *implementation* lives here — those land in `proxy/internal/middleware/builtin/*` (covered in module 31). The package contract is: + +1. The proxy hands a `Manager` to its config-apply path. The synth pushes per-path `PathTargetBinding` lists (`proxy/internal/middleware/manager.go:26`) into `Manager.Rebuild`, which resolves each spec via the `Registry`/`Resolver` (`proxy/internal/middleware/registry.go:81-121`) and produces an immutable `Chain` keyed by `serviceID|pathID` (`proxy/internal/middleware/manager.go:410-412`). +2. The reverse-proxy handler captures the request body via `bodytap.CaptureRequest`, calls `Chain.RunRequest`, applies returned mutations (already filtered by `chain.applyMutations`), forwards to the upstream behind a `bodytap.CapturingResponseWriter`, then calls `Chain.RunResponse` and `Chain.RunTerminal`. +3. Middlewares are inert plugins that receive a deep-cloned `Input` and return an `Output` whose decision/mutations are clamped by the dispatcher's `filterOutput` (`proxy/internal/middleware/dispatcher.go:149-172`). + +Everything that crosses the framework boundary in either direction is value-typed and deep-copied — middlewares cannot mutate the live request directly, and the framework cannot inadvertently leak middleware-owned slices into the request hot path. + +## Files + +| Path | Role | +| ---- | ---- | +| `proxy/internal/middleware/middleware.go` | `Middleware` + `Factory` interfaces. | +| `proxy/internal/middleware/types.go` | `Slot`, `FailMode`, `Decision`, all limit constants, `Input`/`Output`/`Mutations`/`UpstreamRewrite`/`AuthHeader` value types. | +| `proxy/internal/middleware/spec.go` | Apply-time `Spec` (validated wire shape + runtime-injected fields) and `Clone`. | +| `proxy/internal/middleware/registry.go` | `Registry` (factory map, RWMutex) and `Resolver` (Spec → bound `Middleware`). | +| `proxy/internal/middleware/manager.go` | `Manager`, `chainTable` reverse index, `Rebuild`/`Invalidate*`, async chain close. | +| `proxy/internal/middleware/chain.go` | `Chain.RunRequest`/`RunResponse`/`RunTerminal`, mutation gating, `cloneInputFor`. | +| `proxy/internal/middleware/chain_test.go` | Metadata threading, LIFO response order, rewrite gating, UserGroups propagation, terminal accumulation. | +| `proxy/internal/middleware/dispatcher.go` | Timeout/panic recovery, fail-mode, error classification, `filterOutput`. | +| `proxy/internal/middleware/decision.go` | `RenderDenyResponse`, deny-code regex, status clamp. | +| `proxy/internal/middleware/headerpolicy.go` | Compile-in header denylist + `FilterHeaderMutations`. | +| `proxy/internal/middleware/bodypolicy.go` | `ValidateBodyReplace` / `ApplyBodyReplace` smuggling guards. | +| `proxy/internal/middleware/keys.go` | Metadata key namespace constants. | +| `proxy/internal/middleware/metadata.go` | `Accumulator` — allowlist, per-mw/per-request byte caps, redaction. | +| `proxy/internal/middleware/metrics.go` | OTel instrument bundle (`proxy.middleware.*`). | +| `proxy/internal/middleware/redaction.go` | `Scan` — PEM/JWT/AWS/bearer/Luhn-validated CC patterns. | +| `proxy/internal/middleware/bodytap/request.go` | Capture + replay reader, `Budget` semaphore, bypass reason codes. | +| `proxy/internal/middleware/bodytap/response.go` | `CapturingResponseWriter` (tee with `PassthroughWriter` for Flusher/Hijacker preservation). | + +## Slot model + +Three slots, declared per-middleware exactly once (`proxy/internal/middleware/types.go:27-41`): + +- **`SlotOnRequest`** (`Slot=1`) — runs **before** the upstream call, in registration order. May `DecisionDeny`, may emit `Mutations` (header add/remove, body replace, `UpstreamRewrite`) when both `Spec.CanMutate` and `Middleware.MutationsSupported()` are true. May emit metadata. Each middleware in the slot sees metadata that earlier ones in the same slot just emitted (`proxy/internal/middleware/chain.go:144-178`) — this is how the framework gives middlewares an intra-slot side channel without a global bag. +- **`SlotOnResponse`** (`Slot=2`) — runs **after** the upstream returns, in **reverse** registration order. Cannot deny (clamped in `dispatcher.filterOutput`, `proxy/internal/middleware/dispatcher.go:153-157`). May still mutate response headers in principle, but the current chain only forwards `RewriteUpstream` from on_request, so on_response mutations are observe-only in practice. Threads the same per-slot metadata view as on_request. +- **`SlotTerminal`** (`Slot=3`) — runs **after** every on_response middleware has emitted, in registration order. Sees the full accumulated bag plus prior terminal emissions (`chain.go:221-245`). Cannot deny, cannot mutate (`dispatcher.go:168-170`). Designed for sinks (access log, metrics push, audit emitter). + +Splitting a feature across slots (e.g. "parse on the way out, ship on terminal") is the explicit architectural choice — `types.go:7-15` and `types.go:22-25` make it clear no middleware participates in more than one slot. + +## Architecture & flow + +### Chain dispatch + +```mermaid +sequenceDiagram + autonumber + participant H as proxy HTTP handler + participant BT as bodytap.CaptureRequest + participant CH as Chain + participant DI as Dispatcher + participant MW as Middleware (per slot) + participant US as Upstream + participant CW as CapturingResponseWriter + + H->>BT: CaptureRequest(r, cfg, budget) + BT-->>H: body[], truncated, release() + H->>CH: RunRequest(ctx, r, Input, Accumulator) + loop on_request, registration order + CH->>CH: cloneInputFor(in, OnRequest) + CH->>DI: Invoke(ctx, spec, mw, call) + DI->>MW: mw.Invoke(callCtx, in) + MW-->>DI: Output{decision, metadata, mutations?} + DI->>DI: filterOutput (clamp deny, gate mutations) + DI-->>CH: filtered Output + CH->>CH: Accumulator.Emit (allowlist + caps + redact) + alt DecisionDeny + CH-->>H: denied, merged, rewrite + else allow + CH->>CH: applyMutations(r, m) and capture rewrite + end + end + CH-->>H: nil, merged, rewrite + H->>US: ProxyRequest (with rewrite/mutations applied) + US-->>CW: bytes (streamed, tee'd into cap-bounded buf) + CW-->>H: passthrough complete + H->>CH: RunResponse(ctx, Input{RespBody:CW.Body(),...}, acc) + loop on_response, REVERSE order (LIFO) + CH->>DI: Invoke (same wrappers) + end + H->>CH: RunTerminal(ctx, Input{Metadata:full bag}, acc) + H->>BT: release() + CW.Release() +``` + +### Body-tap mechanics (request + response) + +```mermaid +flowchart LR + subgraph req[Request capture — bodytap.CaptureRequest] + R0[r.Body] --> R1{cfg.MaxRequestBytes > 0?\nUpgrade absent?\nContent-Type allowed?\nCL <= cap?} + R1 -- no --> R2[bypass = reason\nbody = nil\nr.Body untouched] + R1 -- yes --> R3[Budget.Acquire(cap)] + R3 -- denied --> R4[bypass=BypassBudget] + R3 -- ok --> R5[io.LimitReader(r.Body, cap+1)\nio.ReadAll] + R5 --> R6{len > cap?} + R6 -- truncated --> R7[viewable = buf[:cap]\nr.Body = replayReadCloser{buf, tail}] + R6 -- whole --> R8[r.Body = NopCloser(bytes.Reader(buf))\nclose original] + R7 --> R9[(release captured\nbudget on req end)] + R8 --> R9 + end + + subgraph resp[Response capture — CapturingResponseWriter] + W0[client] -.-> CW[Write(p)] + CW --> P1[PassthroughWriter.Write(p)\n— bytes leave to client first] + P1 --> P2{!stopped?} + P2 -- yes --> P3{remaining = cap - buf.Len()} + P3 --> P4[buf.Write(p[:take])\nset truncated if take P5[silent drop into the tee\n(client write already done)] + end +``` + +The body-tap is the highest-leak-risk surface in this module; three details matter: + +1. **Request capture is "read-and-replay", not "read-and-forward".** `CaptureRequest` always swaps `r.Body` for either a `bytes.Reader` (whole body fit) or a `replayReadCloser` that replays the captured prefix then drains the remaining stream from the original body (`bodytap/request.go:178-201`). This means the **upstream still sees the full body even when the tap truncates**. The original `r.Body` is **not** closed in the truncated branch — `replayReadCloser.Close()` only closes the tail (`bodytap/request.go:199-201`), which is the same reader, so close once on request end is correct, but reviewers should confirm the upstream proxy always reads to EOF (otherwise the tail is leaked). +2. **Response capture is a write-through tee.** `CapturingResponseWriter.Write` forwards to the underlying writer **first** (`bodytap/response.go:116-117`), then tees into `buf` under its own mutex. Client never blocks on the tee. `Flusher`/`Hijacker` are preserved via the embedded `responsewriter.PassthroughWriter`. SSE/chunked streams flow through untouched; middlewares only see the bounded prefix. +3. **Budget is a single shared semaphore.** `Manager` constructs one `bodytap.Budget` at startup (`manager.go:138-144`, default `256 MiB` from `bodytap/request.go:39`). Every capture pre-acquires its full `MaxRequestBytes` / `MaxResponseBytes` from the budget regardless of actual body size; that prevents a flood of small captures from collectively exceeding the cap, but it also means a misconfigured `MaxRequestBytes = 1 MiB` with 256 concurrent requests already exhausts the default budget. Reviewers should sanity-check the operator-facing defaults that ship with synth-service. + +The framework explicitly aborts capture (and increments `proxy.middleware.capture_bypass_total`) before reading the first byte when `Upgrade`/`Connection: upgrade` is set (`bodytap/request.go:120-125`), when the content-type isn't in the allowlist (`bodytap/request.go:126-128`), or when the advertised `Content-Length` already exceeds the cap (`bodytap/request.go:131-133`). This is the right place to make sure WebSocket upgrades and large file uploads never reach the buffer. + +## Public contracts + +- **`Middleware` interface** (`middleware.go:14-36`): `ID()`, `Version()`, `Slot()`, `AcceptedContentTypes()`, `MetadataKeys()`, `MutationsSupported()`, `Invoke(ctx, *Input) (*Output, error)`, `Close()`. `MetadataKeys()` is the **closed set** the middleware is allowed to emit — the accumulator drops anything outside it (`metadata.go:71-75`). `Close` must be idempotent (called even when `Invoke` was never reached). +- **`Factory` interface** (`middleware.go:44-47`): `ID()`, `New(rawConfig []byte) (Middleware, error)`. `RawConfig` is opaque JSON bytes on the wire (`spec.go:6-12`); each factory owns its own typed config. +- **`Decision` type** (`types.go:59-69`): `Allow=0`, `Deny=1`, `Passthrough=2`. Default-zero is permissive — important because every middleware that omits `Decision` gets `Allow`. Dispatcher clamps `Deny` to `Passthrough` outside `SlotOnRequest` (`dispatcher.go:153-157`). +- **`Mutations`** (`types.go:196-201`): `HeadersAdd`/`HeadersRemove` (filtered through `headerpolicy.go`), `BodyReplace` (gated through `bodypolicy.go`), and `RewriteUpstream`. `RewriteUpstream` is **last-write-wins** within the on_request slot (`chain.go:170-172`, locked down by `TestChain_RunRequest_LatestRewriteWins`). +- **Metadata propagation keys** (`keys.go`): all keys live in a single file and follow `^[a-z][a-z0-9_-]*(\.[a-z0-9_-]*)+$` (`metadata.go:8`). Framework-injected error tagging uses `mw..error_kind` (`keys.go:81`) so operators can distinguish framework-emitted entries from middleware-emitted ones. + +## Invariants + +- **Per-request context isolation.** `cloneInputFor` deep-copies every mutable field (`Headers`, `RespHeaders`, `Metadata`, `Body`, `RespBody`, `UserGroups`, `UserGroupNames`) before each invocation (`chain.go:286-308`). A misbehaving middleware that mutates `in.Headers` only corrupts its own copy. +- **Body-tap bounded by capture limit.** Request side uses `io.LimitReader(r.Body, limit+1)` (`bodytap/request.go:152`) — the `+1` is how the code detects truncation (`bodytap/request.go:160`); the surfaced buffer is sliced back down to `limit`. Response side stops teeing once `buf.Len() >= cap` (`bodytap/response.go:121-133`). Neither side can grow the buffer past the configured cap. +- **Headers/body redaction order.** Accumulator runs `Scan(value)` **before** counting cost (`metadata.go:81-82`), so the byte budgets are computed against post-redaction sizes. `Scan` order is PEM → JWT → AWS key → bearer → Luhn-validated CC (`redaction.go:25-51`) — the comment block in `redaction.go:8-13` is explicit that this is best-effort, not DLP. +- **No middleware can starve the chain.** Every invocation runs inside `context.WithTimeout(ctx, clampTimeout(spec.Timeout))` in a separate goroutine (`dispatcher.go:51-94`), with the deadline race-`select`ed against the result channel. A blocked middleware fires the timeout path, gets fail-mode'd, and `IncError(kind=timeout)`. Timeouts are clamped to `[10ms, 5s]` (`types.go:80-86`, `dispatcher.go:174-185`). +- **Panic recovery.** `recover()` captures the panic, logs only the type + a 4 KiB stack prefix (no panic value — avoids leaking secrets the middleware was processing), and produces a `panicError` that flows through fail-mode (`dispatcher.go:64-76`). +- **Chain immutability + atomic swap.** `chainTable` is cloned on every `Rebuild`/`Invalidate*` and swapped via `atomic.Pointer` (`manager.go:44-69`, `manager.go:221-300`). Readers (`ChainFor`) are lock-free; writers serialise on `writeMu`. The retired chain is `Close`-d in a background goroutine bounded by `chainCloseTimeout = 2 * MaxTimeout` (`manager.go:21-22`, `manager.go:326-346`), so in-flight invocations finish on the old chain after the swap. + +## Things to scrutinize + +### Correctness + +- **Chain ordering deterministic from synth output?** `Manager.buildChain` iterates `b.Specs` in slice order and appends to `bound` (`manager.go:366-391`); `NewChain` then partitions by slot but **preserves slice order within each slot** (`chain.go:50-60`). So order on the wire = order observed at runtime. Synth must therefore emit specs in the intended execution order — there is no per-spec `Priority` field. Worth flagging. +- **Decision short-circuit semantics.** `RunRequest` returns immediately on `DecisionDeny` (`chain.go:164-167`) **with the metadata accumulated so far** plus the `denied.Metadata`. Callers that ignore `merged` on deny will lose framework-injected `mw..error_kind` entries. The proxy runtime is the only caller; confirm it always feeds `merged` into the access log on the deny path as well. +- **`UpstreamRewrite` `AuthHeader` bypass** (`types.go:218-235`). The `AuthHeader`/`StripHeaders` fields *intentionally* bypass the header denylist on the basis that the proxy itself rewrites auth. The denylist still blocks middleware-emitted `HeadersAdd: Authorization=...`. This is a delicate carve-out — review the runtime consumer to confirm only the trusted upstream-build path unpacks `AuthHeader`, never the generic `applyMutations` loop. +- **`replayReadCloser.Close` only closes the tail** (`bodytap/request.go:199-201`). The replay buffer doesn't own a resource, so this is correct, but it conflates "replay finished" with "underlying body closed". If a caller `Close()`s without reading to EOF, the original body is closed but the captured prefix is lost; harmless for the proxy path (upstream always reads to EOF) but worth a doc-comment. + +### Security + +- **Body-tap memory bounds.** Discussed above — bounded by `MaxBodyCapBytes = 1 MiB` per direction (`types.go:77`) and the shared `Budget` (default 256 MiB). The concerning case is the **deep-copy in `cloneInputFor`** (`chain.go:300-306`): every middleware invocation gets its **own copy** of `Body` and `RespBody`. A chain of N middlewares with a 1 MiB body allocates N MiB of transient bytes per request. With `MaxMiddlewaresPerChain = 16` (`types.go:103`) that's up to 16 MiB extra per in-flight request. Worth pricing into the budget model. +- **Header redaction completeness.** `denyHeaders` (`headerpolicy.go:5-17`) covers the auth/forwarding family and framing (`Content-Length`, `Transfer-Encoding`, `Trailer`). `denyHeaderPrefixes` covers `X-Authenticated-*`, `X-Forwarded-*`, `X-Remote-*`, `X-NetBird-*`. Notably absent: `Range`, `If-Match`/`If-None-Match` (mutation could cause cache poisoning), `Origin`/`Referer`. Not necessarily wrong, but worth a deliberate decision. +- **Metadata key collisions across middlewares.** The accumulator has no cross-middleware uniqueness check; two middlewares with the same key in their allowlist can both emit it, and both copies land in `merged` (`metadata.go:51-99`). Downstream consumers must tolerate duplicates. Worth documenting. +- **Deny rendering.** `RenderDenyResponse` only allows codes matching `^[a-z][a-z0-9._-]{0,63}$` (`decision.go:9`), redacts/truncates message + detail values, caps `Details` at 8 entries (`decision.go:42-50`), clamps status to `[400,499]\{401}` (`decision.go:65-73`). The deny body type is fixed; middlewares cannot inject arbitrary JSON. + +### Concurrency + +- **Per-request state vs shared state in factories.** Each `Factory.New` is called once per chain build; the returned `Middleware` instance is **shared across all requests** for that chain. `Invoke` must be reentrant. The framework does not enforce this — a buggy middleware that holds per-call state on the struct will silently race. Suggest a `// Invoke must be safe for concurrent use` doc on the interface. +- **`chainTable` clone-on-write** is correct, but `addChain`/`removeChain` mutate the *cloned* table before the swap (`manager.go:71-108`), and they're called under `writeMu`. Readers only ever see the post-swap pointer. Good. +- **`Chain.inflight` WaitGroup**. `Run*` does `Add(1)`/`Done()` (`chain.go:142-143`, `chain.go:194-195`, `chain.go:225-226`); `Close` waits on it bounded by ctx (`chain.go:75-85`). One concern: a *new* `RunRequest` can `Add(1)` *after* `Close` started waiting if the caller still holds a stale chain pointer. `WaitGroup` does not panic on this if the count was already > 0 at `Wait` time, but it does panic if `Add` happens after `Wait` returns and another `Wait` runs. `Close` is documented one-shot, so single-`Wait` is fine, but callers must drop the chain reference before calling `Close`. Worth a code comment near `Close`. +- **Goroutine leaks.** `Dispatcher.Invoke` spawns one goroutine per call and *always* writes to a buffered (cap=1) channel (`dispatcher.go:62-76`), so even if the timeout fires the goroutine completes its send and exits. No leak. +- **`closeChainsAsync`** detaches retired chains into a goroutine (`manager.go:326-346`). If `Manager` is never GC'd this is fine, but there's no shutdown hook to wait on outstanding closes. Reviewers should confirm the proxy shutdown path explicitly drains in-flight requests before tearing down `Manager`, or accept that the last chain-close round may be cut short on exit. + +### Performance + +- **Allocations per request.** `cloneInputFor` allocates new slices for `Headers`, `RespHeaders`, `Metadata`, `Body`, `RespBody`, `UserGroups`, `UserGroupNames` — once per middleware per request. For a typical 5-middleware chain on a 1 KiB body that's ~10 small slice allocs plus one `Body` copy each. Not a hot-path crisis, but `sync.Pool` for the per-call `Input` would be a natural follow-up. +- **Accumulator allocates a fresh `allowSet` per `Emit` call** (`metadata.go:55-58`). One per middleware per slot pass = up to 48 per request. Cheap, but worth noting. +- **Regex cost.** `Scan` runs five regex passes on every accepted metadata value (`redaction.go:25-51`). Bounded by `MaxMetadataValueBytes = 4 KiB` so worst case is small. + +### Observability + +- **Per-middleware metrics.** `proxy.middleware.requests_total{middleware,target_id,outcome}` (`metrics.go:34-41`), `duration_ms`, `invocations_total`, `errors_total{kind}`, `metadata_rejected_total{reason}`, `header_mutation_blocked_total{header}`, `capture_bypass_total{reason}`. Comprehensive surface; operators can alert on `errors_total{kind=panic}` and `errors_total{kind=timeout}` separately. **Latency histogram is in milliseconds with default OTel buckets** — for a 10ms–5s timeout range default buckets cover OK, but a custom bucket set centred on 1–500ms would resolve the agent-network response-parser tail better. +- **Decision logs.** Panic logs (`dispatcher.go:69`) include `request_id`, type, and stack but not the panic value (safe). `Chain.Close` logs middleware-close errors at debug (`chain.go:91`). `applyMutations` logs body-replace rejections at warn (`chain.go:278`). No log on the deny path itself — by design, since the access-log terminal middleware is expected to record outcomes. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `proxy/internal/middleware/chain_test.go:77` | `RunRequest` threads metadata across on_request middlewares (regression for the "later mw can't see earlier mw's emissions" bug). | +| `chain_test.go:110` | `RunResponse` reverse-order threading. | +| `chain_test.go:142` | `cost_meter`-shaped scenario: response_parser registered after cost_meter still emits *before* cost_meter sees the bag (guards the `cost.skipped=missing_tokens` regression). | +| `chain_test.go:178` | `UpstreamRewrite` last-write-wins. | +| `chain_test.go:206` | No middleware emits → nil rewrite. | +| `chain_test.go:224` | Rewrite filtered when `CanMutate=false`. | +| `chain_test.go:245` | `Input.UserGroups` propagates verbatim through `cloneInputFor`. | +| `chain_test.go:304` | Terminal middlewares see the full accumulated bag + prior terminal emissions. | + +**Gaps** worth raising with the author: +- No direct test for `Dispatcher.Invoke` timeout / panic / fail-mode behaviour at the framework level (covered indirectly by built-in tests, but a unit test pinning `errors_total{kind=...}` labels would be cheap insurance). +- No test for `bodytap.CaptureRequest` truncated replay (the upstream-sees-full-body invariant is exactly the kind of thing a regression would silently break). +- No test for `Budget` exhaustion behaviour under concurrency. +- No test for `Manager.InvalidateMiddleware` + `LiveServiceCheck` race (the auth-revocation race the comment at `manager.go:33-38` calls out is the load-bearing reason for `LiveServiceCheck`). + +## Known limitations / explicit non-goals + +- **No middleware-to-middleware RPC.** Side-channel is metadata only. +- **No streaming body inspection.** Middlewares see a bounded prefix; SSE / chunked parsing happens against that prefix in the response middleware. +- **No per-spec priority.** Order is registration order in the spec slice. +- **No retry / circuit-breaker** on middleware errors. Fail-mode is binary (open/closed) and per-spec. +- **Mutations cannot rewrite the request URL path or query** — only `RewriteUpstream` can change scheme/host (+ optional path replacement, see `types.go:218-235`). +- **Redaction is best-effort.** Explicitly documented in `redaction.go:8-13`. Not a DLP solution. + +## Cross-references + +- Upstream wire shape: [../modules/10-shared-api.md](10-shared-api.md) (Spec/RawConfig encoding from management). +- Built-in middlewares using this framework: [../modules/31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md). +- Runtime wiring (where `Manager`, `Chain`, and `bodytap` are consumed by the HTTP handler): [../modules/33-proxy-runtime.md](33-proxy-runtime.md). +- End-to-end request flow including capture + chain dispatch: [../01-end-to-end-flows.md](../01-end-to-end-flows.md). +- Top-level architecture: [../00-overview.md](../00-overview.md). diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md new file mode 100644 index 000000000..904de6424 --- /dev/null +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -0,0 +1,365 @@ +# proxy/middleware-builtin — the LLM chain + +The registry-mounted middleware set the proxy executes on every agent-network +LLM request. The two highest-blast-radius areas are the **capture-pointer +semantics** and the **limit_check ⇒ limit_record** record-once invariant. + +Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK +adapters + pricing catalog this chain delegates to. + +--- + +## Module boundary + +This module is the registry-mounted middleware set the proxy executes on +every agent-network LLM request. Each sub-package registers itself via +`init()` +([builtin.go:32–34](../../../proxy/internal/middleware/builtin/builtin.go)); +the proxy server anonymous-imports the set +([all_test.go:11–19](../../../proxy/internal/middleware/builtin/all_test.go)) +so the registry is populated at boot. The chain is wired by the management +synthesiser and executed by the framework +(`proxy/internal/middleware/{chain,dispatcher,accumulator}.go` — both out +of scope). Everything here reads from / writes to one envelope: the +`middleware.KV` metadata bag plus `middleware.Mutations` for header/body +rewrites. + +## The 8 middlewares + +| Name | Slot | Inputs (metadata read) | Outputs (metadata written) | Side effects | +|---|---|---|---|---| +| `llm_request_parser` | OnRequest | `Input.{URL,Body,BodyTruncated}` | `llm.{provider,model,stream,request_prompt_raw,capture_truncated}` | none | +| `llm_router` | OnRequest | `llm.model`, `Input.{URL,UserGroups}` | `llm.{resolved_provider_id,authorising_groups}`, `llm_policy.{decision,reason}` | upstream rewrite + auth strip/inject | +| `llm_limit_check` | OnRequest | `llm.{resolved_provider_id,model}`, `Input.{AccountID,UserID,UserGroups}` | `llm.{selected_policy_id,attribution_group_id,attribution_window_seconds}`, `llm_policy.{decision,reason}` | gRPC `CheckLLMPolicyLimits` | +| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite | +| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) | +| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none | +| `cost_meter` | OnResponse | `llm.{provider,model}`, token buckets | `cost.usd_total` or `cost.skipped` | pricing lookup | +| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` | + +[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go) +locks the ID set; adding or removing one is a conscious extension. + +## Files + +| File | LOC | Notes | +|---|---:|---| +| `builtin.go` | 86 | Registry + `FactoryContext` (ctx, data dir, meter, logger, mgmt client) | +| `all_test.go` | 41 | Locks the 8-ID registry surface | +| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path | +| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating | +| `llm_router/*` | 385 / 84 / 586 | Three-pass route selection (model → groups → path-prefix) | +| `llm_limit_check/*` | 196 / 38 / 182 | Pre-flight `CheckLLMPolicyLimits` (2s, fail-open) | +| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders | +| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction | +| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit | +| `cost_meter/*` | 181 / 84 / 439 | Token → USD via `proxy/internal/llm/pricing` | +| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) | + +## Per-middleware + +### llm_request_parser + +Detects the LLM provider via `llm.DetectParser` (URL sniff) or by name via +`llm.ParserByName` when synthesiser stamps `provider_id` +([middleware.go:96–99](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +**Path-routed providers short-circuit first:** `parseVertexPath` and +`parseBedrockPath` ([middleware.go:85–94](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)) +pull the model + vendor out of the URL before parser selection runs — Vertex +from `/v1/projects/.../publishers/{pub}/models/{model}:{action}` (publisher → +vendor via `vertexPublisherVendor`), Bedrock from `/model/{id}/{action}` with +`normalizeBedrockModel` stripping the region prefix + version suffix. See +[50-path-routed-providers.md](./50-path-routed-providers.md) for the full path +grammar. For body-routed providers it decodes the body into `RequestFacts` +(model + stream) and extracts the prompt. On +`capture_prompt=true` (or absent — see capture-pointer semantics below) the +prompt is run through `llm_guardrail.RedactPII` when `redact_pii=true` and +truncated rune-safely to 3500 bytes +([middleware.go:109–122](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +**Key invariant:** redaction is parser-side, not guardrail-side — access-log +reads `llm.request_prompt_raw` directly. + +### llm_router + +Three-pass route selection in `matchRoute` +([middleware.go:241–300](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): +filter by `Models` claim → vendor-pin (a vendor-tagged request never crosses to +another vendor's route) → filter by `AllowedGroupIDs` intersection → model +precedence over path → tie-break by longest `UpstreamPath` prefix match. +Model-miss returns `llm_policy.model_not_routable`; known-but-unauthorised +returns `llm_policy.no_authorised_provider`. **Key invariant:** auth-header +strip+inject rides on `UpstreamRewrite.{StripHeaders,AuthHeader}` +([middleware.go:606–646](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +— NOT `HeadersAdd/HeadersRemove` — because the framework's mutation gate +blocks `Authorization` on the generic header path. + +**Path-routed providers route before the model table.** `Invoke` checks +`isVertexPath` / `isBedrockPath` +([middleware.go:138–216](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +ahead of the model lookup, so a path-carried model can't be claimed by a +same-vendor body-routed provider. `matchPathRoute` enforces the route's `Models` +allowlist (empty = catch-all) even though the model came from the URL. +Two path-only behaviours: +- **Vertex unmeterable publisher** — when `llm_request_parser` emits no + `llm.provider` (e.g. Gemini/`google`), the router denies with + `llm_policy.unmeterable_publisher` (403) rather than forward it uncounted. +- **GCP token minting** — when the route carries `GCPServiceAccountKeyB64` + (set from a `keyfile::` api_key), `gcpBearer` mints + caches a short-lived + OAuth2 token per request instead of injecting a static value; a bad key or + unreachable token endpoint denies with `llm_policy.upstream_auth_failed` + (502). Bedrock uses its static bearer token directly (no minting). +- **`/bedrock` prefix** — an optional `/bedrock` gateway-namespace prefix is + accepted and stripped via `RewriteUpstream.StripPathPrefix` so the native + `/model/...` path reaches the upstream. + +Full treatment in [50-path-routed-providers.md](./50-path-routed-providers.md). + +### llm_limit_check + +Pre-flight gate. Reads `llm.resolved_provider_id`, calls +`CheckLLMPolicyLimits` with a 2s context timeout +([middleware.go:24, 97–106](../../../proxy/internal/middleware/builtin/llm_limit_check/middleware.go)), +on allow stamps `llm.selected_policy_id`, `llm.attribution_group_id`, +`llm.attribution_window_seconds`. **Key invariant:** fail-open. Nil +`MgmtClient`, empty provider id, or RPC error returns `allowNoAttribution()` +— management outage doesn't take down every LLM request. Operators audit via +the access-log; a future flag may switch this to fail-closed. + +### llm_identity_inject + +Dispatches per-rule between LiteLLM-shaped `HeaderPair` +([middleware.go:169](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)) +and Portkey-shaped `JSONMetadata` +([middleware.go:292](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)). +Identity is the peer's email (or `UserID` fallback); tags are the +**authorising-groups intersection** emitted by `llm_router`, not the full +`UserGroups` — a peer in 5 groups authorised under 1 only tags as that 1. +**Anti-spoof:** every `HeadersAdd` is preceded by a `HeadersRemove` of the +same name; the framework runs `Remove` before `Add` so client-supplied +identity never reaches the upstream. Body-level inject (`tags_in_body`, +`end_user_id_in_body`) is skipped on empty / truncated / non-JSON bodies so +header attribution stays intact. + +### llm_guardrail + +Model allowlist deny + optional prompt-capture-with-redaction. Allowlist +match is case-insensitive via `normaliseModel`; empty allowlist disables the +check. Prompt capture reads `llm.request_prompt_raw` and emits +`llm.request_prompt` only when `prompt_capture.enabled` +([middleware.go:149–165](../../../proxy/internal/middleware/builtin/llm_guardrail/middleware.go)). +**Key invariant:** `RedactPII` is the exported function the parsers call — +single PII contract across all three keys. + +### llm_response_parser + +Buffered and SSE paths share one `Invoke` +([middleware.go:102–127](../../../proxy/internal/middleware/builtin/llm_response_parser/middleware.go)): +content-type sniffing dispatches to `invokeBuffered` (JSON, status<400) or +`invokeStreaming` (text/event-stream, partial bodies tolerated). Streaming +delegates to `accumulateStream` +([streaming.go:21–30](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) +using `llm.NewScanner`. A third path, `accumulateBedrockStream` +([streaming_bedrock.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go)), +decodes the AWS binary event-stream (`application/vnd.amazon.eventstream`) +returned by Bedrock's `-stream` actions — InvokeModel `chunk` frames wrap a +base64 Anthropic event, Converse frames carry text + a trailing usage block. +Cached / cache-creation buckets emit only when non-zero, preserving the existing +token schema. + +### cost_meter + +Reads `llm.provider` + `llm.model` + token buckets, looks up per-1k rate via +`pricing.Loader`, emits `cost.usd_total` or a closed-set `cost.skipped` +reason (`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`, +`unknown_model`). Loader's hot-reload goroutine is bound to proxy-lifetime +context via `startReloader`. **Key invariant:** provider-shape switch lives +in `pricing.Table.Cost` (sibling doc) — `cost_meter` stays provider-agnostic. + +### llm_limit_record + +Post-flight write. Always returns `DecisionAllow`; response has already been +served so RPC errors mustn't surface (logged at `Debugf`). Skip-on-no-signal +at line 81 (zero tokens + zero cost). **Key invariant:** the +skip-on-missing-attribution guard at line 98 is a safety net independent of +the framework's deny short-circuit — if the gate denied and the framework +still runs the recorder, the recorder skips on absent +`UserID`+`groupID`+`UserGroups` and no phantom counter materialises. + +## Full-chain diagram (canonical order) + +```mermaid +flowchart TD + A[HTTP request] --> B[llm_request_parser
OnRequest] + B -->|llm.provider, llm.model,
llm.stream, llm.request_prompt_raw| C[llm_router
OnRequest] + C -->|llm.resolved_provider_id,
llm.authorising_groups,
upstream rewrite + auth| D[llm_limit_check
OnRequest] + D -->|deny path| Z1[403 llm_policy.*] + D -->|allow + llm.selected_policy_id,
llm.attribution_group_id,
llm.attribution_window_seconds| E[llm_identity_inject
OnRequest] + E -->|header strip+inject
+ optional body rewrite| F[llm_guardrail
OnRequest] + F -->|deny: model_blocked| Z2[403 llm_policy.model_blocked] + F -->|allow + llm.request_prompt| G[upstream LLM call] + G --> H[llm_response_parser
OnResponse] + H -->|llm.{input,output,total,cached_input,cache_creation}_tokens,
llm.response_completion| I[cost_meter
OnResponse] + I -->|cost.usd_total or cost.skipped| J[llm_limit_record
OnResponse] + J --> K[response to client] +``` + +## limit_check ⇒ limit_record record-once invariant + +```mermaid +sequenceDiagram + participant LC as llm_limit_check + participant M as management gRPC + participant U as upstream LLM + participant LR as llm_limit_record + participant DB as sqlite consumption table + + LC->>M: CheckLLMPolicyLimits (2s) + alt allow + M-->>LC: selected_policy_id, attribution_group_id, window_s + LC->>U: stamps attribution metadata + U-->>LR: response + tokens (via llm_response_parser + cost_meter) + LR->>M: RecordLLMUsage (5s, debug-on-error) + M->>DB: increment (user, group, window) row + else deny + M-->>LC: llm_policy.token_cap_exceeded + Note over LR: framework short-circuits; even if invoked,
recorder skips on absent UserID+groupID+UserGroups + else mgmt nil / rpc error + LC-->>LC: allowNoAttribution() — fail open + Note over LR: no window_s ⇒ recorder books only account-level
budget rules (which run independently) + end +``` + +The integration test +[agentnetwork_chain_integration_test.go](../../../proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go) +exercises all three branches against a real sqlite store + bufconn gRPC — +no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter` +(line 130), `TestChain_DenyPath_GateRejectsAndNoConsumptionWritten` (line +207), `TestChain_CapExhaustTransition` (line 265). + +## Public contracts (per-middleware JSON config) + +| Middleware | Config shape | +|---|---| +| `llm_request_parser` | `{provider_id?, redact_pii?, capture_prompt?: *bool}` ([factory.go:19–37](../../../proxy/internal/middleware/builtin/llm_request_parser/factory.go)) | +| `llm_router` | `{providers: [{id, models, upstream_scheme, upstream_host, upstream_path?, auth_header_name, auth_header_value, allowed_group_ids}]}` | +| `llm_limit_check` | `{}` — pulls `MgmtClient` from `FactoryContext` | +| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` | +| `llm_guardrail` | `{model_allowlist: []string, prompt_capture: {enabled, redact_pii}}` | +| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` | +| `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) | +| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` | + +All factories accept empty / null / `{}` / whitespace as zero-value config; +only structurally invalid JSON is rejected so misconfig surfaces at chain +build time. + +## Invariants + +1. **limit_check ↔ limit_record paired.** They MUST appear together. Gate + stamps attribution metadata on the request leg; recorder reads it on the + response leg. If a chain contains only the recorder, the + skip-on-missing-attribution guard at + [llm_limit_record/middleware.go:81–87, 98–103](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go) + keeps counters consistent but no enforcement runs. Only-gate means + counters never tick and headroom appears infinite. + +2. **`capture_prompt` / `capture_completion` pointer semantics.** Both are + `*bool`. `nil` = "preserve legacy emit" (back-compat default for + non-agent-network callers and pre-toggle tests). `false` = suppress the + key entirely (access-log row carries zero prompt / completion content). + `true` = emit. The synthesiser sets the pointer explicitly to the + account's `EnablePromptCollection` toggle. The handling lives + in [llm_request_parser/factory.go:55–61](../../../proxy/internal/middleware/builtin/llm_request_parser/factory.go) + and the symmetric [llm_response_parser/middleware.go:62–68](../../../proxy/internal/middleware/builtin/llm_response_parser/middleware.go); + a missing pointer must not be treated as `false` (that would suppress + capture for legacy non-agent-network callers). + `redact_pii` is an orthogonal `bool` controlling **form** of emitted + content, not whether it's emitted. + +3. **`redact_pii` is parser-side.** Both parsers import + `llm_guardrail.RedactPII` and run it BEFORE stamping the metadata bag. + Load-bearing because the access-log sink reads `llm.request_prompt_raw` + and `llm.response_completion` directly — by the time `llm_guardrail` + runs its own pass on `llm.request_prompt`, the raw key has already been + stamped. Tests: `TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt`, + `TestInvoke_RedactPii_RedactsCompletionBeforeEmit`. + +4. **Metadata allowlist enforcement.** Every middleware declares + `MetadataKeys()`. The framework accumulator drops any KV outside that + allowlist. When adding a new key, also extend the docstring in + `middleware/keys.go`. + +5. **Closed deny-code set.** All deny paths emit one of: + `llm_policy.model_not_routable`, `llm_policy.no_authorised_provider`, + `llm_policy.model_blocked`, `llm_policy.token_cap_exceeded`, + `llm_policy.unmeterable_publisher` (path-routed Vertex publisher with no + parser → 403), `llm_policy.upstream_auth_failed` (GCP token mint failure → + 502), or the management-supplied code on `llm_limit_check`. These surface + verbatim; arbitrary middleware text never reaches the wire. + +## Things to scrutinise + +**Correctness.** `llm_router` model match treats an empty `Models` slice as +"claim every model" +([middleware.go:238–248](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +for gateway-style providers — confirm no real provider record ships with an +empty `Models` by accident. Path-prefix tie-break falls back to declaration +order when no candidate prefix-matches, so the synthesiser must emit a +deterministic order. `llm_limit_record` discards `strconv.ParseInt` errors +([middleware.go:78–80](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go)) +— relies on `llm_response_parser` always emitting parseable values; spot-check +the streaming partial path on truncated bodies. + +**Security.** Auth headers must NEVER appear on `Mutations.HeadersAdd/Remove` +for the router — a direct headers path would bypass the framework gate. The +capture-pointer handling is the kind of place a bug ships PII to logs +silently; every synthesiser config path must set the pointer explicitly. +`llm_identity_inject` body inject silently skips on a +non-object `metadata` field +([middleware.go:262–270](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)) +— header path still attributes, but body-level tag-budget enforcement +doesn't run for that request. + +**Concurrency.** `cost_meter` shares a `pricing.Loader` via +`atomic.Pointer[Table]`; readers always see a consistent table. Every +middleware is a stateless value receiver. Integration test uses real bufconn +gRPC — race detector is the meaningful bar. + +**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost` +is O(1); SSE accumulation is single-pass. No map allocation per call. + +**Observability.** Every deny stamps `llm_policy.decision=deny` and a +matching `llm_policy.reason` — access-log can pivot on either. +`llm_limit_record` only logs at `Debugf` on RPC failure +([middleware.go:125–130](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go)); +operators need an alternate signal (metric on `RecordLLMUsage` failures) for +counter accuracy. + +## Test coverage + +| File | Tests | Notes | +|---|---:|---| +| `all_test.go` | 1 | Registry surface lock | +| `agentnetwork_chain_integration_test.go` | 3 | Allow/deny/cap-exhaust vs live sqlite + bufconn gRPC | +| `llm_request_parser/middleware_test.go` | 18 | `provider_id` bypass, redaction, capture-pointer, rune-safe truncation | +| `llm_router/middleware_test.go` | 19 | Three-pass match, deny codes, path-prefix tie-break, header strip+inject | +| `llm_limit_check/middleware_test.go` | 6 | Allow/deny, fail-open on nil mgmt / RPC error, attribution stamping | +| `llm_identity_inject/middleware_test.go` | 28 | HeaderPair, JSONMetadata, ExtraHeaders, body inject, anti-spoof | +| `llm_guardrail/middleware_test.go` | 15 | Allowlist case-insensitivity, prompt capture toggle, deny shape | +| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven | +| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation | +| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort | +| `cost_meter/middleware_test.go` | 17 | Each skip reason, provider-shape, pricing loader integration | +| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed | + +## Cross-references + +- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters + + SSE framer + pricing loader. +- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP + token minting, `/bedrock` prefix: + [50-path-routed-providers.md](./50-path-routed-providers.md). +- Upstream config: `management/server/agentnetwork/synthesizer` (out of scope). +- Framework: `proxy/internal/middleware/{chain,dispatcher,accumulator,registry}.go`. +- Metadata key registry: `proxy/internal/middleware/keys.go`. +- gRPC surface: `proto.ProxyServiceClient.{CheckLLMPolicyLimits,RecordLLMUsage}`. diff --git a/docs/agent-networks/modules/32-proxy-llm-parsers.md b/docs/agent-networks/modules/32-proxy-llm-parsers.md new file mode 100644 index 000000000..0376bc988 --- /dev/null +++ b/docs/agent-networks/modules/32-proxy-llm-parsers.md @@ -0,0 +1,392 @@ +# proxy/llm-parsers — SDK adapters + pricing + SSE + +The runtime-agnostic LLM library: the OpenAI Responses API (`/v1/responses`) +and the older Chat Completions API (`/v1/chat/completions`), the Anthropic +Messages API (`/v1/messages`), the SSE wire format (`event:` / `data:` lines, +`\n\n` framing, CRLF tolerance), and per-provider token accounting (OpenAI's +cached-prompt **subset** vs Anthropic's cache_read **additive** model). The +pricing table's per-provider cost formula is the highest-leverage place a +small bug would silently mis-bill operators. + +Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) +— the 8 middlewares that consume this package's parsers + pricing loader. + +--- + +## Module boundary + +`proxy/internal/llm` is the runtime-agnostic LLM library shared by every +middleware that needs to understand provider-specific shapes. Zero +proxy-framework dependencies: + +- `parser.go` — `Parser` interface, `Provider` enum, public factories + (`Parsers`, `DetectParser`, `ParserByName`). +- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls. +- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`). +- `errors.go` — sentinels callers branch on with `errors.Is`. +- `pricing/` — embedded-default + hot-reload override table with + symlink-safe Unix loader (build-tagged stub elsewhere). +- `fixtures/` — captured request/response/stream bodies the tests replay. + +The package carries zero proxy-framework dependencies so the same parsers can +be reused later by a WASM adapter +([parser.go:1–6](../../../proxy/internal/llm/parser.go)). + +## Files + +| File | LOC | Notes | +|---|---:|---| +| `parser.go` | 104 | Interface + factories + `Provider{Unknown,OpenAI,Anthropic}` enum | +| `openai.go` | 347 | Chat Completions + Completions + Responses API; cached_tokens subset | +| `openai_test.go` | 222 | 11 tests; fixture replay + cached/Responses-API matrix | +| `anthropic.go` | 172 | Messages + legacy `/v1/complete`; cache_read + cache_creation additive | +| `anthropic_test.go` | 154 | 7 tests including streaming-extraction-skipped contract | +| `bedrock.go` | 190 | AWS Bedrock InvokeModel (snake_case) + Converse (camelCase) response shapes; model lives in URL path | +| `bedrock_test.go` | — | InvokeModel + Converse usage shapes; AWS event-stream content-type → `ErrStreamingUnsupported` on buffered `ParseResponse` | +| `sse.go` | 117 | `bufio`-backed scanner; CRLF normalised; trailing-event handling | +| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits | +| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values | +| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` | +| `pricing/pricing.go` | 421 | `Loader`, `Table`, `Entry`; embedded defaults + atomic swap + mtime reload | +| `pricing/pricing_unix.go` | 69 | `O_NOFOLLOW` + fstat-from-FD + 1 MiB cap | +| `pricing/pricing_other.go` | 21 | Stub returning "not supported on this platform" | +| `pricing/pricing_test.go` | 432 | 21 tests — symlink rejection, reload race, path traversal, oversize | +| `pricing/defaults_pricing.yaml` | 85 | go:embed source of truth | +| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream + pricing starter | + +## Request body → parser dispatch + +```mermaid +flowchart TD + A[HTTP request
URL + JSON body] --> B{ParserByName?
provider_id config set} + B -- yes --> P[matched Parser] + B -- no --> C[DetectParser] + C --> D{loop Parsers
OpenAIParser, AnthropicParser} + D -- DetectFromURL match --> P + D -- no match --> X[ok=false
middleware skips] + P --> E[ParseRequest body] + E -->|err: ErrMalformedRequest| Y[middleware emits provider only] + E --> F[RequestFacts
model + stream] + P --> G[ExtractPrompt body] + G --> H[joinMessages
extractContentParts
decodeStringOrJoin] + H --> I[prompt text
or empty] + F --> J[stamps llm.model + llm.stream] + I --> K[stamps llm.request_prompt_raw
subject to capture_prompt gate] +``` + +OpenAI's URL hints +([openai.go:27–33](../../../proxy/internal/llm/openai.go)) include +both `/v1/chat/completions` and the bare `/chat/completions` — the latter +covers Cloudflare AI Gateway, which rewrites the canonical version segment. +Anthropic's hints are `/v1/messages` and `/v1/complete` +([anthropic.go:14–17](../../../proxy/internal/llm/anthropic.go)). +Both implementations use case-insensitive substring matching so a proxy prefix +strip / rewrite doesn't defeat detection. + +`ParserByName` ([parser.go:93–103](../../../proxy/internal/llm/parser.go)) +is the **agent-network bypass**: the synthesiser knows which parser to use +because it built the synth service from the catalog, so it stamps +`provider_id` on the parser config and the middleware skips URL sniffing +entirely. This is what makes the same parser set work whether the request +flows to OpenAI direct, to LiteLLM, to Portkey, or to any gateway with a +non-canonical URL shape. + +**Path-routed providers (Vertex AI, Bedrock) bypass both `ParserByName` and +`DetectParser`.** The model and the parser surface live in the URL path, so the +request middleware extracts them directly (`parseVertexPath` / +`parseBedrockPath`) before the parser-selection step. For Vertex the publisher +segment picks the parser (`anthropic` → Anthropic parser; `google`/Gemini → +none, request denied as unmeterable). For Bedrock the dedicated `BedrockParser` +handles the response. Full treatment in +[50-path-routed-providers.md](./50-path-routed-providers.md). + +## Streaming response → SSE chunker → response parser → completion + token count + +```mermaid +sequenceDiagram + participant U as upstream LLM + participant LR as llm_response_parser
(OnResponse) + participant S as llm.NewScanner
(SSE framer) + participant P as Parser-specific accumulator
(accumulateOpenAIStream
or accumulateAnthropicStream) + + U-->>LR: text/event-stream
(buffered prefix in RespBody) + LR->>S: NewScanner(bytes.NewReader(body)) + loop until EOF or [DONE] + S-->>LR: Event{Type, Data} + LR->>P: dispatch per event.Type
(OpenAI: data-only
Anthropic: named events) + P-->>P: accumulate completion text
track usage from final frame + end + P-->>LR: llm.Usage + completion string + LR->>LR: appendUsage stamps
llm.{input,output,total,cached_input,cache_creation}_tokens + LR->>LR: truncateCompletion(3500 bytes, rune-safe) + LR->>LR: redactPII if redact_pii && captureCompletion +``` + +`Scanner.Next` +([sse.go:44–87](../../../proxy/internal/llm/sse.go)) returns one +event per `\n\n` boundary; multiple `data:` lines join with `\n`; comment lines +(starting with `:`) are skipped per the SSE spec; a trailing event without a +closing blank line is still returned before `io.EOF` so a server that closes +the connection cleanly doesn't lose the last frame +([sse.go:55–58](../../../proxy/internal/llm/sse.go)). CRLF is +normalised in `trimEOL` so fixtures captured from live servers replay +unchanged. + +## Per-provider + +### OpenAI + +[openai.go:54–67](../../../proxy/internal/llm/openai.go) defines +`openAIRequest` with three prompt fields: `messages` (Chat Completions), +`prompt` (legacy), `input` (Responses API). The decoder uses +`json.RawMessage` so each shape is parsed lazily. + +`ParseResponse` +([openai.go:117–146](../../../proxy/internal/llm/openai.go)) +accepts both naming conventions: Chat Completions returns +`prompt_tokens`/`completion_tokens`, Responses API returns +`input_tokens`/`output_tokens`. `pickInt64` prefers Responses-API names and +falls back — same parser handles both endpoints without per-route config. +`openAICachedTokens` mirrors the fallback for +`input_tokens_details.cached_tokens` vs `prompt_tokens_details.cached_tokens`. + +**Key invariant:** `CachedInputTokens` for OpenAI is a SUBSET of +`InputTokens`. The cost meter clamps to guard against malformed upstream +responses where `cached > total`. + +### Anthropic + +[anthropic.go:37–49](../../../proxy/internal/llm/anthropic.go) +defines `anthropicRequest` covering Messages API (`system` + `messages[]`) +and legacy `/v1/complete` (`prompt` string). `ExtractPrompt` emits +`system: ` first when present, then per-message `role: content`. + +`ParseResponse` +([anthropic.go:82–104](../../../proxy/internal/llm/anthropic.go)) +fills three independent token buckets: `InputTokens`, `CacheReadInputTokens`, +`CacheCreationInputTokens`. Latter two are **additive** (not subset). +`TotalTokens` sums all four so downstream dashboards render one "tokens" +number without double-counting. + +`ExtractCompletion` walks `content[]` `{type, text}` parts and concatenates +non-empty text with newlines, falling back to legacy `completion`. + +### Bedrock + +[bedrock.go](../../../proxy/internal/llm/bedrock.go) implements the +`Parser` interface for the AWS Bedrock runtime. Bedrock is **path-routed**: the +model lives in the URL (`/model/{id}/{action}`), so the request middleware +extracts it (see [50-path-routed-providers.md](./50-path-routed-providers.md)) +and `ParseRequest` is a deliberate no-op. The parser's real work is on the +response leg, covering both Bedrock body shapes: + +- **InvokeModel** — vendor-native. Anthropic-on-Bedrock returns snake_case usage + (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, + `cache_creation_input_tokens`) with the same additive cache buckets as + first-party Anthropic. +- **Converse** — unified camelCase (`inputTokens`, `outputTokens`, + `totalTokens`). `firstNonZero` folds the two naming conventions into one + `Usage`; when Converse omits `totalTokens` the parser sums the buckets. + +`ProviderName()` returns `"bedrock"` — its own `defaults_pricing.yaml` block, +keyed by the **normalised** model id (region prefix + version suffix stripped by +the request parser). `ParseResponse` returns `ErrStreamingUnsupported` for an +AWS binary event-stream content-type (`application/vnd.amazon.eventstream`, +`isAWSEventStream`) so the caller routes to the streaming accumulator instead. + +### SSE framing + +`Scanner` is `bufio`-backed, 64 KiB read buffer, 1 MiB max line so a +malicious upstream can't blow process memory +([sse.go:33–38, 97–100](../../../proxy/internal/llm/sse.go)). +`splitField` strips one space after the `:` per the SSE spec. Documented +`not safe for concurrent use`; every consumer creates a fresh scanner per +response body. Streaming accumulators live in the middleware package +([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) +but use `llm.NewScanner` so the framing contract stays here. + +### Pricing catalog + +`Table.Cost` +([pricing.go:129–174](../../../proxy/internal/llm/pricing/pricing.go)) +is the cost formula — most security-relevant math in this module: + +| Provider | Formula | +|---|---| +| `openai` | `(inTokens − clamped) × InputPer1K + clamped × CachedInputPer1K + outTokens × OutputPer1K` where `clamped = min(cachedInput, inTokens)` | +| `anthropic`, `bedrock` | `inTokens × InputPer1K + cachedInput × CacheReadPer1K + cacheCreation × CacheCreationPer1K + outTokens × OutputPer1K` | +| default | `inTokens × InputPer1K + outTokens × OutputPer1K` | + +`bedrock` shares the Anthropic additive-cache formula +([pricing.go:172-174](../../../proxy/internal/llm/pricing/pricing.go)): +Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic +Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces +to `input + output`. + +Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in +to discounts by setting the field. + +`Loader` +([pricing.go:212–268](../../../proxy/internal/llm/pricing/pricing.go)) +overlays an optional `pricing.yaml` from data-dir on top of the go:embed +defaults. Atomic pointer swap means readers never observe a partial update. +The mtime-poll reloader (30s default cadence) keeps the previous table on +parse failure so cost annotation never goes blank during a botched edit. + +`defaults_pricing.yaml` is the source of truth for built-in pricing. +Operator overrides only carry the entries they want to change. + +## Public contracts + +**`Parser` interface** +([parser.go:50–66](../../../proxy/internal/llm/parser.go)): + +```go +type Parser interface { + Provider() Provider + ProviderName() string + DetectFromURL(path string) bool + ParseRequest(body []byte) (RequestFacts, error) + ParseResponse(status int, contentType string, body []byte) (Usage, error) + ExtractPrompt(body []byte) string + ExtractCompletion(status int, contentType string, body []byte) string +} +``` + +Adding a provider means implementing this interface and appending to the +slice returned by `Parsers()` ([parser.go:78–84](../../../proxy/internal/llm/parser.go)). +Order matters: `DetectFromURL` ties resolve by registration order. +`Parsers()` today returns `{OpenAIParser, AnthropicParser, BedrockParser}`. + +**`Provider` enum** +([parser.go:8–18](../../../proxy/internal/llm/parser.go)): +`ProviderUnknown = 0`, `ProviderOpenAI = 1`, `ProviderAnthropic = 2`, +`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat +them as wire-stable — new providers must take fresh numbers. + +**`Pricing` lookup** +([pricing.go:129](../../../proxy/internal/llm/pricing/pricing.go)): + +```go +func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) +``` + +Nil-safe: `t.Cost` on a nil receiver returns `(0, false)` +([pricing.go:130–132](../../../proxy/internal/llm/pricing/pricing.go)). +`ok=false` means provider or model is absent from the loaded table; the caller +emits `cost.skipped=unknown_model`. + +## Invariants + +1. **Cross-platform pricing build.** `pricing_unix.go` carries the only + functional `loadPricing` (uses `syscall.O_NOFOLLOW` and `f.Stat()` on an + open descriptor — both Unix-only). `pricing_other.go` is a build-tag + fallback that returns `"not supported on this platform"` + ([pricing_other.go:14–16](../../../proxy/internal/llm/pricing/pricing_other.go)). + The proxy is Linux-only in production today; a Windows port needs an + equivalent path-as-handle implementation. Reviewers building on Windows + should expect this surface to return an error at startup if an override + file is configured. + +2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end + in `\n\n` still yields its accumulated event before `io.EOF` + ([sse.go:55–58](../../../proxy/internal/llm/sse.go)). Tests: + `TestSSEScanner_OpenAIFixture`, `TestSSEScanner_AnthropicFixture`, + `TestSSEScanner_MultilineData`, `TestSSEScanner_CRLF`. The streaming + accumulators ride on this: `accumulateAnthropicStream` and + `accumulateOpenAIStream` `break` on any scanner error to return partial + usage rather than aborting + ([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)). + +3. **`defaults_pricing.yaml` is the source of truth.** Compiled into the + binary via `//go:embed` + ([pricing.go:29–30](../../../proxy/internal/llm/pricing/pricing.go)). + `DefaultTable()` parses once and panics on parse failure + ([pricing.go:42–49](../../../proxy/internal/llm/pricing/pricing.go)) + — by design: a broken embedded YAML must not ship to production. + +4. **Loader path validation.** `resolveMiddlewareDataPath` + ([pricing.go:370–394](../../../proxy/internal/llm/pricing/pricing.go)) + rejects absolute paths, traversal segments, and basenames that fail + `basenameRegex = ^[a-zA-Z0-9._-]+$`. The resolved path must remain + inside `baseDir` even after `filepath.Clean`. Tests: + `TestNewLoader_PathValidation`, `TestNewLoader_PathValidation_Extended`, + `TestNewLoader_SymlinkOutsideBaseDirRejected`, `TestNewLoader_SymlinkRejected`. + +5. **Unix loader symlink safety.** `O_NOFOLLOW` on open, `f.Stat()` on the + open descriptor (never re-stat by path), `info.Mode().IsRegular()` check, + `io.LimitReader(f, maxPricingBytes+1)` with a final size assertion + ([pricing_unix.go:25–57](../../../proxy/internal/llm/pricing/pricing_unix.go)). + A mid-read symlink swap is detected because the fstat is on the original + fd. Test: `TestNewLoader_RejectsOversizedFile_FixesM4`. + +6. **`yaml.NewDecoder(...).KnownFields(true)`** + ([pricing.go:397–398](../../../proxy/internal/llm/pricing/pricing.go)) + rejects YAML files that carry fields not in the schema. A typo in an + operator override file fails loud instead of silently zeroing rates. + +## Things to scrutinise + +**Correctness.** Verify OpenAI cached-prompt clamp at +[pricing.go:147–149](../../../proxy/internal/llm/pricing/pricing.go) +short-circuits before subtraction. `Anthropic.TotalTokens` sums all four +buckets (in + out + cache_read + cache_creation) — downstream dashboards +need to know this differs from `input + output`. +`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a +request sending all three reports only `messages` (uncommon but worth +noting). + +**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event +errors from `Scanner.Next` and both accumulators stop with partial usage. +Pricing file 1 MiB cap is orders of magnitude larger than realistic. Confirm +new schema additions are mirrored in both `pricingFile` and `Entry`; +`KnownFields(true)` will reject silently-typo'd operator overrides +otherwise. + +**Concurrency.** `Loader.table` is `atomic.Pointer[Table]`; readers never +block or see a torn table. `Loader.Reload` is one goroutine, cancelled via +context (`TestLoader_ReloadBackgroundLoopCancellation`). `DefaultTable()` +uses `sync.Once`. Per-call `Scanner` instances mean no shared state across +concurrent response-parser calls. + +**Perf.** `Table.Cost` is two map lookups + multiplications, O(1). +`Scanner.Next` is one `ReadString('\n')` per line. Pricing reload poll 30s. + +**Observability.** Reload failures count via `metric.Int64Counter` keyed +`plugin`; warning log rate-limited at 5 min so a broken file doesn't flood. +Parser errors return sentinels — middleware uses `errors.Is` to map to the +right `cost.skipped` reason. + +## Test coverage + +| File | Tests | Coverage highlights | +|---|---:|---| +| `parser_test.go` | 3 | `Parsers()` shape lock, `DetectParser` URL matrix, provider enum stability | +| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays | +| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays | +| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection | +| `pricing/pricing_test.go` | 21 | Provider-shape switch; cached-rate fallback; cached-clamp; symlink rejection (target outside basedir + symlink to file); path validation matrix; oversize rejection; reload-keeps-previous-on-parse-error; mtime change detection; goroutine cancellation | + +**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)): +`openai_chat_completion.json` (chat.completions with usage), +`openai_responses.json` (Responses API shape), +`openai_stream.txt` (3 deltas + usage + `[DONE]`), +`anthropic_messages.json` (Messages API non-streaming), +`anthropic_stream.txt` (full 7-event sequence: message_start → +content_block_{start,delta×2,stop} → message_delta (usage) → message_stop), +`pricing.yaml` (realistic-pricing starter for operator overrides). + +## Cross-references + +- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) + — the chain that calls `llm.Parsers()`, `llm.ParserByName`, + `llm.NewScanner`, `pricing.NewLoader`. +- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the + Bedrock AWS event-stream accumulator: + [50-path-routed-providers.md](./50-path-routed-providers.md). +- Direct callers: `llm_request_parser/middleware.go:82–94`, + `llm_response_parser/middleware.go:113–123`, + `llm_response_parser/streaming.go:65, 142`, `cost_meter/factory.go:49–57`. +- Related elsewhere: the agent-network synthesiser stamping `provider_id` + is covered in the management-side module guide; proxy server boot + + `FactoryContext` construction is covered in the proxy-framework guide. diff --git a/docs/agent-networks/modules/33-proxy-runtime.md b/docs/agent-networks/modules/33-proxy-runtime.md new file mode 100644 index 000000000..f553473f8 --- /dev/null +++ b/docs/agent-networks/modules/33-proxy-runtime.md @@ -0,0 +1,194 @@ +# proxy/runtime — translate + serve + log + +> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target. +> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareDataDir`, `MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. + +## Module boundary + +Turns the synth-service wire format from `ProxyService.SyncMappings`/`GetMappingUpdate` into in-process middleware chains and runs them on top of the existing `httputil.ReverseProxy`. Four concerns: (a) **translate** — `proto.MiddlewareConfig` → validated `middleware.Spec` (proxy/middleware_translate.go) + self-register the eight built-ins (proxy/middleware_register.go); (b) **boot + rebuild** — construct the `middleware.Manager`, share the OTel meter, install the live-service check, rebuild per-path chains on every `addMapping`/`modifyMapping` (proxy/server.go); (c) **serve** — resolve chain at request time, capture bodies under a global budget, invoke `RunRequest`/`RunResponse`/`RunTerminal`, render deny responses, apply `UpstreamRewrite` (proxy/internal/proxy/reverseproxy.go); (d) **log + tag** — emit access-log entries with the new `agent_network` flag, gate emission on `EnableLogCollection` via `DisableAccessLog` (proxy/internal/accesslog). + +**Inert for non-agent-network targets**: nil or empty chain → existing fast path (reverseproxy.go:127-139); `SuppressAccessLog` defaults false so the access-log middleware emits unchanged. + +## Files + +| Path | Role | +| ---- | ---- | +| proxy/middleware_translate.go | proto→Spec translation; slot/failmode/timeout mapping; caps | +| proxy/middleware_translate_test.go | translator unit tests | +| proxy/middleware_register.go | blank-imports the eight builtins for `init()` registration | +| proxy/server.go | `initMiddlewareManager`, `rebuildMiddlewareChains`, `isLiveService`, `buildMiddlewareBindings`, new Server fields, `protoToMapping` stamps AgentNetwork/DisableAccessLog/CaptureConfig/Middlewares | +| proxy/internal/proxy/reverseproxy.go | `WithMiddlewareManager`, chain dispatch, body capture, `applyUpstreamRewrite`/`Headers`, `buildRequestInput`, response-leg respInput identity fields | +| proxy/internal/proxy/reverseproxy_test.go | `TestBuildRequestInput_PropagatesIdentityAndGroups` | +| proxy/internal/proxy/context.go | `agentNetwork`, `suppressAccessLog`, `userGroupNames` on `CapturedData` | +| proxy/internal/proxy/servicemapping.go | new `PathTarget` fields | +| proxy/internal/proxy/agent_network_chain_realstack_test.go | end-to-end self-contained chain test | +| proxy/internal/accesslog/logger.go | `logEntry.AgentNetwork` → `proto.AccessLog` | +| proxy/internal/accesslog/middleware.go | reads `GetAgentNetwork()`; gates `l.log` on `!GetSuppressAccessLog()` | +| proxy/internal/accesslog/middleware_test.go | suppress/default/preserves-usage assertions | +| proxy/internal/auth/middleware_test.go | tunnel-peer group propagation contract | +| proxy/internal/metrics/metrics.go | `Meter()` getter for the middleware manager | + +## Architecture & flow + +### Synth-service ingestion → translate → register → serve + +```mermaid +flowchart TD + A[Management SyncMappings/GetMappingUpdate] --> B["processMappings\nserver.go:1492"] + B --> C{Mapping type} + C -->|CREATED| D["addMapping → setupHTTPMapping → updateMapping"] + C -->|MODIFIED| E["modifyMapping → cleanupMappingRoutes → setupHTTPMapping → updateMapping"] + C -->|REMOVED| F["removeMapping → cleanupMappingRoutes → invalidateMiddlewareChains"] + D --> G["protoToMapping\nserver.go:2181"] + E --> G + G --> H["translateMiddlewareConfigs\nmiddleware_translate.go:55"] + G --> I["translateMiddlewareCaptureConfig\nmiddleware_translate.go:18"] + H --> J["[]middleware.Spec on PathTarget"] + I --> K["*bodytap.Config on PathTarget"] + J --> L["proxy.AddMapping\nservicemapping.go:118"] + K --> L + L --> M["rebuildMiddlewareChains\nserver.go:2017 → Manager.Rebuild"] + F --> N["Manager.Invalidate(serviceID)"] +``` + +### Per-request lifecycle through the chain + accesslog + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant M as accesslog.Middleware + participant A as auth.Middleware (Protect) + participant RP as ReverseProxy.ServeHTTP + participant CH as middleware.Chain + participant U as Upstream + C->>M: HTTP request + M->>M: NewCapturedData(requestID), WithCapturedData(ctx) + M->>A: next.ServeHTTP + A->>A: Private → ValidateTunnelPeer → stamp UserID/Email/Groups/GroupNames/AuthMethod + A->>RP: next.ServeHTTP + RP->>RP: findTargetForRequest → targetResult + RP->>RP: stamp ServiceID/AccountID/AgentNetwork/SuppressAccessLog on CapturedData + RP->>RP: resolveChain via Manager.ChainFor + alt chain == nil or Empty + RP->>U: httputil.ReverseProxy.ServeHTTP (fast path) + else chain non-empty + RP->>RP: bodytap.CaptureRequest (global budget) + RP->>CH: RunRequest + CH-->>RP: denyOutput? requestMeta + upstreamRewrite + alt deny + RP->>C: RenderDenyResponse + else allow + RP->>RP: capturingWriter + applyUpstreamRewrite/Headers + RP->>U: httputil.ReverseProxy.ServeHTTP(respWriter) + U-->>RP: response + RP->>CH: RunResponse (respInput carries UserGroups) + RP->>CH: RunTerminal (merged request+response metadata) + end + end + RP-->>M: handler returns + M->>M: build logEntry incl. AgentNetwork + alt SuppressAccessLog == true + M->>M: skip l.log; still trackUsage + else default + M->>M: l.log → goroutine SendAccessLog + end +``` + +### EnableLogCollection suppression path + +```mermaid +flowchart LR + S["agentnetwork.Settings.EnableLogCollection"] --> B["synthesizer: target.DisableAccessLog = !EnableLogCollection"] + B --> P["proto PathTargetOptions.disable_access_log (field 13)"] + P --> T["protoToMapping reads GetDisableAccessLog()\nserver.go:2211"] + T --> M["PathTarget.DisableAccessLog\nservicemapping.go:47"] + M --> R["ServeHTTP: cd.SetSuppressAccessLog\nreverseproxy.go:106"] + R --> G["accesslog middleware: if !GetSuppressAccessLog l.log\nmiddleware.go:95"] + R --> U["trackUsage unconditional — bandwidth telemetry preserved"] +``` + +**Ingestion** lands as a `ProxyMapping` batch on `handleSyncMappingsStream`/`handleMappingStream`. `processMappings` dispatches to `addMapping`/`modifyMapping`/`removeMapping`; HTTP goes `setupHTTPMapping → updateMapping → protoToMapping`. `protoToMapping` (server.go:2181) is the single translation surface that materialises `[]middleware.Spec`, `*bodytap.Config`, `AgentNetwork`, `DisableAccessLog` onto each `PathTarget`; `updateMapping` finishes with `s.proxy.AddMapping(m)` (atomic swap under `mappingsMux`) and `s.rebuildMiddlewareChains(svcID, m)`. + +At **request time** the access-log middleware stamps `CapturedData`; the auth chain runs (Private services lift `peer_group_ids` from `ValidateTunnelPeer` — auth/middleware_test.go:322). `ReverseProxy.ServeHTTP` resolves the chain; nil or empty → original `httputil.ReverseProxy`, no body capture. When a chain matches, body is captured under the global budget, `RunRequest` produces an `UpstreamRewrite` (`llm_router` selects a provider, rewrites scheme/host/path, injects `Authorization`), and `RunResponse`+`RunTerminal` run after the upstream returns. The terminal slot sees the merged metadata bag — that's how `llm_limit_record` ships the consumption sample. The **access-log** addition: `logEntry.AgentNetwork` from `GetAgentNetwork()` onto `proto.AccessLog.AgentNetwork`; the gate at middleware.go:95 honors `EnableLogCollection`, skipping `l.log` but keeping `trackUsage` so bandwidth telemetry survives. + +## Public contracts touched + +- `proxy.Server.MiddlewareDataDir` (string) — base dir for file-backed middleware config (server.go:238-241). +- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:248-250). +- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56). +- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default. +- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258). +- `accesslog.logEntry.AgentNetwork` + `proto.AccessLog.AgentNetwork` (logger.go:131, 268). +- `metrics.Metrics.Meter()` exposes the OTel meter for the middleware manager (metrics.go:53-58). + +## Invariants + +- **Synth-service updates are live (no proxy restart).** Every `MODIFIED` flows through `modifyMapping → cleanupMappingRoutes` (invalidates chains) `→ setupHTTPMapping → updateMapping → rebuildMiddlewareChains`. **ProxyMapping.Private preservation:** the relevant logic lives in `management/internals/shared/grpc/proxy.go:shallowCloneMapping`, not this module, but it surfaces here — if a `MODIFIED` synth service arrives `private=false`, auth skips `ValidateTunnelPeer`, `CapturedData.UserGroups` stays empty, and `llm_router` denies with `llm_policy.no_authorised_provider` until a management restart re-pushes the snapshot. This module assumes `mapping.GetPrivate()` is correct on every batch. +- **`EnableLogCollection=false` suppresses access-log writes but middleware still runs.** Gate is one `if !cd.GetSuppressAccessLog()` immediately around `l.log(entry)` (middleware.go:95); `trackUsage` runs below the gate. Locked by `TestMiddleware_SuppressAccessLog_PreservesUsageTracking` (middleware_test.go:139). +- **`agent_network` flag on access-log entries is set when the chain processed the request.** Source `target.AgentNetwork`, stamped at reverseproxy.go:105, read at accesslog/middleware.go:86. +- **auth → builtin group propagation.** `Protect` writes `UserGroups`/`UserGroupNames`; `buildRequestInput` (reverseproxy.go:333) copies them into `middleware.Input`. The response-leg `respInput` (reverseproxy.go:196-223) also carries `UserEmail`/`UserGroups`/`UserGroupNames` — `llm_limit_record` needs `UserGroups` to ship `group_ids` so management's group-targeted budget rules match (comment at reverseproxy.go:211-215). +- **Empty chains stay on the fast path.** `ServeHTTP` skips body capture and the run sequence when `chain == nil || chain.Empty()` (reverseproxy.go:127). +- **Self-registration is the only way a builtin reaches the registry.** `middleware_register.go` blank-imports each builtin; `init()` adds the factory to `mwbuiltin.DefaultRegistry()`. Missing it → translator drops the entry with a warn (translate.go:97). + +## Things to scrutinize + +### Correctness +- **Translate edge cases** — drops on nil cfg, empty ID, unknown ID, UNSPECIFIED slot; each logs one warn; volume bounded by `MaxMiddlewaresPerChain`. +- **Re-translate without dropping in-flight requests** — `Manager.Rebuild` is the only call from `rebuildMiddlewareChains`. Reverse proxy reads `ChainFor` once per request (reverseproxy.go:327) and runs the captured `*Chain` for the whole request. Verify in module 30 that `Rebuild` swaps atomically. +- **ProxyMapping.Private preservation** — enforced management-side in `shallowCloneMapping`. Proxy-side regression catches: `TestProtect_PrivateService_TunnelPeerGroupsPropagate` + the integration test. +- **Body-capture cleanup** — `defer releaseBudget()` (reverseproxy.go:145) and `defer capturingWriter.Release()` (reverseproxy.go:180) must run on every return; confirm no future `return` lands between acquisition and defer. +- **`applyUpstreamRewrite` clones the URL** — `cloned := *orig` value-copies `*url.URL`; safe because overwritten fields are strings, not slices/maps (reverseproxy.go:285-292). + +### Security +- **Translate validates every config** — registry membership rejects unknown IDs; UNSPECIFIED slot drops; ID-less drops; raw config copied (not aliased) at translate.go:109. +- **`AuthHeader`/`StripHeaders` only reachable via `UpstreamRewrite`** — regular mutation surface goes through the framework denylist (`Authorization`/`Cookie` blocked); only the router middleware can replace `Authorization` (reverseproxy.go:296-304). Confirm in module 30 nothing outside the proxy-trusted path populates `UpstreamRewrite.AuthHeader`. +- **`stampNetBirdIdentity` strips client-sent values first** (reverseproxy.go:742-743) — anti-spoof for `X-NetBird-User`/`X-NetBird-Groups`; control chars filtered; comma-bearing labels dropped (reverseproxy_test.go:1217/:1243/:1193). +- **Auth → group propagation** — `auth/middleware_test.go:322` and `:366` cover the contract. If auth ever stops calling `ValidateTunnelPeer` for Private services, every agent-network request silently denies. + +### Concurrency +- **Chain replacement under in-flight requests** — `findTargetForRequest` takes `mappingsMux.RLock`; `AddMapping` writes. `resolveChain` calls `ChainFor` once; even if `Rebuild` swaps mid-request, in-flight requests keep running on the captured pointer. +- **`CapturedData` mutation across slots** — accessors take `sync.RWMutex`; slices deep-copied on both Set and Get. Verify no caller mutates the returned slice expecting it to land back. +- **`Manager.Invalidate` race** — `removeMapping` invalidates after `cleanupMappingRoutes`; mapping read happens before chain resolution, so requests before invalidate run captured chains; later ones fail `findTargetForRequest`. +- **`Logger.log` goroutine** — `logSem` caps at `maxLogWorkers = 4096`; overflow → `dropped.Add(1)` + debug log. Middleware test uses a buffered channel and 150ms negative-assertion window — review whether 150ms holds on slow CI. + +### Backward compatibility +- **Non-agent-network services unaffected** — `protoToMapping` reads new fields only when `opts != nil`; defaults leave `Middlewares`/`CaptureConfig` nil → chain resolves nil → fast path. Existing `reverseproxy_test.go` (non-chain) still passes. +- **`disable_access_log` is proto field 13, default false** — every existing target unset; gate is no-op. Locked by `TestMiddleware_SuppressAccessLog_DefaultEmitsLog` (middleware_test.go:104). +- **`Server` additions optional** — 256 MiB default when `MiddlewareCaptureBudgetBytes ≤ 0` (server.go:1997-2000). + +### Performance +- **Translate cost per push** — O(n) with per-entry registry lookup and `config_json` copy; negligible vs. the upstream gRPC unmarshal. +- **Empty-chain hot path** — one `ChainFor` map lookup + one `chain.Empty()` check; no allocation delta vs. pre-PR. +- **Body capture buffer churn** — `bodytap.CaptureRequest` allocates `MaxRequestBytes` per chain-hitting request; `releaseBudget` ties allocation to the 256 MiB proxy-wide budget. Confirm in module 30 the budget is a hard cap. + +### Observability +- **Metrics** — `Metrics.Meter()` shared with `middleware.NewMetrics` (server.go:1990-1993) so middleware instruments land in the same prometheus exporter. No new metrics defined here. +- **Access-log accuracy** — every entry carries `AgentNetwork`; terminal-slot metadata merged into `CapturedData.Metadata` (reverseproxy.go:238-241). +- **Deny logs at `Infof`** (reverseproxy.go:170) — review whether `Info` is too noisy at high deny rates; consider Debug or rate-limit. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| proxy/middleware_translate_test.go | Empty/nil → nil; field preservation; unknown ID skip; nil registry permissive; timeout clamping; fail-mode + slot incl. UNSPECIFIED-drop; empty-ID drop; truncation above + at `MaxMiddlewaresPerChain` | +| proxy/internal/proxy/reverseproxy_test.go | Rewrite host/headers/cookies/query; trusted proxy; path forwarding; classifyProxyError; X-NetBird-User/Groups anti-spoof + CSV-join + control-char/comma rejection + fallback-to-ID; `TestBuildRequestInput_PropagatesIdentityAndGroups` (UserGroups/Email/GroupNames/AgentNetwork reach `middleware.Input`) | +| proxy/internal/proxy/agent_network_chain_realstack_test.go | **The end-to-end integration test.** Drives a real agent-network request through `ReverseProxy.ServeHTTP` with the chain the synthesizer produces, against an in-process management gRPC (bufconn) backed by a real sqlite store + real `agentnetwork.Manager`, plus an `httptest` upstream — no external infrastructure or real LLM. Guarantees: (1) response-leg `respInput` carries `UserGroups` so `llm_limit_record` ships non-empty `group_ids` and the admin-group consumption row increments; (2) `RedactPii=true` redacts both prompt and completion on captured metadata; (3) the full chain runs against a real management stack. **Line 189-211 inlines the proto→Spec mapping** instead of calling the proxy's private `translateMiddlewareConfig` — keep that inline mirror in sync with `proxy/middleware_translate.go` or the test silently diverges from production. | +| proxy/internal/accesslog/middleware_test.go | `SuppressAccessLog=true` skips `SendAccessLog` (150ms negative wait); default emits one send (2s positive); usage tracking runs under suppression | +| proxy/internal/auth/middleware_test.go | `TestProtect_PrivateService_TunnelPeerGroupsPropagate` proves `peer_group_ids` reach `CapturedData.UserGroups`; `TestProtect_PrivateService_TunnelPeerDenied` proves rejected peers 403 without reaching the handler | + +The integration test runs in a few seconds with no external infrastructure — exercising the real synthesizer, `Manager.Rebuild`, `ServeHTTP` dispatch, and `llm_limit_record` writing a real consumption row through the real `agentnetwork.Manager` over real gRPC. + +## Known limitations / explicit non-goals + +- **Translator does not validate `RawConfig` JSON** — factory's job at `New([]byte)`. Confirm in module 30 that a per-binding factory failure doesn't poison the rest of the chain. +- **No throttle on management push rate** — every `MODIFIED` triggers `Manager.Rebuild`. Mitigation upstream. +- **Streaming responses (SSE)** — body capture is streaming-aware, but response-leg middleware runs only after the response completes; long SSE streams delay `llm_limit_record` until close. +- **OIDC-only path doesn't carry tunnel-peer groups** — agent-network synth services rely on the Private tunnel-peer path; JWT groups claim is the only carrier for non-Private OIDC. +- **`agent_network` flag on L4 entries** not added; HTTP-only. +- **`mw.capture.bypass_reason` metadata key** documented at reverseproxy.go:151,184; namespace this in module 30/31 to avoid collisions. + +## Cross-references +- Upstream: [shared/api](10-shared-api.md), [proxy/middleware-framework](30-proxy-middleware-framework.md), [proxy/middleware-builtin](31-proxy-middleware-builtin.md), [proxy/llm-parsers](32-proxy-llm-parsers.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/40-dashboard.md b/docs/agent-networks/modules/40-dashboard.md new file mode 100644 index 000000000..4ed9021bb --- /dev/null +++ b/docs/agent-networks/modules/40-dashboard.md @@ -0,0 +1,228 @@ +# dashboard — UI for agent-networks + +This module documents code that lives in the **dashboard repo** (under +`src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`), not +in this repo. It is co-located here so backend readers see the full picture. + +> **Risk level:** Medium. The new surface is isolated under `src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`, but it also reshapes the sidebar, splits `/peers`, renames `reverse-proxy/clusters` → `self-hosted-proxies`, and overlays the Control Center graph. Regressions here would be cross-cutting. +> **Backward-compat impact:** Additive on the API side. Breaking on URL/navigation: `/peers` redirects to `/peers/devices` (src/app/(dashboard)/peers/page.tsx:7-15), `/reverse-proxy/clusters` was renamed to `/reverse-proxy/self-hosted-proxies`, the sidebar lost Access Control / Networks / Reverse Proxy / DNS / standalone Guardrails / Consumption / Activity (Navigation.tsx:165-171 — routes still resolve via URL), and the standalone `/agent-network/{access-log,consumption,global-controls}` routes are gone in favor of `/agent-network/observability`. + +## Module boundary + +The dashboard is the only place an operator interacts with agent-networks: provider catalog, configured providers, policies, guardrails, account-level budget rules, account settings (collection / redaction toggles), per-request access log, and consumption rollups all render, paginate, and edit here. Data flows in via SWR (`useFetchApi`) keyed by REST URL. One big context provider (`src/modules/agent-network/AIProvidersProvider.tsx`) aggregates five resources (providers, policies, guardrails, budget rules, settings) plus the proxy access-log stream filtered to `agent_network=true`, and exposes `add* / update* / toggle* / delete*` mutators that call through `useApiCall` and re-`mutate()` SWR. Pages mount the provider once at the top and compose presentational tables and modals beneath. The control-center page additionally fetches `/agent-network/{providers,policies}` directly (control-center/page.tsx:123-130) to overlay graph nodes. + +## What the UI delivers + +- **AI Observability** page with four tabs: Access Logs, Budget Dashboard, + Budget Settings, Log Settings (replaces the standalone access-log, + consumption, and global-controls routes). +- **Providers** page: provider catalog + connect/edit wizard with per-vendor + copy (LiteLLM, Portkey, Bifrost, Cloudflare, Vercel, OpenRouter, custom). +- **Policies** page: group → provider authorization with per-policy Limits + (minute-granular windows) + guardrail attach. +- **Guardrails** page: reusable model-allowlist + prompt-capture sets. +- **Account controls**: Log Collection / Prompt Collection / Redact PII toggles. +- **Budget rules**: account-level rules reusing the policy Limits UI. +- **Control Center overlay**: provider + agent-policy nodes on the graph. +- **Navigation + peers reshaping**: peers split into Devices / Agents, + `reverse-proxy/clusters` renamed to `self-hosted-proxies`, sidebar + repackaged for agent-network focus. + +## Surface added + +### New pages + +| Route | Purpose | Backing module(s) | +| ----- | ------- | ----------------- | +| `/agent-network` | Redirect to `/agent-network/providers` | page.tsx:7-15 | +| `/agent-network/providers` | List + connect providers; header surfaces per-account base URL | providers/page.tsx + AgentProvidersTable + AIProviderModal | +| `/agent-network/policies` | Group → Provider authorization with per-policy Limits + Guardrail attach | policies/page.tsx + AgentPoliciesTable + AgentPolicyModal | +| `/agent-network/guardrails` | Reusable guardrail sets (model allowlist + prompt capture) | guardrails/page.tsx + AgentGuardrailsTable + AgentGuardrailModal | +| `/agent-network/observability` | Tabs: Access Logs / Budget Dashboard / Budget Settings / Log Settings | observability/page.tsx | +| `/peers/devices`, `/peers/agents` | Split of `/peers`, shared via `PeersListView` keyed by `kind` | peers/{devices,agents}/page.tsx | +| `/reverse-proxy/self-hosted-proxies` | Renamed from `clusters` | self-hosted-proxies/page.tsx | + +Removed in favor of `/agent-network/observability`: `/agent-network/access-log`, `/agent-network/consumption`, `/agent-network/global-controls`. + +### New modules under src/modules/agent-network + +| File | Role | +| ---- | ---- | +| AIProvidersProvider.tsx (~1158 LOC) | Aggregates every agent-network resource via SWR; normalises snake↔camel; exposes mutators; holds wizard-open state | +| AIProviderModal.tsx (~1268 LOC) | Connect / edit provider wizard with per-vendor copy (Bifrost, Portkey, LiteLLM, Cloudflare, Vercel, OpenRouter, custom) | +| AIProviderLogo + useProviderCatalog | Catalog-driven brand swatch + SWR hook over `/agent-network/catalog/providers` | +| AgentPoliciesTable + AgentPolicyModal + AgentPolicyGuardrailsTab + AgentPolicyLimitsTab | Policies; modal has 3 tabs (Rule, Limits, Guardrails) | +| AgentGuardrailsTable + AgentGuardrailModal + AgentGuardrailBrowseModal + AgentGuardrailChecksCell | Guardrails CRUD + attach-from-policy | +| AgentBudgetRulesTable + AgentBudgetRuleModal | Account-level budget rules; modal reuses AgentPolicyLimitsTab verbatim | +| AgentAccountControlsCard | Three account-wide toggles (Log Collection / Prompt Collection / Redact PII) | +| AgentAccessLogTable + AgentAccessLogExpandedRow | Access log on `/events/proxy?agent_network=true` | +| AgentConsumptionPanel + AgentConsumptionTable | Token + cost panel: charts + counter table | +| table/AgentProvidersTable + AgentProviderActionCell | Providers table + per-row actions | +| data/mockData.ts | Domain types and a few residual `MOCK_*` constants (see scrutinize) | + +### Touched non-agent-network areas + +- **control-center**: agent-network overlay (provider + agent-policy nodes); removed the All Networks dropdown; hid the Networks tab in FlowSelector (FlowSelector.tsx:9-14 — enum value kept so `?tab=networks` still type-checks); wrapped `ControlCenterView` in `AIProvidersProvider` (page.tsx:73-83); `agentPolicyNode` clicks routed to a separate state slot (page.tsx:1871-1874). New node renderers: nodes/ProviderNode.tsx, nodes/AgentPolicyNode.tsx (registered at utils/nodes.ts:21-22). +- **peers**: Split into Devices and Agents sub-routes; shared via `PeersListView` keyed by `kind` (PeersListView.tsx:24-95). New compact-toolbar `UserFilterSelector` (users/UserFilterSelector.tsx). +- **reverse-proxy**: Folder rename `clusters/` → `self-hosted-proxies/`; deleted `ClustersFeaturesCell.tsx`, `ClusterTypeIndicator.tsx`; new ReverseProxyClusterTargetSelector for cluster target type; Private toggle on target modal; body-capture knobs removed; new ReverseProxyEventExpandedRow. +- **events**: `ReverseProxyEventsUserCell` rewritten with user + peer fallback (ReverseProxyEventsUserCell.tsx:14-21), shared with the access-log table. +- **navigation**: Full repackaging in Navigation.tsx — Agent Network items flattened (no collapsible parent), distinct icons per item; Access Control, Networks, Reverse Proxy, DNS, standalone Guardrails, Consumption, Activity removed (still URL-reachable, per lines 165-171). + +## Architecture & flow + +### Page → Provider → Table/Modal hierarchy + +```mermaid +graph TD + Nav[Navigation.tsx] + Nav --> ProvidersPage[/agent-network/providers/] + Nav --> PoliciesPage[/agent-network/policies/] + Nav --> GuardrailsPage[/agent-network/guardrails/] + Nav --> ObsPage[/agent-network/observability/] + + ProvidersPage --> AIPP1[AIProvidersProvider] + PoliciesPage --> AIPP2[AIProvidersProvider] + GuardrailsPage --> AIPP3[AIProvidersProvider] + ObsPage --> AIPP4[AIProvidersProvider] + ObsPage -.wraps.-> GroupsProvider + ObsPage -.wraps.-> PeersProvider + + AIPP1 --> ProvTable[AgentProvidersTable] + ProvTable --> ProvModal[AIProviderModal] + AIPP2 --> PolTable[AgentPoliciesTable] + PolTable --> PolModal[AgentPolicyModal] + PolModal --> PolGuardTab[AgentPolicyGuardrailsTab] + PolModal --> PolLimitsTab[AgentPolicyLimitsTab] + PolGuardTab --> GuardBrowse[AgentGuardrailBrowseModal] + PolGuardTab --> GuardModal[AgentGuardrailModal] + AIPP3 --> GuardTable[AgentGuardrailsTable] + GuardTable --> GuardModal + AIPP4 --> Tabs[Tabs] + Tabs --> AccessLog[AgentAccessLogTable] + Tabs --> Consumption[AgentConsumptionPanel] + Tabs --> BudgetRules[AgentBudgetRulesTable] + Tabs --> AccountCtl[AgentAccountControlsCard] + BudgetRules --> BudgetModal[AgentBudgetRuleModal] + BudgetModal -.reuses.-> PolLimitsTab +``` + +### AI Observability tab page + +```mermaid +graph LR + Page[AIObservabilityPage] --> RA[RestrictedAccess
permission.services.read] + RA --> GP[GroupsProvider] + GP --> PP[PeersProvider] + PP --> AIP[AIProvidersProvider] + AIP --> Tabs[Tabs / TabsList] + Tabs --> T1[Access Logs
AgentAccessLogTable] + Tabs --> T2[Budget Dashboard
AgentConsumptionPanel] + Tabs --> T3[Budget Settings
AgentBudgetRulesTable] + Tabs --> T4[Log Settings
AgentAccountControlsCard] + T1 -.GET.-> EP[/events/proxy?agent_network=true/] + T2 -.GET poll 5s.-> CONS[/agent-network/consumption/] + T3 -.GET/PUT.-> BR[/agent-network/budget-rules/] + T4 -.GET/PUT.-> ST[/agent-network/settings/] +``` + +### Data fetch path + +```mermaid +graph TD + Page[Page component] --> Prov[AIProvidersProvider] + Prov -->|useFetchApi| SWR[(SWR cache
key = URL)] + SWR -.GET.-> P[/agent-network/providers/] + SWR -.GET.-> POL[/agent-network/policies/] + SWR -.GET.-> G[/agent-network/guardrails/] + SWR -.GET.-> BR[/agent-network/budget-rules/] + SWR -.GET ignoreError.-> ST[/agent-network/settings/] + SWR -.GET.-> CAT[/agent-network/catalog/providers/] + SWR -.GET pageSize=100.-> EVT[/events/proxy agent_network=true/] + Prov --> Mut[useApiCall.post/put/del] + Mut -.on success.-> MutateSWR[SWR mutate keys] + Prov --> Children[Tables / Modals via useAIProviders] +``` + +Every list view reaches management through SWR over `/api/agent-network/*`. The provider context maps snake-case payloads to camelCase domain types (`fromAPI`, `policyFromAPI`, `guardrailFromAPI`, `budgetRuleFromAPI`, `settingsFromAPI`, `accessLogFromAPI` — AIProvidersProvider.tsx:138-562) and back via matching `*ToRequest` adaptors. The access log piggy-backs on `/events/proxy` with `agent_network=true&page_size=100` (line 707-709) and decodes LLM-specific fields from per-event `metadata`. Group IDs on events are resolved to current names through the surrounding GroupsProvider catalog (lines 515-521, 717-731) — no extra round trip. Mutators run `*ToRequest`, await `useApiCall.post/put/del`, call SWR `mutate()`, then `notify`. Errors caught and surfaced via `notify` — no exceptions escape into render. The Connect Provider modal's open state lives in the provider itself (`isWizardOpen` at lines 732-735) so the providers-page empty-state CTA and the table's + button share one modal. Control-center re-fetches `/agent-network/{providers,policies}` directly on top of `AIProvidersProvider` — SWR de-dupes but the code path is harder to reason about. + +## Public contracts consumed + +- `GET/POST /api/agent-network/providers`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/policies`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/guardrails`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/budget-rules`, `PUT/DELETE /:id` +- `GET/PUT /api/agent-network/settings` (ignoreError-tolerant; 404 = not yet bootstrapped — auto-bootstrap on first provider create via `bootstrap_cluster` field — AIProvidersProvider.tsx:737-760) +- `GET /api/agent-network/catalog/providers` (read-only declarative; backend owns vendor list, IDs, brand colors, models, extra_headers, identity_injection — useProviderCatalog.ts:6-95) +- `GET /api/agent-network/consumption` (polled every 5s on Budget Dashboard — ConsumptionPanel.tsx:53,65-71) +- `GET /api/events/proxy?agent_network=true&page_size=100` (shared with Proxy Events) +- `permission?.services?.read` gates every agent-network route via RestrictedAccess. + +`AIProviderId` is a closed union in dashboard types (data/mockData.ts:8-21) but the converter tolerates anything the backend ships — unknown ids fall through to `"custom"` (AIProvidersProvider.tsx:497-506). Catalog values are pure read-through: anything declared in `extra_headers` renders in the modal automatically, copy keyed by header name (`EXTRA_HEADER_UI` in AIProviderModal.tsx:61-89), labeled-fallback for unknown ones. + +## Invariants + +- Provider context wrap order on user-attribution pages: `GroupsProvider > PeersProvider > AIProvidersProvider` (observability/page.tsx:87-89). Reverse it and access-log group resolution silently drops names. +- Every agent-network route checks `permission?.services?.read` via `RestrictedAccess` (observability/page.tsx:85, providers/page.tsx:184, policies/page.tsx:53, guardrails/page.tsx:55). +- Modal `key={open ? 1 : 0}` pattern is used to force unmount/remount on close so internal `useState` resets between edits (AgentBudgetRuleModal.tsx:60, AgentPolicyModal.tsx:66). Removing this would leak prior-row state into a new-row session. +- `mockData.ts` is the canonical home for ALL agent-network domain types; `MOCK_*` constants must never reach a production code path. One leak remains (below). + +## Things to scrutinize + +### Correctness + +- **Tab-state URL hand-off is one-way.** observability/page.tsx:53-58 reads `?tab=` on mount (despite the file comment at line 28 saying URL hand-off is future) but `setTab` does NOT push back, so reload preserves the chosen tab only if it came in via the link. Inconsistent with control-center (page.tsx:1817-1831). +- **Provider overlay runs only in `applySingleGroupView` / `applyPeerView`** (control-center/page.tsx:557, 1159-1166). User view does NOT show providers — if agent-network is a primary lens, that's a gap. +- **Two useEffects race to invalidate the control-center layout.** page.tsx:1655-1657 drops `layoutInitialized` when `agentPolicies` / `agentProviders` arrive; the main effect (1786-1799) also lists them as deps. Functional but fragile — watch for flash-of-empty-graph. +- **`updateProvider` / `updatePolicy` / `updateBudgetRule` use `??` on `enabled`** (AIProvidersProvider.tsx:784, 859, 1018). Toggle paths are safe; any caller sending `enabled: false` thinking "leave it off" gets `existing.enabled` instead. Audit modal callers. +- **Form validation in modals is minimal.** Window-seconds picker — mockData.ts:209-215 documents "minimum 60 — one minute" but there is no matching UI guard in PolicyLimitsTab; the backend validator is the enforcement point. + +### Security + +- **No client-side enforcement claims** — every cap, allowlist, and toggle is display + edit; proxy is the source of truth for deny decisions (AccessLogTable.tsx:177-191 renders backend-emitted `denyReason` as-is). +- **Prompt display is gated by what the backend stamps.** When `enable_prompt_collection` is OFF the proxy must not put prompt/completion into event metadata; the dashboard renders whatever it gets verbatim (AccessLogTable lines 532-534, AccessLogExpandedRow.tsx:42-57). No UI filter on top of backend collection switches. +- Account Controls disables `Redact PII` when `Prompt Collection` is off (AgentAccountControlsCard.tsx:122) and clears it on off-transition (line 100), but relies on backend to enforce the same gate at write — confirm PUT handler rejects `redact_pii=true && enable_prompt_collection=false`. +- **Bifrost identity-header overrides**: empty-string vs nil semantics documented in AIProvidersProvider.tsx:772-781 ("omitted = preserve, empty = explicit clear"). Mishandling could leak group attribution to a header the operator thought disabled. Focused read of Bifrost code path in AIProviderModal.tsx recommended. + +### Accessibility + +- Observability TabsList (observability/page.tsx:96-113) uses the shared Tabs component — should inherit Radix roving-tabindex. All four TabsTriggers carry only icon + text, no `aria-label`; fine because text is visible. +- Modal focus traps are inherited from the shared Modal; agent-network modals don't override them. Quick keyboard pass recommended. +- `EndpointBadge` Copy button (providers/page.tsx:66-76) has an `aria-label`, good. + +### Performance + +- `AgentConsumptionPanel` polls `/agent-network/consumption` every 5s (ConsumptionPanel.tsx:53,70). Tab switches unmount the panel, so the poll stops — verify in network panel. +- `AgentAccessLogTable` is hard-capped at 100 rows via `page_size=100` (AIProvidersProvider.tsx:707-709). Server-side pagination is future work; high-traffic tenants miss everything past row 100 — known limitation. +- Observability page mounts providers ONCE at page level (observability/page.tsx:87-89); tab switches keep SWR cache hot. Moving the provider mount inside `TabsContent` would re-fetch the access log on every switch. + +### Visual consistency + +- The observability tab style mirrors peers/page.tsx. Outer Tabs `pt-4 pb-0 mb-0`, TabsList `px-8` (observability/page.tsx:94-96) — confirm chrome height matches so the page doesn't visually jump. +- Sidebar: `Boxes` for Providers, `AccessControlIcon` for Policies, `TelescopeIcon` for AI Observability (Navigation.tsx:113,120,133). Reusing `AccessControlIcon` makes Policies look identical to the (now hidden) Access Control item — if Access Control ever comes back, they collide. +- `AgentNetworkIcon` is used in breadcrumbs on every agent-network page but NOT in the sidebar (per-page icons instead). Deliberate departure — record so it doesn't get reverted. + +## Test coverage + +- **Cypress**: One file (`cypress/e2e/test.cy.ts`) covering only the install-page copy-to-clipboard flow. NOTHING covers agent-network UI. +- **Component / unit tests**: `src/utils/version.test.ts` is the only `.test.*` file in the repo. The agent-network modules ship without component tests. +- Data-cy hooks exist on key controls: `save-account-controls` (AgentAccountControlsCard.tsx:71), `enable-log-collection`, `enable-prompt-collection`, `redact-pii`, plus existing `data-cy={policy.name}` / `data-cy={provider.name}` on ActiveInactiveRow. Sufficient hooks for Cypress flows; none written yet. +- **Tooling gap (pre-existing):** `npm run lint` (`next lint`) is broken in Next 16 — the `lint` subcommand was removed from the Next CLI in 16.x, so the dashboard effectively has no working lint gate. The fix is to add either a flat-config `eslint .` script or wire ESLint via an explicit `eslint-config-next` invocation. + +## Known limitations / explicit non-goals + +- **`data/mockData.ts` still contains `MOCK_GROUPS`, `MOCK_PROVIDERS`, `MOCK_PEERS`.** Only `MOCK_GROUPS` is referenced from production — AgentPoliciesTable.tsx:45,76 uses it as a name-lookup fallback when a policy references a group ID the real GroupsProvider doesn't know about. `MOCK_PROVIDERS` / `MOCK_PEERS` are unreferenced; safe to delete. The file is `/* eslint-disable */` so dead-code warnings don't flag them. +- **Tab-state URL hand-off on observability page is one-way** (read-only). +- **Access log hard-capped at 100 rows**; no server-side pagination. +- **No optimistic updates.** All mutations are round-trip; failures rollback via SWR revalidation. +- **`FlowView.NETWORKS` retained but hidden** from FlowSelector (FlowSelector.tsx:9-14). Old `?tab=networks` links still route to the hidden view because `applyNetworksView` still runs. +- **Redirects are not query-preserving** — `router.replace("/peers/devices")` (peers/page.tsx:13) strips any incoming filter params. +- **Control-center cross-fetches** `/agent-network/{providers,policies}` directly on top of `AIProvidersProvider`. Could be collapsed. +- **Sidebar permanently hides Access Control, Networks, Reverse Proxy, standalone Guardrails, DNS, Activity, Consumption.** Routes still resolve via URL (Navigation.tsx:165-171); intentional. + +## Cross-references + +- Upstream API contracts: [shared/api](10-shared-api.md) +- Backend persistence: [management/store](20-management-store.md) +- Backend handler wiring: [management/handlers + wiring](22-management-handlers-wiring.md) +- End-to-end flow narrative: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level overview: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/50-path-routed-providers.md b/docs/agent-networks/modules/50-path-routed-providers.md new file mode 100644 index 000000000..b7cda3a97 --- /dev/null +++ b/docs/agent-networks/modules/50-path-routed-providers.md @@ -0,0 +1,251 @@ +# path-routed providers — Vertex AI + Bedrock + +This guide pulls the **path-routed** provider story together in one place +because it crosses the catalog, the synthesiser, the request parser, and the +router. The relevant building blocks are the `llm_router` / +`llm_request_parser` middlewares +([31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md)), the +per-provider parser surface ([32-proxy-llm-parsers.md](32-proxy-llm-parsers.md)), +and the synthesiser's catalog → `ProviderRoute` mapping +([21-management-agentnetwork.md](21-management-agentnetwork.md)). + +Sibling modules: [31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md) +(router + request parser) and [32-proxy-llm-parsers.md](32-proxy-llm-parsers.md) +(Bedrock parser + pricing). + +--- + +## What "path-routed" means + +Most catalog providers carry the model in the request **body** (`{"model": …}`), +so `llm_router` selects an upstream by matching the model name against each +provider's `Models` claim. Two providers instead carry the model in the **URL +path**, so they are routed by path before the model/vendor table is consulted: + +| Catalog id | Style flag | Request path shape | +|---|---|---| +| `vertex_ai_api` | `IsVertexPathStyle` → `ProviderRoute.Vertex` | `/v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{action}` | +| `bedrock_api` | `IsBedrockPathStyle` → `ProviderRoute.Bedrock` | `/model/{modelId}/{action}` (optionally behind `/bedrock`) | + +The catalog declares the style with +[`catalog.IsVertexPathStyle` / `catalog.IsBedrockPathStyle`](../../../management/server/agentnetwork/catalog/catalog.go) +and the synthesiser copies the result onto the router route as the `Vertex` / +`Bedrock` booleans +([synthesizer.go:450-451](../../../management/server/agentnetwork/synthesizer.go)). +On the request leg `llm_router.Invoke` dispatches `isVertexPath` / `isBedrockPath` +**before** the model lookup +([llm_router/middleware.go:138-216](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +so a model the parser extracted from the path can't be claimed by a same-vendor +*body-routed* provider (e.g. `claude-*` on `api.anthropic.com`). + +## Google Vertex AI (`vertex_ai_api`) + +### Catalog entry + +`KindProvider`, parser surface left unset on the catalog entry — the request +parser picks the parser from the URL **publisher** segment, not from +`ParserID`. Upstream host is `-aiplatform.googleapis.com` +(`https://aiplatform.googleapis.com` for the `global` location). The catalog +lists the Claude-on-Vertex lineup (`claude-opus-4-*`, `claude-sonnet-4-*`, +`claude-haiku-4-5`, `claude-fable-5`) at the same per-token rates as the +first-party Anthropic entry +([catalog.go:333-363](../../../management/server/agentnetwork/catalog/catalog.go)). + +### Credential — service-account OAuth (`keyfile::`) + +Vertex does **not** accept a static API key. The operator sets the provider +`api_key` to: + +``` +keyfile:: +``` + +The synthesiser recognises the `keyfile::` prefix in `providerAuthHeader` +([synthesizer.go:897-903](../../../management/server/agentnetwork/synthesizer.go)), +emits **no** static auth value, and carries the base64 key material on the +route as `GCPServiceAccountKeyB64` +([factory.go:56-61](../../../proxy/internal/middleware/builtin/llm_router/factory.go)). +At request time the router mints a short-lived OAuth2 access token from the key +(cloud-platform scope) and injects `Authorization: Bearer ` — +never the key itself +([llm_router/middleware.go:621-692](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): + +- One auto-refreshing `oauth2.TokenSource` is cached per key (keyed by a + SHA-256 of the base64 material), so token minting happens once and refreshes + amortise across requests. +- Mint / refresh is bounded by a 10s timeout HTTP client (`gcpTokenTimeout`) so + a slow Google token endpoint can't hang the request. +- A malformed key or an unreachable token endpoint fails the request with + `llm_policy.upstream_auth_failed` at HTTP **502** (an upstream problem, not a + policy denial) — see `denyUpstreamAuth`. + +### Metering — Anthropic-on-Vertex only + +The request parser extracts `{publisher, model, action}` from the path +(`parseVertexPath`, [llm_request_parser/middleware.go:237-263](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)), +strips the `@version` suffix from the model, and maps the publisher to a parser +surface via `vertexPublisherVendor`: + +- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic + parser, priced under the **`anthropic`** block in `defaults_pricing.yaml` + (the parser emits the standard Anthropic provider label, so Vertex Claude + reuses first-party Anthropic prices). +- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup + today). +- anything else (notably `google` / Gemini) → empty vendor → **no parser**. + +**Gemini is intentionally denied as unmeterable.** When the parser emits no +`llm.provider` for a Vertex publisher, `llm_router` returns +`llm_policy.unmeterable_publisher` (403) rather than forwarding the request +uncounted — serving it would bypass token / budget metering +([llm_router/middleware.go:144-162, 712-728](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)). +A Gemini parser would lift this restriction; until then the `google` publisher +is omitted from the catalog. + +> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price +> premium that the base per-token rates do **not** model — cost annotations for +> those regions read low. Operators who need exact regional billing override +> the affected entries in `pricing.yaml`. + +## AWS Bedrock (`bedrock_api`) + +### Catalog entry + +`KindProvider`, upstream host `bedrock-runtime..amazonaws.com`. Metered +models are the Anthropic-on-Bedrock lineup (`anthropic.claude-*`) plus Amazon +Nova and Llama 3.3 entries +([catalog.go:300-332](../../../management/server/agentnetwork/catalog/catalog.go)). +Anthropic-on-Bedrock reuses the first-party Claude prices (with additive cache +buckets); Nova / Llama report no cache, so cost is `input + output`. + +### Credential — static bearer token + +Bedrock uses the **AWS Bedrock API key** as a static bearer. The operator sets +the provider `api_key` directly (no `keyfile::` prefix); the catalog template +is `Authorization: Bearer ${API_KEY}` +([catalog.go:306-307](../../../management/server/agentnetwork/catalog/catalog.go)). +No token minting — the synthesiser substitutes the key into the template and +the router injects the resulting `Authorization` header after stripping inbound +vendor auth (including client-supplied AWS SigV4 material: `X-Amz-Date`, +`X-Amz-Security-Token`, `X-Amz-Content-Sha256`, see `strippedAuthHeaders`). + +### Model id form — cross-region inference profiles + +Bedrock model ids in the request path must be the cross-region +**inference-profile** form, e.g. +`eu.anthropic.claude-sonnet-4-5-20250929-v1:0`. The bare +`anthropic.claude-…` id is rejected by AWS. `normalizeBedrockModel` +([llm_request_parser/middleware.go:398-414](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)) +strips the region prefix (`us.` / `eu.` / `apac.` / `global.`), an optional ARN +wrapper, and the `-YYYYMMDD-vN[:N]` version/throughput suffix so the normalised +id (`anthropic.claude-sonnet-4-5`) matches the catalog/pricing key. + +### Supported endpoints + actions + +`/model/{modelId}/{action}` where action ∈ `invoke`, +`invoke-with-response-stream`, `converse`, `converse-stream` +([llm_request_parser/middleware.go:363-390](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +`invoke` / `converse` are non-streaming; the `-stream` actions set the streaming +flag. + +- **InvokeModel** body uses the vendor-native shape — for Anthropic that means + `"anthropic_version":"bedrock-2023-05-31"` and snake_case usage with additive + cache buckets. +- **Converse** uses the unified camelCase shape with a precomputed `totalTokens`. +- The `BedrockParser` reads both shapes on the response leg + ([bedrock.go](../../../proxy/internal/llm/bedrock.go)); the request parser + doesn't need to distinguish them (`ParseRequest` is a no-op — model + stream + come from the path). + +### Streaming — AWS binary event-stream + +The `-stream` actions return `application/vnd.amazon.eventstream` (the AWS +binary event-stream framing), and streaming **is metered**. +`accumulateBedrockStream` +([llm_response_parser/streaming_bedrock.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go)) +decodes the frames with `aws-sdk-go-v2/aws/protocol/eventstream`: + +- InvokeModel `chunk` frames wrap a base64 `{"bytes":…}` payload carrying a + vendor-native (Anthropic) stream event — folded through the shared Anthropic + stream accumulator. +- Converse `contentBlockDelta` frames carry text; the trailing `metadata` frame + carries the final usage block. +- A truncated stream (cut at the body-tap capture cap) decodes best-effort: + frames up to the cut are applied and partial usage is returned. + +### Optional `/bedrock` gateway-namespace prefix + +Clients may place an optional `/bedrock` prefix before the native path +(`/bedrock/model/{modelId}/{action}`) to disambiguate Bedrock from other +providers that also use `/model/...`. Both the request parser +(`trimBedrockNamespace`) and the router (`splitBedrockNamespace`) accept it. +When the prefix is present, the router sets +`RewriteUpstream.StripPathPrefix = "/bedrock"` so the **native** path +(`/model/...`) is what reaches `bedrock-runtime..amazonaws.com` +([llm_router/middleware.go:168-184, 320-348](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)). + +## Model allowlist on path-routed providers + +Because the model lives in the URL rather than the body, a path-routed provider +credential could otherwise be used for any model the upstream supports. The +router still enforces the route's `Models` allowlist via `matchPathRoute` +([llm_router/middleware.go:370-416](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): + +1. Filter to routes of the matching style (`Vertex` / `Bedrock`). +2. Filter to routes whose `AllowedGroupIDs` authorise the caller's groups + (else `no_authorised_provider`). +3. Filter to routes that **claim the requested model**. As with body-routed + providers, an **empty `Models` list = catch-all** (serve any model); + a non-empty list serves only the listed models (else `model_not_routable`). +4. Multiple survivors disambiguate by longest `UpstreamPath` prefix match. + +So an operator who lists explicit models on a Vertex/Bedrock provider gets a +hard allowlist; an operator who leaves `Models` empty accepts every model the +upstream serves (still subject to the unmeterable-publisher gate on Vertex). + +Model-less OpenAI endpoints (`GET /v1/models`) are **never** routed to a +Vertex/Bedrock provider — `matchModelless` skips path-routed routes +([llm_router/middleware.go:427-462](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +so a model-listing call can't be rewritten onto an upstream that would 404 it. + +## Catalog ↔ pricing cross-check + +Catalog prices and context windows are cross-checked against LiteLLM's +`model_prices_and_context_window.json`. The proxy's embedded +`defaults_pricing.yaml` covers **every metered first-party model** the catalog +enumerates — guarded by +`TestDefaultTable_FirstPartyModelCoverage` +([pricing/defaults_coverage_test.go](../../../proxy/internal/llm/pricing/defaults_coverage_test.go)), +which fails if a catalog model has no embedded price. Bedrock entries are keyed +by the **normalised** id the request parser emits (region prefix + version +suffix stripped). Vertex Claude carries no Bedrock-style prefix, so it prices +straight off the `anthropic` block. + +## Things to scrutinise + +**Security.** The Vertex service-account key is never forwarded — only a minted +short-lived bearer. Confirm the key material stays out of access logs (it lives +on `ProviderRoute.GCPServiceAccountKeyB64`, not in any emitted metadata key). +The unmeterable-publisher deny is the only thing standing between an +operator-misconfigured Vertex provider and unmetered Gemini traffic; verify +`vertexPublisherVendor` stays conservative (deny by default for unknown +publishers). + +**Correctness.** `normalizeBedrockModel` is the join between the wire id and the +pricing key — a model that normalises to something not in `defaults_pricing.yaml` +meters at `cost.skipped=unknown_model` rather than failing the request. The +`/bedrock` prefix strip must run on both the parser side (so the model is +extracted) and the router side (so the upstream path is native); a regression in +either silently breaks the other. + +**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a +~10% premium not modelled by base pricing — flagged in both the catalog comment +and `defaults_pricing.yaml`. Operators needing exact regional billing override +the relevant entries. + +## Cross-references + +- Router + request-parser detail: [31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md) +- Bedrock parser + pricing + SSE / event-stream: [32-proxy-llm-parsers.md](32-proxy-llm-parsers.md) +- Catalog → route synthesis + `keyfile::` handling: [21-management-agentnetwork.md](21-management-agentnetwork.md) +- Overview: [../00-overview.md](../00-overview.md) diff --git a/e2e/agentnetwork/bootstrap_test.go b/e2e/agentnetwork/bootstrap_test.go new file mode 100644 index 000000000..a73d55117 --- /dev/null +++ b/e2e/agentnetwork/bootstrap_test.go @@ -0,0 +1,30 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCombinedBootstrap proves Pillar 1: the shared combined server came up and +// the /api/setup-minted PAT authenticates a real management API call through +// the typed REST client (the bootstrap itself ran in TestMain). +func TestCombinedBootstrap(t *testing.T) { + ctx := context.Background() + + require.NotEmpty(t, srv.PAT, "TestMain must have minted an admin PAT") + + users, err := srv.API().Users.List(ctx) + require.NoError(t, err, "authenticated Users.List must round-trip") + require.NotEmpty(t, users, "the bootstrapped account must have at least one user") + + var emails []string + for _, u := range users { + emails = append(emails, u.Email) + } + assert.Contains(t, emails, "admin@netbird.test", "the bootstrapped owner should appear in the users list") +} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go new file mode 100644 index 000000000..5e3a79273 --- /dev/null +++ b/e2e/agentnetwork/chat_test.go @@ -0,0 +1,281 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// providerCase is one entry in the live provider matrix. The same scenario runs +// for every available provider; availability is keyed off env vars so the suite +// covers whatever credentials are present (source ~/.llm-keys locally / set the +// Actions secrets in CI). +type providerCase struct { + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path +} + +// availableProviders builds the matrix from the provider env vars that are set. +func availableProviders() []providerCase { + var ps []providerCase + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + ps = append(ps, providerCase{name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, model: "gpt-4o-mini", kind: harness.WireChat}) + } + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) + } + if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { + ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) + } + if k, u := os.Getenv("OPENROUTER_TOKEN"), os.Getenv("OPENROUTER_URL"); k != "" && u != "" { + // Distinct model string from Vercel so each provider routes unambiguously + // while all are enabled together. + ps = append(ps, providerCase{name: "openrouter", catalogID: "openrouter", upstream: u, apiKey: k, model: "openai/gpt-4o", kind: harness.WireChat}) + } + if k, u := os.Getenv("CLOUDFLARE_TOKEN"), os.Getenv("CLOUDFLARE_URL"); k != "" && u != "" { + // Cloudflare AI Gateway routes by a provider segment in the URL path; + // append the openai provider unless the gateway URL already carries one. + if !strings.Contains(u, "/openai") { + u = strings.TrimRight(u, "/") + "/openai" + } + // Raw model (distinct string from OpenAI's gpt-4o-mini). + ps = append(ps, providerCase{name: "cloudflare", catalogID: "cloudflare_ai_gateway", upstream: u, apiKey: k, model: "gpt-4o", kind: harness.WireChat}) + } + // Vertex (vertex_ai_api): Anthropic-on-Vertex, path-routed, SA-OAuth + // (api_key = keyfile::). The model travels in the rawPredict path rather + // than the body, so the provider is created without a models array. Region + // defaults to "global" (host aiplatform.googleapis.com); a real region uses + // -aiplatform.googleapis.com. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + project := os.Getenv("GOOGLE_VERTEX_PROJECT") + if project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + model := os.Getenv("GOOGLE_VERTEX_MODEL") + if model == "" { + model = "claude-sonnet-4-5@20250929" + } + ps = append(ps, providerCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, model: model, kind: harness.WireVertex, + project: project, region: region, + }) + } + } + + // Bedrock: path-routed, bearer auth. Model is a cross-region inference + // profile id (distinct string from the first-party Anthropic case). + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "us-east-1" + } + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireMessages}) + } + return ps +} + +// providerRequest builds a create request for a matrix provider: enabled, with +// a uniquely-priced model for body-routed providers and none for the +// path-routed Vertex (whose model lives in the request path). +func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { + req := api.AgentNetworkProviderRequest{ + Name: pc.name, + ProviderId: pc.catalogID, + UpstreamUrl: pc.upstream, + ApiKey: &pc.apiKey, + Enabled: ptr(true), + } + if pc.kind != harness.WireVertex { + req.Models = &[]api.AgentNetworkProviderModel{ + {Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002}, + } + } + return req +} + +// TestProvidersMatrix is Pillar 3: it provisions every available provider (all +// enabled, each with a unique model so routing stays unambiguous), runs proxy + +// client once, and drives the same live chat-completion scenario through each +// provider over the WireGuard tunnel. Each provider must return 200 and produce +// an ingested access-log row. +func TestProvidersMatrix(t *testing.T) { + matrix := availableProviders() + if len(matrix) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run the provider matrix") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + // Group + setup key the client joins into; the policy authorizes it. + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-agents"}) + require.NoError(t, err, "create agents group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Create every provider, all enabled, each with a unique model string so the + // proxy's connect-time snapshot carries them all and model→provider routing + // is unambiguous (provider toggles after connect don't reconcile to the + // proxy, so we enable everything up front). The first create bootstraps the + // cluster. + ids := make([]string, 0, len(matrix)) + for i, pc := range matrix { + req := providerRequest(pc) + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + ids = append(ids, prov.Id) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + // Token limit at the 60s window floor with caps far above the few hundred + // tokens this suite drives, so it never blocks traffic but switches on + // usage metering, which is what makes consumption rows get recorded. + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + // Proxy (global CLI token) + client, brought up once. + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + + for _, pc := range matrix { + pc := pc + t.Run(pc.name, func(t *testing.T) { + before, _ := srv.ListAccessLogs(ctx) + + // Unique per provider so we can find this provider's row by its + // session id and confirm the marker propagated end-to-end. + sessionID := "e2e-session-" + pc.name + + // Retry briefly to absorb tunnel/DNS jitter on the first call. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + var c int + var b string + var cerr error + if pc.kind == harness.WireVertex { + c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID) + } else { + c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + } + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, "chat through %s (%s %s) should return 200; body: %s", pc.name, pc.kind, pc.model, body) + + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + return lerr == nil && logs.TotalRecords > before.TotalRecords + }, 30*time.Second, 2*time.Second, "an access-log row should be ingested for %s", pc.name) + + // The session id sent as x-session-id must round-trip into the + // access-log row for this provider. + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name) + }) + } + + // Metering: the policy's uncapped token limit switches on usage recording, + // so the live traffic just driven must surface as consumption rows with + // positive token counts. Consumption is account-scoped (keyed by source + // group / user and time window, not per provider), and ingest is async, so + // poll for any row that has booked tokens. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic") +} diff --git a/e2e/agentnetwork/main_test.go b/e2e/agentnetwork/main_test.go new file mode 100644 index 000000000..17c5e00be --- /dev/null +++ b/e2e/agentnetwork/main_test.go @@ -0,0 +1,46 @@ +//go:build e2e + +// Package agentnetwork holds the container-based agent-network e2e suite. A +// single combined server is built and bootstrapped once per package run +// (TestMain) and shared across tests via srv; each test creates and cleans up +// its own resources so order doesn't matter. +package agentnetwork + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/netbirdio/netbird/e2e/harness" +) + +// srv is the shared combined server for the package, ready (PAT-authenticated) +// by the time any Test runs. +var srv *harness.Combined + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + // Generous timeout to cover a cold image build on first run. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + var err error + srv, err = harness.StartCombined(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err) + return 1 + } + defer func() { _ = srv.Terminate(context.Background()) }() + + if _, err := srv.Bootstrap(ctx); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err) + return 1 + } + + return m.Run() +} diff --git a/e2e/agentnetwork/management_test.go b/e2e/agentnetwork/management_test.go new file mode 100644 index 000000000..cfd03f63c --- /dev/null +++ b/e2e/agentnetwork/management_test.go @@ -0,0 +1,221 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func ptr[T any](v T) *T { return &v } + +// newProvider creates an OpenAI-catalog provider with a dummy key (these tests +// never call the upstream) and registers cleanup. +func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider { + t.Helper() + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-dummy-e2e-key"), + BootstrapCluster: ptr("eu.proxy.netbird.test"), + }) + require.NoError(t, err, "create provider %q", name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + return prov +} + +// requireClientError asserts err is a REST APIError with a 4xx status. +func requireClientError(t *testing.T, err error) { + t.Helper() + var apiErr *rest.APIError + require.ErrorAs(t, err, &apiErr, "expected a REST APIError") + assert.GreaterOrEqual(t, apiErr.StatusCode, 400, "expected a 4xx status") + assert.Less(t, apiErr.StatusCode, 500, "expected a 4xx status") +} + +// TestProviderLifecycle covers create → get → list → delete → 404 for every +// available real provider catalog (and a synthetic OpenAI provider when no +// provider keys are set), so each catalog's create and field round-trip is +// exercised. Create is offline — no upstream call — so this stays fast and +// burns no provider quota. +func TestProviderLifecycle(t *testing.T) { + ctx := context.Background() + + cases := availableProviders() + if len(cases) == 0 { + cases = []providerCase{{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", + apiKey: "sk-dummy-e2e-key", model: "gpt-4o-mini", kind: harness.WireChat, + }} + } + + for i, pc := range cases { + i, pc := i, pc + t.Run(pc.name, func(t *testing.T) { + req := providerRequest(pc) + req.Name = "lc-" + pc.name + // Bootstrap the cluster on the first create in case the matrix has + // not run (e.g. no provider keys → settings not yet bootstrapped). + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create %s provider", pc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + assert.NotEmpty(t, prov.Id, "created provider must have an id") + assert.Equal(t, pc.catalogID, prov.ProviderId, "catalog id must round-trip") + assert.Equal(t, req.Name, prov.Name, "name must round-trip") + assert.Equal(t, pc.upstream, prov.UpstreamUrl, "upstream must round-trip") + + got, err := srv.GetProvider(ctx, prov.Id) + require.NoError(t, err, "get provider") + assert.Equal(t, prov.Id, got.Id) + + list, err := srv.ListProviders(ctx) + require.NoError(t, err, "list providers") + var ids []string + for _, p := range list { + ids = append(ids, p.Id) + } + assert.Contains(t, ids, prov.Id, "created provider must appear in the list") + + require.NoError(t, srv.DeleteProvider(ctx, prov.Id), "delete provider") + _, err = srv.GetProvider(ctx, prov.Id) + requireClientError(t, err) + }) + } +} + +// TestProviderValidation exercises the create-time validation rules. These are +// uniform across catalogs (no per-provider required-field rules exist: a +// catalog-specific malformed value such as a Vertex key without the keyfile:: +// prefix is accepted at create and only fails at the proxy), so the cases here +// are catalog-agnostic: missing API key, unknown catalog id, an invalid upstream +// URL, and a blank name. +func TestProviderValidation(t *testing.T) { + ctx := context.Background() + + _, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "No Key", + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "Unknown Catalog", + ProviderId: "totally_unknown_provider", + UpstreamUrl: "https://example.com", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "Bad Upstream", + ProviderId: "openai_api", + UpstreamUrl: "not-a-url", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: " ", + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) +} + +// TestSettingsRoundTrip flips the collection toggles and confirms cluster / +// subdomain stay immutable, then restores the original state. +func TestSettingsRoundTrip(t *testing.T) { + ctx := context.Background() + + // Settings are bootstrapped on first provider create. + newProvider(t, ctx, "Settings Bootstrap") + + before, err := srv.GetSettings(ctx) + require.NoError(t, err, "get settings") + require.NotEmpty(t, before.Cluster, "settings must carry an assigned cluster") + + flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: !before.EnableLogCollection, + EnablePromptCollection: !before.EnablePromptCollection, + RedactPii: !before.RedactPii, + }) + require.NoError(t, err, "update settings") + assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip") + assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip") + assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates") + assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates") + + // Restore the original toggles. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + }) + require.NoError(t, err, "restore settings") +} + +// TestPolicyWindowFloor rejects an enabled limit below the 60s window floor and +// accepts one at the floor. +func TestPolicyWindowFloor(t *testing.T) { + ctx := context.Background() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-policy-grp"}) + require.NoError(t, err, "create source group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + prov := newProvider(t, ctx, "Policy Provider") + + limits := func(window int64) *api.AgentNetworkPolicyLimits { + return &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 1000, + UserCap: 1000, + WindowSeconds: window, + }, + } + } + + _, err = srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-below-floor", + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: limits(30), + }) + requireClientError(t, err) + + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-at-floor", + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: limits(60), + }) + require.NoError(t, err, "policy at the 60s floor must be accepted") + assert.NotEmpty(t, pol.Id, "created policy must have an id") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) +} + +// TestConsumptionList confirms the read endpoint always returns an array, never +// a 404/500. +func TestConsumptionList(t *testing.T) { + ctx := context.Background() + + rows, err := srv.ListConsumption(ctx) + require.NoError(t, err, "consumption list must not error") + assert.NotNil(t, rows, "consumption must be a JSON array (possibly empty)") +} diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client new file mode 100644 index 000000000..114577d60 --- /dev/null +++ b/e2e/harness/Dockerfile.client @@ -0,0 +1,24 @@ +# Multistage build for the NetBird client used in e2e tests. The repo has no +# source-building client Dockerfile (client/Dockerfile packages a goreleaser +# artifact), so this mirrors its alpine runtime + entrypoint while compiling the +# CGO-free client inline. BuildKit cache mounts keep rebuilds incremental. + +FROM golang:1.25-bookworm AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/netbird ./client + +FROM alpine:3.24 +RUN apk add --no-cache bash ca-certificates ip6tables iproute2 iptables +ENV NETBIRD_BIN="/usr/local/bin/netbird" \ + NB_LOG_FILE="console,/var/log/netbird/client.log" \ + NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ + NB_ENABLE_CAPTURE="false" \ + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" +ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] +COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --from=builder /out/netbird /usr/local/bin/netbird diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go new file mode 100644 index 000000000..192385ab1 --- /dev/null +++ b/e2e/harness/agentnetwork.go @@ -0,0 +1,130 @@ +//go:build e2e + +package harness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The shared REST client doesn't (yet) expose typed agent-network methods, so +// these helpers drive the /api/agent-network/* endpoints through the client's +// NewRequest primitive — reusing its auth, error handling (rest.APIError on +// non-2xx), and transport — while still speaking the generated api types. + +// anRequest issues an agent-network API call and decodes the JSON response into +// T. A non-2xx response surfaces as a *rest.APIError from the client, which +// tests inspect for negative-path status assertions. +func anRequest[T any](ctx context.Context, c *Combined, method, path string, body any) (T, error) { + var out T + var reader io.Reader + if body != nil { + bs, err := json.Marshal(body) + if err != nil { + return out, fmt.Errorf("marshal %s %s: %w", method, path, err) + } + reader = bytes.NewReader(bs) + } + + resp, err := c.api.NewRequest(ctx, method, path, reader, nil) + if err != nil { + return out, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, fmt.Errorf("decode %s %s response: %w", method, path, err) + } + return out, nil +} + +// anDelete issues a DELETE and discards the (empty-object) body. +func anDelete(ctx context.Context, c *Combined, path string) error { + resp, err := c.api.NewRequest(ctx, http.MethodDelete, path, nil, nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// CreateProvider creates an agent-network provider. +func (c *Combined) CreateProvider(ctx context.Context, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPost, "/api/agent-network/providers", req) +} + +// GetProvider fetches a provider by id. +func (c *Combined) GetProvider(ctx context.Context, id string) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers/"+id, nil) +} + +// ListProviders returns all providers for the account. +func (c *Combined) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) { + return anRequest[[]api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers", nil) +} + +// DeleteProvider removes a provider by id. +func (c *Combined) DeleteProvider(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/providers/"+id) +} + +// SetProviderEnabled toggles a provider's enabled flag, preserving its other +// fields (the API key is omitted, which keeps the stored one). Used to run one +// provider at a time so model→provider routing is unambiguous. +func (c *Combined) SetProviderEnabled(ctx context.Context, id string, enabled bool) error { + p, err := c.GetProvider(ctx, id) + if err != nil { + return err + } + _, err = anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, api.AgentNetworkProviderRequest{ + Name: p.Name, + ProviderId: p.ProviderId, + UpstreamUrl: p.UpstreamUrl, + Enabled: &enabled, + Models: &p.Models, + }) + return err +} + +// CreatePolicy creates an agent-network policy. +func (c *Combined) CreatePolicy(ctx context.Context, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) { + return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPost, "/api/agent-network/policies", req) +} + +// UpdatePolicy replaces a policy by id. +func (c *Combined) UpdatePolicy(ctx context.Context, id string, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) { + return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPut, "/api/agent-network/policies/"+id, req) +} + +// DeletePolicy removes a policy by id. +func (c *Combined) DeletePolicy(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/policies/"+id) +} + +// GetSettings returns the account's agent-network settings row. It exists only +// after the first provider create bootstraps it. +func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil) +} + +// UpdateSettings applies the mutable collection toggles. +func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req) +} + +// ListConsumption returns the account's consumption rows (possibly empty). +func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) { + return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil) +} + +// ListAccessLogs returns the account's agent-network access-log page (the +// flattened per-request rows the proxy ships and management ingests). +func (c *Combined) ListAccessLogs(ctx context.Context) (api.AgentNetworkAccessLogsResponse, error) { + return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, "/api/agent-network/access-logs", nil) +} diff --git a/e2e/harness/bootstrap.go b/e2e/harness/bootstrap.go new file mode 100644 index 000000000..defa03c14 --- /dev/null +++ b/e2e/harness/bootstrap.go @@ -0,0 +1,47 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Bootstrap creates the initial admin owner through the unauthenticated +// /api/setup endpoint and returns the plaintext admin PAT. It also wires an +// authenticated REST client on the Combined (see API). create_pat requires the +// server to run with NB_SETUP_PAT_ENABLED=true, which the harness sets. A +// second call returns an error (the server reports setup already completed). +func (c *Combined) Bootstrap(ctx context.Context) (string, error) { + // The setup endpoint is unauthenticated; use a tokenless client. + setupClient := rest.NewWithOptions(rest.WithManagementURL(c.BaseURL)) + + createPAT := true + expireDays := 1 + resp, err := setupClient.Instance.Setup(ctx, api.PostApiSetupJSONRequestBody{ //nolint:gosec // static throwaway test credentials + Email: "admin@netbird.test", + Password: "Netbird-e2e-Passw0rd!", + Name: "E2E Admin", + CreatePat: &createPAT, + PatExpireIn: &expireDays, + }) + if err != nil { + return "", fmt.Errorf("instance setup: %w", err) + } + if resp.PersonalAccessToken == nil || *resp.PersonalAccessToken == "" { + return "", fmt.Errorf("setup succeeded but no PAT returned (is NB_SETUP_PAT_ENABLED set?)") + } + + c.PAT = *resp.PersonalAccessToken + c.api = rest.New(c.BaseURL, c.PAT) + return c.PAT, nil +} + +// API returns the PAT-authenticated management REST client. It is nil until +// Bootstrap runs. +func (c *Combined) API() *rest.Client { + return c.api +} diff --git a/e2e/harness/cert.go b/e2e/harness/cert.go new file mode 100644 index 000000000..c8a28e470 --- /dev/null +++ b/e2e/harness/cert.go @@ -0,0 +1,66 @@ +//go:build e2e + +package harness + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// writeSelfSignedCert generates a self-signed TLS cert/key pair covering the +// given DNS names and writes them as tls.crt / tls.key in dir. The proxy serves +// this for the agent-network endpoint; the client curls with -k, so validity +// chains don't matter — the proxy just needs a usable cert to present. +func writeSelfSignedCert(dir string, dnsNames []string) error { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("generate serial: %w", err) + } + + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: dnsNames[0]}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: dnsNames, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("create certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(filepath.Join(dir, "tls.crt"), certPEM, 0o644); err != nil { //nolint:gosec // public cert, bind-mounted and read by the proxy container + return fmt.Errorf("write cert: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + // World-readable so the (non-root) proxy container can read the bind-mounted + // key on Linux CI runners; this is a throwaway self-signed e2e key. + if err := os.WriteFile(filepath.Join(dir, "tls.key"), keyPEM, 0o644); err != nil { //nolint:gosec // throwaway self-signed e2e key, must be readable by the proxy container uid + return fmt.Errorf("write key: %w", err) + } + return nil +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go new file mode 100644 index 000000000..cf7ef8945 --- /dev/null +++ b/e2e/harness/client.go @@ -0,0 +1,256 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" +) + +const ( + clientDockerfile = "e2e/harness/Dockerfile.client" + // defaultClientImage is the local tag the client is built under from + // clientDockerfile. Override with NB_E2E_CLIENT_IMAGE: a value with a "/" is + // pulled as a published image; a bare tag is built under that name. + defaultClientImage = "netbird-client:e2e" + clientAlias = "client" + curlImage = "curlimages/curl:latest" +) + +// Client is a running NetBird client container joined to the combined server. +type Client struct { + container testcontainers.Container +} + +// StartClient builds the client image and runs it on the combined server's +// network, joining via the given setup key. The image entrypoint brings the +// daemon up automatically; callers wait for connectivity with WaitConnected / +// WaitProxyPeer. +func StartClient(ctx context.Context, c *Combined, setupKey string) (*Client, error) { + root, err := repoRoot() + if err != nil { + return nil, err + } + clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile) + if err != nil { + return nil, err + } + + req := testcontainers.ContainerRequest{ + Image: clientImage, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {clientAlias}}, + Env: map[string]string{ + "NB_MANAGEMENT_URL": combinedExposedURL, + "NB_SETUP_KEY": setupKey, + "NB_LOG_LEVEL": "info", + // Match the proxy: the combined relay is WebSocket-only, so the + // client must use WS transport to keep a stable relay link to it. + "NB_RELAY_TRANSPORT": "ws", + }, + HostConfigModifier: func(hc *container.HostConfig) { + hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE") + }, + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start client container: %w", err) + } + return &Client{container: ctr}, nil +} + +// Restart bounces the client connection (netbird down/up) so it pulls a fresh +// network map — the documented workaround for a freshly-joined client not yet +// seeing a synthesized agent-network service. +func (cl *Client) Restart(ctx context.Context) error { + if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil { + return fmt.Errorf("netbird down: %w", err) + } + time.Sleep(2 * time.Second) + code, reader, err := cl.container.Exec(ctx, []string{"netbird", "up"}, tcexec.Multiplexed()) + if err != nil { + return fmt.Errorf("netbird up: %w", err) + } + if code != 0 { + out, _ := io.ReadAll(reader) + return fmt.Errorf("netbird up exited %d: %s", code, string(out)) + } + return nil +} + +// Status returns `netbird status` output from inside the client. +func (cl *Client) Status(ctx context.Context) (string, error) { + code, reader, err := cl.container.Exec(ctx, []string{"netbird", "status"}, tcexec.Multiplexed()) + if err != nil { + return "", err + } + out, _ := io.ReadAll(reader) + if code != 0 { + return string(out), fmt.Errorf("netbird status exited %d", code) + } + return string(out), nil +} + +// WaitConnected polls until the client reports Management: Connected. +func (cl *Client) WaitConnected(ctx context.Context, timeout time.Duration) error { + return cl.pollStatus(ctx, timeout, "Management: Connected") +} + +// WaitProxyPeer polls until the client sees the proxy peer connected (1/1). +func (cl *Client) WaitProxyPeer(ctx context.Context, timeout time.Duration) error { + return cl.pollStatus(ctx, timeout, "1/1 Connected") +} + +func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want string) error { + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + out, _ := cl.Status(ctx) + last = out + if strings.Contains(out, want) { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last) +} + +// ResolveProxyIP resolves the agent-network endpoint to the proxy peer's +// NetBird IP from inside the client (via magic DNS). +func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { + code, reader, err := cl.container.Exec(ctx, []string{"getent", "hosts", endpoint}, tcexec.Multiplexed()) + if err != nil { + return "", err + } + out, _ := io.ReadAll(reader) + if code != 0 { + return "", fmt.Errorf("getent hosts %s exited %d", endpoint, code) + } + fields := strings.Fields(string(out)) + if len(fields) == 0 { + return "", fmt.Errorf("no address for %s", endpoint) + } + return fields[0], nil +} + +// Wire shapes for Chat. +const ( + // WireChat is the OpenAI-compatible /v1/chat/completions shape. + WireChat = "chat" + // WireMessages is the Anthropic /v1/messages shape. + WireMessages = "messages" + // WireVertex is the Anthropic-on-Vertex rawPredict shape: the client posts + // the full Vertex model path and the proxy mints the SA OAuth token. + WireVertex = "vertex" +) + +// Chat issues a chat-completion POST to the agent-network endpoint over the +// client's tunnel, returning the HTTP status and response body. kind selects +// the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty +// sessionID is sent as the universal x-session-id header the proxy records. +func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) +} + +// Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike +// Chat, the model is carried in the request path (project/region/model), so the +// proxy routes by path and mints the service-account OAuth token; the body uses +// the Vertex anthropic_version rather than a model field. A non-empty sessionID +// is sent as the universal x-session-id header the proxy records. +func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) { + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model) + body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + +// withSessionID appends the x-session-id header when sessionID is non-empty. +func withSessionID(headers []string, sessionID string) []string { + if sessionID == "" { + return headers + } + return append(headers, "x-session-id: "+sessionID) +} + +// post runs curl in a throwaway container sharing the client's network +// namespace so the request traverses the WireGuard tunnel, pinning the endpoint +// to the proxy IP. It returns the HTTP status and response body. +func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + url := "https://" + endpoint + path + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-sk", "--connect-timeout", "5", "--max-time", "90", + "--resolve", endpoint + ":443:" + proxyIP, + "-o", "/dev/stderr", "-w", "%{http_code}", + "-X", "POST", url, + "-H", "Content-Type: application/json", + } + for _, h := range extraHeaders { + args = append(args, "-H", h) + } + args = append(args, "--data", body) + cmd := exec.CommandContext(ctx, "docker", args...) + // -w writes the status code to stdout; -o /dev/stderr writes the body to + // stderr so we can capture both separately. + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return 0, stderr.String(), fmt.Errorf("curl through tunnel: %w", err) + } + + code := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &code) + return code, stderr.String(), nil +} + +// Logs returns the client container logs, for diagnostics on failure. +func (cl *Client) Logs(ctx context.Context) string { + return containerLogs(ctx, cl.container) +} + +// Terminate stops the client container. +func (cl *Client) Terminate(ctx context.Context) error { + if cl.container == nil { + return nil + } + return cl.container.Terminate(ctx) +} + +// containerLogs reads up to 256 KiB of a container's logs for diagnostics. +func containerLogs(ctx context.Context, c testcontainers.Container) string { + if c == nil { + return "" + } + r, err := c.Logs(ctx) + if err != nil { + return fmt.Sprintf("", err) + } + defer r.Close() + b, _ := io.ReadAll(io.LimitReader(r, 256<<10)) + return string(b) +} diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go new file mode 100644 index 000000000..a6f43a139 --- /dev/null +++ b/e2e/harness/combined.go @@ -0,0 +1,243 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/netbirdio/netbird/shared/management/client/rest" +) + +const ( + combinedDockerfile = "combined/Dockerfile.multistage" + // defaultCombinedImage is the local tag the combined server is built under + // from combinedDockerfile, so the e2e exercises this branch's code. Override + // with NB_E2E_COMBINED_IMAGE: a value containing a "/" is pulled as a + // published image; a bare tag is built under that name instead. + defaultCombinedImage = "netbird-combined:e2e" + combinedHTTPPort = "8080/tcp" + + // combinedAlias is the combined server's network alias AND the deployment + // domain. The working manual setup uses a single NETBIRD_DOMAIN for the + // management exposed address, the proxy domain, and the agent-network + // cluster — so we mirror that: peers reach management/signal/relay at this + // name, the proxy registers this as its cluster, and the agent-network + // endpoint is .. + combinedAlias = "netbird.local" + combinedExposedURL = "http://" + combinedAlias + ":8080" + + // containerIssuer is the embedded IdP issuer, used only for internal JWT + // validation (peers authenticate with setup keys / proxy tokens, not OIDC), + // so the in-container localhost address is fine. + containerIssuer = "http://localhost:8080/oauth2" +) + +// Combined is a running combined NetBird server (management + signal + relay + +// STUN + embedded IdP) plus the connection details tests need. It owns the +// shared docker network that the proxy and client containers join. +type Combined struct { + container testcontainers.Container + network *testcontainers.DockerNetwork + // BaseURL is the host-reachable management API root, e.g. http://127.0.0.1:51234. + BaseURL string + // PAT is the admin Personal Access Token minted via Bootstrap. + PAT string + + api *rest.Client + workDir string +} + +// StartCombined builds the combined server from its multistage Dockerfile and +// boots it with setup-PAT enabled on a fresh shared network, returning once the +// API is serving. The caller still owns minting the admin PAT via Bootstrap. +func StartCombined(ctx context.Context) (*Combined, error) { + root, err := repoRoot() + if err != nil { + return nil, err + } + + combinedImage, err := resolveImage(ctx, root, "NB_E2E_COMBINED_IMAGE", defaultCombinedImage, combinedDockerfile) + if err != nil { + return nil, err + } + + net, err := network.New(ctx) + if err != nil { + return nil, fmt.Errorf("create shared network: %w", err) + } + + // Work dir under /tmp so Docker Desktop file sharing (which excludes + // macOS's /var/folders TMPDIR) can bind-mount it. + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-combined-*") + if err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create work dir: %w", err) + } + + cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, containerIssuer) + if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container + _ = net.Remove(ctx) + return nil, fmt.Errorf("write combined config: %w", err) + } + if err := os.MkdirAll(filepath.Join(workDir, "data"), 0o755); err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create datadir: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: combinedImage, + ExposedPorts: []string{combinedHTTPPort}, + Networks: []string{net.Name}, + NetworkAliases: map[string][]string{net.Name: {combinedAlias}}, + Env: map[string]string{ + "NB_SETUP_PAT_ENABLED": "true", + // Skip the GeoLite DB download — it blocks startup and agent-network + // ingest doesn't use geolocation. + "NB_DISABLE_GEOLOCATION": "true", + }, + Cmd: []string{"--config", "/nb/config.yaml"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/nb") + }, + WaitingFor: wait.ForHTTP("/api/instance"). + WithPort(combinedHTTPPort). + WithStatusCodeMatcher(func(status int) bool { return status == 200 }). + WithStartupTimeout(120 * time.Second), + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("start combined container: %w", err) + } + + host, err := c.Host(ctx) + if err != nil { + _ = c.Terminate(ctx) + _ = net.Remove(ctx) + return nil, fmt.Errorf("container host: %w", err) + } + mapped, err := c.MappedPort(ctx, nat.Port(combinedHTTPPort)) + if err != nil { + _ = c.Terminate(ctx) + _ = net.Remove(ctx) + return nil, fmt.Errorf("mapped port: %w", err) + } + + return &Combined{ + container: c, + network: net, + BaseURL: fmt.Sprintf("http://%s:%s", host, mapped.Port()), + workDir: workDir, + }, nil +} + +// resolveImage returns the image to run for a component. By default it builds +// the image from the repo Dockerfile under localTag, so the e2e exercises the +// branch's code. The env override changes this: a value containing a "/" is a +// registry reference that testcontainers pulls (e.g. to test a published +// release); a bare tag is built under that name instead. +func resolveImage(ctx context.Context, root, envKey, localTag, dockerfile string) (string, error) { + if v := os.Getenv(envKey); v != "" { + if strings.Contains(v, "/") { + return v, nil + } + localTag = v + } + if err := buildImage(ctx, root, dockerfile, localTag); err != nil { + return "", err + } + return localTag, nil +} + +// buildImage builds an image from a repo Dockerfile via buildx with BuildKit, so +// the Dockerfile cache mounts are honored and unchanged layers are reused. The +// result is loaded into the docker image store so testcontainers runs it by tag. +// When NB_E2E_BUILDX_CACHE names a directory (CI, with a container-driver +// builder from docker/setup-buildx-action), layer cache is read from and written +// to it as a local cache so actions/cache can persist it across runs; the Go +// compile itself still re-runs, as BuildKit mount caches can't be exported. +func buildImage(ctx context.Context, root, dockerfile, tag string) error { + args := []string{"buildx", "build", "-f", dockerfile, "-t", tag, "--load"} + if dir := os.Getenv("NB_E2E_BUILDX_CACHE"); dir != "" { + args = append(args, + "--cache-from", "type=local,src="+dir, + "--cache-to", "type=local,dest="+dir+",mode=max", + ) + } + args = append(args, ".") + + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = root + cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("build image %s: %w\n%s", tag, err, string(out)) + } + return nil +} + +// CreateProxyTokenCLI mints a proxy access token via the server's `token +// create` CLI inside the container — the same path the manual install uses. +// This yields a GLOBAL (account-less) token, so the proxy serves the whole +// cluster (SynthesizeServicesForCluster); an account-scoped REST token instead +// drives the per-account path. Returns the plaintext token. +func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string, error) { + code, reader, err := c.container.Exec(ctx, + []string{"/go/bin/netbird-server", "token", "create", "--name", name, "--config", "/nb/config.yaml"}, + tcexec.Multiplexed()) + if err != nil { + return "", fmt.Errorf("exec token create: %w", err) + } + out, _ := io.ReadAll(reader) + if code != 0 { + return "", fmt.Errorf("token create exited %d: %s", code, string(out)) + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Token:") { + tok := strings.TrimSpace(strings.TrimPrefix(line, "Token:")) + if tok != "" { + return tok, nil + } + } + } + return "", fmt.Errorf("token not found in CLI output: %s", string(out)) +} + +// Logs returns the combined server container logs, for diagnostics. +func (c *Combined) Logs(ctx context.Context) string { + return containerLogs(ctx, c.container) +} + +// Terminate stops the container, removes the shared network, and cleans the +// work dir. +func (c *Combined) Terminate(ctx context.Context) error { + var err error + if c.container != nil { + err = c.container.Terminate(ctx) + } + if c.network != nil { + _ = c.network.Remove(ctx) + } + if c.workDir != "" { + _ = os.RemoveAll(c.workDir) + } + return err +} diff --git a/e2e/harness/config.go b/e2e/harness/config.go new file mode 100644 index 000000000..b4bed60a2 --- /dev/null +++ b/e2e/harness/config.go @@ -0,0 +1,26 @@ +//go:build e2e + +package harness + +// combinedConfigYAML is a minimal combined-server config for tests: plain HTTP +// on :8080 (no TLS cert configured → the server serves HTTP and expects to sit +// behind a reverse proxy, which is exactly what we want for in-cluster tests), +// embedded IdP, local signal/relay/STUN, and a sqlite store under the mounted +// data dir. exposedAddress is the address peers use to reach this container; it +// is overridden per-run so the value matches the container's network alias. +const combinedConfigYAML = `server: + listenAddress: ":8080" + exposedAddress: "%s" + healthcheckAddress: ":9000" + metricsPort: 9090 + logLevel: "info" + logFile: "console" + authSecret: "e2e-relay-secret" + dataDir: "/nb/data" + disableAnonymousMetrics: true + disableGeoliteUpdate: true + auth: + issuer: "%s" + store: + engine: "sqlite" +` diff --git a/e2e/harness/doc.go b/e2e/harness/doc.go new file mode 100644 index 000000000..937d8e664 --- /dev/null +++ b/e2e/harness/doc.go @@ -0,0 +1,13 @@ +//go:build e2e + +// Package harness provides a self-contained, OIDC-free way to stand up NetBird +// components in containers for end-to-end tests. It is feature-agnostic: any +// suite can ask for a live management server (with an admin PAT minted through +// the unauthenticated /api/setup bootstrap) and, later, a proxy and client. +// +// The harness compiles each component once in a cached builder container and +// mounts the resulting binary into a slim runtime container, so iterating on a +// branch doesn't pay a full image rebuild per run. Everything is gated behind +// the `e2e` build tag so normal builds and unit tests never pull in +// testcontainers. +package harness diff --git a/e2e/harness/paths.go b/e2e/harness/paths.go new file mode 100644 index 000000000..d7df6bbfa --- /dev/null +++ b/e2e/harness/paths.go @@ -0,0 +1,29 @@ +//go:build e2e + +package harness + +import ( + "fmt" + "os" + "path/filepath" +) + +// repoRoot walks up from the working directory to the module root (the +// directory holding go.mod), so the Docker build context is correct no matter +// which package the test runs from. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found above %s", dir) + } + dir = parent + } +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go new file mode 100644 index 000000000..8db2c140f --- /dev/null +++ b/e2e/harness/proxy.go @@ -0,0 +1,122 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + proxyDockerfile = "proxy/Dockerfile.multistage" + // defaultProxyImage is the local tag the reverse proxy is built under from + // proxyDockerfile. Override with NB_E2E_PROXY_IMAGE: a value with a "/" is + // pulled as a published image; a bare tag is built under that name. + defaultProxyImage = "netbird-reverse-proxy:e2e" + proxyAlias = "proxy" + + // AgentNetworkCluster is the proxy cluster the e2e provider bootstraps and + // the proxy serves. It must equal the management's exposed domain + // (combinedAlias) — the working manual setup uses one NETBIRD_DOMAIN for + // both. The agent-network endpoint is .. + AgentNetworkCluster = combinedAlias +) + +// Proxy is a running agent-network gateway (netbird proxy) container. +type Proxy struct { + container testcontainers.Container + workDir string +} + +// StartProxy builds the proxy image and runs it on the combined server's +// network, registered via the given account proxy token and serving the +// AgentNetworkCluster over a self-signed wildcard cert. It does not wait for +// peer connectivity — callers poll management for the proxy peer. +func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, error) { + root, err := repoRoot() + if err != nil { + return nil, err + } + proxyImage, err := resolveImage(ctx, root, "NB_E2E_PROXY_IMAGE", defaultProxyImage, proxyDockerfile) + if err != nil { + return nil, err + } + + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-proxy-*") + if err != nil { + return nil, fmt.Errorf("create proxy work dir: %w", err) + } + // MkdirTemp creates the dir 0700; widen it so the non-root proxy container + // can traverse the bind-mounted cert dir on Linux CI runners. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir, must be traversable by the proxy container uid + return nil, fmt.Errorf("chmod proxy cert dir: %w", err) + } + if err := writeSelfSignedCert(workDir, []string{"*." + AgentNetworkCluster, AgentNetworkCluster}); err != nil { + return nil, err + } + + req := testcontainers.ContainerRequest{ + Image: proxyImage, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {proxyAlias}}, + Env: map[string]string{ + "NB_PROXY_TOKEN": proxyToken, + "NB_PROXY_MANAGEMENT_ADDRESS": combinedExposedURL, + "NB_PROXY_DOMAIN": AgentNetworkCluster, + "NB_PROXY_ADDRESS": ":443", + "NB_PROXY_CERTIFICATE_DIRECTORY": "/certs", + "NB_PROXY_HEALTH_ADDRESS": ":8081", + "NB_PROXY_LOG_LEVEL": "debug", + "NB_PROXY_PRIVATE": "true", + // Management is plain HTTP in-cluster, so allow the proxy token to + // ride a non-TLS gRPC connection. + "NB_PROXY_ALLOW_INSECURE": "true", + // The combined server multiplexes the relay over WebSocket on :8080 + // (no QUIC listener). The proxy's embedded relay client defaults to + // QUIC, which fails here and flaps the relay link, churning the + // proxy peer so it never stably registers. Force WS transport. + "NB_RELAY_TRANSPORT": "ws", + // Trace the embedded client (relay / signal / handshake) so + // peer-registration issues are visible in the proxy logs. + "NB_PROXY_CLIENT_LOG_LEVEL": "trace", + }, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/certs") + hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE", "NET_BIND_SERVICE") + }, + WaitingFor: wait.ForLog("Initial mapping sync complete").WithStartupTimeout(90 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start proxy container: %w", err) + } + + return &Proxy{container: ctr, workDir: workDir}, nil +} + +// Logs returns the proxy container logs, for diagnostics on failure. +func (p *Proxy) Logs(ctx context.Context) string { + return containerLogs(ctx, p.container) +} + +// Terminate stops the proxy container and cleans its work dir. +func (p *Proxy) Terminate(ctx context.Context) error { + var err error + if p.container != nil { + err = p.container.Terminate(ctx) + } + if p.workDir != "" { + _ = os.RemoveAll(p.workDir) + } + return err +} diff --git a/go.mod b/go.mod index 9a57de1c9..e1c762607 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/DeRuina/timberjack v1.4.2 github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.38.3 + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 github.com/aws/aws-sdk-go-v2/config v1.31.6 github.com/aws/aws-sdk-go-v2/credentials v1.18.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 @@ -49,6 +50,8 @@ require ( github.com/crowdsecurity/go-cs-bouncer v0.0.21 github.com/dexidp/dex v2.13.0+incompatible github.com/dexidp/dex/api/v2 v2.4.0 + github.com/docker/docker v28.0.1+incompatible + github.com/docker/go-connections v0.6.0 github.com/ebitengine/purego v0.8.4 github.com/eko/gocache/lib/v4 v4.2.0 github.com/eko/gocache/store/go_cache/v4 v4.2.2 @@ -158,7 +161,6 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/awnumar/memcall v0.4.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect @@ -188,8 +190,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.0.1+incompatible // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fredbi/uri v1.1.1 // indirect diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 0d8fb3c47..f1b1832d2 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -116,6 +116,24 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p c.EphemeralPeersManager.OnPeerDisconnected(ctx, peer) } +// injectAllProxyPolicies prepares an account for the per-peer network-map +// computation. It prepends the in-memory agent-network services synthesised +// from the account's current provider/policy state to account.Services so +// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick +// them up alongside persisted reverse-proxy services. Synthesised services +// are never persisted; the account is loaded fresh per cycle so re-prepending +// is safe and idempotent. Accounts without agent-network providers get an +// empty synth slice — no behaviour change. +func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) { + synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id) + if err != nil { + log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", account.Id, err) + } else if len(synth) > 0 { + account.Services = append(synth, account.Services...) + } + account.InjectProxyPolicies(ctx) +} + func (c *Controller) CountStreams() int { return c.peersUpdateManager.CountStreams() } @@ -150,7 +168,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin var wg sync.WaitGroup semaphore := make(chan struct{}, 10) - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -281,7 +299,15 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s var wg sync.WaitGroup semaphore := make(chan struct{}, 10) - account.InjectProxyPolicies(ctx) + // The affected-peer path MUST mirror sendUpdateAccountPeers (line 171) + // here: injectAllProxyPolicies prepends the synthesised agent-network + // services BEFORE InjectProxyPolicies + private-service policies run. + // Previously this path called only account.InjectProxyPolicies, which + // skipped the synth-services prepend — so peer-level changes + // (proxy restart, embedded peer connect/disconnect) propagated a + // network map that omitted the synth DNS zone, and the agent kept + // resolving against the stale or absent record. + c.injectAllProxyPolicies(ctx, account) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -399,7 +425,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("failed to get validated peers: %v", err) } - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -603,7 +629,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr return nil, nil, 0, err } - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) if err != nil { @@ -874,7 +900,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N return nil, err } - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index caef362cb..c0fcefc7d 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -3,7 +3,9 @@ package controller import ( "context" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" @@ -16,6 +18,10 @@ type Repository interface { GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error) GetPeerByID(ctx context.Context, accountID string, peerID string) (*peer.Peer, error) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) + // SynthesizeAgentNetworkServices returns the in-memory reverse-proxy + // services synthesised from the account's agent-network provider/policy + // state. Empty for accounts without agent-network providers. + SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) } type repository struct { @@ -50,6 +56,10 @@ func (r *repository) GetPeerByID(ctx context.Context, accountID string, peerID s return r.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) } +func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return agentnetwork.SynthesizeServices(ctx, r.store, accountID) +} + func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) { return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID) } diff --git a/management/internals/modules/agentnetwork/accesslog_ingest.go b/management/internals/modules/agentnetwork/accesslog_ingest.go new file mode 100644 index 000000000..59e53efa2 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_ingest.go @@ -0,0 +1,215 @@ +package agentnetwork + +import ( + "context" + "math" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/store" +) + +// Metadata keys the proxy stamps on agent-network access-log entries. These +// mirror the constants in proxy/internal/middleware/keys.go and form the wire +// contract between the proxy and management; management flattens them into +// queryable columns. Keep in sync with the proxy side. +const ( + metaKeyProvider = "llm.provider" + metaKeyModel = "llm.model" + metaKeyResolvedProviderID = "llm.resolved_provider_id" + metaKeySelectedPolicyID = "llm.selected_policy_id" + metaKeyPolicyDecision = "llm_policy.decision" + metaKeyPolicyReason = "llm_policy.reason" + metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCostUSDTotal = "cost.usd_total" + metaKeyStream = "llm.stream" + metaKeySessionID = "llm.session_id" + metaKeyAuthorisingGroups = "llm.authorising_groups" + metaKeyRequestPrompt = "llm.request_prompt" + metaKeyResponseCompletion = "llm.response_completion" +) + +// IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry +// and persists it in the dedicated agent-network tables (instead of the shared +// reverse-proxy table), in two parts: +// +// - The stripped usage record is written unconditionally — usage/cost is +// collected on every request regardless of the account's log-collection +// toggle (the proxy ships a usage-only entry when logging is disabled). +// - The full access-log row (with request detail + prompt) is written only +// when the account's EnableLogCollection setting is on. This setting read +// is the authoritative gate; the proxy-side strip is defense in depth. +func IngestAccessLog(ctx context.Context, s store.Store, logEntry *accesslogs.AccessLogEntry) error { + entry, groups := flattenAccessLog(logEntry) + + usage, usageGroups := usageFromFlattenedLog(entry, groups) + if err := s.CreateAgentNetworkUsage(ctx, usage, usageGroups); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "model": entry.Model, + }).Errorf("failed to save agent-network usage: %v", err) + return err + } + + settings, err := s.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, entry.AccountID) + if err != nil { + // No settings row (or a transient read error) means we can't confirm + // log collection is enabled — usage is already saved, so skip the full + // row rather than fail the whole ingest. + log.WithContext(ctx).Debugf("skipping full agent-network access-log row for account %s: %v", entry.AccountID, err) + return nil + } + if !settings.EnableLogCollection { + return nil + } + + if err := s.CreateAgentNetworkAccessLog(ctx, entry, groups); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "service_id": entry.ServiceID, + "model": entry.Model, + "status": entry.StatusCode, + }).Errorf("failed to save agent-network access log: %v", err) + return err + } + return nil +} + +// flattenAccessLog converts a reverse-proxy AccessLogEntry (whose LLM +// dimensions live in the opaque Metadata map) into the flattened +// agent-network row + authorising-group child rows. +func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLog, []types.AgentNetworkAccessLogGroup) { + meta := e.Metadata + + var sourceIP string + if e.GeoLocation.ConnectionIP != nil { + sourceIP = e.GeoLocation.ConnectionIP.String() + } + + entry := &types.AgentNetworkAccessLog{ + ID: e.ID, + AccountID: e.AccountID, + ServiceID: e.ServiceID, + Timestamp: e.Timestamp, + UserID: e.UserId, + SourceIP: sourceIP, + Method: e.Method, + Host: e.Host, + Path: e.Path, + Duration: e.Duration, + StatusCode: e.StatusCode, + AuthMethod: e.AuthMethodUsed, + BytesUpload: e.BytesUpload, + BytesDownload: e.BytesDownload, + + Provider: meta[metaKeyProvider], + Model: meta[metaKeyModel], + SessionID: meta[metaKeySessionID], + ResolvedProviderID: meta[metaKeyResolvedProviderID], + SelectedPolicyID: meta[metaKeySelectedPolicyID], + Decision: meta[metaKeyPolicyDecision], + DenyReason: meta[metaKeyPolicyReason], + InputTokens: parseMetaInt(meta, metaKeyInputTokens), + OutputTokens: parseMetaInt(meta, metaKeyOutputTokens), + TotalTokens: parseMetaInt(meta, metaKeyTotalTokens), + CostUSD: parseMetaFloat(meta, metaKeyCostUSDTotal), + Stream: parseMetaBool(meta, metaKeyStream), + RequestPrompt: meta[metaKeyRequestPrompt], + ResponseCompletion: meta[metaKeyResponseCompletion], + } + + var groups []types.AgentNetworkAccessLogGroup + for _, gid := range parseGroupCSV(meta[metaKeyAuthorisingGroups]) { + groups = append(groups, types.AgentNetworkAccessLogGroup{ + LogID: entry.ID, + GroupID: gid, + AccountID: entry.AccountID, + }) + } + return entry, groups +} + +// usageFromFlattenedLog derives the stripped usage record (and its group child +// rows) from an already-flattened access-log entry. The usage row shares the +// log's ID so the two correlate. +func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) { + usage := &types.AgentNetworkUsage{ + ID: e.ID, + AccountID: e.AccountID, + Timestamp: e.Timestamp, + UserID: e.UserID, + ResolvedProviderID: e.ResolvedProviderID, + Provider: e.Provider, + Model: e.Model, + SessionID: e.SessionID, + InputTokens: e.InputTokens, + OutputTokens: e.OutputTokens, + TotalTokens: e.TotalTokens, + CostUSD: e.CostUSD, + } + + usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups)) + for _, g := range groups { + usageGroups = append(usageGroups, types.AgentNetworkUsageGroup{ + UsageID: usage.ID, + GroupID: g.GroupID, + AccountID: g.AccountID, + }) + } + return usage, usageGroups +} + +// parseMetaInt parses a non-negative token count. Negative or unparseable +// values are clamped to 0 so a malformed metric can't persist a negative +// counter. +func parseMetaInt(meta map[string]string, key string) int64 { + if v, err := strconv.ParseInt(strings.TrimSpace(meta[key]), 10, 64); err == nil && v >= 0 { + return v + } + return 0 +} + +// parseMetaFloat parses a non-negative, finite cost. Negative, NaN, Inf, or +// unparseable values are clamped to 0 so a malformed metric can't poison the +// stored cost. +func parseMetaFloat(meta map[string]string, key string) float64 { + if v, err := strconv.ParseFloat(strings.TrimSpace(meta[key]), 64); err == nil && v >= 0 && !math.IsInf(v, 0) { + return v + } + return 0 +} + +func parseMetaBool(meta map[string]string, key string) bool { + v, _ := strconv.ParseBool(strings.TrimSpace(meta[key])) + return v +} + +// parseGroupCSV splits the comma-separated authorising-group id list the proxy +// emits, trimming blanks and de-duplicating. Dedup matters because the group +// rows are keyed by (log_id, group_id) / (usage_id, group_id): a repeated id +// in the CSV would otherwise produce a duplicate primary key and fail the +// insert transaction. +func parseGroupCSV(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + if _, dup := seen[p]; dup { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + } + return out +} diff --git a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go new file mode 100644 index 000000000..431ce680e --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go @@ -0,0 +1,124 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/store" +) + +// newIngestTestEntry builds an agent-network reverse-proxy access-log entry whose +// LLM dimensions live in the opaque Metadata map, as the proxy ships it. +func newIngestTestEntry() *accesslogs.AccessLogEntry { + return &accesslogs.AccessLogEntry{ + ID: "log-1", + AccountID: testAccountID, + ServiceID: "svc-1", + Timestamp: time.Now().UTC(), + Method: "POST", + Host: testEndpoint, + Path: "/v1/chat/completions", + StatusCode: 200, + UserId: "user-1", + AgentNetwork: true, + Metadata: map[string]string{ + metaKeyProvider: "openai", + metaKeyModel: "gpt-5.4", + metaKeyResolvedProviderID: "prov-1", + metaKeySessionID: "sess-1", + metaKeyInputTokens: "100", + metaKeyOutputTokens: "50", + metaKeyTotalTokens: "150", + metaKeyCostUSDTotal: "0.0123", + metaKeyStream: "true", + metaKeyRequestPrompt: "hello", + metaKeyResponseCompletion: "world", + // repeated id must be de-duplicated before the group rows insert. + metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops", + }, + } +} + +// TestIngestAccessLog_RealStore_LogCollectionOff persists the usage ledger +// unconditionally but skips the full access-log row when the account hasn't +// opted into log collection. +func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnableLogCollection = false + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry())) + + usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, usage, 1, "usage row must be written even with log collection off") + assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata") + assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata") + assert.InDelta(t, 0.0123, usage[0].CostUSD, 1e-9, "cost must round-trip from metadata") + + logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + assert.Empty(t, logs, "full access-log row must be skipped while log collection is off") +} + +// TestIngestAccessLog_RealStore_LogCollectionOn writes both the usage ledger and +// the full access-log row once the account opts in, carrying the request detail +// and prompt through. +func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnableLogCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry())) + + usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, usage, 1, "usage row must be written when log collection is on") + + logs, total, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, int64(1), total, "exactly one access-log row expected") + require.Len(t, logs, 1, "full access-log row must be written when log collection is on") + assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata") + assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on") + assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on") + assert.True(t, logs[0].Stream, "stream flag must flatten from metadata") +} + +func TestParseGroupCSV_DedupAndTrim(t *testing.T) { + assert.Nil(t, parseGroupCSV(""), "empty CSV yields no groups") + assert.Equal(t, []string{"a", "b"}, parseGroupCSV(" a , b , a ,"), + "group CSV must trim, drop blanks, and de-duplicate preserving first-seen order") +} + +func TestParseMetaInt_ClampsNegativeAndJunk(t *testing.T) { + meta := map[string]string{"ok": " 42 ", "neg": "-5", "junk": "abc"} + assert.Equal(t, int64(42), parseMetaInt(meta, "ok"), "valid count parses with surrounding space trimmed") + assert.Equal(t, int64(0), parseMetaInt(meta, "neg"), "negative count clamps to 0") + assert.Equal(t, int64(0), parseMetaInt(meta, "junk"), "unparseable count clamps to 0") + assert.Equal(t, int64(0), parseMetaInt(meta, "missing"), "missing key clamps to 0") +} + +func TestParseMetaFloat_ClampsNegativeInfAndJunk(t *testing.T) { + meta := map[string]string{"ok": "1.5", "neg": "-1", "inf": "Inf", "junk": "x"} + assert.InDelta(t, 1.5, parseMetaFloat(meta, "ok"), 1e-9, "valid cost parses") + assert.Equal(t, float64(0), parseMetaFloat(meta, "neg"), "negative cost clamps to 0") + assert.Equal(t, float64(0), parseMetaFloat(meta, "inf"), "non-finite cost clamps to 0") + assert.Equal(t, float64(0), parseMetaFloat(meta, "junk"), "unparseable cost clamps to 0") +} diff --git a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go new file mode 100644 index 000000000..7d53d7547 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go @@ -0,0 +1,343 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" +) + +// baseTime is a fixed reference so session timestamps (and therefore the +// default MAX(timestamp) DESC ordering) are deterministic across runs. +var baseTime = time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + +// accessLogRow builds an agent-network access-log row for the shared test +// account. Functional options tweak the LLM dimensions a given test cares +// about; everything else gets a sane, allow/200 default. +func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentNetworkAccessLog)) *types.AgentNetworkAccessLog { + e := &types.AgentNetworkAccessLog{ + ID: id, + AccountID: testAccountID, + ServiceID: "svc-1", + Timestamp: ts, + UserID: "user-1", + SessionID: sessionID, + Method: "POST", + Host: testEndpoint, + Path: "/v1/chat/completions", + StatusCode: 200, + Decision: "allow", + Provider: "openai", + Model: "gpt-5.4", + ResolvedProviderID: "prov-1", + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + CostUSD: 0.01, + } + for _, o := range opts { + o(e) + } + return e +} + +func withUser(u string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { e.UserID = u } +} + +func withModel(m string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { e.Model = m } +} + +func withProvider(vendor, resolvedID string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { + e.Provider = vendor + e.ResolvedProviderID = resolvedID + } +} + +func withDeny(reason string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { + e.Decision = "deny" + e.DenyReason = reason + e.StatusCode = 403 + } +} + +func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { + e.InputTokens = in + e.OutputTokens = out + e.TotalTokens = total + e.CostUSD = cost + } +} + +func withGroups(gids ...string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { e.GroupIDs = gids } +} + +// seedAccessLogs writes rows (and their authorising-group child rows) directly +// into the store, bypassing ingest so a test can control every dimension. +func seedAccessLogs(t *testing.T, s store.Store, rows ...*types.AgentNetworkAccessLog) { + t.Helper() + ctx := context.Background() + for _, r := range rows { + var groups []types.AgentNetworkAccessLogGroup + for _, g := range r.GroupIDs { + groups = append(groups, types.AgentNetworkAccessLogGroup{ + LogID: r.ID, + GroupID: g, + AccountID: r.AccountID, + }) + } + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, r, groups), "seed access-log row %s", r.ID) + } +} + +func newSessionsTestStore(t *testing.T) store.Store { + t.Helper() + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + t.Cleanup(cleanup) + return s +} + +// sessionIDs projects the session ids from a page of session summaries, in +// order, for concise ordering assertions. +func sessionIDs(sessions []*types.AgentNetworkAccessLogSession) []string { + out := make([]string, 0, len(sessions)) + for _, s := range sessions { + out = append(out, s.SessionID) + } + return out +} + +// TestAccessLogSessions_FoldAndAggregate verifies that multiple entries sharing +// a session id fold into one summary with summed usage, distinct +// provider/model lists, a deny rollup, and correct first/last activity bounds. +func TestAccessLogSessions_FoldAndAggregate(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + // sess-a: three entries spanning 3 minutes, two providers/models, one deny. + seedAccessLogs(t, s, + accessLogRow("a1", "sess-a", baseTime, + withProvider("openai", "prov-openai"), withModel("gpt-5.4"), + withTokens(100, 50, 150, 0.01), withGroups("grp-eng")), + accessLogRow("a2", "sess-a", baseTime.Add(1*time.Minute), + withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"), + withTokens(200, 80, 280, 0.02), withGroups("grp-eng", "grp-ops")), + accessLogRow("a3", "sess-a", baseTime.Add(2*time.Minute), + withProvider("openai", "prov-openai"), withModel("gpt-5.4"), + withTokens(10, 5, 15, 0.001), withDeny("llm_policy.token_cap_exceeded")), + // sess-b: a single allow entry. + accessLogRow("b1", "sess-b", baseTime.Add(30*time.Minute), + withTokens(1, 2, 3, 0.5)), + ) + + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, int64(2), total, "two distinct sessions") + require.Len(t, sessions, 2) + + // Default sort is last-activity DESC, so sess-b (12:30) precedes sess-a (12:02). + require.Equal(t, []string{"sess-b", "sess-a"}, sessionIDs(sessions)) + + a := sessions[1] + assert.Equal(t, "sess-a", a.SessionID) + assert.Equal(t, 3, a.RequestCount, "three requests folded") + assert.Equal(t, int64(310), a.InputTokens, "input tokens summed") + assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed") + assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed") + assert.InDelta(t, 0.031, a.CostUSD, 1e-9, "cost summed") + assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny") + assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers") + assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models") + assert.ElementsMatch(t, []string{"grp-eng", "grp-ops"}, a.GroupIDs, "union of authorising groups") + assert.Equal(t, baseTime, a.StartedAt.UTC(), "started at is the earliest entry") + assert.Equal(t, baseTime.Add(2*time.Minute), a.EndedAt.UTC(), "ended at is the latest entry") + assert.Len(t, a.Entries, 3, "entries carried through") + + b := sessions[0] + assert.Equal(t, "sess-b", b.SessionID) + assert.Equal(t, 1, b.RequestCount) + assert.Equal(t, "allow", b.Decision) +} + +// TestAccessLogSessions_SessionlessRowsAreSingletons verifies that entries with +// no session id each form their own singleton session keyed by the row id. +func TestAccessLogSessions_SessionlessRowsAreSingletons(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + seedAccessLogs(t, s, + accessLogRow("solo-1", "", baseTime), + accessLogRow("solo-2", "", baseTime.Add(time.Minute)), + // A real session with two entries, to prove they don't merge with the singletons. + accessLogRow("g1", "sess-x", baseTime.Add(2*time.Minute)), + accessLogRow("g2", "sess-x", baseTime.Add(3*time.Minute)), + ) + + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, int64(3), total, "two singletons + one grouped session") + require.Len(t, sessions, 3) + + for _, sess := range sessions { + if sess.SessionID == "sess-x" { + assert.Equal(t, 2, sess.RequestCount, "grouped session folds both entries") + } else { + assert.Empty(t, sess.SessionID, "singleton carries no session id") + assert.Equal(t, 1, sess.RequestCount, "singleton has exactly one request") + } + } +} + +// TestAccessLogSessions_Pagination verifies that paging returns the correct +// slice of sessions in stable order, with a stable total across pages and no +// overlap between pages. +func TestAccessLogSessions_Pagination(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + // Five sessions, each a single entry, with increasing timestamps so the + // default MAX(timestamp) DESC order is sess-5, sess-4, sess-3, sess-2, sess-1. + rows := make([]*types.AgentNetworkAccessLog, 0, 5) + for i := 1; i <= 5; i++ { + rows = append(rows, accessLogRow( + "row-"+itoa(i), "sess-"+itoa(i), baseTime.Add(time.Duration(i)*time.Minute))) + } + seedAccessLogs(t, s, rows...) + + page := func(p int) []*types.AgentNetworkAccessLogSession { + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, + types.AgentNetworkAccessLogFilter{Page: p, PageSize: 2}) + require.NoError(t, err) + require.Equal(t, int64(5), total, "total session count is stable across pages") + return sessions + } + + assert.Equal(t, []string{"sess-5", "sess-4"}, sessionIDs(page(1)), "page 1: two newest") + assert.Equal(t, []string{"sess-3", "sess-2"}, sessionIDs(page(2)), "page 2: next two") + assert.Equal(t, []string{"sess-1"}, sessionIDs(page(3)), "page 3: remaining one") + assert.Empty(t, page(4), "page 4: past the end is empty") +} + +// TestAccessLogSessions_Filtering verifies each filter is applied before +// grouping, so the session set (and total) reflect only matching entries. +func TestAccessLogSessions_Filtering(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + seedAccessLogs(t, s, + accessLogRow("r1", "sess-1", baseTime.Add(1*time.Minute), + withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4")), + accessLogRow("r2", "sess-2", baseTime.Add(2*time.Minute), + withUser("bob"), withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"), + withDeny("llm_policy.no_authorized_provider"), withGroups("grp-ops")), + accessLogRow("r3", "sess-3", baseTime.Add(3*time.Minute), + withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4"), + withGroups("grp-eng")), + ) + + filterCases := []struct { + name string + filter types.AgentNetworkAccessLogFilter + wantIDs []string + wantTot int64 + }{ + { + name: "by session id", + filter: types.AgentNetworkAccessLogFilter{SessionID: strp("sess-2")}, + wantIDs: []string{"sess-2"}, + wantTot: 1, + }, + { + name: "by user id", + filter: types.AgentNetworkAccessLogFilter{UserID: strp("alice")}, + wantIDs: []string{"sess-3", "sess-1"}, // last-activity DESC + wantTot: 2, + }, + { + name: "by model", + filter: types.AgentNetworkAccessLogFilter{Models: []string{"claude-haiku-4-5"}}, + wantIDs: []string{"sess-2"}, + wantTot: 1, + }, + { + name: "by resolved provider id", + filter: types.AgentNetworkAccessLogFilter{ProviderIDs: []string{"prov-openai"}}, + wantIDs: []string{"sess-3", "sess-1"}, + wantTot: 2, + }, + { + name: "by decision deny", + filter: types.AgentNetworkAccessLogFilter{Decision: strp("deny")}, + wantIDs: []string{"sess-2"}, + wantTot: 1, + }, + { + name: "by authorising group", + filter: types.AgentNetworkAccessLogFilter{GroupIDs: []string{"grp-eng"}}, + wantIDs: []string{"sess-3"}, + wantTot: 1, + }, + { + name: "by date range excludes earlier", + filter: types.AgentNetworkAccessLogFilter{ + StartDate: tp(baseTime.Add(90 * time.Second)), // after r1 (12:01), before r2 (12:02) + }, + wantIDs: []string{"sess-3", "sess-2"}, + wantTot: 2, + }, + } + + for _, tc := range filterCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, tc.filter) + require.NoError(t, err) + assert.Equal(t, tc.wantTot, total, "filtered total") + assert.Equal(t, tc.wantIDs, sessionIDs(sessions), "filtered session ids in order") + }) + } +} + +// TestAccessLogSessions_SortByCost verifies session-level aggregate sorting: +// ordering by summed cost, ascending and descending. +func TestAccessLogSessions_SortByCost(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + // cheap: 0.01 total; mid: 0.05 total; pricey: 0.20 total (two entries). + seedAccessLogs(t, s, + accessLogRow("c1", "cheap", baseTime.Add(1*time.Minute), withTokens(1, 1, 2, 0.01)), + accessLogRow("m1", "mid", baseTime.Add(2*time.Minute), withTokens(1, 1, 2, 0.05)), + accessLogRow("p1", "pricey", baseTime.Add(3*time.Minute), withTokens(1, 1, 2, 0.15)), + accessLogRow("p2", "pricey", baseTime.Add(4*time.Minute), withTokens(1, 1, 2, 0.05)), + ) + + desc, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, + types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "desc"}) + require.NoError(t, err) + require.Equal(t, int64(3), total) + assert.Equal(t, []string{"pricey", "mid", "cheap"}, sessionIDs(desc), "descending by summed cost") + + asc, _, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, + types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "asc"}) + require.NoError(t, err) + assert.Equal(t, []string{"cheap", "mid", "pricey"}, sessionIDs(asc), "ascending by summed cost") +} + +// strp / tp / itoa are tiny local helpers to keep the filter table terse. +func strp(s string) *string { return &s } + +func tp(t time.Time) *time.Time { return &t } + +func itoa(i int) string { return string(rune('0' + i)) } diff --git a/management/internals/modules/agentnetwork/affectedpeers_hook.go b/management/internals/modules/agentnetwork/affectedpeers_hook.go new file mode 100644 index 000000000..58347666d --- /dev/null +++ b/management/internals/modules/agentnetwork/affectedpeers_hook.go @@ -0,0 +1,15 @@ +package agentnetwork + +import "github.com/netbirdio/netbird/management/server/affectedpeers" + +// init registers the agent-network service synthesiser with the affectedpeers +// resolver. Agent-network reverse-proxy services are synthesised on demand and +// never persisted, so the resolver can't load them from the store; without them +// it can't fold the embedded proxy peer into the affected set on a client +// group/peer change, and the proxy never learns a newly authorised client until +// it reconnects. Registered here (rather than via a direct +// affectedpeers→agentnetwork import) to avoid an import cycle +// (agentnetwork → account → affectedpeers). +func init() { + affectedpeers.SetAgentNetworkSynthesizer(SynthesizeServices) +} diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go new file mode 100644 index 000000000..baf622778 --- /dev/null +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -0,0 +1,749 @@ +// Package catalog defines the static set of Agent Network providers +// recognized by the management server. The catalog is consulted both to +// validate provider_id on create/update and to surface the available +// providers (and their models) to the dashboard. +package catalog + +import "github.com/netbirdio/netbird/shared/management/http/api" + +// Model is the in-memory representation of a catalog model. +type Model struct { + ID string + Label string + InputPer1k float64 + OutputPer1k float64 + ContextWindow int +} + +// ProviderKind groups catalog entries for UI presentation. The split +// is semantic, not technical: +// - KindProvider: the upstream is a vendor's first-party API (OpenAI, +// Anthropic, Mistral, Bedrock, etc.) — NetBird talks straight to +// the model provider. +// - KindGateway: the upstream is itself a routing / aggregation layer +// in front of multiple providers (LiteLLM, Portkey, Helicone, …). +// These typically need NetBird identity stamped onto upstream +// requests so the gateway's analytics and budgets attribute to the +// real caller; that's what IdentityInjection is for. +// - KindCustom: the catch-all "OpenAI-compatible self-hosted endpoint" +// entry (vLLM, Ollama, custom inference servers). +// +// Frontend uses Kind to group the provider Select in the modal so an +// operator can spot at a glance which catalog entries proxy other +// providers vs. talk straight to one. Backend doesn't dispatch on Kind +// today; it's purely a presentation hint. +type ProviderKind string + +const ( + KindProvider ProviderKind = "provider" + KindGateway ProviderKind = "gateway" + KindCustom ProviderKind = "custom" +) + +// Provider is the in-memory representation of a catalog provider. +type Provider struct { + ID string + Name string + Description string + DefaultHost string + // Kind groups this entry for UI presentation; see ProviderKind. + Kind ProviderKind + // AuthHeaderName is the HTTP header the provider's API expects + // the credential under (e.g. "Authorization" for OpenAI, + // "x-api-key" for Anthropic). Combined with AuthHeaderTemplate + // at synthesis time to inject the auth header on every upstream + // request. + AuthHeaderName string + AuthHeaderTemplate string + DefaultContentType string + BrandColor string + // ParserID names the proxy LLM parser surface this provider + // speaks (matches llm.Parser.ProviderName: "openai", + // "anthropic"). Multiple catalog ids may share a parser surface + // (e.g. azure_openai_api and mistral_api both speak the OpenAI + // shape). Empty when no parser is yet implemented for the + // surface — the proxy middleware then falls back to URL sniffing + // or skips request-side enrichment. + ParserID string + // IdentityInjection, when non-nil, instructs the proxy to stamp + // the caller's NetBird identity onto upstream requests under the + // configured header names. Used for gateways like LiteLLM that + // key budgets and attribution off request headers (the gateway + // otherwise has no way to learn which user / group made the call). + // The proxy strips the same header names from the inbound request + // before stamping ours, so an app can't spoof identity by setting + // these headers itself. + IdentityInjection *IdentityInjection + // ExtraHeaders is a catalog-declared list of additional per- + // provider routing/config headers the proxy stamps on every + // upstream request. Distinct from AuthHeaderName/Template (which + // always carries the API_KEY) and from IdentityInjection (caller + // identity). Each entry surfaces an optional input on the + // dashboard's provider modal whose value lives on the provider + // record's ExtraValues map (keyed by ExtraHeader.Name). Empty + // list = no extra inputs rendered. Used today by Portkey for + // "x-portkey-config: pc-..." (a saved-config id that resolves + // upstream provider + credentials on Portkey's hosted side). + ExtraHeaders []ExtraHeader + Models []Model +} + +// ExtraHeader names a single optional per-provider routing/config +// header. Catalog declares N of these per provider type; the operator +// fills any subset on the provider record (see Provider.ExtraValues). +// At synth time, only entries with a non-empty operator value are +// stamped; the proxy's identity-inject middleware applies anti-spoof +// (Remove + Add) so a client can't supply these headers themselves. +// +// UI copy (label / help text / tooltip) for each known Name lives on +// the dashboard, not here — the backend's job is just to declare +// which wire headers are accepted. New provider needs an extra +// header? Add the Name here AND the matching UI copy on the dashboard. +type ExtraHeader struct { + // Name is the wire header name, e.g. "x-portkey-config". + Name string +} + +// IdentityInjection describes how the proxy stamps NetBird identity onto +// upstream gateway requests. Exactly one shape must be set — they're +// mutually exclusive and dispatched by the inject middleware. +// +// Shape choice tracks the wire convention the upstream gateway uses, +// not the vendor name. New gateways with a known shape become a catalog +// entry, not a new code path. +type IdentityInjection struct { + // HeaderPair emits separate headers per identity dimension + // (end-user id, tags as CSV). LiteLLM and OpenAI-compatible + // self-hosted gateways that read identity from dedicated headers. + HeaderPair *HeaderPairInjection + // JSONMetadata emits a single header carrying a JSON object with + // reserved keys for user / groups / etc. Portkey, Helicone-style + // metadata headers, anything that wants a structured envelope. + JSONMetadata *JSONMetadataInjection +} + +// HeaderPairInjection is the LiteLLM-style wire convention. +type HeaderPairInjection struct { + // Customizable, when true, marks the wire header names as + // operator-overridable: the dashboard surfaces EndUserIDHeader + // and TagsHeader as editable inputs (defaults shown as + // placeholders) and the synthesizer pulls the actual values from + // the provider record's IdentityHeader* fields rather than from + // these defaults. An empty operator value disables stamping for + // that dimension. Used today for Bifrost, whose log-metadata / + // telemetry header prefix (x-bf-lh-* vs x-bf-dim-*) is a + // per-operator choice; LiteLLM and similar gateways with a fixed + // wire protocol leave this false so the catalog defaults are + // authoritative. + Customizable bool + // EndUserIDHeader receives the caller's display identity (user + // email when the peer is attached to a user, else peer.Name), + // e.g. "x-litellm-end-user-id". + EndUserIDHeader string + // TagsHeader receives the caller's NetBird group display names + // as a CSV, e.g. "x-litellm-tags". + TagsHeader string + // TagsInBody, when true, additionally writes the tag list into + // the request body's metadata.tags array (a JSON path the + // gateway parses for budget enforcement). LiteLLM only honours + // metadata.tags for tag-budget gating — its x-litellm-tags + // header path feeds spend tracking but bypasses + // _tag_max_budget_check entirely. Body inject is skipped when + // the request body is empty, truncated, non-JSON, or when an + // existing metadata field is a non-object value (defensive: we + // never clobber a client-supplied non-object). The header path + // remains a robust fallback for spend tracking in those cases. + TagsInBody bool + // EndUserIDInBody, when true, additionally writes the display + // identity into the request body's top-level "user" field (the + // OpenAI-standard end-user identifier). LiteLLM resolves the end + // user id from headers first then body, so for LiteLLM this is + // belt-and-suspenders. It matters when an OpenAI-compatible + // gateway downstream of LiteLLM (or OpenAI direct, bypassing + // LiteLLM) only reads the body, and as anti-spoof: client- + // supplied "user" values are overwritten with our trusted + // identity. Same skip rules as TagsInBody. + EndUserIDInBody bool +} + +// JSONMetadataInjection is the Portkey-style wire convention: a single +// header carrying a JSON object. NetBird identity fields land under the +// configured reserved keys; missing keys (empty string) are skipped at +// emit time. +type JSONMetadataInjection struct { + // Customizable, when true, marks the JSON keys as operator- + // overridable. The dashboard surfaces UserKey and GroupsKey as + // editable inputs (the catalog values shown as placeholders) and + // the synthesizer pulls the actual JSON-key names from the + // provider record's IdentityHeader* fields. Same field reuse as + // HeaderPair's customizable path — the dimensions (user identity, + // groups) are the same, only the wire encoding differs (JSON key + // vs HTTP header name). An empty operator value disables emission + // for that dimension. Used today for Cloudflare AI Gateway, whose + // cf-aig-metadata header accepts arbitrary JSON keys; Portkey + // leaves this false because its keys are reserved by the Portkey + // schema. + Customizable bool + // Header is the wire header name carrying the JSON payload, e.g. + // "x-portkey-metadata". + Header string + // UserKey is the JSON key for the caller's display identity. + // Portkey reserves "_user" for this dimension. + UserKey string + // GroupsKey is the JSON key for the caller's NetBird groups, + // emitted as a CSV string value (Portkey requires string values). + GroupsKey string + // MaxValueLength caps each emitted JSON value, in bytes. Portkey + // enforces a 128-char limit per value; oversized values are + // truncated rather than failing the request. 0 disables the cap. + MaxValueLength int +} + +// providers is the canonical list of supported Agent Network providers. +// Update this list together with the dashboard's PROVIDER_CATALOG. +var providers = []Provider{ + { + ID: "openai_api", + Kind: KindProvider, + Name: "OpenAI API", + Description: "GPT, Responses API, and Embeddings", + DefaultHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#10A37F", + ParserID: "openai", + // Pricing + context windows cross-checked against LiteLLM's + // model_prices_and_context_window.json. Notable corrections from + // earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40 + // per MTok, gpt-4o from $5/$15 to $2.50/$10, and the GPT-5 + // family context windows split between 1.05M for full-size + // models and 272K for mini/nano/codex variants. + Models: []Model{ + {ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000}, + {ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000}, + {ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000}, + {ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 272000}, + {ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 128000}, + {ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576}, + {ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000}, + {ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000}, + {ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385}, + {ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191}, + {ID: "text-embedding-3-small", Label: "text-embedding-3-small", InputPer1k: 0.00002, OutputPer1k: 0, ContextWindow: 8191}, + }, + }, + { + ID: "anthropic_api", + Kind: KindProvider, + Name: "Anthropic API", + Description: "Claude Messages API", + DefaultHost: "api.anthropic.com", + AuthHeaderName: "x-api-key", + AuthHeaderTemplate: "${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#D97757", + ParserID: "anthropic", + // Per Anthropic's current model lineup. Pricing in USD per 1k + // tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at + // 200K. claude-3-7-sonnet and claude-3-5-haiku retired + // 2026-02-19 — dropped from the catalog. claude-opus-4-1 + // deprecated, retires 2026-08-05 — kept until the cutover. + // claude-mythos-5 omitted: Project Glasswing access only, not a + // general-availability target. claude-fable-5 requires the + // account to be on >= 30-day data retention or all requests + // 400. + Models: []Model{ + {ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000}, + }, + }, + { + ID: "azure_openai_api", + Kind: KindProvider, + Name: "Azure OpenAI API", + Description: "Azure-hosted OpenAI deployments", + DefaultHost: ".openai.azure.com", + AuthHeaderName: "api-key", + AuthHeaderTemplate: "${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#0078D4", + ParserID: "openai", + // Mirrors openai_api pricing — Azure resells OpenAI models at the + // same per-token rates, just under different deployment names. + Models: []Model{ + {ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000}, + {ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000}, + {ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385}, + }, + }, + { + ID: "bedrock_api", + Kind: KindProvider, + Name: "AWS Bedrock API", + Description: "Anthropic, Meta, Cohere via Bedrock", + DefaultHost: "bedrock-runtime..amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#FF9900", + // Anthropic models on Bedrock take the anthropic.* prefix and + // follow the same lineup / pricing as the first-party Anthropic + // catalog entry above. claude-3-7-sonnet and claude-3-5-haiku + // were retired upstream on 2026-02-19 — dropped from the + // Bedrock list too. Amazon Nova entries cross-checked against + // LiteLLM (added Nova Micro + the new Nova 2 Lite preview). + // Llama 3.3 70B entry kept unchanged — LiteLLM tracks only + // per-region Llama 3 entries; standalone 3.3 not yet listed. + Models: []Model{ + {ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, + {ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + {ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, + {ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000}, + {ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000}, + {ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000}, + {ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000}, + {ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000}, + {ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000}, + }, + }, + { + ID: "vertex_ai_api", + Kind: KindProvider, + Name: "Google Vertex AI API", + Description: "Anthropic Claude models hosted on Vertex AI", + DefaultHost: "-aiplatform.googleapis.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#4285F4", + // Vertex carries the model in the URL path and authenticates with a + // service-account-minted OAuth token (api_key = "keyfile::"). + // Only Anthropic-on-Vertex is metered today: the request parser maps the + // anthropic publisher to the Anthropic parser, so the lineup + prices + // mirror the first-party Anthropic catalog (LiteLLM vertex_ai/claude-* + // confirms the same per-token rates; cross-region profiles in eu/apac + // carry a ~10% premium that base pricing does not model). Gemini (the + // google publisher) is intentionally omitted until a Gemini parser + // exists — the router denies unmeterable publishers rather than forward + // them uncounted. + Models: []Model{ + {ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000}, + }, + }, + { + ID: "mistral_api", + Kind: KindProvider, + Name: "Mistral API", + Description: "Mistral cloud API", + DefaultHost: "api.mistral.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#FF7000", + ParserID: "openai", + // Pricing + context windows cross-checked against LiteLLM. Key + // gotchas the marketing page hides: + // - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2), + // NOT Medium 3.5 ($1.50/$7.50). Catalog exposes both. + // - `mistral-large-latest` aliases to Large 3 — 262K context, + // cheaper than Medium 3.5. + // - Magistral models are tuned for reasoning but cap context + // at only 40K (vs 128K-262K elsewhere). + // - `codestral-latest` still routes to the old 2405 build + // ($1/$3) per LiteLLM; the newer codestral-2508 is both + // cheaper and longer-context. Both exposed. + // - Pixtral was folded into the main Large/Medium series; no + // standalone vision entry. + Models: []Model{ + {ID: "mistral-large-latest", Label: "Mistral Large 3", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 262144}, + {ID: "mistral-medium-latest", Label: "Mistral Medium 3.1", InputPer1k: 0.0004, OutputPer1k: 0.002, ContextWindow: 131072}, + {ID: "mistral-medium-3-5", Label: "Mistral Medium 3.5", InputPer1k: 0.0015, OutputPer1k: 0.0075, ContextWindow: 262144}, + {ID: "mistral-small-latest", Label: "Mistral Small 3.2", InputPer1k: 0.00006, OutputPer1k: 0.00018, ContextWindow: 131072}, + {ID: "magistral-medium-latest", Label: "Magistral Medium (reasoning)", InputPer1k: 0.002, OutputPer1k: 0.005, ContextWindow: 40000}, + {ID: "magistral-small-latest", Label: "Magistral Small (reasoning)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 40000}, + {ID: "devstral-medium-latest", Label: "Devstral Medium 2 (coding)", InputPer1k: 0.0004, OutputPer1k: 0.002, ContextWindow: 256000}, + {ID: "devstral-small-latest", Label: "Devstral Small 2 (coding)", InputPer1k: 0.0001, OutputPer1k: 0.0003, ContextWindow: 256000}, + {ID: "codestral-2508", Label: "Codestral 2508", InputPer1k: 0.0003, OutputPer1k: 0.0009, ContextWindow: 256000}, + {ID: "codestral-latest", Label: "Codestral (legacy 2405)", InputPer1k: 0.001, OutputPer1k: 0.003, ContextWindow: 32000}, + {ID: "ministral-3-14b-2512", Label: "Ministral 3 14B", InputPer1k: 0.0002, OutputPer1k: 0.0002, ContextWindow: 262144}, + {ID: "ministral-8b-latest", Label: "Ministral 8B", InputPer1k: 0.00015, OutputPer1k: 0.00015, ContextWindow: 262144}, + {ID: "ministral-3-3b-2512", Label: "Ministral 3 3B", InputPer1k: 0.0001, OutputPer1k: 0.0001, ContextWindow: 131072}, + {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, + }, + }, + { + ID: "litellm_proxy", + Kind: KindGateway, + Name: "LiteLLM Proxy", + Description: "Bring your own LiteLLM proxy with NetBird identity stamped on every request", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#0EA5E9", + ParserID: "openai", + // IdentityInjection requires a LiteLLM virtual key minted with + // metadata.allow_client_tags=true; the master key silently drops + // caller tags. Tags go out via both the x-litellm-tags header and + // body metadata.tags: LiteLLM enforces budgets from the body only, + // so the header is the spend-tracking fallback when body injection + // can't run. See the Agent Network provider docs for key setup. + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + TagsInBody: true, + EndUserIDInBody: true, + }, + }, + Models: []Model{}, + }, + { + ID: "portkey", + Kind: KindGateway, + Name: "Portkey AI Gateway", + Description: "Portkey AI Gateway with NetBird identity stamped via x-portkey-metadata", + DefaultHost: "api.portkey.ai", + // Portkey hosted requires x-portkey-api-key (account key) + // plus a routing decision per request. The simplest routing + // path is a saved Portkey config id stamped via + // x-portkey-config — operators paste the pc-... id once and + // Portkey resolves the upstream provider + virtual key from + // it. ExtraHeaders below surfaces the input. Alternative: + // callers author "@org/model" in the body; both flows + // coexist (per-request authoring still works without a + // configured value). + AuthHeaderName: "x-portkey-api-key", + AuthHeaderTemplate: "${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#FF5C00", + ParserID: "openai", + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "x-portkey-metadata", + UserKey: "_user", + GroupsKey: "groups", + MaxValueLength: 128, + }, + }, + ExtraHeaders: []ExtraHeader{ + {Name: "x-portkey-config"}, + }, + Models: []Model{}, + }, + { + ID: "bifrost", + Kind: KindGateway, + Name: "Bifrost", + Description: "Maxim AI's Bifrost gateway. Point upstream URL at /openai/v1 or /anthropic/v1 on your Bifrost host depending on which body shape your apps use.", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#7C3AED", + // ParserID empty: the proxy's request parser sniffs the URL + // path. Bifrost's /openai/v1/... contains "/v1/chat/completions" + // (matches OpenAIParser.DetectFromURL); /anthropic/v1/messages + // contains "/v1/messages" (matches AnthropicParser). Operators + // who paste a different prefix get no usage parsing and the + // cost meter skips with skipMissingProvider — degraded but + // non-fatal. + ParserID: "", + // Identity-injection headers are operator-customisable. The + // HeaderPair values below are PLACEHOLDERS surfaced by the + // dashboard; the actual values stamped on the wire come from + // the provider record's IdentityHeaderUserID / + // IdentityHeaderGroups fields. An empty operator value + // disables stamping for that dimension (the inject middleware + // already no-ops on empty header names). Defaulting to the + // x-bf-dim- family so the values land in Bifrost's + // Prometheus/OTEL pipelines when the operator declares the + // label names in their client.prometheus_labels config — see + // docs.getbifrost.ai/features/telemetry. Operators who use + // the always-on x-bf-lh- log-metadata family (no Bifrost-side + // declaration required) just edit the inputs. + // + // Bifrost virtual keys (sk-bf-*) ride Authorization: Bearer. + // Operators provision the VK on their Bifrost (UI / + // config.json / POST /api/governance/virtual-keys) and paste + // the returned sk-bf-... as ${API_KEY}. Pin v1.4+ to avoid + // the v1.3.0 x-bf-vk regression (maximhq/bifrost#632). + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "x-bf-dim-netbird_user_id", + TagsHeader: "x-bf-dim-netbird_groups", + Customizable: true, + }, + }, + Models: []Model{}, + }, + { + ID: "cloudflare_ai_gateway", + Kind: KindGateway, + Name: "Cloudflare AI Gateway", + Description: "Cloudflare AI Gateway. Operator pastes the gateway URL (with the upstream provider slug like /openai or /anthropic so the URL sniffer dispatches to the right parser) and a per-gateway authentication token. Recommended setup is BYOK / Stored Keys: Cloudflare manages the upstream provider credential and the gateway token is the only secret NetBird needs.", + DefaultHost: "", + AuthHeaderName: "cf-aig-authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#F38020", + // ParserID empty: like Bifrost, the proxy's parser-detect + // sniffs the URL path. /openai/... contains the OpenAI hint + // substrings; /anthropic/v1/messages contains /v1/messages + // (matches AnthropicParser). The /compat universal endpoint + // also speaks OpenAI shape so OpenAIParser handles it. + // Operators who paste a different prefix degrade to no-cost + // (skipMissingProvider) but the request still flows. + ParserID: "", + // cf-aig-metadata is a single header carrying a JSON object; + // up to five string/number/boolean values per request. NetBird + // occupies two slots (user id + groups CSV) and leaves three + // for operator-added context. JSON keys are operator- + // customisable so Cloudflare-side log filters can use the + // operator's existing label conventions instead of NetBird's + // defaults — hence Customizable=true. The dashboard surfaces + // the catalog values as placeholders; only the values stored + // on the provider record's IdentityHeader* fields land on the + // wire (empty operator value = key is omitted from the JSON, + // since applyJSONMetadata already skips empty keys). + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "cf-aig-metadata", + UserKey: "netbird_user_id", + GroupsKey: "netbird_groups", + Customizable: true, + // Cloudflare's docs don't specify a per-value cap; + // leaving 0 disables the truncate path. Header-level + // constraint is "5 entries max" rather than length. + MaxValueLength: 0, + }, + }, + Models: []Model{}, + }, + { + ID: "vercel_ai_gateway", + Kind: KindGateway, + Name: "Vercel AI Gateway", + Description: "Vercel's unified API for hundreds of models. Single endpoint, OpenAI-compatible body, model dispatch via prefix (openai/..., anthropic/..., google/..., xai/...). Per-user / per-tag attribution lands in Vercel's Custom Reporting API and observability dashboard.", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#000000", + // Vercel always speaks OpenAI shape on /v1/chat/completions — + // the model prefix in the body picks the upstream provider. + // No URL sniffing needed; pin the parser directly. + ParserID: "openai", + // HeaderPair shape with fixed wire names dictated by Vercel's + // Custom Reporting API contract. Customizable=false because + // renaming the headers makes Vercel silently stop attributing + // — the gateway's reporting endpoint only matches its own + // header names. Same fixed-protocol position as LiteLLM. + // + // Caveats operators should know: + // - up to 10 tags total per request (deduped); 11+ → HTTP 400 + // - each tag must be 1-64 chars + // - user up to 256 chars (NetBird user emails fit) + // - $0.075 per 1k unique user/tag values written + // We don't enforce the caps in the inject middleware today; + // operators in groups beyond the 10-tag limit will see Vercel + // 400s and need to re-scope their group memberships. + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "ai-reporting-user", + TagsHeader: "ai-reporting-tags", + }, + }, + Models: []Model{}, + }, + { + ID: "openrouter", + Kind: KindGateway, + Name: "OpenRouter", + Description: "OpenRouter's unified API for hundreds of models. Single endpoint at openrouter.ai/api/v1, OpenAI-compatible body, model dispatch via prefix (anthropic/claude-..., openai/gpt-..., google/gemini-..., etc.). Per-user attribution lands in OpenRouter's analytics via the OpenAI-standard `user` body field; OpenRouter has no groups / tags dimension at request time.", + DefaultHost: "openrouter.ai/api/v1", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#6F4FF2", + // OpenRouter is single-endpoint OpenAI-shape on /api/v1/chat/completions — + // model prefix in the body picks the upstream provider. + // Pinning the parser saves URL sniffing. + ParserID: "openai", + // HeaderPair shape with EndUserIDInBody as the only active + // dimension. OpenRouter's per-user attribution is the + // OpenAI-standard `user` body field, not a header — and + // OpenRouter offers no per-request groups / tags dimension at + // all. Customizable=false because the field name is locked by + // OpenAI's spec; renaming would just defeat the inject. + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDInBody: true, + }, + }, + // HTTP-Referer + X-OpenRouter-Title surface in OpenRouter's + // app rankings and per-app analytics. Operators paste their + // own app URL + display name on the provider record so their + // requests show under their brand instead of "no app". Both + // are static per-deployment, not per-request, hence the + // ExtraHeaders mechanism (operator-typed value, stamped on + // every request to this provider). Skip X-OpenRouter-Categories + // for now — the marketplace-categories dimension is + // niche-enough that we'd add it on demand. + ExtraHeaders: []ExtraHeader{ + {Name: "HTTP-Referer"}, + {Name: "X-OpenRouter-Title"}, + }, + Models: []Model{}, + }, + { + ID: "custom", + Kind: KindCustom, + Name: "Custom / Self-hosted", + Description: "OpenAI-compatible endpoint (vLLM, Ollama, …)", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#9CA3AF", + Models: []Model{}, + }, +} + +// All returns a copy of the full catalog. +func All() []Provider { + out := make([]Provider, len(providers)) + copy(out, providers) + return out +} + +// Lookup returns the catalog entry with the given id, if any. +func Lookup(id string) (Provider, bool) { + for _, p := range providers { + if p.ID == id { + return p, true + } + } + return Provider{}, false +} + +// IsKnown reports whether the given id refers to a catalog entry. +func IsKnown(id string) bool { + _, ok := Lookup(id) + return ok +} + +// IsVertexPathStyle reports whether a provider uses the Google Vertex AI +// request shape — the model is carried in the URL path +// (/v1/projects/{p}/locations/{r}/publishers/{pub}/models/{model}:{action}) +// rather than the body, so the proxy routes it by path instead of by model. +func IsVertexPathStyle(providerID string) bool { + return providerID == "vertex_ai_api" +} + +// IsBedrockPathStyle reports whether a provider uses the AWS Bedrock request +// shape — the model is carried in the URL path (/model/{modelId}/{action}, +// action being invoke, invoke-with-response-stream, converse, or +// converse-stream) rather than the body, so the proxy routes it by path. +func IsBedrockPathStyle(providerID string) bool { + return providerID == "bedrock_api" +} + +// ToAPIResponse renders a catalog provider as the API representation. +func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider { + models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models)) + for _, m := range p.Models { + models = append(models, api.AgentNetworkCatalogModel{ + Id: m.ID, + Label: m.Label, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + ContextWindow: m.ContextWindow, + }) + } + kind := api.AgentNetworkCatalogProviderKindProvider + switch p.Kind { + case KindGateway: + kind = api.AgentNetworkCatalogProviderKindGateway + case KindCustom: + kind = api.AgentNetworkCatalogProviderKindCustom + } + resp := api.AgentNetworkCatalogProvider{ + Id: p.ID, + Name: p.Name, + Description: p.Description, + DefaultHost: p.DefaultHost, + Kind: kind, + AuthHeaderTemplate: p.AuthHeaderTemplate, + DefaultContentType: p.DefaultContentType, + BrandColor: p.BrandColor, + Models: models, + } + if len(p.ExtraHeaders) > 0 { + extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders)) + for _, h := range p.ExtraHeaders { + extras = append(extras, api.AgentNetworkCatalogExtraHeader{ + Name: h.Name, + }) + } + resp.ExtraHeaders = &extras + } + // Surface IdentityInjection so the dashboard can decide whether + // to render editable inputs vs. a read-only mappings strip per + // shape's customizable flag. HeaderPair (Bifrost) and + // JSONMetadata (Cloudflare, Portkey) are mutually exclusive on a + // given catalog entry; emit whichever shape is set. + if p.IdentityInjection != nil { + injection := &api.AgentNetworkCatalogIdentityInjection{} + if hp := p.IdentityInjection.HeaderPair; hp != nil { + injection.HeaderPair = &api.AgentNetworkCatalogHeaderPairInjection{ + Customizable: hp.Customizable, + EndUserIdHeader: hp.EndUserIDHeader, + TagsHeader: hp.TagsHeader, + } + } + if jm := p.IdentityInjection.JSONMetadata; jm != nil { + injection.JsonMetadata = &api.AgentNetworkCatalogJSONMetadataInjection{ + Customizable: jm.Customizable, + Header: jm.Header, + UserKey: jm.UserKey, + GroupsKey: jm.GroupsKey, + } + } + if injection.HeaderPair != nil || injection.JsonMetadata != nil { + resp.IdentityInjection = injection + } + } + return resp +} diff --git a/management/internals/modules/agentnetwork/handlers/access_log_handler.go b/management/internals/modules/agentnetwork/handlers/access_log_handler.go new file mode 100644 index 000000000..4484d8c91 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/access_log_handler.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// addAccessLogEndpoints registers the read-only, server-side-filtered +// agent-network access-log listing and the aggregated usage overview. +func (h *handler) addAccessLogEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/access-logs", h.listAccessLogs).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/access-log-sessions", h.listAccessLogSessions).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/usage/overview", h.getUsageOverview).Methods("GET", "OPTIONS") +} + +func (h *handler) getUsageOverview(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + // Reuse the access-log filter for the shared date/user/group/provider/model + // params; pagination/sort/search are irrelevant for an aggregate. + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + // Bound the aggregation window so an unbounded or over-wide query can't load + // an account's entire usage history into memory. + filter.ApplyUsageOverviewBounds(time.Now()) + granularity := types.ParseUsageGranularity(r.URL.Query().Get("granularity")) + + buckets, err := h.manager.GetUsageOverview(r.Context(), userAuth.AccountId, userAuth.UserId, filter, granularity) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]api.AgentNetworkUsageBucket, 0, len(buckets)) + for _, b := range buckets { + out = append(out, b.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) listAccessLogs(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rows, total, err := h.manager.ListAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + data := make([]api.AgentNetworkAccessLog, 0, len(rows)) + for _, row := range rows { + data = append(data, row.ToAPIResponse()) + } + + pageSize := filter.GetLimit() + totalPages := 0 + if pageSize > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + + util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogsResponse{ + Data: data, + Page: filter.Page, + PageSize: pageSize, + TotalRecords: int(total), + TotalPages: totalPages, + }) +} + +// listAccessLogSessions returns the access logs grouped by session: the page +// unit is a session (total counts sessions), each carrying an aggregate summary +// and its ordered entries. Accepts the same filters as listAccessLogs. +func (h *handler) listAccessLogSessions(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + sessions, total, err := h.manager.ListAccessLogSessions(r.Context(), userAuth.AccountId, userAuth.UserId, filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + data := make([]api.AgentNetworkAccessLogSession, 0, len(sessions)) + for _, sess := range sessions { + data = append(data, sess.ToAPIResponse()) + } + + pageSize := filter.GetLimit() + totalPages := 0 + if pageSize > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + + util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogSessionsResponse{ + Data: data, + Page: filter.Page, + PageSize: pageSize, + TotalRecords: int(total), + TotalPages: totalPages, + }) +} diff --git a/management/internals/modules/agentnetwork/handlers/budget_handler.go b/management/internals/modules/agentnetwork/handlers/budget_handler.go new file mode 100644 index 000000000..5630de17f --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/budget_handler.go @@ -0,0 +1,172 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// addBudgetRuleEndpoints registers the account-level budget rule routes. +func (h *handler) addBudgetRuleEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/budget-rules", h.getAllBudgetRules).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules", h.createBudgetRule).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.getBudgetRule).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.updateBudgetRule).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.deleteBudgetRule).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllBudgetRules(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rules, err := h.manager.GetAllBudgetRules(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkBudgetRule, 0, len(rules)) + for _, rule := range rules { + out = append(out, rule.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ruleID := mux.Vars(r)["ruleId"] + if ruleID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w) + return + } + + rule, err := h.manager.GetBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, rule.ToAPIResponse()) +} + +func (h *handler) createBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkBudgetRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateBudgetRule(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rule := types.NewAccountBudgetRule(userAuth.AccountId) + rule.FromAPIRequest(&req) + + created, err := h.manager.CreateBudgetRule(r.Context(), userAuth.UserId, rule) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updateBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ruleID := mux.Vars(r)["ruleId"] + if ruleID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w) + return + } + + var req api.AgentNetworkBudgetRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateBudgetRule(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rule := &types.AccountBudgetRule{ID: ruleID, AccountID: userAuth.AccountId} + rule.FromAPIRequest(&req) + + updated, err := h.manager.UpdateBudgetRule(r.Context(), userAuth.UserId, rule) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deleteBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ruleID := mux.Vars(r)["ruleId"] + if ruleID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w) + return + } + + if err := h.manager.DeleteBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// validateBudgetRule rejects malformed budget rules. It reuses the policy limit +// validation since the cap shape is identical, and rejects empty target entries. +func validateBudgetRule(req *api.AgentNetworkBudgetRuleRequest) error { + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if req.TargetGroups != nil { + for _, id := range *req.TargetGroups { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "target_groups must not contain empty entries") + } + } + } + if req.TargetUsers != nil { + for _, id := range *req.TargetUsers { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "target_users must not contain empty entries") + } + } + } + return validatePolicyLimits(req.Limits) +} diff --git a/management/internals/modules/agentnetwork/handlers/budget_handler_test.go b/management/internals/modules/agentnetwork/handlers/budget_handler_test.go new file mode 100644 index 000000000..4038761c5 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/budget_handler_test.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestBudgetRuleHandler_RoundTrip seeds a budget rule via the store and asserts +// the GET wire shape carries targets and the reused PolicyLimits cap shape. The +// create/update/delete success paths go through accountManager.StoreEvent which +// this fixture doesn't wire — they are covered by the manager-level no-mock +// test (TestAgentNetwork_BudgetRuleCRUD_RealManager). +func TestBudgetRuleHandler_RoundTrip(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rule := &agentNetworkTypes.AccountBudgetRule{ + ID: "ainbud_test", + AccountID: testAccountID, + Name: "org-monthly", + Enabled: true, + TargetGroups: []string{"grp-eng"}, + TargetUsers: []string{"user-alice"}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 100000, UserCap: 10000, WindowSeconds: 2_592_000}, + BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 500, WindowSeconds: 2_592_000}, + }, + } + require.NoError(t, f.store.SaveAgentNetworkBudgetRule(context.Background(), rule)) + + rec := f.do(t, http.MethodGet, "/agent-network/budget-rules/"+rule.ID, "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkBudgetRule + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "org-monthly", got.Name, "name must round-trip") + assert.Equal(t, []string{"grp-eng"}, got.TargetGroups, "target groups must round-trip") + assert.Equal(t, []string{"user-alice"}, got.TargetUsers, "target users must round-trip") + assert.Equal(t, int64(100000), got.Limits.TokenLimit.GroupCap, "token group cap must round-trip") + assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget window must round-trip") +} + +// TestBudgetRuleHandler_ListReturnsArray asserts the list endpoint returns a +// JSON array (never null) for an account with no rules. +func TestBudgetRuleHandler_ListReturnsArray(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodGet, "/agent-network/budget-rules", "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + assert.Equal(t, "[]", trimSpace(rec.Body.String()), "empty account must return an empty array, not null") +} + +// TestBudgetRuleHandler_RejectsMissingName covers the validation path (which +// runs before the manager call, so it works without a wired accountManager). +func TestBudgetRuleHandler_RejectsMissingName(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + body := `{ + "name": "", + "limits": { + "token_limit": {"enabled": false, "group_cap": 0, "user_cap": 0, "window_seconds": 0}, + "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0} + } + }` + rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "missing name must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "name", + "rejection body must name the offending field, proving the validation path: %s", rec.Body.String()) +} + +// TestBudgetRuleHandler_RejectsSubMinuteWindow proves budget rules reuse the +// policy-limit validation (enabled limit needs window >= 60s). +func TestBudgetRuleHandler_RejectsSubMinuteWindow(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + body := `{ + "name": "bad-window", + "limits": { + "token_limit": {"enabled": true, "group_cap": 1000, "user_cap": 0, "window_seconds": 30}, + "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0} + } + }` + rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "sub-minute window must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "window_seconds", + "rejection body must name the offending window_seconds field, proving the validation path: %s", rec.Body.String()) +} + +// TestSettingsHandler_GetExposesCollectionToggles asserts the GET settings wire +// shape carries the account-level collection toggles after a store seed. +func TestSettingsHandler_GetExposesCollectionToggles(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ + AccountID: testAccountID, + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + EnableLogCollection: true, + EnablePromptCollection: true, + RedactPii: false, + })) + + rec := f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.True(t, got.EnableLogCollection, "log collection toggle must surface on the wire") + assert.True(t, got.EnablePromptCollection, "prompt collection toggle must surface on the wire") + assert.False(t, got.RedactPii, "redact toggle must surface its false value") + assert.Equal(t, "violet.eu.proxy.netbird.io", got.Endpoint, "endpoint stays computed from immutable cluster+subdomain") +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + for len(s) > 0 && (s[0] == '\n' || s[0] == ' ' || s[0] == '\t' || s[0] == '\r') { + s = s[1:] + } + return s +} diff --git a/management/internals/modules/agentnetwork/handlers/consumption_handler.go b/management/internals/modules/agentnetwork/handlers/consumption_handler.go new file mode 100644 index 000000000..654f23109 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/consumption_handler.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// addConsumptionEndpoints registers the read-only Agent Network +// consumption listing — backs the dashboard's basic counter view. +func (h *handler) addConsumptionEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/consumption", h.listConsumption).Methods("GET", "OPTIONS") +} + +func (h *handler) listConsumption(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rows, err := h.manager.ListConsumption(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]api.AgentNetworkConsumption, 0, len(rows)) + for _, row := range rows { + out = append(out, consumptionToAPI(row)) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func consumptionToAPI(c *types.Consumption) api.AgentNetworkConsumption { + windowStart := c.WindowStartUTC + updatedAt := c.UpdatedAt + return api.AgentNetworkConsumption{ + DimensionKind: api.AgentNetworkConsumptionDimensionKind(c.DimensionKind), + DimensionId: c.DimensionID, + WindowSeconds: c.WindowSeconds, + WindowStartUtc: windowStart, + TokensInput: c.TokensInput, + TokensOutput: c.TokensOutput, + CostUsd: c.CostUSD, + UpdatedAt: &updatedAt, + } +} diff --git a/management/internals/modules/agentnetwork/handlers/guardrails_handler.go b/management/internals/modules/agentnetwork/handlers/guardrails_handler.go new file mode 100644 index 000000000..81f19b9f1 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/guardrails_handler.go @@ -0,0 +1,171 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// addGuardrailEndpoints registers all Agent Network guardrail routes. +func (h *handler) addGuardrailEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/guardrails", h.getAllGuardrails).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/guardrails", h.createGuardrail).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.getGuardrail).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.updateGuardrail).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.deleteGuardrail).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllGuardrails(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrails, err := h.manager.GetAllGuardrails(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkGuardrail, 0, len(guardrails)) + for _, g := range guardrails { + out = append(out, g.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrailID := mux.Vars(r)["guardrailId"] + if guardrailID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w) + return + } + + guardrail, err := h.manager.GetGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, guardrail.ToAPIResponse()) +} + +func (h *handler) createGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkGuardrailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateGuardrail(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrail := types.NewGuardrail(userAuth.AccountId) + guardrail.FromAPIRequest(&req) + + created, err := h.manager.CreateGuardrail(r.Context(), userAuth.UserId, guardrail) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updateGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrailID := mux.Vars(r)["guardrailId"] + if guardrailID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w) + return + } + + var req api.AgentNetworkGuardrailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateGuardrail(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrail := &types.Guardrail{ + ID: guardrailID, + AccountID: userAuth.AccountId, + } + guardrail.FromAPIRequest(&req) + + updated, err := h.manager.UpdateGuardrail(r.Context(), userAuth.UserId, guardrail) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deleteGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrailID := mux.Vars(r)["guardrailId"] + if guardrailID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w) + return + } + + if err := h.manager.DeleteGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func validateGuardrail(req *api.AgentNetworkGuardrailRequest) error { + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + + c := req.Checks + if c.ModelAllowlist.Enabled { + for _, id := range c.ModelAllowlist.Models { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "model_allowlist.models must not contain empty entries") + } + } + } + return nil +} diff --git a/management/internals/modules/agentnetwork/handlers/handlers_test.go b/management/internals/modules/agentnetwork/handlers/handlers_test.go new file mode 100644 index 000000000..27ebea5dd --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go @@ -0,0 +1,256 @@ +package handlers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +const ( + testAccountID = "acc-1" + testUserID = "user-bob" +) + +// agentNetworkHandlerFixture builds a real agentnetwork.Manager with +// a sqlite store and an always-allow permissions mock, then exposes +// the HTTP handlers via a gorilla router. Tests issue requests +// through httptest and assert on the wire shape — the same path the +// dashboard exercises. +type agentNetworkHandlerFixture struct { + store store.Store + manager agentnetwork.Manager + router *mux.Router +} + +func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("sqlite store not properly supported on Windows yet") + } + t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine)) + + st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + ctrl := gomock.NewController(t) + perms := permissions.NewMockManager(ctrl) + // Always-allow: the handler tests are about wire shape, not + // authz. Authz is covered by the manager's own tests. + perms.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, context.Background(), nil). + AnyTimes() + + manager := agentnetwork.NewManager(st, perms, nil, nil) + h := &handler{manager: manager} + + router := mux.NewRouter() + h.addPolicyEndpoints(router) + h.addConsumptionEndpoints(router) + h.addBudgetRuleEndpoints(router) + h.addSettingsEndpoints(router) + + return &agentNetworkHandlerFixture{ + store: st, + manager: manager, + router: router, + } +} + +func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +// seedProvider persists a minimal provider record so policy create +// passes the manager's destination_provider_ids existence check. +func (f *agentNetworkHandlerFixture) seedProvider(t *testing.T, id string) { + t.Helper() + require.NoError(t, f.store.SaveAgentNetworkProvider(context.Background(), &agentNetworkTypes.Provider{ + ID: id, + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "test-" + id, + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + Enabled: true, + SessionPrivateKey: "test-priv-key", + SessionPublicKey: "test-pub-key", + })) +} + +// TestPolicyHandler_WindowSecondsRoundTrip ports bash 10 to Go: +// assert that a policy with window_seconds on both Token + Budget +// halves round-trips through GET unchanged AND that legacy +// window_hours / window_days are absent from the JSON response. We +// seed the policy directly via the store rather than POST-ing +// because the create path goes through the manager's +// accountManager.StoreEvent which we don't wire in this fixture; the +// on-wire shape is what matters here, and the POST validation path +// is covered separately by the RejectsSubMinuteWindow test. +func TestPolicyHandler_WindowSecondsRoundTrip(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + policy := &agentNetworkTypes.Policy{ + ID: "ainpol_test", + AccountID: testAccountID, + Name: "round-trip", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 10000, UserCap: 5000, WindowSeconds: 86_400}, + BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 10.0, UserCapUsd: 2.5, WindowSeconds: 2_592_000}, + }, + } + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), policy)) + + rec := f.do(t, http.MethodGet, "/agent-network/policies/"+policy.ID, "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkPolicy + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, int64(86_400), got.Limits.TokenLimit.WindowSeconds, "token_limit.window_seconds must round-trip") + assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget_limit.window_seconds must round-trip") + + // Legacy field names must NOT appear in the response — would + // signal that the management server is still emitting the old + // shape and would fool a v1 dashboard into rendering days/hours. + assert.NotContains(t, rec.Body.String(), "window_hours", + "legacy window_hours field must be absent from the on-wire response") + assert.NotContains(t, rec.Body.String(), "window_days", + "legacy window_days field must be absent from the on-wire response") +} + +// TestPolicyHandler_RejectsSubMinuteWindow ports bash 20 to Go: an +// enabled limit with window_seconds < 60 must surface as a 4xx +// because anything finer than per-minute produces an untenable +// volume of consumption rows for a feature whose value comes from +// per-window cap enforcement. +func TestPolicyHandler_RejectsSubMinuteWindow(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + f.seedProvider(t, "prov-1") + + body := `{ + "name": "sub-minute-window", + "enabled": true, + "source_groups": ["grp-engineers"], + "destination_provider_ids": ["prov-1"], + "guardrail_ids": [], + "limits": { + "token_limit": {"enabled": true, "group_cap": 10000, "user_cap": 5000, "window_seconds": 30}, + "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0} + } + }` + rec := f.do(t, http.MethodPost, "/agent-network/policies", body) + // 422 specifically (InvalidArgument) proves the window-validation path — + // a route miss would be 404 and an auth failure 403, so a generic 4xx + // would let those false-pass. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "enabled token_limit with window_seconds<60 must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "window_seconds", + "rejection body must name the offending window_seconds field, proving it's the validation path: %s", rec.Body.String()) +} + +// TestConsumptionHandler_EmptyAccountReturnsArray ports bash 30 to +// Go: GET /agent-network/consumption on a clean account always +// returns a JSON array (possibly empty), never a 404 / 500. The +// dashboard depends on this shape to render its empty state. +func TestConsumptionHandler_EmptyAccountReturnsArray(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodGet, "/agent-network/consumption", "") + require.Equal(t, http.StatusOK, rec.Code) + + var rows []api.AgentNetworkConsumption + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows), + "response must always be a JSON array — even when empty: %s", rec.Body.String()) + assert.Empty(t, rows) +} + +// TestConsumptionHandler_PopulatedAccountListsRows mirrors the +// /consumption read after a few RecordConsumption calls. Validates +// the wire shape carries every field the dashboard reads (dim_kind, +// dim_id, window_seconds, window_start_utc, tokens, cost_usd) and +// rows are ordered window-newest-first. +func TestConsumptionHandler_PopulatedAccountListsRows(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + require.NoError(t, f.manager.RecordConsumption( + context.Background(), testAccountID, + agentNetworkTypes.DimensionGroup, "grp-engineers", + 86_400, 100, 50, 0.0125, + )) + require.NoError(t, f.manager.RecordConsumption( + context.Background(), testAccountID, + agentNetworkTypes.DimensionUser, testUserID, + 86_400, 100, 50, 0.0125, + )) + + rec := f.do(t, http.MethodGet, "/agent-network/consumption", "") + require.Equal(t, http.StatusOK, rec.Code) + + var rows []api.AgentNetworkConsumption + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows)) + require.Len(t, rows, 2, "two RecordConsumption calls must yield two rows") + + // Index by dim_kind so we can assert the full wire shape of each row, + // including the dimension id and the aligned window start the dashboard + // keys on. Both rows share totals and window. + byKind := make(map[string]api.AgentNetworkConsumption, len(rows)) + for _, row := range rows { + assert.Equal(t, int64(100), row.TokensInput) + assert.Equal(t, int64(50), row.TokensOutput) + assert.InDelta(t, 0.0125, row.CostUsd, 1e-9) + assert.Equal(t, int64(86_400), row.WindowSeconds) + assert.False(t, row.WindowStartUtc.IsZero(), "window_start_utc must be set on every row") + byKind[string(row.DimensionKind)] = row + } + + groupRow, ok := byKind["group"] + require.True(t, ok, "group dimension must surface") + assert.Equal(t, "grp-engineers", groupRow.DimensionId, "group row must carry the source group id as dimension_id") + + userRow, ok := byKind["user"] + require.True(t, ok, "user dimension must surface") + assert.Equal(t, testUserID, userRow.DimensionId, "user row must carry the user id as dimension_id") + + // Both rows fall in the same aligned window (same length, recorded + // together), so window_start_utc must match across them. + assert.Equal(t, groupRow.WindowStartUtc, userRow.WindowStartUtc, + "rows recorded in the same window must share the aligned window_start_utc") +} diff --git a/management/internals/modules/agentnetwork/handlers/policies_handler.go b/management/internals/modules/agentnetwork/handlers/policies_handler.go new file mode 100644 index 000000000..b821a5295 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/policies_handler.go @@ -0,0 +1,228 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// minWindowSeconds is the floor enforced on enabled token / budget +// limit windows. One minute is short enough for fine-grained burst +// control without producing untenable consumption-row volume at scale. +const minWindowSeconds int64 = 60 + +// addPolicyEndpoints registers all Agent Network policy routes on the +// shared handler. +func (h *handler) addPolicyEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/policies", h.getAllPolicies).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/policies", h.createPolicy).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/policies/{policyId}", h.getPolicy).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/policies/{policyId}", h.updatePolicy).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/policies/{policyId}", h.deletePolicy).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllPolicies(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policies, err := h.manager.GetAllPolicies(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkPolicy, 0, len(policies)) + for _, p := range policies { + out = append(out, p.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getPolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policyID := mux.Vars(r)["policyId"] + if policyID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w) + return + } + + policy, err := h.manager.GetPolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, policy.ToAPIResponse()) +} + +func (h *handler) createPolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkPolicyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validatePolicy(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policy := types.NewPolicy(userAuth.AccountId) + policy.FromAPIRequest(&req) + + created, err := h.manager.CreatePolicy(r.Context(), userAuth.UserId, policy) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updatePolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policyID := mux.Vars(r)["policyId"] + if policyID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w) + return + } + + var req api.AgentNetworkPolicyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validatePolicy(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policy := &types.Policy{ + ID: policyID, + AccountID: userAuth.AccountId, + } + policy.FromAPIRequest(&req) + + updated, err := h.manager.UpdatePolicy(r.Context(), userAuth.UserId, policy) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deletePolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policyID := mux.Vars(r)["policyId"] + if policyID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w) + return + } + + if err := h.manager.DeletePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func validatePolicy(req *api.AgentNetworkPolicyRequest) error { + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if len(req.SourceGroups) == 0 { + return status.Errorf(status.InvalidArgument, "source_groups must contain at least one group id") + } + for _, id := range req.SourceGroups { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "source_groups must not contain empty entries") + } + } + if len(req.DestinationProviderIds) == 0 { + return status.Errorf(status.InvalidArgument, "destination_provider_ids must contain at least one provider id") + } + for _, id := range req.DestinationProviderIds { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "destination_provider_ids must not contain empty entries") + } + } + if req.GuardrailIds != nil { + for _, id := range *req.GuardrailIds { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "guardrail_ids must not contain empty entries") + } + } + } + if req.Limits != nil { + if err := validatePolicyLimits(*req.Limits); err != nil { + return err + } + } + return nil +} + +func validatePolicyLimits(l api.AgentNetworkPolicyLimits) error { + if l.TokenLimit.Enabled { + if l.TokenLimit.WindowSeconds < minWindowSeconds { + return status.Errorf(status.InvalidArgument, "limits.token_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds) + } + if l.TokenLimit.GroupCap < 0 { + return status.Errorf(status.InvalidArgument, "limits.token_limit.group_cap must not be negative") + } + if l.TokenLimit.UserCap < 0 { + return status.Errorf(status.InvalidArgument, "limits.token_limit.user_cap must not be negative") + } + if l.TokenLimit.GroupCap == 0 && l.TokenLimit.UserCap == 0 { + return status.Errorf(status.InvalidArgument, "limits.token_limit requires group_cap or user_cap to be greater than zero when enabled") + } + } + if l.BudgetLimit.Enabled { + if l.BudgetLimit.WindowSeconds < minWindowSeconds { + return status.Errorf(status.InvalidArgument, "limits.budget_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds) + } + if l.BudgetLimit.GroupCapUsd < 0 { + return status.Errorf(status.InvalidArgument, "limits.budget_limit.group_cap_usd must not be negative") + } + if l.BudgetLimit.UserCapUsd < 0 { + return status.Errorf(status.InvalidArgument, "limits.budget_limit.user_cap_usd must not be negative") + } + if l.BudgetLimit.GroupCapUsd == 0 && l.BudgetLimit.UserCapUsd == 0 { + return status.Errorf(status.InvalidArgument, "limits.budget_limit requires group_cap_usd or user_cap_usd to be greater than zero when enabled") + } + } + return nil +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go new file mode 100644 index 000000000..13da137d5 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -0,0 +1,217 @@ +// Package handlers serves the Agent Network HTTP API. +// +// All persistence is delegated to agentnetwork.Manager so this layer only +// translates between the wire format (api.AgentNetworkProvider*) and the +// domain types. +package handlers + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager agentnetwork.Manager +} + +// RegisterEndpoints registers all Agent Network routes. +func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) { + h := &handler{manager: manager} + router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/providers/{providerId}", h.deleteProvider).Methods("DELETE", "OPTIONS") + h.addPolicyEndpoints(router) + h.addGuardrailEndpoints(router) + h.addSettingsEndpoints(router) + h.addConsumptionEndpoints(router) + h.addAccessLogEndpoints(router) + h.addBudgetRuleEndpoints(router) +} + +func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { + if _, err := nbcontext.GetUserAuthFromContext(r.Context()); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + entries := catalog.All() + out := make([]api.AgentNetworkCatalogProvider, 0, len(entries)) + for _, e := range entries { + out = append(out, e.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providers, err := h.manager.GetAllProviders(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkProvider, 0, len(providers)) + for _, p := range providers { + out = append(out, p.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providerID := mux.Vars(r)["providerId"] + if providerID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w) + return + } + + provider, err := h.manager.GetProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, provider.ToAPIResponse()) +} + +func (h *handler) createProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validate(&req, true); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + provider := types.NewProvider(userAuth.AccountId) + provider.FromAPIRequest(&req) + + bootstrapCluster := "" + if req.BootstrapCluster != nil { + bootstrapCluster = *req.BootstrapCluster + } + + created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider, bootstrapCluster) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updateProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providerID := mux.Vars(r)["providerId"] + if providerID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w) + return + } + + var req api.AgentNetworkProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validate(&req, false); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + provider := &types.Provider{ + ID: providerID, + AccountID: userAuth.AccountId, + } + provider.FromAPIRequest(&req) + + updated, err := h.manager.UpdateProvider(r.Context(), userAuth.UserId, provider) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deleteProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providerID := mux.Vars(r)["providerId"] + if providerID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w) + return + } + + if err := h.manager.DeleteProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error { + if strings.TrimSpace(req.ProviderId) == "" { + return status.Errorf(status.InvalidArgument, "provider_id is required") + } + if !catalog.IsKnown(req.ProviderId) { + return status.Errorf(status.InvalidArgument, "provider_id %q is not a known catalog provider", req.ProviderId) + } + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if strings.TrimSpace(req.UpstreamUrl) == "" { + return status.Errorf(status.InvalidArgument, "upstream_url is required") + } + u, err := url.Parse(strings.TrimSpace(req.UpstreamUrl)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return status.Errorf(status.InvalidArgument, "upstream_url must be a full http(s) URL") + } + if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") { + return status.Errorf(status.InvalidArgument, "api_key is required") + } + return nil +} diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler.go b/management/internals/modules/agentnetwork/handlers/settings_handler.go new file mode 100644 index 000000000..c65efad0f --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/settings_handler.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// addSettingsEndpoints registers the Agent Network settings routes. The +// settings row is bootstrapped server-side on first provider create; GET reads +// it and PUT updates the mutable collection toggles (cluster/subdomain stay +// immutable). +func (h *handler) addSettingsEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS") +} + +// updateSettings applies the collection toggles to the account's settings row. +func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + settings := &types.Settings{AccountID: userAuth.AccountId} + settings.FromAPIRequest(&req) + + updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +// getSettings returns the account's agent-network settings. The settings +// row is bootstrapped on first provider create, so freshly-onboarded +// accounts have nothing to read. Rather than 404-ing in that case (which +// the dashboard would have to special-case), return a JSON null with 200 +// so consumers can branch on the body alone. +func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.NotFound { + util.WriteJSONObject(r.Context(), w, nil) + return + } + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse()) +} diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen.go b/management/internals/modules/agentnetwork/labelgen/labelgen.go new file mode 100644 index 000000000..b45ff4ea8 --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/labelgen.go @@ -0,0 +1,66 @@ +// Package labelgen produces DNS-safe Agent Network subdomain labels. +package labelgen + +import ( + "fmt" + "math/rand" + "sort" + "sync" +) + +// pickAttempts caps the random retries before falling back to the +// suffixed form. Eight is a soft compromise: with a near-empty taken +// set the very first pick almost always succeeds; when the wordlist is +// densely populated the fallback eventually fires anyway. +const pickAttempts = 8 + +var ( + dedupOnce sync.Once + uniqWords []string +) + +// uniqueWords returns the wordlist deduplicated and sorted for +// deterministic exhaustion behaviour. Lazy-built once per process. +func uniqueWords() []string { + dedupOnce.Do(func() { + seen := make(map[string]struct{}, len(words)) + uniqWords = make([]string, 0, len(words)) + for _, w := range words { + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + uniqWords = append(uniqWords, w) + } + sort.Strings(uniqWords) + }) + return uniqWords +} + +// PickUnique selects a label not already in `taken`. It tries up to +// pickAttempts random picks; on exhaustion it scans the deduplicated +// wordlist for any remaining free entry, and if none is left appends +// `-` to a deterministic word and returns. The caller +// is responsible for seeding rng (math/rand). +func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string { + pool := uniqueWords() + if len(pool) == 0 { + return fallbackSuffix + } + + for i := 0; i < pickAttempts; i++ { + w := pool[rng.Intn(len(pool))] + if _, ok := taken[w]; !ok { + return w + } + } + + for _, w := range pool { + if _, ok := taken[w]; !ok { + return w + } + } + + w := pool[rng.Intn(len(pool))] + return fmt.Sprintf("%s-%s", w, fallbackSuffix) +} diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen_test.go b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go new file mode 100644 index 000000000..f03a3501d --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go @@ -0,0 +1,101 @@ +package labelgen + +import ( + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPickUnique_DeterministicWithSeededRng locks the property the +// caller relies on: same seed + same taken set → same pick. Without +// that, the bootstrap flow can't reproduce a label across retries. +func TestPickUnique_DeterministicWithSeededRng(t *testing.T) { + taken := map[string]struct{}{} + + rngA := rand.New(rand.NewSource(42)) + rngB := rand.New(rand.NewSource(42)) + + a := PickUnique(rngA, taken, "abcd") + b := PickUnique(rngB, taken, "abcd") + + assert.Equal(t, a, b, "Same seed and taken set must produce identical pick") +} + +// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with +// every word in the pool except a handful and confirms PickUnique +// finds one of the remaining free entries instead of returning the +// fallback form. +func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) { + pool := uniqueWords() + require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything") + + free := map[string]struct{}{ + pool[0]: {}, + pool[len(pool)/2]: {}, + pool[len(pool)-1]: {}, + } + + taken := make(map[string]struct{}, len(pool)) + for _, w := range pool { + if _, ok := free[w]; ok { + continue + } + taken[w] = struct{}{} + } + + rng := rand.New(rand.NewSource(7)) + got := PickUnique(rng, taken, "abcd") + + _, isFree := free[got] + assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got) + assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form") +} + +// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and +// confirms PickUnique appends the supplied suffix instead of +// returning a duplicate. +func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) { + pool := uniqueWords() + + taken := make(map[string]struct{}, len(pool)) + for _, w := range pool { + taken[w] = struct{}{} + } + + rng := rand.New(rand.NewSource(99)) + got := PickUnique(rng, taken, "abcd") + + assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce -; got %q", got) + + prefix := strings.TrimSuffix(got, "-abcd") + found := false + for _, w := range pool { + if w == prefix { + found = true + break + } + } + assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix) +} + +// TestUniqueWords_DropsDuplicates guards against authoring slips in +// words.go: every entry must be unique and DNS-safe. +func TestUniqueWords_DropsDuplicates(t *testing.T) { + pool := uniqueWords() + seen := make(map[string]struct{}, len(pool)) + for _, w := range pool { + _, dup := seen[w] + assert.False(t, dup, "Duplicate entry %q in deduplicated pool", w) + seen[w] = struct{}{} + assert.GreaterOrEqual(t, len(w), 4, "Word %q is shorter than 4 chars", w) + assert.LessOrEqual(t, len(w), 12, "Word %q is longer than 12 chars", w) + for _, r := range w { + ok := r >= 'a' && r <= 'z' + assert.True(t, ok, "Word %q contains non-lowercase-ASCII rune %q", w, r) + } + } + assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words") +} diff --git a/management/internals/modules/agentnetwork/labelgen/words.go b/management/internals/modules/agentnetwork/labelgen/words.go new file mode 100644 index 000000000..2028ff23d --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/words.go @@ -0,0 +1,136 @@ +// Package labelgen produces DNS-safe Agent Network subdomain labels. +// +// The wordlist below is a curated subset drawn from public-domain +// nature / common-noun pools (e.g. EFF's diceware lists). Every entry +// is lowercase ASCII, 4–12 chars, no hyphens, no digits, and was +// hand-checked to avoid offensive, brand, or region-specific terms. +package labelgen + +// words is the pool PickUnique selects from. The slice is intentionally +// not sorted — random picks distribute across the list naturally. +var words = []string{ + "acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst", + "anchor", "antler", "apple", "apricot", "arcade", "arctic", "arrow", "ashen", + "aspen", "atlas", "atom", "aurora", "autumn", "azure", + "badger", "bamboo", "banana", "banjo", "barley", "barn", "basalt", "basil", + "basin", "bayou", "beach", "beacon", "beaver", "beech", "beetle", "berry", + "birch", "bison", "blossom", "blue", "bobcat", "bonsai", "boulder", "branch", + "brass", "breeze", "bridge", "bright", "brook", "broom", "brown", "buffalo", + "bumble", "burrow", "butter", "button", + "cabin", "cactus", "calm", "camel", "campfire", "canary", "candle", "canoe", + "canyon", "cardinal", "carrot", "cascade", "castle", "cedar", "celery", "cello", + "cement", "cherry", "chestnut", "chime", "cinnamon", "cinder", "citron", "clay", + "clear", "cliff", "clock", "cloud", "clover", "coast", "cobalt", "cobble", + "cocoa", "coffee", "comet", "compass", "copper", "coral", "corner", "cosmos", + "cotton", "cougar", "country", "coyote", "cove", "crane", "crater", "creek", + "crescent", "crimson", "crocus", "crystal", "cypress", + "daffodil", "dahlia", "daisy", "dawn", "deer", "delta", "denim", "desert", + "dewdrop", "diamond", "dolphin", "doodle", "dove", "dragon", "drift", "drop", + "dune", "dusk", "dusty", + "eagle", "earth", "echo", "elder", "elkhorn", "ember", "emerald", "emperor", + "evergreen", "evening", + "falcon", "fawn", "feather", "fern", "fiddle", "field", "fiesta", "finch", + "firepit", "firefly", "fjord", "flame", "flax", "fleece", "flint", "floral", + "flower", "flute", "foal", "foggy", "forest", "fountain", "foxglove", "fresh", + "frost", "fuchsia", "fudge", + "gable", "galaxy", "garden", "garnet", "gazelle", "geode", "geyser", "ginger", + "glacier", "glade", "glass", "glow", "gold", "goose", "gorge", "gourd", + "granite", "grape", "grass", "gravel", "grayling", "greenery", "grizzly", "grove", + "gull", "gumdrop", "gust", + "hammock", "harbor", "harvest", "hawk", "hazel", "heather", "hedge", "heron", + "hibiscus", "hickory", "hideaway", "highland", "hill", "hive", "hollow", "honey", + "hopper", "horizon", "hummingbird", "husky", + "iceberg", "indigo", "iris", "island", "ivory", "ivybush", + "jade", "jasmine", "jasper", "jaybird", "jelly", "jewel", "jonquil", "journey", + "juniper", "jupiter", "jute", + "kale", "kangaroo", "kayak", "kelp", "kestrel", "kettle", "khaki", "kindling", + "kingfisher", "kiwi", "knapweed", "koala", + "lagoon", "lake", "lantern", "larch", "lark", "laurel", "lava", "lavender", + "leaf", "lemon", "lichen", "light", "lilac", "lily", "lime", "limestone", + "linden", "linen", "lion", "lobster", "locust", "loon", "lotus", "lumber", + "lunar", "lupine", "lynx", + "madrone", "magenta", "magnolia", "mahogany", "mallow", "mango", "manor", "maple", + "marble", "marigold", "marina", "marlin", "marsh", "mauve", "meadow", "melody", + "melon", "merlin", "metal", "midnight", "milk", "millet", "mineral", "mint", + "mirror", "mist", "mitten", "molasses", "moon", "moose", "morning", "moss", + "mountain", "mulberry", "muscat", "mustard", + "narwhal", "navy", "nectar", "needle", "nest", "nettle", "newt", "nightfall", + "noon", "nook", "north", "nova", "nutmeg", + "oaken", "oasis", "oatmeal", "ocean", "ochre", "octagon", "olive", "onyx", + "opal", "orange", "orbit", "orchard", "orchid", "oregano", "orion", "osprey", + "otter", "outpost", "owlet", "oyster", + "painter", "palace", "palm", "pansy", "panther", "papaya", "paprika", "parsley", + "partridge", "passage", "pastel", "patio", "peach", "peacock", "pear", "pearl", + "pebble", "pecan", "pelican", "penguin", "peony", "pepper", "perch", "peridot", + "pewter", "phoenix", "pier", "pillar", "pine", "pineapple", "pinto", "piper", + "pistachio", "plain", "planet", "plateau", "platinum", "plum", "plume", "polar", + "pollen", "pond", "poplar", "poppy", "porcelain", "portal", "portrait", "potato", + "prairie", "primrose", "prism", "puffin", "pumpkin", + "quail", "quartz", "quaver", "quill", "quince", "quinoa", + "rabbit", "raccoon", "radish", "rain", "rainbow", "raindrop", "rapids", "raspberry", + "raven", "ravine", "redwood", "reed", "reef", "ridge", "river", "robin", + "rocket", "rubyred", "rose", "rosemary", "rosewood", "ruffle", "rugby", "russet", + "rustic", "ryefield", + "saffron", "sage", "salmon", "sand", "sandstone", "sapphire", "savanna", "scarlet", + "scout", "seal", "season", "seaweed", "sequoia", "shadow", "shamrock", "shell", + "sherbet", "shore", "silver", "siskin", "skybloom", "skyline", "sleet", "smoke", + "snail", "snapdragon", "snow", "snowflake", "snowy", "solar", "song", "sonic", + "sorrel", "south", "sparkle", "sparrow", "spice", "spider", "spinach", "spire", + "spring", "sprout", "spruce", "squirrel", "starfish", "starlight", "stoat", "stone", + "stork", "storm", "stream", "studio", "summer", "sunbeam", "sundew", "sunny", + "sunrise", "sunset", "swallow", "swan", "sweet", "sycamore", + "tangelo", "tangerine", "tansy", "taupe", "teak", "teal", "thicket", "thistle", + "thrush", "thunder", "tide", "tiger", "tinder", "topaz", "torch", "tortoise", + "tower", "trail", "tranquil", "tundra", "tulip", "turquoise", "turtle", "twig", + "twilight", + "umber", "uplands", + "valley", "vanilla", "velvet", "venus", "verdant", "verdigris", "vermilion", "violet", + "vista", "vivid", "volcano", "vortex", + "walnut", "warbler", "watercress", "waterfall", "wave", "waxwing", "weasel", "westwind", + "whale", "whisker", "whisper", "wicker", "wildwood", "willow", "winter", "wisp", + "wisteria", "wolf", "wombat", "woodland", "woolly", "wren", "wreath", + "yarrow", "yellow", "yewtree", "yodel", + "zebra", "zenith", "zephyr", "zinnia", + "alabaster", "alfalfa", "almanac", "anise", "antelope", "arbor", "arena", "armadillo", + "avocet", "azalea", "balsam", "bayou", "beacon", "blizzard", "bluebell", "bluebird", + "bluejay", "bobolink", "borage", "boreal", "buckeye", "buckthorn", "buttercup", + "cabana", "calico", "canopy", "caraway", "cardamom", "cattail", "celadon", "centaur", + "chambray", "chamois", "champlain", "chestnuts", "chickadee", "chinook", "chipmunk", "cinnabar", + "cirrus", "citrine", "clematis", "copperhead", + "crocodile", "currant", "cuttlebone", "daffy", "dapple", "delphinium", "dervish", "diamondback", + "dogwood", "dolphins", "dragonfly", "driftwood", "dusk", "dustpan", "ebony", "edelweiss", + "emperor", "endive", "estuary", "everglade", "fairway", "feldspar", "fennel", "fieldstone", + "firebrand", "firefly", "fireweed", "firework", "flagstone", "fossil", "frostbite", "galleon", + "gardener", "geranium", "gingko", "ginseng", "goldfish", "goldfinch", "goldenrod", "graphite", + "greenfinch", "guppy", "haiku", "halibut", "hammerhead", "harbinger", "harvest", "hatchling", + "havana", "hawthorn", "hazelnut", "heartwood", "henna", "heron", "highrise", "homestead", + "honeycomb", "honeydew", "horseshoe", "hyacinth", "iceland", "icicle", "indigobird", "ironwood", + "jacaranda", "jamboree", "javelina", "jellyfish", "junebug", "kaleido", "kayaker", "kerchief", + "keystone", "kingdom", "labrador", "lacewing", "ladybug", "lakeside", "lamplight", "leopard", + "lighthouse", "lilypad", "lullaby", "magnet", "mahonia", "mandolin", "manzanita", "maraschino", + "mariner", "marsupial", "mastodon", "matterhorn", "mayflower", "mayfly", "meadowlark", "merlot", + "meteor", "midshipman", "millpond", "mimosa", "minnow", "mockingbird", "molten", "monarch", + "monsoon", "moondust", "moonlight", "moorland", "morning", "mossland", "mountain", "mulch", + "narcissus", "nautilus", "nettlebush", "northstar", "nuthatch", "obsidian", "okra", "olivine", + "opalescent", "orchidea", "orchard", "ornament", "outrigger", "oxalis", "paddler", "paintbrush", + "papyrus", "paradise", "pasture", "patchwork", "pathway", "peridot", "periwinkle", "petalbloom", + "petrel", "petunia", "phlox", "pikeperch", "pinecone", "pioneer", "pipevine", "platypus", + "pomelo", "pondweed", "porpoise", "powder", "promise", "puddle", "pumice", "puzzle", + "quetzal", "quicksilver", "raccoon", "ragwort", "rainforest", "ramble", "rapid", "rascal", + "raspberry", "redbud", "redfern", "redpoll", "reedling", "ringtail", "riverbed", "riverbird", + "riverstone", "rockcress", "roebuck", "rosebay", "rosehip", "rosemary", "rowan", "rumble", + "runaway", "rustler", "sagebrush", "sailcloth", "salamander", "salsify", "samphire", "sandbar", + "sanddollar", "sandpiper", "santolina", "sapodilla", "sassafras", "scallion", "schooner", "seafoam", + "seafrost", "seagrass", "seahorse", "seaport", "seashell", "seaspray", "shamble", "shimmer", + "shoreline", "silkmoth", "silverfox", "skylark", "snapdragon", "snowberry", "snowdrop", "snowfall", + "snowmelt", "softwood", "songbird", "sorghum", "southwind", "speedwell", "spinnaker", "spruce", + "starlight", "starling", "stormcloud", "summit", "sundance", "sundew", "sundial", "sunflower", + "surface", "swallowtail", "sweetcorn", "sycamore", "tabletop", "tamarack", "tamarind", "tangerine", + "tarragon", "telescope", "thicket", "thrasher", "thunder", "thyme", "tideline", "timberland", + "tinderbox", "topiary", "torchwood", "totem", "tradewind", "treasure", "tremolo", "trinket", + "trumpetvine", "tugboat", "tundra", "turnstone", "underbrush", "vagabond", "valerian", "vanilla", + "velveteen", "vermilion", "vinca", "vineyard", "violet", "voyager", "wagonwheel", "walnutwood", + "watermark", "watershed", "waterway", "wavefront", "westerly", "whaleback", "whetstone", "wicker", + "wildbloom", "wildflower", "wilderness", "windsong", "windward", "winterberry", "woodbine", "woodfern", + "woodland", "woodthrush", "woolgrass", "yellowfin", "zenithal", "zucchini", +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go new file mode 100644 index 000000000..d88e0d77c --- /dev/null +++ b/management/internals/modules/agentnetwork/manager.go @@ -0,0 +1,911 @@ +package agentnetwork + +import ( + "context" + "errors" + "fmt" + "math/rand" + "slices" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/status" +) + +// ensureSessionKeys mints an ed25519 session keypair on the provider +// when one is missing. Idempotent: skips when both fields are already +// populated (e.g. update or migrated rows). The keys are used by the +// synthesised reverse-proxy service to sign / verify session JWTs +// after a successful OIDC handshake. +func ensureSessionKeys(p *types.Provider) error { + if p.SessionPrivateKey != "" && p.SessionPublicKey != "" { + return nil + } + pair, err := sessionkey.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate provider session keys: %w", err) + } + p.SessionPrivateKey = pair.PrivateKey + p.SessionPublicKey = pair.PublicKey + return nil +} + +// Manager governs the lifecycle of Agent Network providers and policies. +type Manager interface { + GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) + GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) + CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) + UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) + DeleteProvider(ctx context.Context, accountID, userID, providerID string) error + + GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) + GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) + CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) + UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) + DeletePolicy(ctx context.Context, accountID, userID, policyID string) error + + GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) + GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) + CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) + UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) + DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error + + GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) + GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) + CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) + UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) + DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error + + GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) + UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) + + ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) + ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) + ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) + GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) + StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) + RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error + RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error + RecordUsage(ctx context.Context, in RecordUsageInput) error + SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) +} + +// PolicySelectionInput is the per-request selection envelope. The +// proxy populates it from CapturedData (account, user, groups) plus +// the provider llm_router resolved. +type PolicySelectionInput struct { + AccountID string + UserID string + GroupIDs []string + ProviderID string +} + +// PolicySelectionResult names the policy that "pays" for this request +// plus the deny envelope when every applicable policy has exhausted +// every cap. AttributionGroupID is the lowest group id (string sort) +// of caller_groups ∩ selected_policy.source_groups; empty when no +// group dimension applies. WindowSeconds is the chosen policy's +// effective window length in seconds (token_limit's wins when both +// halves are enabled with mismatched windows; budget_limit's +// otherwise; 0 when no caps are configured at all). +type PolicySelectionResult struct { + Allow bool + SelectedPolicyID string + AttributionGroupID string + WindowSeconds int64 + DenyCode string + DenyReason string +} + +type managerImpl struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager + proxyController proxy.Controller + + // reconcileCache holds the last set of synthesised proxy mappings + // per account so reconcile can emit precise Create/Update/Delete + // updates instead of a full re-push on every mutation. Keyed by + // accountID, then by synthesised service ID. + reconcileMu sync.Mutex + reconcileCache map[string]map[string]*proto.ProxyMapping + + // labelRngMu guards labelRng. PickUnique consumes math/rand.Source + // state; concurrent provider creates would otherwise race. + labelRngMu sync.Mutex + labelRng *rand.Rand +} + +// NewManager constructs the persistent Agent Network manager. The +// manager persists provider/policy/guardrail configuration and, on +// every mutation, reconciles the in-memory synthesised reverse-proxy +// services with the proxy cluster via proxyController. Pass nil for +// proxyController to disable the reconcile push (useful in tests). +func NewManager( + store store.Store, + permissionsManager permissions.Manager, + accountManager account.Manager, + proxyController proxy.Controller, +) Manager { + return &managerImpl{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + proxyController: proxyController, + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + labelRng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) +} + +// CreateProvider persists a new provider for the account. bootstrapCluster +// is used only when the per-account agent-network Settings row hasn't +// been created yet; otherwise it is ignored (the cluster is pinned on +// Settings and every provider in the account routes through it). +func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) { + if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil { + return nil, err + } + + // An empty api_key would silently produce a synthesised service + // that 401s on every upstream request. Surface the misconfiguration + // at create time instead. + if strings.TrimSpace(provider.APIKey) == "" { + return nil, status.Errorf(status.InvalidArgument, "api_key is required when creating an agent network provider") + } + + if provider.ID == "" { + fresh := types.NewProvider(provider.AccountID) + provider.ID = fresh.ID + provider.CreatedAt = fresh.CreatedAt + provider.UpdatedAt = fresh.UpdatedAt + } + + if err := ensureSessionKeys(provider); err != nil { + return nil, err + } + + if err := m.store.SaveAgentNetworkProvider(ctx, provider); err != nil { + return nil, fmt.Errorf("save agent network provider: %w", err) + } + + if strings.TrimSpace(bootstrapCluster) != "" { + if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil { + // The provider create has already succeeded; logging the + // bootstrap miss matches the plan's PoC behaviour. The synth + // path treats a missing settings row as a no-op, and the next + // provider create retries the bootstrap. + log.WithContext(ctx).Debugf("agent-network bootstrap settings for account %s on cluster %s: %v", provider.AccountID, bootstrapCluster, err) + } + } + + m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderCreated, provider.EventMeta()) + m.reconcile(ctx, provider.AccountID) + + return provider, nil +} + +func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) { + if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthUpdate, provider.AccountID, provider.ID) + if err != nil { + return nil, fmt.Errorf("failed to get agent network provider: %w", err) + } + + // Preserve the API key if the caller didn't rotate it. A + // whitespace-only value is treated as "not rotated" rather than a + // real key, but it must not silently overwrite a valid stored key. + if provider.APIKey == "" { + provider.APIKey = existing.APIKey + } else if strings.TrimSpace(provider.APIKey) == "" { + return nil, status.Errorf(status.InvalidArgument, "api_key must be non-blank when rotating an agent network provider") + } + // Always preserve the session keypair across updates so existing + // session cookies stay valid. The keys are server-managed and + // never surfaced through the API. + provider.SessionPrivateKey = existing.SessionPrivateKey + provider.SessionPublicKey = existing.SessionPublicKey + if err := ensureSessionKeys(provider); err != nil { + return nil, err + } + provider.CreatedAt = existing.CreatedAt + provider.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkProvider(ctx, provider); err != nil { + return nil, fmt.Errorf("save agent network provider: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderUpdated, provider.EventMeta()) + m.reconcile(ctx, provider.AccountID) + + return provider, nil +} + +func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error { + if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil { + return err + } + + provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthUpdate, accountID, providerID) + if err != nil { + return fmt.Errorf("failed to get agent network provider: %w", err) + } + + // Refuse to delete while any policy still references this provider. + // The operator must detach it first. + policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("failed to get agent network policies: %w", err) + } + var blocking []string + for _, p := range policies { + if slices.Contains(p.DestinationProviderIDs, providerID) { + blocking = append(blocking, p.Name) + } + } + if len(blocking) > 0 { + return status.Errorf( + status.InvalidArgument, + "provider is in use by %d %s (%s); detach it before deleting", + len(blocking), + pluralize(len(blocking), "policy", "policies"), + strings.Join(blocking, ", "), + ) + } + + if err := m.store.DeleteAgentNetworkProvider(ctx, accountID, providerID); err != nil { + return fmt.Errorf("failed to delete agent network provider: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, providerID, accountID, activity.AgentNetworkProviderDeleted, provider.EventMeta()) + m.reconcile(ctx, accountID) + + return nil +} + +func pluralize(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} + +func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID) +} + +func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) { + if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil { + return nil, err + } + + if policy.ID == "" { + fresh := types.NewPolicy(policy.AccountID) + policy.ID = fresh.ID + policy.CreatedAt = fresh.CreatedAt + policy.UpdatedAt = fresh.UpdatedAt + } + + if err := m.validateProviderRefs(ctx, policy.AccountID, policy.DestinationProviderIDs); err != nil { + return nil, err + } + + if err := m.store.SaveAgentNetworkPolicy(ctx, policy); err != nil { + return nil, fmt.Errorf("failed to save agent network policy: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, policy.ID, policy.AccountID, activity.AgentNetworkPolicyCreated, policy.EventMeta()) + m.reconcile(ctx, policy.AccountID) + + return policy, nil +} + +func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) { + if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthUpdate, policy.AccountID, policy.ID) + if err != nil { + return nil, fmt.Errorf("failed to get agent network policy: %w", err) + } + + if err := m.validateProviderRefs(ctx, policy.AccountID, policy.DestinationProviderIDs); err != nil { + return nil, err + } + + policy.CreatedAt = existing.CreatedAt + policy.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkPolicy(ctx, policy); err != nil { + return nil, fmt.Errorf("failed to save agent network policy: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, policy.ID, policy.AccountID, activity.AgentNetworkPolicyUpdated, policy.EventMeta()) + m.reconcile(ctx, policy.AccountID) + + return policy, nil +} + +func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error { + if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil { + return err + } + + policy, err := m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID) + if err != nil { + return fmt.Errorf("failed to get agent network policy: %w", err) + } + + if err := m.store.DeleteAgentNetworkPolicy(ctx, accountID, policyID); err != nil { + return fmt.Errorf("failed to delete agent network policy: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, policyID, accountID, activity.AgentNetworkPolicyDeleted, policy.EventMeta()) + m.reconcile(ctx, accountID) + + return nil +} + +func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID) +} + +func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) { + if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil { + return nil, err + } + + if guardrail.ID == "" { + fresh := types.NewGuardrail(guardrail.AccountID) + guardrail.ID = fresh.ID + guardrail.CreatedAt = fresh.CreatedAt + guardrail.UpdatedAt = fresh.UpdatedAt + } + + if err := m.store.SaveAgentNetworkGuardrail(ctx, guardrail); err != nil { + return nil, fmt.Errorf("failed to save agent network guardrail: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, guardrail.ID, guardrail.AccountID, activity.AgentNetworkGuardrailCreated, guardrail.EventMeta()) + m.reconcile(ctx, guardrail.AccountID) + + return guardrail, nil +} + +func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) { + if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthUpdate, guardrail.AccountID, guardrail.ID) + if err != nil { + return nil, fmt.Errorf("failed to get agent network guardrail: %w", err) + } + + guardrail.CreatedAt = existing.CreatedAt + guardrail.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkGuardrail(ctx, guardrail); err != nil { + return nil, fmt.Errorf("failed to save agent network guardrail: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, guardrail.ID, guardrail.AccountID, activity.AgentNetworkGuardrailUpdated, guardrail.EventMeta()) + m.reconcile(ctx, guardrail.AccountID) + + return guardrail, nil +} + +func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error { + if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil { + return err + } + + guardrail, err := m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthUpdate, accountID, guardrailID) + if err != nil { + return fmt.Errorf("failed to get agent network guardrail: %w", err) + } + + if err := m.store.DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID); err != nil { + return fmt.Errorf("failed to delete agent network guardrail: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, guardrailID, accountID, activity.AgentNetworkGuardrailDeleted, guardrail.EventMeta()) + m.reconcile(ctx, accountID) + + return nil +} + +// GetAllBudgetRules returns every account-level budget rule for the account. +func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID) +} + +// GetBudgetRule returns a single account-level budget rule. +func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID) +} + +// CreateBudgetRule persists a new account-level budget rule. Budget rules are +// enforced at request time (CheckLLMPolicyLimits), not baked into the synth +// proxy config, so no reconcile is needed. +func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil { + return nil, err + } + + if rule.ID == "" { + fresh := types.NewAccountBudgetRule(rule.AccountID) + rule.ID = fresh.ID + rule.CreatedAt = fresh.CreatedAt + rule.UpdatedAt = fresh.UpdatedAt + } + + if err := m.store.SaveAgentNetworkBudgetRule(ctx, rule); err != nil { + return nil, fmt.Errorf("save agent network budget rule: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, rule.ID, rule.AccountID, activity.AgentNetworkBudgetRuleCreated, rule.EventMeta()) + + return rule, nil +} + +// UpdateBudgetRule updates an existing account-level budget rule. +func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthUpdate, rule.AccountID, rule.ID) + if err != nil { + return nil, fmt.Errorf("get agent network budget rule: %w", err) + } + + rule.CreatedAt = existing.CreatedAt + rule.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkBudgetRule(ctx, rule); err != nil { + return nil, fmt.Errorf("save agent network budget rule: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, rule.ID, rule.AccountID, activity.AgentNetworkBudgetRuleUpdated, rule.EventMeta()) + + return rule, nil +} + +// DeleteBudgetRule removes an account-level budget rule. +func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error { + if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil { + return err + } + + rule, err := m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthUpdate, accountID, ruleID) + if err != nil { + return fmt.Errorf("get agent network budget rule: %w", err) + } + + if err := m.store.DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID); err != nil { + return fmt.Errorf("delete agent network budget rule: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, ruleID, accountID, activity.AgentNetworkBudgetRuleDeleted, rule.EventMeta()) + + return nil +} + +// UpdateSettings applies the mutable account-level settings — the collection +// toggles — onto the existing row. Cluster and Subdomain are immutable and are +// preserved from the persisted row regardless of the input. Because the +// collection toggles change the synthesised service config (prompt-capture +// gating, access-log emission), a reconcile is triggered so the proxy and peer +// network maps converge on the new state. +func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) { + if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID) + if err != nil { + return nil, fmt.Errorf("get agent network settings: %w", err) + } + + existing.EnableLogCollection = settings.EnableLogCollection + existing.EnablePromptCollection = settings.EnablePromptCollection + existing.RedactPii = settings.RedactPii + existing.AccessLogRetentionDays = settings.AccessLogRetentionDays + existing.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil { + return nil, fmt.Errorf("save agent network settings: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{ + "log_collection": existing.EnableLogCollection, + "prompt_collection": existing.EnablePromptCollection, + "redact_pii": existing.RedactPii, + }) + m.reconcile(ctx, settings.AccountID) + + return existing, nil +} + +// validateProviderRefs ensures every destination provider id refers to a +// provider that exists in the same account. +func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string, providerIDs []string) error { + if len(providerIDs) == 0 { + return nil + } + for _, id := range providerIDs { + if _, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, id); err != nil { + // Only a genuine not-found means the reference is invalid; a + // store/runtime error must propagate as-is rather than be + // masked as a client validation error. + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "destination_provider_ids: provider %s does not exist", id) + } + return fmt.Errorf("get destination provider %s: %w", id, err) + } + } + return nil +} + +// GetSettings returns the agent-network settings row for the account. +// Returns the underlying status.NotFound when no row has been +// bootstrapped yet (i.e. the account has no providers). +func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) +} + +// bootstrapSettingsIfNeeded creates the per-account agent-network +// settings row when missing. The cluster comes from the create-time +// hint the dashboard sends (auto-picked from the active cluster list); +// the subdomain is picked from the curated wordlist avoiding +// collisions on the same cluster. Idempotent: if a row already exists +// it is returned untouched and the hint is ignored. +func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) { + if accountID == "" { + return nil, fmt.Errorf("bootstrap settings: account id is required") + } + if strings.TrimSpace(providerCluster) == "" { + return nil, fmt.Errorf("bootstrap settings: provider cluster is required") + } + + existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + if err == nil { + return existing, nil + } + var sErr *status.Error + if !errors.As(err, &sErr) || sErr.Type() != status.NotFound { + return nil, fmt.Errorf("get agent network settings: %w", err) + } + + siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster) + if err != nil { + return nil, fmt.Errorf("list agent network settings on cluster: %w", err) + } + taken := make(map[string]struct{}, len(siblings)) + for _, s := range siblings { + taken[s.Subdomain] = struct{}{} + } + + suffix := accountID + if len(suffix) > 4 { + suffix = suffix[:4] + } + + m.labelRngMu.Lock() + subdomain := labelgen.PickUnique(m.labelRng, taken, suffix) + m.labelRngMu.Unlock() + + now := time.Now().UTC() + settings := &types.Settings{ + AccountID: accountID, + Cluster: providerCluster, + Subdomain: subdomain, + // Logs on by default; usage is collected regardless. Retention bounds + // how long full log rows are kept. + EnableLogCollection: true, + AccessLogRetentionDays: types.DefaultAccessLogRetentionDays, + CreatedAt: now, + UpdatedAt: now, + } + if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil { + return nil, fmt.Errorf("save agent network settings: %w", err) + } + return settings, nil +} + +// ListConsumption returns every consumption row recorded for the +// account, ordered window-newest-first. Backs the dashboard's basic +// counter view; permission gate is the same Read role that gates +// every other agent-network surface. +func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID) +} + +// ListAccessLogs returns a paginated, server-side-filtered page of +// agent-network access logs plus the total count matching the filter. +func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, 0, err + } + return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter) +} + +// ListAccessLogSessions returns a paginated, server-side-filtered page of +// agent-network access logs grouped by session, plus the total number of +// sessions matching the filter. +func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, 0, err + } + return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter) +} + +// GetUsageOverview returns the filtered usage rows aggregated into time buckets +// at the requested granularity, oldest-first. +func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter) + if err != nil { + return nil, err + } + return types.AggregateUsageByGranularity(rows, granularity), nil +} + +// StartAccessLogCleanup launches a background sweep that periodically deletes +// each account's agent-network access-log rows older than that account's +// AccessLogRetentionDays. Usage records are never swept. A non-positive +// interval defaults to 24h. +func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) { + if cleanupIntervalHours <= 0 { + cleanupIntervalHours = 24 + } + interval := time.Duration(cleanupIntervalHours) * time.Hour + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + m.cleanupAccessLogsOnce(ctx) // run once on startup + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.cleanupAccessLogsOnce(ctx) + } + } + }() +} + +// cleanupAccessLogsOnce sweeps every account's expired access-log rows against +// its configured retention. Best-effort: a per-account failure is logged and +// the sweep continues. +func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) { + settings, err := m.store.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone) + if err != nil { + log.WithContext(ctx).Errorf("agent-network access-log cleanup: list settings: %v", err) + return + } + for _, s := range settings { + if s.AccessLogRetentionDays <= 0 { + continue // keep indefinitely + } + cutoff := time.Now().UTC().AddDate(0, 0, -s.AccessLogRetentionDays) + deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, s.AccountID, cutoff) + if err != nil { + log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", s.AccountID, err) + continue + } + if deleted > 0 { + log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, s.AccountID, s.AccessLogRetentionDays) + } + } +} + +// RecordConsumption increments the (dim, window) counter by the +// supplied deltas. The window_start is computed from time.Now under +// the supplied window_seconds so callers don't have to pre-align — +// the proxy's post-flight path simply hands us tokens + cost and +// which dimension we're booking against. +func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error { + if accountID == "" || dimID == "" || windowSeconds <= 0 { + return status.Errorf(status.InvalidArgument, "account_id, dim_id and window_seconds must be set") + } + windowStart := types.WindowStart(time.Now(), windowSeconds) + return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) +} + +func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error { + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + return nil +} + +type mockManager struct{} + +// NewManagerMock returns a no-op manager useful for tests. +func NewManagerMock() Manager { + return &mockManager{} +} + +func (*mockManager) GetAllProviders(_ context.Context, _, _ string) ([]*types.Provider, error) { + return []*types.Provider{}, nil +} + +func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provider, error) { + return &types.Provider{}, nil +} + +func (*mockManager) CreateProvider(_ context.Context, _ string, p *types.Provider, _ string) (*types.Provider, error) { + return p, nil +} + +func (*mockManager) UpdateProvider(_ context.Context, _ string, p *types.Provider) (*types.Provider, error) { + return p, nil +} + +func (*mockManager) DeleteProvider(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetAllPolicies(_ context.Context, _, _ string) ([]*types.Policy, error) { + return []*types.Policy{}, nil +} + +func (*mockManager) GetPolicy(_ context.Context, _, _, _ string) (*types.Policy, error) { + return &types.Policy{}, nil +} + +func (*mockManager) CreatePolicy(_ context.Context, _ string, p *types.Policy) (*types.Policy, error) { + return p, nil +} + +func (*mockManager) UpdatePolicy(_ context.Context, _ string, p *types.Policy) (*types.Policy, error) { + return p, nil +} + +func (*mockManager) DeletePolicy(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetAllGuardrails(_ context.Context, _, _ string) ([]*types.Guardrail, error) { + return []*types.Guardrail{}, nil +} + +func (*mockManager) GetGuardrail(_ context.Context, _, _, _ string) (*types.Guardrail, error) { + return &types.Guardrail{}, nil +} + +func (*mockManager) CreateGuardrail(_ context.Context, _ string, g *types.Guardrail) (*types.Guardrail, error) { + return g, nil +} + +func (*mockManager) UpdateGuardrail(_ context.Context, _ string, g *types.Guardrail) (*types.Guardrail, error) { + return g, nil +} + +func (*mockManager) DeleteGuardrail(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetAllBudgetRules(_ context.Context, _, _ string) ([]*types.AccountBudgetRule, error) { + return []*types.AccountBudgetRule{}, nil +} + +func (*mockManager) GetBudgetRule(_ context.Context, _, _, _ string) (*types.AccountBudgetRule, error) { + return &types.AccountBudgetRule{}, nil +} + +func (*mockManager) CreateBudgetRule(_ context.Context, _ string, r *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + return r, nil +} + +func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + return r, nil +} + +func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) { + return nil, status.Errorf(status.NotFound, "agent network settings not found") +} + +func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) { + return s, nil +} + +func (*mockManager) ListConsumption(_ context.Context, _, _ string) ([]*types.Consumption, error) { + return nil, nil +} + +func (*mockManager) ListAccessLogs(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + return nil, 0, nil +} + +func (*mockManager) ListAccessLogSessions(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + return nil, 0, nil +} + +func (*mockManager) GetUsageOverview(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter, _ types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { + return nil, nil +} + +func (*mockManager) StartAccessLogCleanup(_ context.Context, _ int) {} + +func (*mockManager) RecordConsumption(_ context.Context, _ string, _ types.ConsumptionDimension, _ string, _, _, _ int64, _ float64) error { + return nil +} + +func (*mockManager) RecordAccountBudgetUsage(_ context.Context, _, _ string, _ []string, _, _ int64, _ float64) error { + return nil +} + +func (*mockManager) RecordUsage(_ context.Context, _ RecordUsageInput) error { + return nil +} diff --git a/management/internals/modules/agentnetwork/policyselect.go b/management/internals/modules/agentnetwork/policyselect.go new file mode 100644 index 000000000..9203a1910 --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect.go @@ -0,0 +1,660 @@ +package agentnetwork + +import ( + "context" + "fmt" + "math" + "sort" + "time" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// validateUsageDeltas rejects negative or non-finite usage counters before they +// reach the consumption store, so a bad delta can't decrement or poison totals. +// The store batch method enforces the same invariant; this is the manager-level +// guard so direct callers fail fast with a clear error. +func validateUsageDeltas(tokensIn, tokensOut int64, costUSD float64) error { + if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return status.Errorf(status.InvalidArgument, "usage deltas must be non-negative and finite") + } + return nil +} + +// Deny codes the proxy surfaces back to the caller when every +// applicable policy is exhausted. The proxy converts these into +// upstream-shaped error responses. +const ( + //nolint:gosec // policy deny code label, not a credential + denyCodeTokenCapExceeded = "llm_policy.token_cap_exceeded" + //nolint:gosec // policy deny code label, not a credential + denyCodeBudgetCapExceeded = "llm_policy.budget_cap_exceeded" + //nolint:gosec // account deny code label, not a credential + denyCodeAccountTokenCapExceeded = "llm_account.token_cap_exceeded" + //nolint:gosec // account deny code label, not a credential + denyCodeAccountBudgetCapExceeded = "llm_account.budget_cap_exceeded" +) + +// consumptionCache holds the consumption counters prefetched for one +// policy-selection request, keyed by ConsumptionKey. A miss returns a zero +// counter — the same contract the store's single-row getter uses for absent +// rows — so the eval logic is identical whether a counter exists yet or not. +type consumptionCache map[types.ConsumptionKey]*types.Consumption + +func (c consumptionCache) get(accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) *types.Consumption { + key := types.ConsumptionKey{Kind: kind, DimID: dimID, WindowSeconds: windowSeconds, WindowStartUTC: windowStart.UTC()} + if row, ok := c[key]; ok && row != nil { + return row + } + return &types.Consumption{ + AccountID: accountID, + DimensionKind: kind, + DimensionID: dimID, + WindowSeconds: windowSeconds, + WindowStartUTC: windowStart.UTC(), + } +} + +// addLimitKeys records the user/group consumption keys a single enabled (token +// or budget) limit window reads for the given attribution group, into a dedup +// set. attrGroup may be empty (no group dimension applies). +func addLimitKeys(set map[types.ConsumptionKey]struct{}, userID, attrGroup string, windowSeconds int64, now time.Time) { + if windowSeconds <= 0 { + return + } + ws := types.WindowStart(now, windowSeconds) + if userID != "" { + set[types.ConsumptionKey{Kind: types.DimensionUser, DimID: userID, WindowSeconds: windowSeconds, WindowStartUTC: ws}] = struct{}{} + } + if attrGroup != "" { + set[types.ConsumptionKey{Kind: types.DimensionGroup, DimID: attrGroup, WindowSeconds: windowSeconds, WindowStartUTC: ws}] = struct{}{} + } +} + +// prefetchConsumption loads, in one store round-trip, every consumption counter +// that the account-budget ceiling and the candidate policies will read while +// scoring this request. This replaces the per-cap point reads the selector +// previously issued one at a time (the N+1 on the hot path). +func (m *managerImpl) prefetchConsumption(ctx context.Context, in PolicySelectionInput, rules []*types.AccountBudgetRule, candidates []*types.Policy, now time.Time) (consumptionCache, error) { + set := make(map[types.ConsumptionKey]struct{}) + for _, p := range candidates { + attr := lowestIntersect(p.SourceGroups, in.GroupIDs) + if p.Limits.TokenLimit.Enabled { + addLimitKeys(set, in.UserID, attr, p.Limits.TokenLimit.WindowSeconds, now) + } + if p.Limits.BudgetLimit.Enabled { + addLimitKeys(set, in.UserID, attr, p.Limits.BudgetLimit.WindowSeconds, now) + } + } + for _, r := range rules { + if r == nil || !r.Enabled || !budgetRuleApplies(r, in) { + continue + } + attr := lowestIntersect(r.TargetGroups, in.GroupIDs) + if r.Limits.TokenLimit.Enabled { + addLimitKeys(set, in.UserID, attr, r.Limits.TokenLimit.WindowSeconds, now) + } + if r.Limits.BudgetLimit.Enabled { + addLimitKeys(set, in.UserID, attr, r.Limits.BudgetLimit.WindowSeconds, now) + } + } + if len(set) == 0 { + return consumptionCache{}, nil + } + keys := make([]types.ConsumptionKey, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + rows, err := m.store.GetAgentNetworkConsumptionBatch(ctx, store.LockingStrengthNone, in.AccountID, keys) + if err != nil { + return nil, fmt.Errorf("batch read consumption: %w", err) + } + return consumptionCache(rows), nil +} + +// SelectPolicyForRequest picks the policy that "pays" for the +// incoming request. The chosen policy is the one with the largest +// pool that still has headroom — drain the bigger bucket first, +// fall through to the next-biggest only when the current one's +// group cap or shared per-user cap is exhausted. This matches +// operator intuition for layered tiers ("privileged group has the +// 10k budget, regular group has 1k as the safety net") and avoids +// the load-balancer flapping that fraction-based scoring produces +// once any cap has been touched. +// +// Ordering across non-exhausted candidates: +// 1. Policies with NO enabled caps (catch-all-allow) win over any +// capped policy — operators who configure unlimited access +// expect requests to attribute there until they explicitly add +// caps. +// 2. Larger group token cap wins. +// 3. Larger group budget USD cap wins. +// 4. Larger user token cap wins. +// 5. Larger user budget USD cap wins. +// 6. Older created_at wins (deterministic final tiebreak so +// multi-node selection converges). +// +// Returns Allow=true with empty SelectedPolicyID when no policy in +// the account targets the (provider, caller-groups) combination — +// llm_router is the gate that owns "no policy authorises this +// request" semantics; this function trusts that authorisation has +// already happened upstream and only does the limit-aware +// attribution. +func (m *managerImpl) SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) { + if in.AccountID == "" { + return nil, status.Errorf(status.InvalidArgument, "account_id is required") + } + + now := time.Now().UTC() + + rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, in.AccountID) + if err != nil { + return nil, fmt.Errorf("list account budget rules: %w", err) + } + policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, in.AccountID) + if err != nil { + return nil, fmt.Errorf("list account policies: %w", err) + } + candidates := filterApplicablePolicies(policies, in) + + // Prefetch every consumption counter the ceiling + candidate policies will + // read, in a single store round-trip, then score against the cache. + cache, err := m.prefetchConsumption(ctx, in, rules, candidates, now) + if err != nil { + return nil, err + } + + // Account-level budget rules are an always-on ceiling, evaluated + // independently of policy selection (they bind even for catch-all-allow + // policies or requests that match no policy). All applicable rules must + // pass — this is where min-wins lives. + if deny, code, reason := checkAccountBudget(in, rules, cache, now); deny { + return &PolicySelectionResult{Allow: false, DenyCode: code, DenyReason: reason}, nil + } + + if len(candidates) == 0 { + return &PolicySelectionResult{Allow: true}, nil + } + scored, lastDenyCode, lastDenyReason := scoreCandidates(in, candidates, cache, now) + if len(scored) == 0 { + return &PolicySelectionResult{ + Allow: false, + DenyCode: lastDenyCode, + DenyReason: lastDenyReason, + }, nil + } + + sort.SliceStable(scored, func(i, j int) bool { + // Catch-all-allow (no caps configured) wins outright over + // any capped policy. + iNoCap := isUncapped(scored[i].policy) + jNoCap := isUncapped(scored[j].policy) + if iNoCap != jNoCap { + return iNoCap + } + // Bigger pool drains first. Group caps dominate (shared + // across the group) before individual caps. + if a, b := groupCapTokens(scored[i].policy), groupCapTokens(scored[j].policy); a != b { + return a > b + } + if a, b := groupCapBudgetUsd(scored[i].policy), groupCapBudgetUsd(scored[j].policy); a != b { + return a > b + } + if a, b := userCapTokens(scored[i].policy), userCapTokens(scored[j].policy); a != b { + return a > b + } + if a, b := userCapBudgetUsd(scored[i].policy), userCapBudgetUsd(scored[j].policy); a != b { + return a > b + } + return scored[i].policy.CreatedAt.Before(scored[j].policy.CreatedAt) + }) + + winner := scored[0] + return &PolicySelectionResult{ + Allow: true, + SelectedPolicyID: winner.policy.ID, + AttributionGroupID: winner.attributionGroup, + WindowSeconds: winner.windowSeconds, + }, nil +} + +// filterApplicablePolicies returns the enabled policies that target +// the requested provider and have at least one of the caller's groups +// in their source_groups. Caller's group set is matched +// case-sensitively against policy.SourceGroups. +func filterApplicablePolicies(policies []*types.Policy, in PolicySelectionInput) []*types.Policy { + if len(policies) == 0 { + return nil + } + groupSet := make(map[string]struct{}, len(in.GroupIDs)) + for _, g := range in.GroupIDs { + if g != "" { + groupSet[g] = struct{}{} + } + } + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil || !p.Enabled { + continue + } + if !sliceContains(p.DestinationProviderIDs, in.ProviderID) { + continue + } + if !anyGroupMatches(p.SourceGroups, groupSet) { + continue + } + out = append(out, p) + } + return out +} + +// candidate is the per-policy intermediate the selector ranks. A +// policy that's been exhausted on any enabled cap never makes it +// into this slice; the selector's deny envelope carries the latest +// exhaustion's reason out separately. +type candidate struct { + policy *types.Policy + attributionGroup string + windowSeconds int64 +} + +// scoreCandidates evaluates every applicable policy against the +// caller's current consumption. Exhausted policies are filtered out +// of the returned slice; the most recent exhaustion's deny code + +// human reason is returned alongside so the caller can surface it +// when no candidate survives. +func scoreCandidates( + in PolicySelectionInput, + candidates []*types.Policy, + cache consumptionCache, + now time.Time, +) ([]candidate, string, string) { + out := make([]candidate, 0, len(candidates)) + var lastDenyCode, lastDenyReason string + + for _, p := range candidates { + c, exhausted, denyCode, denyReason := scoreOne(in, p, cache, now) + if exhausted { + lastDenyCode = denyCode + lastDenyReason = denyReason + continue + } + out = append(out, c) + } + return out, lastDenyCode, lastDenyReason +} + +// scoreOne checks a single policy for cap exhaustion. Returns the +// candidate envelope when the policy still has headroom on every +// enabled cap; reports exhausted=true with a deny code naming the +// offending cap kind otherwise. +func scoreOne( + in PolicySelectionInput, + p *types.Policy, + cache consumptionCache, + now time.Time, +) (candidate, bool, string, string) { + attrGroup := lowestIntersect(p.SourceGroups, in.GroupIDs) + c := candidate{ + policy: p, + attributionGroup: attrGroup, + windowSeconds: effectiveWindowSeconds(p), + } + + if p.Limits.TokenLimit.Enabled && p.Limits.TokenLimit.WindowSeconds > 0 { + if exhausted, reason := evalTokenCap(cache, in.AccountID, in.UserID, attrGroup, p.Limits.TokenLimit, now, "policy "+p.ID); exhausted { + return candidate{}, true, denyCodeTokenCapExceeded, reason + } + } + + if p.Limits.BudgetLimit.Enabled && p.Limits.BudgetLimit.WindowSeconds > 0 { + if exhausted, reason := evalBudgetCap(cache, in.AccountID, in.UserID, attrGroup, p.Limits.BudgetLimit, now, "policy "+p.ID); exhausted { + return candidate{}, true, denyCodeBudgetCapExceeded, reason + } + } + + return c, false, "", "" +} + +// evalTokenCap reports whether the token limit is already exhausted for the +// caller in its own window. attrGroup may be empty (no group dimension applies). +// label identifies the cap source ("policy " or "account rule ") for the +// deny reason. It is the shared primitive behind both policy and account-rule +// enforcement. +func evalTokenCap( + cache consumptionCache, + accountID, userID, attrGroup string, + tl types.PolicyTokenLimit, + now time.Time, + label string, +) (bool, string) { + windowStart := types.WindowStart(now, tl.WindowSeconds) + + if tl.UserCap > 0 && userID != "" { + row := cache.get(accountID, types.DimensionUser, userID, tl.WindowSeconds, windowStart) + used := row.TokensInput + row.TokensOutput + if used >= tl.UserCap { + return true, fmt.Sprintf("user token cap exhausted on %s (used %d of %d)", label, used, tl.UserCap) + } + } + + if tl.GroupCap > 0 && attrGroup != "" { + row := cache.get(accountID, types.DimensionGroup, attrGroup, tl.WindowSeconds, windowStart) + used := row.TokensInput + row.TokensOutput + if used >= tl.GroupCap { + return true, fmt.Sprintf("group token cap exhausted on %s (used %d of %d)", label, used, tl.GroupCap) + } + } + + return false, "" +} + +// evalBudgetCap is the budget (USD) counterpart of evalTokenCap. +func evalBudgetCap( + cache consumptionCache, + accountID, userID, attrGroup string, + bl types.PolicyBudgetLimit, + now time.Time, + label string, +) (bool, string) { + windowStart := types.WindowStart(now, bl.WindowSeconds) + + if bl.UserCapUsd > 0 && userID != "" { + row := cache.get(accountID, types.DimensionUser, userID, bl.WindowSeconds, windowStart) + if row.CostUSD >= bl.UserCapUsd { + return true, fmt.Sprintf("user budget cap exhausted on %s (used $%.4f of $%.4f)", label, row.CostUSD, bl.UserCapUsd) + } + } + + if bl.GroupCapUsd > 0 && attrGroup != "" { + row := cache.get(accountID, types.DimensionGroup, attrGroup, bl.WindowSeconds, windowStart) + if row.CostUSD >= bl.GroupCapUsd { + return true, fmt.Sprintf("group budget cap exhausted on %s (used $%.4f of $%.4f)", label, row.CostUSD, bl.GroupCapUsd) + } + } + + return false, "" +} + +// checkAccountBudget evaluates every applicable account-level budget rule as an +// all-must-pass ceiling. A rule applies when the caller is in its TargetUsers, +// one of its TargetGroups, or it has no targets at all (account-wide). Returns +// deny=true with an llm_account.* code on the first exhausted rule. Group caps +// attribute to the lowest intersecting group (the same model policies use), so +// multi-group behavior is unchanged. +func checkAccountBudget(in PolicySelectionInput, rules []*types.AccountBudgetRule, cache consumptionCache, now time.Time) (bool, string, string) { + for _, r := range rules { + if r == nil || !r.Enabled || !budgetRuleApplies(r, in) { + continue + } + attrGroup := lowestIntersect(r.TargetGroups, in.GroupIDs) + label := "account rule " + r.ID + + if r.Limits.TokenLimit.Enabled && r.Limits.TokenLimit.WindowSeconds > 0 { + if exhausted, reason := evalTokenCap(cache, in.AccountID, in.UserID, attrGroup, r.Limits.TokenLimit, now, label); exhausted { + return true, denyCodeAccountTokenCapExceeded, reason + } + } + + if r.Limits.BudgetLimit.Enabled && r.Limits.BudgetLimit.WindowSeconds > 0 { + if exhausted, reason := evalBudgetCap(cache, in.AccountID, in.UserID, attrGroup, r.Limits.BudgetLimit, now, label); exhausted { + return true, denyCodeAccountBudgetCapExceeded, reason + } + } + } + + return false, "", "" +} + +// budgetRuleApplies reports whether an account budget rule binds the caller: +// a direct user match, a group intersection, or an untargeted (account-wide) +// rule. +func budgetRuleApplies(r *types.AccountBudgetRule, in PolicySelectionInput) bool { + if len(r.TargetUsers) == 0 && len(r.TargetGroups) == 0 { + return true + } + if in.UserID != "" && sliceContains(r.TargetUsers, in.UserID) { + return true + } + groupSet := make(map[string]struct{}, len(in.GroupIDs)) + for _, g := range in.GroupIDs { + if g != "" { + groupSet[g] = struct{}{} + } + } + return anyGroupMatches(r.TargetGroups, groupSet) +} + +// RecordAccountBudgetUsage fans the served request's usage out to every +// applicable account budget rule's own (dimension, window) counter. The user +// dimension is always booked when a rule has a user-applicable cap; the group +// dimension books against the rule's lowest intersecting group. This runs +// alongside the policy-window record so account ceilings accumulate in their own +// windows (commonly monthly) independently of the per-policy window. +func (m *managerImpl) RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error { + if accountID == "" { + return status.Errorf(status.InvalidArgument, "account_id is required") + } + if err := validateUsageDeltas(tokensIn, tokensOut, costUSD); err != nil { + return err + } + rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("list account budget rules: %w", err) + } + set := make(map[types.ConsumptionKey]struct{}) + addAccountBudgetKeys(set, PolicySelectionInput{AccountID: accountID, UserID: userID, GroupIDs: groupIDs}, rules, time.Now().UTC()) + if len(set) == 0 { + return nil + } + return m.store.IncrementAgentNetworkConsumptionBatch(ctx, accountID, keysSlice(set), tokensIn, tokensOut, costUSD) +} + +// RecordUsageInput carries everything RecordUsage books for one served request. +type RecordUsageInput struct { + AccountID string + UserID string + AttributionGroupID string // selected policy's attribution group (policy window) + GroupIDs []string + WindowSeconds int64 // selected policy's window; 0 means no policy cap + TokensIn int64 + TokensOut int64 + CostUSD float64 +} + +// RecordUsage books a served request's usage against every counter it touches — +// the selected policy's per-(user, group) window plus every applicable account +// budget rule's own window — deduplicated and written in a single transaction. +// Two counters that collapse to the same (dimension, window) tuple are booked +// once, so a single request can never double-count against one cap. +func (m *managerImpl) RecordUsage(ctx context.Context, in RecordUsageInput) error { + if in.AccountID == "" { + return status.Errorf(status.InvalidArgument, "account_id is required") + } + if err := validateUsageDeltas(in.TokensIn, in.TokensOut, in.CostUSD); err != nil { + return err + } + now := time.Now().UTC() + set := make(map[types.ConsumptionKey]struct{}) + + // Policy-window dimensions are booked only when a policy cap bound this + // request (window > 0). A zero window means catch-all-allow / no policy cap; + // the account fan-out below still books against the budget rules' windows. + if in.WindowSeconds > 0 { + addLimitKeys(set, in.UserID, in.AttributionGroupID, in.WindowSeconds, now) + } + + rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, in.AccountID) + if err != nil { + return fmt.Errorf("list account budget rules: %w", err) + } + addAccountBudgetKeys(set, PolicySelectionInput{AccountID: in.AccountID, UserID: in.UserID, GroupIDs: in.GroupIDs}, rules, now) + + if len(set) == 0 { + return nil + } + return m.store.IncrementAgentNetworkConsumptionBatch(ctx, in.AccountID, keysSlice(set), in.TokensIn, in.TokensOut, in.CostUSD) +} + +// addAccountBudgetKeys adds the (dimension, window) keys a served request books +// against every applicable account budget rule into the dedup set. +func addAccountBudgetKeys(set map[types.ConsumptionKey]struct{}, in PolicySelectionInput, rules []*types.AccountBudgetRule, now time.Time) { + for _, r := range rules { + if r == nil || !r.Enabled || !budgetRuleApplies(r, in) { + continue + } + attrGroup := lowestIntersect(r.TargetGroups, in.GroupIDs) + for _, window := range ruleWindows(r) { + addLimitKeys(set, in.UserID, attrGroup, window, now) + } + } +} + +// keysSlice flattens a ConsumptionKey set into a slice. +func keysSlice(set map[types.ConsumptionKey]struct{}) []types.ConsumptionKey { + keys := make([]types.ConsumptionKey, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + return keys +} + +// ruleWindows returns the distinct enabled window lengths a budget rule books +// against (token window and/or budget window, deduplicated). +func ruleWindows(r *types.AccountBudgetRule) []int64 { + var windows []int64 + if r.Limits.TokenLimit.Enabled && r.Limits.TokenLimit.WindowSeconds > 0 { + windows = append(windows, r.Limits.TokenLimit.WindowSeconds) + } + if r.Limits.BudgetLimit.Enabled && r.Limits.BudgetLimit.WindowSeconds > 0 { + bw := r.Limits.BudgetLimit.WindowSeconds + if len(windows) == 0 || windows[0] != bw { + windows = append(windows, bw) + } + } + return windows +} + +// effectiveWindowSeconds returns the window length the proxy should +// hand back to RecordLLMUsage. When both halves are enabled with +// different windows, token_limit wins (the more common config); when +// only one is enabled that one wins; when neither is enabled the +// returned value is 0 — RecordLLMUsage treats 0 as "no limit +// tracking" and skips the increment, which is the right pass-through +// for catch-all-allow policies with no caps configured. +func effectiveWindowSeconds(p *types.Policy) int64 { + if p.Limits.TokenLimit.Enabled && p.Limits.TokenLimit.WindowSeconds > 0 { + return p.Limits.TokenLimit.WindowSeconds + } + if p.Limits.BudgetLimit.Enabled && p.Limits.BudgetLimit.WindowSeconds > 0 { + return p.Limits.BudgetLimit.WindowSeconds + } + return 0 +} + +// lowestIntersect returns the lowest-by-string-sort element of +// callerGroups ∩ sourceGroups. Empty when the intersection is empty. +// Lowest is deterministic so multi-node selection converges. +func lowestIntersect(sourceGroups, callerGroups []string) string { + if len(sourceGroups) == 0 || len(callerGroups) == 0 { + return "" + } + srcSet := make(map[string]struct{}, len(sourceGroups)) + for _, g := range sourceGroups { + srcSet[g] = struct{}{} + } + var best string + for _, g := range callerGroups { + if _, ok := srcSet[g]; !ok { + continue + } + if best == "" || g < best { + best = g + } + } + return best +} + +func anyGroupMatches(sourceGroups []string, callerSet map[string]struct{}) bool { + for _, g := range sourceGroups { + if _, ok := callerSet[g]; ok { + return true + } + } + return false +} + +// isUncapped reports whether a policy has any enabled cap with a +// positive limit value. Mirrors the eval functions' guards: a policy +// with token_limit.enabled=true but every cap value at 0 still +// counts as uncapped because the eval would query nothing and bind +// nothing. +func isUncapped(p *types.Policy) bool { + tl := p.Limits.TokenLimit + if tl.Enabled && tl.WindowSeconds > 0 && (tl.GroupCap > 0 || tl.UserCap > 0) { + return false + } + bl := p.Limits.BudgetLimit + if bl.Enabled && bl.WindowSeconds > 0 && (bl.GroupCapUsd > 0 || bl.UserCapUsd > 0) { + return false + } + return true +} + +// groupCapTokens returns the policy's group-token cap when the token +// limit is enabled, zero otherwise. Drives the primary "bigger pool +// first" sort. +func groupCapTokens(p *types.Policy) int64 { + if p.Limits.TokenLimit.Enabled { + return p.Limits.TokenLimit.GroupCap + } + return 0 +} + +// groupCapBudgetUsd returns the policy's group-budget cap in USD +// when the budget limit is enabled, zero otherwise. Secondary sort +// key after token group cap so budget-only policies still order +// predictably. +func groupCapBudgetUsd(p *types.Policy) float64 { + if p.Limits.BudgetLimit.Enabled { + return p.Limits.BudgetLimit.GroupCapUsd + } + return 0 +} + +// userCapTokens returns the policy's per-user token cap when the +// token limit is enabled, zero otherwise. Tertiary sort key, used +// when group caps tie or are absent. +func userCapTokens(p *types.Policy) int64 { + if p.Limits.TokenLimit.Enabled { + return p.Limits.TokenLimit.UserCap + } + return 0 +} + +// userCapBudgetUsd returns the policy's per-user budget cap in USD +// when the budget limit is enabled, zero otherwise. Quaternary sort +// key for budget-only policies whose group caps tie or are absent. +func userCapBudgetUsd(p *types.Policy) float64 { + if p.Limits.BudgetLimit.Enabled { + return p.Limits.BudgetLimit.UserCapUsd + } + return 0 +} + +func sliceContains(haystack []string, needle string) bool { + for _, v := range haystack { + if v == needle { + return true + } + } + return false +} + +// mockManager fallback so tests that don't care about selection still +// compile. +func (*mockManager) SelectPolicyForRequest(_ context.Context, _ PolicySelectionInput) (*PolicySelectionResult, error) { + return &PolicySelectionResult{Allow: true}, nil +} diff --git a/management/internals/modules/agentnetwork/policyselect_account_realstore_test.go b/management/internals/modules/agentnetwork/policyselect_account_realstore_test.go new file mode 100644 index 000000000..c3b13a6cc --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_account_realstore_test.go @@ -0,0 +1,181 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" +) + +// GC-2 no-mock enforcement tests for the account-budget ceiling. They drive the +// real store + real consumption accounting through SelectPolicyForRequest and +// RecordAccountBudgetUsage, asserting min-wins (account binds independently of +// policy), targeting (groups + direct users), and the record fan-out. + +func accountWideUserTokenRule(id string, userCap, window int64) *types.AccountBudgetRule { + r := types.NewAccountBudgetRule(realSelectAccount) + r.ID = id + r.Limits.TokenLimit = types.PolicyTokenLimit{Enabled: true, UserCap: userCap, WindowSeconds: window} + return r +} + +// TestSelectPolicy_RealStore_AccountCeilingBindsEvenWithUncappedPolicy proves +// min-wins: the account user ceiling denies once exhausted even though a +// catch-all-allow (uncapped) policy would otherwise pass the request. The +// account gate runs independently of and ahead of policy selection. +func TestSelectPolicy_RealStore_AccountCeilingBindsEvenWithUncappedPolicy(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + // An uncapped (catch-all-allow) policy: enabled token limit, zero caps. + uncapped := capPolicy("pol-open", realSelectAccount, []string{"grp-eng"}, "prov-1", 0, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, uncapped)) + + // Account-wide user ceiling of 100 tokens in an hourly window. + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-1", 100, 3_600))) + + in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"} + + // Fresh: account ceiling has headroom, uncapped policy wins. + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh account ceiling must allow") + + // Drain the account user ceiling via the fan-out path. + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", []string{"grp-eng"}, 100, 0, 0)) + + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "account ceiling must deny even though the policy is uncapped (min-wins)") + assert.Equal(t, denyCodeAccountTokenCapExceeded, res.DenyCode, "deny must carry the llm_account.* code") +} + +// TestSelectPolicy_RealStore_AccountGroupCeiling proves a group-targeted rule +// binds the caller's group dimension. +func TestSelectPolicy_RealStore_AccountGroupCeiling(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + rule := types.NewAccountBudgetRule(realSelectAccount) + rule.ID = "ainbud-grp" + rule.TargetGroups = []string{"grp-eng"} + rule.Limits.BudgetLimit = types.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 5.0, WindowSeconds: 2_592_000} + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule)) + + in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"} + + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh group ceiling must allow") + + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", []string{"grp-eng"}, 0, 0, 5.0)) + + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "group budget ceiling must deny once spent") + assert.Equal(t, denyCodeAccountBudgetCapExceeded, res.DenyCode, "account budget deny code") +} + +// TestSelectPolicy_RealStore_AccountTargetUsersBindsOnlyThatUser proves a +// TargetUsers rule tightens only the named user, leaving others unbound. +func TestSelectPolicy_RealStore_AccountTargetUsersBindsOnlyThatUser(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + rule := types.NewAccountBudgetRule(realSelectAccount) + rule.ID = "ainbud-alice" + rule.TargetUsers = []string{"alice"} + rule.Limits.TokenLimit = types.PolicyTokenLimit{Enabled: true, UserCap: 100, WindowSeconds: 3_600} + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule)) + + // Record alice's usage to the rule window. + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "alice", nil, 100, 0, 0)) + + aliceIn := PolicySelectionInput{AccountID: realSelectAccount, UserID: "alice", ProviderID: "prov-1"} + res, err := mgr.SelectPolicyForRequest(ctx, aliceIn) + require.NoError(t, err) + assert.False(t, res.Allow, "alice is bound by the TargetUsers rule and is exhausted") + + bobIn := PolicySelectionInput{AccountID: realSelectAccount, UserID: "bob", ProviderID: "prov-1"} + res, err = mgr.SelectPolicyForRequest(ctx, bobIn) + require.NoError(t, err) + assert.True(t, res.Allow, "bob is not in TargetUsers, so the rule must not bind him") +} + +// TestSelectPolicy_RealStore_AccountRuleRecordsToOwnWindow proves the record +// fan-out books usage in the rule's own window (distinct from any policy +// window), so the account ceiling accumulates independently. +func TestSelectPolicy_RealStore_AccountRuleRecordsToOwnWindow(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-w", 100, 3_600))) + + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", nil, 60, 0, 0)) + + // Same user, a policy-style daily window must NOT see the account-window + // usage — windows are independent counters. + dailyRow, err := s.GetAgentNetworkConsumption(ctx, store.LockingStrengthNone, realSelectAccount, types.DimensionUser, "user-1", 86_400, types.WindowStart(time.Now().UTC(), 86_400)) + require.NoError(t, err) + assert.Equal(t, int64(0), dailyRow.TokensInput+dailyRow.TokensOutput, "daily window must be untouched by the hourly account-rule record") + + // A second record pushes the hourly account window to its cap → deny. + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", nil, 40, 0, 0)) + res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", ProviderID: "prov-1"}) + require.NoError(t, err) + assert.False(t, res.Allow, "100 tokens recorded in the rule's hourly window must exhaust the 100-token ceiling") + assert.Equal(t, denyCodeAccountTokenCapExceeded, res.DenyCode, "account token deny code") +} + +// TestRecordUsage_RealStore_BooksPolicyAndAccountWindows proves the batched +// post-flight write books the selected policy's window AND every applicable +// account rule's (independent) window in a single call — the #6 batched-write +// path the proxy's RecordLLMUsage RPC now uses. +func TestRecordUsage_RealStore_BooksPolicyAndAccountWindows(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + // Policy: 100-token group cap on a daily window. Account rule: 100-token + // user ceiling on an hourly window — an independent counter. + policy := capPolicy("pol-1", realSelectAccount, []string{"grp-eng"}, "prov-1", 100, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-1", 100, 3_600))) + + in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"} + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + require.True(t, res.Allow) + require.Equal(t, "pol-1", res.SelectedPolicyID) + + // One batched record books the policy window (group + user @86400) and the + // account rule window (user @3600) atomically. + require.NoError(t, mgr.RecordUsage(ctx, RecordUsageInput{ + AccountID: realSelectAccount, + UserID: "user-1", + AttributionGroupID: res.AttributionGroupID, + GroupIDs: []string{"grp-eng"}, + WindowSeconds: res.WindowSeconds, + TokensIn: 100, + })) + + // The next selection denies — the account hourly ceiling binds first. + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "usage booked by RecordUsage must enforce on the next request") + + // Prove BOTH windows were booked in the one call via a direct batch read. + now := time.Now().UTC() + userKey := types.ConsumptionKey{Kind: types.DimensionUser, DimID: "user-1", WindowSeconds: 3_600, WindowStartUTC: types.WindowStart(now, 3_600)} + groupKey := types.ConsumptionKey{Kind: types.DimensionGroup, DimID: "grp-eng", WindowSeconds: 86_400, WindowStartUTC: types.WindowStart(now, 86_400)} + rows, err := s.GetAgentNetworkConsumptionBatch(ctx, store.LockingStrengthNone, realSelectAccount, []types.ConsumptionKey{userKey, groupKey}) + require.NoError(t, err) + require.Contains(t, rows, userKey, "account rule user/hourly window booked") + require.Contains(t, rows, groupKey, "policy group/daily window booked") + assert.Equal(t, int64(100), rows[userKey].TokensInput, "account hourly user counter") + assert.Equal(t, int64(100), rows[groupKey].TokensInput, "policy daily group counter") +} diff --git a/management/internals/modules/agentnetwork/policyselect_realstore_test.go b/management/internals/modules/agentnetwork/policyselect_realstore_test.go new file mode 100644 index 000000000..cc8cfb1e7 --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_realstore_test.go @@ -0,0 +1,214 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" +) + +// This file is the no-mock regression guard for policy limit enforcement. +// policyselect_test.go pins the same behavior through a gomock store with +// explicit call-sequence expectations — brittle precisely where the upcoming +// account-budget work (GC-2) refactors the cap-eval primitive and adds an +// account-level gate. These tests drive the REAL sqlite store + REAL +// consumption accounting and assert observable behavior (allow / deny / +// selection / attribution), not which store methods get called. They must keep +// passing unchanged after GC-2 lands, which is what proves "current behavior is +// not changed." + +const realSelectAccount = "acc-realselect-1" + +// newRealSelectorMgr builds a managerImpl backed by a real sqlite test store. +func newRealSelectorMgr(t *testing.T) (*managerImpl, store.Store) { + t.Helper() + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + t.Cleanup(cleanup) + return &managerImpl{store: s}, s +} + +// TestSelectPolicy_RealStore_NoApplicablePolicies pins the pass-through: +// nothing targets the (provider, groups) combination, so the selector allows +// without attribution or consumption tracking. +func TestSelectPolicy_RealStore_NoApplicablePolicies(t *testing.T) { + mgr, _ := newRealSelectorMgr(t) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-x"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "no applicable policy must pass through as allow") + assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies") +} + +// TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution pins the v1 +// attribution rule (lowest intersecting group by string sort) through the +// real store, with a fresh (zero) consumption row. +func TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + p := capPolicy("pol-A", realSelectAccount, []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p)) + + res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh state under cap must allow") + assert.Equal(t, "pol-A", res.SelectedPolicyID, "only applicable policy must be selected") + assert.Equal(t, "grp-aa", res.AttributionGroupID, "lowest-by-sort intersecting group must win") + assert.Equal(t, int64(86_400), res.WindowSeconds, "selected policy's window must be returned") +} + +// TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted pins the +// core selection behavior end to end. The two policies bind DISTINCT groups so +// they read separate counters — the only shape where fall-through actually +// yields headroom (policies on the same group share one counter, as +// policyselect_test.go notes). Larger pool wins fresh; after real consumption +// drains the larger group, selection falls through to the smaller; once both +// counters are exhausted the request is denied. +func TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + tight := capPolicy("pol-tight", realSelectAccount, []string{"grp-tight"}, "prov-1", 100, 86_400) + tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + wide := capPolicy("pol-wide", realSelectAccount, []string{"grp-wide"}, "prov-1", 10_000, 86_400) + wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, tight)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, wide)) + + // Caller is in both groups, so both policies apply with independent counters. + in := PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-tight", "grp-wide"}, + ProviderID: "prov-1", + } + + // Fresh: larger pool wins. + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.Equal(t, "pol-wide", res.SelectedPolicyID, "larger pool drains first") + + // Drain only the wide group's counter to its cap. + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-wide", 86_400, 10_000, 0, 0)) + + // Wide exhausted, tight's separate counter is fresh → fall through to tight. + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "tight pool has its own untouched counter") + assert.Equal(t, "pol-tight", res.SelectedPolicyID, "selection falls through to the smaller pool once the larger is exhausted") + + // Drain the tight group's counter too → both exhausted → deny. + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-tight", 86_400, 100, 0, 0)) + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "both group counters exhausted must deny") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "deny code names the offending cap kind") +} + +// TestSelectPolicy_RealStore_BudgetCapDenies pins budget (USD) enforcement +// through the real store: once recorded cost reaches the cap, deny. +func TestSelectPolicy_RealStore_BudgetCapDenies(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + p := &types.Policy{ + ID: "pol-budget", + AccountID: realSelectAccount, + Enabled: true, + SourceGroups: []string{"grp-eng"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + BudgetLimit: types.PolicyBudgetLimit{ + Enabled: true, + GroupCapUsd: 5.0, + WindowSeconds: 86_400, + }, + }, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p)) + + in := PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + } + + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh budget must allow") + + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 0, 0, 5.0)) + + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "cost at the cap must deny") + assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode, "budget deny code must be surfaced") +} + +// TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies pins that two +// policies on the same group+window read one shared consumption counter: usage +// recorded once is visible to both, so exhausting the group budget denies +// regardless of which policy would attribute. +func TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + a := capPolicy("pol-a", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400) + b := capPolicy("pol-b", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, a)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, b)) + + in := PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + } + + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 1_000, 0, 0)) + + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "shared group counter at cap denies both equal policies") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "token deny code on the shared counter") +} + +// TestSelectPolicy_RealStore_DisabledPolicyIgnored pins that a disabled policy +// is invisible to selection even when it otherwise matches. +func TestSelectPolicy_RealStore_DisabledPolicyIgnored(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + p := capPolicy("pol-disabled", realSelectAccount, []string{"grp-eng"}, "prov-1", 10_000, 86_400) + p.Enabled = false + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p)) + + res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "no enabled policy applies → pass-through allow") + assert.Empty(t, res.SelectedPolicyID, "disabled policy must not be selected") +} diff --git a/management/internals/modules/agentnetwork/policyselect_test.go b/management/internals/modules/agentnetwork/policyselect_test.go new file mode 100644 index 000000000..dd7687fe1 --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_test.go @@ -0,0 +1,641 @@ +package agentnetwork + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + nbstatus "github.com/netbirdio/netbird/shared/management/status" +) + +func newSelectorMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore) { + t.Helper() + mockStore := store.NewMockStore(ctrl) + // SelectPolicyForRequest evaluates the account-budget ceiling before policy + // selection. These policy-selection tests don't exercise account rules, so + // default to "no rules" — the no-mock policyselect_realstore_test.go covers + // the account gate's behavior end to end. + mockStore.EXPECT(). + GetAccountAgentNetworkBudgetRules(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil). + AnyTimes() + return &managerImpl{store: mockStore}, mockStore +} + +type usedKey struct { + kind types.ConsumptionDimension + dimID string + window int64 +} + +// expectConsumptionBatch stubs the batched consumption read to return the +// supplied per-(kind, dim, window) counters, filling each row's window start +// from the actual request keys so it always matches what the selector computed. +// Keys absent from used resolve to zero counters. +func expectConsumptionBatch(mockStore *store.MockStore, used map[usedKey]*types.Consumption) { + mockStore.EXPECT(). + GetAgentNetworkConsumptionBatch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ store.LockingStrength, _ string, keys []types.ConsumptionKey) (map[types.ConsumptionKey]*types.Consumption, error) { + out := make(map[types.ConsumptionKey]*types.Consumption) + for _, k := range keys { + if row, ok := used[usedKey{k.Kind, k.DimID, k.WindowSeconds}]; ok { + rc := *row + rc.WindowStartUTC = k.WindowStartUTC + out[k] = &rc + } + } + return out, nil + }). + AnyTimes() +} + +func capPolicy(id, account string, sourceGroups []string, providerID string, tokenCap int64, windowSec int64) *types.Policy { + return &types.Policy{ + ID: id, + AccountID: account, + Enabled: true, + SourceGroups: sourceGroups, + DestinationProviderIDs: []string{providerID}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{ + Enabled: true, + GroupCap: tokenCap, + WindowSeconds: windowSec, + }, + }, + CreatedAt: time.Now().UTC(), + } +} + +// TestSelectPolicy_NoApplicablePolicies covers the pass-through path: +// llm_router authorisation is upstream of selection; when the +// selector finds no policy targeting the (provider, caller-groups) +// combination, it returns Allow with no attribution and lets the +// request continue without consumption tracking. +func TestSelectPolicy_NoApplicablePolicies(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{}, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-x"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "no applicable policies = pass-through allow") + assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies") +} + +// TestSelectPolicy_AllowWithLowestGroupAttribution proves the v1 +// attribution rule: when the caller's groups intersect a policy's +// source_groups in multiple positions, the selector picks the lowest +// group id by string sort so multi-node selection converges. +func TestSelectPolicy_AllowWithLowestGroupAttribution(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := capPolicy("pol-A", "acc-1", []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + // Fresh: zero consumption across the board. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow) + assert.Equal(t, "pol-A", res.SelectedPolicyID) + assert.Equal(t, "grp-aa", res.AttributionGroupID, + "lowest-by-sort intersection wins so multi-node selection converges") + assert.Equal(t, int64(86_400), res.WindowSeconds) +} + +// TestSelectPolicy_LargerPoolWinsAcrossUsageLevels proves the core +// selection rule: among multiple applicable policies with caps, the +// selector picks the one with the larger absolute pool — at every +// usage level, not just at fresh state. The smaller-pool policy is +// only reached when the larger one is exhausted. This is the +// "drain biggest first" semantic operators expect for layered +// tiers; a fraction-based score would flap between the two as +// soon as one is partially used. +func TestSelectPolicy_LargerPoolWinsAcrossUsageLevels(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + tight := capPolicy("pol-tight", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400) + tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + wide := capPolicy("pol-wide", "acc-1", []string{"grp-engineers"}, "prov-1", 10_000, 86_400) + wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{tight, wide}, nil) + + // Both partially used. tight at 50/100 (50% used); wide at + // 50/10000 (0.5% used). Old fraction-based algo would pick wide + // here too — but for the wrong reason ("more relative slack"). + // New algo picks wide because its initial group cap is bigger + // (10000 > 100), and that decision is stable as wide drains. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 50}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-wide", res.SelectedPolicyID, + "the policy with the bigger initial pool wins — operators expect 'drain the privileged tier first', not load-balance across tiers") +} + +// TestSelectPolicy_StaysOnLargerPoolAfterPartialDrain locks the +// stickiness contract reported by operators: with two policies +// where A has a 200-token group cap and B has 150, the very first +// request goes to A AND every subsequent request continues to land +// on A until A's group cap is exhausted — at which point B becomes +// the only candidate. A fraction-based score would flap to B as +// soon as A had any consumption (B's 1.0 fraction beats A's 0.75) +// even though A still has more absolute headroom; that produced +// confusing per-policy attribution ledger entries and stranded +// A's remaining capacity behind B's exhaustion. +func TestSelectPolicy_StaysOnLargerPoolAfterPartialDrain(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policyA := capPolicy("pol-A-200", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400) + policyB := capPolicy("pol-B-150", "acc-1", []string{"grp-engineers"}, "prov-1", 150, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + + // A is partially drained (50/200 used = 25% used; 75% headroom + // remaining). B is fresh (0/150). The old fraction-based score + // would pick B here (1.0 > 0.75 fraction); the new pool-size + // score sticks with A (200 > 150 absolute cap). + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 50}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-A-200", res.SelectedPolicyID, + "once attribution lands on the bigger pool it must STAY there until exhausted — operators expect 'drain A then B', not 'flip to B as soon as A is touched'") +} + +// TestSelectPolicy_FallsThroughToSmallerPoolWhenLargerExhausted +// proves the second half of the stickiness contract: once the +// larger-pool policy IS exhausted, the smaller one takes over. +// Without this we'd deny on requests the smaller policy is fully +// equipped to serve. +func TestSelectPolicy_FallsThroughToSmallerPoolWhenLargerExhausted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policyA := capPolicy("pol-A-200", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400) + // B uses a different window length so it has an INDEPENDENT counter — the + // realistic shape for fall-through. On the SAME (group, window) tuple the + // counter is shared, so A's cap of 200 being reached would also exhaust B's + // 150; independent counters are what let A exhaust while B retains headroom. + policyB := capPolicy("pol-B-150", "acc-1", []string{"grp-engineers"}, "prov-1", 150, 3_600) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 200}, // A: 200 >= 200 → exhausted + {types.DimensionGroup, "grp-engineers", 3_600}: {TokensInput: 100}, // B: 100 < 150 → headroom + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-B-150", res.SelectedPolicyID, + "once the bigger pool is exhausted, the smaller one must take over — denying when capacity remains would strand B's allowance") +} + +// TestSelectPolicy_TiebreakByLargerGroupPool covers the user-reported +// bug: an admin in two groups (Users + Admins) where Users is bound +// by a smaller-group-cap policy (50 group, 100 user) and Admins is +// bound by a bigger-group-cap policy (100 group, 20 user) MUST get +// attributed to the Admins policy on the first request. +// +// Without this rule, the fresh-state fraction is 1.0 for both and +// the older policy wins by created_at. The first 24-token request +// then drains the shared user counter past Admins's tight 20-token +// user cap, locking Admins out of selection forever. The 100-token +// Admins group pool ends up stranded while requests pile onto the +// 50-token Users pool — the opposite of what the operator intended +// when they put the bigger pool on the privileged group. +func TestSelectPolicy_TiebreakByLargerGroupPool(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + // Policy A: Users group, smaller group pool, looser per-user cap. + policyA := &types.Policy{ + ID: "pol-Users", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-Users"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{ + Enabled: true, GroupCap: 50, UserCap: 100, WindowSeconds: 86_400, + }, + }, + // Older — would win the legacy created_at tiebreak. + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + // Policy B: Admins group, bigger group pool, tighter per-user cap. + policyB := &types.Policy{ + ID: "pol-Admins", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-Admins"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{ + Enabled: true, GroupCap: 100, UserCap: 20, WindowSeconds: 86_400, + }, + }, + CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + // Fresh state: every cap evaluation reads zero usage. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-Users", "grp-Admins"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-Admins", res.SelectedPolicyID, + "the bigger group pool wins the fresh-state tiebreak — picking Users first would burn the shared user counter past Admins's tight user cap on the very first request and strand the bigger Admins pool") + assert.Equal(t, "grp-Admins", res.AttributionGroupID) +} + +// TestSelectPolicy_TiebreakByCreatedAt proves the deterministic +// final tiebreak: when two applicable policies have the same +// headroom fraction AND the same group cap (so the larger-pool rule +// can't differentiate either), the older policy wins so attribution +// is stable across replays. +func TestSelectPolicy_TiebreakByCreatedAt(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + older := capPolicy("pol-old", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400) + older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + newer := capPolicy("pol-new", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400) + newer.CreatedAt = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{newer, older}, nil) + // Both at zero consumption → identical headroom fraction. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-old", res.SelectedPolicyID, + "older policy wins on equal-headroom tiebreak so attribution is stable across replays") +} + +// TestSelectPolicy_DeniesWhenAllExhausted proves the deny envelope: +// when every applicable policy has at least one cap fully exhausted, +// the selector returns Allow=false with the most-recent exhaustion's +// deny code + human reason. The proxy's middleware surfaces this as +// a 403 with the canonical llm_policy.* code. +func TestSelectPolicy_DeniesWhenAllExhausted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + a := capPolicy("pol-a", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400) + b := capPolicy("pol-b", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{a, b}, nil) + + // Shared group counter at 200: A (cap 100) and B (cap 200) both exhausted. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 200}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, "every applicable policy exhausted = deny") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode) + assert.Contains(t, res.DenyReason, "token cap exhausted", + "deny reason must name the exhausted cap kind for operator debugging") +} + +// TestSelectPolicy_UncappedPolicyAlwaysWinsAgainstCapped proves the +// catch-all-allow contract: a policy with NO enabled caps wins +// against any capped policy regardless of how much headroom the +// capped one has, because operators who configure unlimited access +// expect requests to attribute there until they explicitly add caps. +func TestSelectPolicy_UncappedPolicyAlwaysWinsAgainstCapped(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + uncapped := &types.Policy{ + ID: "pol-uncapped", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + // All Limits.*.Enabled = false (zero-value). + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + wide := capPolicy("pol-wide", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000_000, 86_400) + wide.CreatedAt = time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) // older than uncapped + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{uncapped, wide}, nil) + // Only the wide policy reads consumption; uncapped doesn't query + // because it has no enabled caps. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-uncapped", res.SelectedPolicyID, + "a no-caps policy must always win selection — that's how operators express 'unlimited access through this path'") + assert.Equal(t, int64(0), res.WindowSeconds, "no caps configured = WindowSeconds=0 so RecordLLMUsage skips counter writes") +} + +// TestSelectPolicy_DisabledPolicyIgnored proves disabled policies +// don't count toward selection — even when they'd otherwise be the +// best match. Operators disable a policy to take it offline; the +// selector must respect that and route through whatever's left. +func TestSelectPolicy_DisabledPolicyIgnored(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + disabled := capPolicy("pol-disabled", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000_000, 86_400) + disabled.Enabled = false + enabled := capPolicy("pol-enabled", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{disabled, enabled}, nil) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-enabled", res.SelectedPolicyID, + "disabled policies must be ignored at selection time") +} + +// TestSelectPolicy_StoreErrorPropagates locks the no-fail-open +// contract: a transient store error must surface to the caller, not +// be silently treated as "no policies = allow". A false allow on the +// hot path would let a request slip past every cap. +func TestSelectPolicy_StoreErrorPropagates(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return(nil, errors.New("boom")) + + _, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + }) + require.Error(t, err, "store errors must surface — never fail open on the hot path") +} + +// TestSelectPolicy_RejectsEmptyAccount is the input-validation guard: +// empty account_id is a programmer error and must surface as +// InvalidArgument, not as a silent zero-result lookup. +func TestSelectPolicy_RejectsEmptyAccount(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, _ := newSelectorMgr(t, ctrl) + + _, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{}) + require.Error(t, err) + var sErr *nbstatus.Error + require.True(t, errors.As(err, &sErr)) + assert.Equal(t, nbstatus.InvalidArgument, sErr.Type()) +} + +// TestSelectPolicy_SharesGroupCounterAcrossPolicies locks the +// counter-keying design fork: counters are keyed on (account, +// dim_kind, dim_id, window_hours, window_start) — NOT on policy_id. +// Two policies that target the same group with the SAME window length +// share one bucket: spend booked under policy A is visible to policy +// B's headroom calculation and counts toward B's cap. +// +// This is what makes "operator's per-group enforcement" sane — caps +// describe how much a GROUP can use, not how much each policy owes. +func TestSelectPolicy_SharesGroupCounterAcrossPolicies(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + // Two policies, both targeting grp-engineers + prov-1, same 24h + // window length. Different cap sizes. + policyA := capPolicy("pol-A", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400) + policyB := capPolicy("pol-B", "acc-1", []string{"grp-engineers"}, "prov-1", 5_000, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + // Both policies query the SAME consumption row — same dim_id, + // same window_hours, same window_start. The mock returns the + // same row for both calls, simulating the shared counter. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 800}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + // 800 used → policy A has 200 tokens left of 1000 (20% headroom); + // policy B has 4200 left of 5000 (84% headroom). B wins. + assert.Equal(t, "pol-B", res.SelectedPolicyID, + "the SAME 800 tokens count toward both policies — counters share the (group, window) key, caps differ per policy") +} + +// TestSelectPolicy_AntiFallThroughOnLowestGroup locks the no-fall- +// through behaviour: when a caller is in multiple of a policy's +// source_groups and the lowest-by-sort group is exhausted, we DENY +// rather than fall through to a less-loaded sibling. Per-group caps +// are independent (each group has its own bucket), but attribution +// is one-shot — operators wanting fall-through must split into +// separate policies. +// +// This nails down semantics future contributors might "improve" into +// fall-through behaviour by accident. +func TestSelectPolicy_AntiFallThroughOnLowestGroup(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + // Policy targets two groups; caller is in both. + policy := capPolicy("pol-1", "acc-1", []string{"grp-aaa", "grp-bbb"}, "prov-1", 100, 86_400) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + + // grp-aaa is the lowest by sort → attribution picks it, and the + // prefetch only collects the attribution group's key. We exhaust + // grp-aaa (100/100); grp-bbb's counter is never requested because the + // selector attributes one-shot to the lowest group, so it can't fall + // through to a less-loaded sibling. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-aaa", 86_400}: {TokensInput: 100}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-aaa", "grp-bbb"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, + "lowest-group-by-sort attribution does NOT fall through to a less-loaded sibling — operators wanting fall-through must split into separate policies") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode) + assert.Contains(t, res.DenyReason, "pol-1", + "deny reason names the exhausted policy id so operators can grep it from the access log") +} + +// TestSelectPolicy_BudgetOnlyExhaustionDenies covers the symmetric +// path to TestSelectPolicy_DeniesWhenAllExhausted but for the budget +// cap: a policy with token_limit DISABLED and budget_limit at-cap +// must deny with llm_policy.budget_cap_exceeded (not the token code). +// +// Without this, the budget evaluation path in evalBudgetCap could +// silently regress and we'd still pass DeniesWhenAllExhausted (which +// only exercises tokens). +func TestSelectPolicy_BudgetOnlyExhaustionDenies(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := &types.Policy{ + ID: "pol-budget", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: false}, + BudgetLimit: types.PolicyBudgetLimit{ + Enabled: true, + GroupCapUsd: 10.00, + WindowSeconds: 86_400, + }, + }, + CreatedAt: time.Now().UTC(), + } + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {CostUSD: 10.50}, // over the $10 cap + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, "budget cap exhausted must deny independently of any token cap state") + assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode, + "deny code must be the budget code — token-only deny would silently regress the budget evaluation path") + assert.Contains(t, res.DenyReason, "budget", "deny reason names the budget cap kind for operator debugging") +} + +// TestSelectPolicy_BudgetTighterThanTokenWins is the dual-cap headroom +// fork: when both Token and Budget are enabled on the same policy, +// the SMALLER remaining ratio gates the policy. A policy with +// abundant token headroom but near-zero budget headroom must deny on +// budget, not pass on tokens. +func TestSelectPolicy_BudgetTighterThanTokenWins(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := &types.Policy{ + ID: "pol-dual", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 10_000_000, WindowSeconds: 86_400}, + BudgetLimit: types.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 1.00, WindowSeconds: 86_400}, + }, + CreatedAt: time.Now().UTC(), + } + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + // One shared counter carries both token usage (ample headroom) and cost + // (at the $1 budget cap); the tighter budget cap gates the policy. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 100, CostUSD: 1.00}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, + "the tighter of (token, budget) wins — abundant token headroom must NOT mask an exhausted budget") + assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode) +} diff --git a/management/internals/modules/agentnetwork/reconcile.go b/management/internals/modules/agentnetwork/reconcile.go new file mode 100644 index 000000000..319553ebc --- /dev/null +++ b/management/internals/modules/agentnetwork/reconcile.go @@ -0,0 +1,131 @@ +package agentnetwork + +import ( + "context" + + log "github.com/sirupsen/logrus" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// reconcile recomputes the synthesised reverse-proxy services for an +// account, diffs them against the previously-synthesised set in the +// in-memory cache, and emits Create / Update / Delete proxy mappings +// to the affected clusters. Also triggers a peer-side network-map +// recompute via accountManager.UpdateAccountPeers so the +// private-service ACL injection picks up the new state immediately. +// +// Reconcile failures are logged and swallowed — the underlying CRUD +// has already completed, and the next mutation (or proxy reconnect) +// will re-converge the cluster's view. +func (m *managerImpl) reconcile(ctx context.Context, accountID string) { + if accountID == "" { + return + } + + defer func() { + if m.accountManager != nil { + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{ + Resource: types.UpdateResourceService, + Operation: types.UpdateOperationUpdate, + }) + } + }() + + if m.proxyController == nil { + return + } + + services, err := SynthesizeServices(ctx, m.store, accountID) + if err != nil { + log.WithContext(ctx).WithError(err).Warnf("agent-network reconcile: synthesise services for account %s", accountID) + return + } + + oidcCfg := m.proxyController.GetOIDCValidationConfig() + current := make(map[string]*proto.ProxyMapping, len(services)) + for _, svc := range services { + if svc == nil || svc.ID == "" { + continue + } + current[svc.ID] = svc.ToProtoMapping(rpservice.Update, "", oidcCfg) + } + + m.reconcileMu.Lock() + previous := m.reconcileCache[accountID] + if previous == nil { + previous = make(map[string]*proto.ProxyMapping) + } + + creates, updates, deletes := diffMappings(previous, current) + if len(current) == 0 { + delete(m.reconcileCache, accountID) + } else { + m.reconcileCache[accountID] = current + } + m.reconcileMu.Unlock() + + for _, mapping := range creates { + mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping)) + } + for _, mapping := range updates { + mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping)) + } + for _, mapping := range deletes { + mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping)) + } +} + +// diffMappings classifies the previous→current transition for a +// single account into Create / Update / Delete sets. +// +// Cluster moves (current.cluster != previous.cluster) are surfaced as +// a Delete on the old cluster + Create on the new — handled by +// emitting both a delete (on previous mapping) and a create (on the +// current mapping) for that service ID. +func diffMappings(previous, current map[string]*proto.ProxyMapping) (creates, updates, deletes []*proto.ProxyMapping) { + for id, cur := range current { + prev, existed := previous[id] + switch { + case !existed: + creates = append(creates, cur) + case prev.GetDomain() == "" || cur.GetAccountId() == prev.GetAccountId() && currentClusterChanged(prev, cur): + deletes = append(deletes, prev) + creates = append(creates, cur) + default: + updates = append(updates, cur) + } + } + for id, prev := range previous { + if _, stillThere := current[id]; !stillThere { + deletes = append(deletes, prev) + } + } + return creates, updates, deletes +} + +func currentClusterChanged(prev, cur *proto.ProxyMapping) bool { + return clusterFromMapping(prev) != clusterFromMapping(cur) +} + +// clusterFromMapping returns the cluster the mapping should be sent +// to. ProxyMapping doesn't carry the cluster directly, so we rely on +// the synthesised service's domain (`.`) and split on +// the first '.'. +func clusterFromMapping(m *proto.ProxyMapping) string { + if m == nil { + return "" + } + domain := m.GetDomain() + for i := 0; i < len(domain); i++ { + if domain[i] == '.' { + return domain[i+1:] + } + } + return "" +} diff --git a/management/internals/modules/agentnetwork/reconcile_test.go b/management/internals/modules/agentnetwork/reconcile_test.go new file mode 100644 index 000000000..0855f0dc1 --- /dev/null +++ b/management/internals/modules/agentnetwork/reconcile_test.go @@ -0,0 +1,232 @@ +package agentnetwork + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore, *proxy.MockController) { + t.Helper() + mockStore := store.NewMockStore(ctrl) + mockProxy := proxy.NewMockController(ctrl) + return &managerImpl{ + store: mockStore, + proxyController: mockProxy, + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + }, mockStore, mockProxy +} + +func newReconcileTestProvider() *types.Provider { + return &types.Provider{ + ID: "prov-1", + AccountID: "acct-1", + ProviderID: "openai_api", + Name: "OpenAI", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + SessionPrivateKey: "test-priv-key", + SessionPublicKey: "test-pub-key", + } +} + +func newReconcileTestPolicy(providerID, sourceGroupID string) *types.Policy { + return &types.Policy{ + ID: "pol-1", + AccountID: "acct-1", + Name: "engineers", + Enabled: true, + SourceGroups: []string{sourceGroupID}, + DestinationProviderIDs: []string{providerID}, + } +} + +func newReconcileTestSettings() *types.Settings { + return &types.Settings{ + AccountID: "acct-1", + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + } +} + +func expectReconcileSynthInputs(mockStore *store.MockStore, ctx context.Context, providers []*types.Provider, policies []*types.Policy, guardrails []*types.Guardrail) { + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(newReconcileTestSettings(), nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1"). + Return(providers, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1"). + Return(policies, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1"). + Return(guardrails, nil) +} + +func TestReconcile_FirstSynth_EmitsCreate(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + expectReconcileSynthInputs(mockStore, ctx, []*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{}) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}) + + var sentMappings []*proto.ProxyMapping + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + sentMappings = append(sentMappings, m) + }) + + mgr.reconcile(ctx, "acct-1") + + require.Len(t, sentMappings, 1, "first synth must emit one mapping") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, sentMappings[0].Type, "first synth is a Create") + assert.Equal(t, "agent-net-svc-acct-1", sentMappings[0].Id, "stable account-scoped virtual service id") + assert.Equal(t, "violet.eu.proxy.netbird.io", sentMappings[0].Domain, "domain comes from settings (subdomain.cluster)") + + mgr.reconcileMu.Lock() + cached := mgr.reconcileCache["acct-1"] + mgr.reconcileMu.Unlock() + require.Len(t, cached, 1, "cache must hold the synth result for next diff") +} + +func TestReconcile_NoChange_EmitsNothingExtra(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + // Two identical synth runs. + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(newReconcileTestSettings(), nil).Times(2) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Provider{provider}, nil).Times(2) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Policy{policy}, nil).Times(2) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Guardrail{}, nil).Times(2) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).Times(2) + + createCalls := 0 + updateCalls := 0 + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), gomock.Any()). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + switch m.Type { + case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED: + createCalls++ + case proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED: + updateCalls++ + } + }). + AnyTimes() + + mgr.reconcile(ctx, "acct-1") + mgr.reconcile(ctx, "acct-1") + + assert.Equal(t, 1, createCalls, "first reconcile creates") + assert.Equal(t, 1, updateCalls, "second reconcile re-pushes as Modified (no semantic change but mapping fields refresh)") +} + +func TestReconcile_PolicyRemoved_EmitsDelete(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + gomock.InOrder( + // First reconcile: provider + policy, synthesised. + mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil), + mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil), + mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Policy{policy}, nil), + mockStore.EXPECT().GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Guardrail{}, nil), + // Second reconcile: policy gone, provider stays but no longer referenced. + mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil), + mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil), + mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Policy{}, nil), + ) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes() + + var seenTypes []proto.ProxyMappingUpdateType + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + seenTypes = append(seenTypes, m.Type) + }). + AnyTimes() + + mgr.reconcile(ctx, "acct-1") + mgr.reconcile(ctx, "acct-1") + + require.Len(t, seenTypes, 2, "create then delete") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, seenTypes[0]) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, seenTypes[1]) + + mgr.reconcileMu.Lock() + _, present := mgr.reconcileCache["acct-1"] + mgr.reconcileMu.Unlock() + assert.False(t, present, "cache for the account must be cleared once nothing is synthesised") +} + +func TestReconcile_NilProxyController_NoOp(t *testing.T) { + ctx := context.Background() + mgr := &managerImpl{ + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + } + // Must not panic; must not query the store. + mgr.reconcile(ctx, "acct-1") +} + +func TestReconcile_EmptyAccountID_NoOp(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, _, _ := newReconcileMgr(t, ctrl) + // Empty accountID short-circuits before any store call. + mgr.reconcile(ctx, "") +} + +func TestClusterFromMapping(t *testing.T) { + tests := []struct { + name string + domain string + want string + }{ + {"simple", "openai.eu.proxy.netbird.io", "eu.proxy.netbird.io"}, + {"deeply nested", "a.b.c.d", "b.c.d"}, + {"no dot", "openai", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clusterFromMapping(&proto.ProxyMapping{Domain: tt.domain}) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go new file mode 100644 index 000000000..9814d1a11 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -0,0 +1,1083 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// apiKeyPlaceholder is the literal substituted with the provider's +// decrypted API key in catalog AuthHeaderTemplate strings. +const apiKeyPlaceholder = "${API_KEY}" //nolint:gosec // template marker, not a credential + +// gcpKeyfilePrefix marks an api_key that holds a base64-encoded GCP +// service-account JSON key ("keyfile::") rather than a static bearer +// token; the proxy mints OAuth tokens from it. Mirrors Aperture's convention. +const gcpKeyfilePrefix = "keyfile::" + +// SynthesizedServiceIDPrefix prefixes the in-memory ID of every +// reverse-proxy service synthesised from Agent Network state. One +// synthesised service exists per (account, cluster); the suffix is the +// account ID so the proxy can dedup mappings cleanly. +const SynthesizedServiceIDPrefix = "agent-net-svc-" + +// agentNetworkRequestCaptureBytes is the request-side body capture cap. +// Kept modest: oversized requests (a long conversation's context can be +// many MB) have their routing fields recovered by the proxy's tolerant +// scan rather than buffered here, so there's no need to size this to the +// largest possible request. +const agentNetworkRequestCaptureBytes = 1 << 20 + +// agentNetworkResponseCaptureBytes is the response-side body capture cap. +// Token usage lives in the trailing SSE message_delta event, so the +// captured prefix must reach the end of the stream. Unlike a request's +// unbounded context, a single response is hard-capped by the model's max +// output tokens (~128K on Opus → a few hundred KB of gzipped SSE even +// with thinking), so 8 MiB is comfortably above any real response and is +// effectively unlimited here — not a moving ceiling. The proxy clamps to +// its own MaxBodyCapBytes at apply time. +const agentNetworkResponseCaptureBytes = 8 << 20 + +// agentNetworkCaptureContentTypes is the set of content types whose +// bodies the proxy buffers for the LLM middlewares. JSON covers +// buffered request and response bodies; SSE covers streaming +// responses (the response parser sums delta tokens across chunks). +var agentNetworkCaptureContentTypes = []string{ + "application/json", + "text/event-stream", +} + +// Middleware IDs the synthesised target chain registers, mirroring the +// proxy-side built-in registry. Order matters: on_request runs in the +// order they're listed; on_response runs in reverse, so cost_meter must +// come BEFORE llm_response_parser in the slice so the parser populates +// tokens before the cost meter reads them. +const ( + middlewareIDLLMRequestParser = "llm_request_parser" + middlewareIDLLMRouter = "llm_router" + middlewareIDLLMIdentityInject = "llm_identity_inject" + middlewareIDLLMLimitCheck = "llm_limit_check" + middlewareIDLLMGuardrail = "llm_guardrail" + middlewareIDCostMeter = "cost_meter" + middlewareIDLLMResponseParser = "llm_response_parser" + middlewareIDLLMLimitRecord = "llm_limit_record" +) + +// SynthesizeServicesForCluster walks every account's agent-network +// settings row pinned to clusterAddr and synthesises the per-account +// gateway service. Used by the proxy-mapping snapshot path where the +// connecting proxy has a specific cluster address and cares about every +// account that routes through it. +// +// Returns nil (no error) when no settings row references the cluster. +// Per-account synthesis failures are skipped rather than dropping every +// account on the cluster. +func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAddr string) ([]*rpservice.Service, error) { + clusterAddr = strings.TrimSpace(clusterAddr) + if clusterAddr == "" { + return nil, nil + } + + settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, clusterAddr) + if err != nil { + return nil, fmt.Errorf("list agent network settings on cluster: %w", err) + } + if len(settingsRows) == 0 { + return nil, nil + } + + var out []*rpservice.Service + for _, settings := range settingsRows { + if settings == nil { + continue + } + services, serr := SynthesizeServices(ctx, s, settings.AccountID) + if serr != nil { + continue + } + for _, svc := range services { + if svc != nil && svc.ProxyCluster == clusterAddr { + out = append(out, svc) + } + } + } + return out, nil +} + +// SynthesizeServiceForDomain resolves a single agent-network service by its +// public endpoint domain. It lists the (few) settings rows on the domain's +// cluster, matches the one whose endpoint equals the domain, and synthesises +// only that account — avoiding full per-account synthesis for every tenant on +// the cluster, which is what auth/session paths previously paid. Returns nil +// (no error) when no account owns the domain. +func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) { + domain = strings.TrimSpace(domain) + cluster := clusterFromDomain(domain) + if domain != "" && cluster != "" { + settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster) + if err != nil { + return nil, fmt.Errorf("list agent network settings on cluster: %w", err) + } + for _, settings := range settingsRows { + if settings == nil || settings.Endpoint() != domain { + continue + } + services, serr := SynthesizeServices(ctx, s, settings.AccountID) + if serr != nil { + return nil, serr + } + for _, svc := range services { + if svc != nil && svc.Domain == domain { + return svc, nil + } + } + break + } + } + return nil, nil //nolint:nilnil // optional lookup: no account owns the domain +} + +// clusterFromDomain returns the cluster portion of an endpoint domain (every +// label after the first). +func clusterFromDomain(domain string) string { + if i := strings.IndexByte(domain, '.'); i >= 0 { + return domain[i+1:] + } + return "" +} + +// SynthesizeServices builds the in-memory reverse-proxy service that +// fronts the account's agent-network gateway. Returns nil when the +// account has no settings row, no enabled providers, or no enabled +// policies — in any of those cases there's nothing useful to expose. +// +// One service per (account, settings.Cluster) is emitted. The router +// middleware encodes a denormalised model→provider routing table +// (auth headers + decrypted API keys baked in); the policy_check +// middleware encodes per-provider authorised group IDs derived from +// the account's enabled policies. +// +// Services are NEVER persisted — callers regenerate them on every +// network-map / proxy-mapping cycle from current state. +func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error) { + settings, ok, err := loadSettings(ctx, s, accountID) + if err != nil { + return nil, err + } + if !ok || strings.TrimSpace(settings.Cluster) == "" { + return nil, nil + } + + providers, err := s.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list agent network providers: %w", err) + } + enabledProviders := filterEnabledProviders(providers) + if len(enabledProviders) == 0 { + return nil, nil + } + + policies, err := s.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list agent network policies: %w", err) + } + enabledPolicies := filterEnabledPolicies(policies) + if len(enabledPolicies) == 0 { + return nil, nil + } + + // Backfill any missing session keypairs before deriving a + // service-level keypair. Old rows pre-date the column; treating + // the gap as a no-op produces an immediate dial failure, so we + // fix it once here and persist for future cycles. + for _, p := range enabledProviders { + if p.SessionPrivateKey != "" && p.SessionPublicKey != "" { + continue + } + if err := backfillProviderSessionKeys(ctx, s, p); err != nil { + return nil, fmt.Errorf("backfill session keys for provider %s: %w", p.ID, err) + } + } + + guardrails, err := s.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list agent network guardrails: %w", err) + } + guardrailsByID := make(map[string]*types.Guardrail, len(guardrails)) + for _, g := range guardrails { + if g != nil { + guardrailsByID[g.ID] = g + } + } + + groupIndex := indexProviderGroups(enabledPolicies) + + routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex) + if err != nil { + return nil, err + } + + identityInjectJSON, err := buildIdentityInjectConfigJSON(enabledProviders, groupIndex) + if err != nil { + return nil, err + } + + mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID) + applyAccountCollectionControls(&mergedGuardrails, settings) + guardrailJSON, err := marshalGuardrailConfig(mergedGuardrails) + if err != nil { + return nil, err + } + + // Use the merged decision (account settings OR policy-required redaction), + // not the raw account flag, so a policy that mandates PII redaction is + // honored by the capture parsers even when the account toggle is off. + middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled) + + priv, pub, err := pickServiceSessionKeys(enabledProviders) + if err != nil { + return nil, err + } + + svc := buildAccountService(accountID, settings, enabledPolicies, middlewares, priv, pub) + return []*rpservice.Service{svc}, nil +} + +// loadSettings returns the account's agent-network settings row. The +// boolean reports whether a row exists; a status.NotFound surfaces as +// (nil, false, nil) so callers can treat "no settings" as "no +// synthesis" without inspecting error types themselves. +func loadSettings(ctx context.Context, s store.Store, accountID string) (*types.Settings, bool, error) { + settings, err := s.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + if err == nil { + return settings, true, nil + } + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.NotFound { + return nil, false, nil + } + return nil, false, fmt.Errorf("get agent network settings: %w", err) +} + +// filterEnabledProviders returns the subset of enabled providers, sorted +// by created_at ascending so the router config is deterministic and +// first-match-wins is stable across synthesis cycles. +func filterEnabledProviders(providers []*types.Provider) []*types.Provider { + out := make([]*types.Provider, 0, len(providers)) + for _, p := range providers { + if p == nil || !p.Enabled { + continue + } + out = append(out, p) + } + sort.SliceStable(out, func(i, j int) bool { + if !out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].CreatedAt.Before(out[j].CreatedAt) + } + return out[i].ID < out[j].ID + }) + return out +} + +// filterEnabledPolicies returns the subset of enabled policies. +func filterEnabledPolicies(policies []*types.Policy) []*types.Policy { + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil || !p.Enabled { + continue + } + out = append(out, p) + } + return out +} + +// backfillProviderSessionKeys mints an ed25519 session keypair on a +// provider row that doesn't have one yet (rows created before the +// keys were persistent fields) and persists it via the store so +// subsequent cycles get stable keys. +func backfillProviderSessionKeys(ctx context.Context, s store.Store, p *types.Provider) error { + pair, err := sessionkey.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate session keys for provider %s: %w", p.ID, err) + } + p.SessionPrivateKey = pair.PrivateKey + p.SessionPublicKey = pair.PublicKey + if err := s.SaveAgentNetworkProvider(ctx, p); err != nil { + return fmt.Errorf("persist backfilled session keys for provider %s: %w", p.ID, err) + } + return nil +} + +// pickServiceSessionKeys returns the keypair the synthesised gateway +// service signs / verifies session JWTs with. The PoC reuses the first +// enabled provider's keypair so existing session cookies survive +// provider edits as long as the first-by-created_at provider stays in +// place. Returns an error when no provider has a usable keypair after +// backfill — that surfaces a misconfigured account loudly instead of +// emitting a service the proxy will reject as "invalid session public +// key size". +func pickServiceSessionKeys(providers []*types.Provider) (priv, pub string, err error) { + for _, p := range providers { + if p.SessionPrivateKey != "" && p.SessionPublicKey != "" { + return p.SessionPrivateKey, p.SessionPublicKey, nil + } + } + return "", "", fmt.Errorf("no provider with session keypair; update one provider to backfill") +} + +// routerConfig mirrors the on-wire shape llm_router accepts. Kept +// private so the synthesiser owns the contract; the proxy-side factory +// JSON-decodes the same shape. +type routerConfig struct { + Providers []routerProviderRoute `json:"providers"` +} + +type routerProviderRoute struct { + ID string `json:"id"` + Vendor string `json:"vendor,omitempty"` + Models []string `json:"models"` + UpstreamScheme string `json:"upstream_scheme"` + UpstreamHost string `json:"upstream_host"` + UpstreamPath string `json:"upstream_path,omitempty"` + AuthHeaderName string `json:"auth_header_name"` + AuthHeaderValue string `json:"auth_header_value"` + AllowedGroupIDs []string `json:"allowed_group_ids,omitempty"` + // Vertex marks a Google Vertex AI provider, whose requests carry the + // model in the URL path. The router selects it by path, bypassing the + // model/vendor table. + Vertex bool `json:"vertex,omitempty"` + // Bedrock marks an AWS Bedrock provider, whose requests carry the model in + // the URL path (/model/{id}/{action}). The router selects it by path, + // bypassing the model/vendor table; auth is a static bearer token. + Bedrock bool `json:"bedrock,omitempty"` + // GCPServiceAccountKeyB64 carries a base64-encoded GCP service-account + // JSON key (from a "keyfile::" api_key). When set, the proxy mints + // + refreshes the OAuth token at request time instead of injecting a static + // AuthHeaderValue. + GCPServiceAccountKeyB64 string `json:"gcp_sa_key_b64,omitempty"` +} + +// indexProviderGroups walks the enabled policies and returns, per +// provider id, the sorted union of source group ids across every +// policy that authorises the provider. Providers with no authorising +// policy are absent from the map. The router consumes this to filter +// candidate routes by the caller's group memberships before the +// path-prefix tiebreak runs. +func indexProviderGroups(policies []*types.Policy) map[string][]string { + sets := make(map[string]map[string]struct{}) + for _, policy := range policies { + if policy == nil { + continue + } + for _, providerID := range policy.DestinationProviderIDs { + if providerID == "" { + continue + } + set, ok := sets[providerID] + if !ok { + set = make(map[string]struct{}) + sets[providerID] = set + } + for _, group := range policy.SourceGroups { + if group != "" { + set[group] = struct{}{} + } + } + } + } + out := make(map[string][]string, len(sets)) + for providerID, set := range sets { + groups := make([]string, 0, len(set)) + for g := range set { + groups = append(groups, g) + } + sort.Strings(groups) + out[providerID] = groups + } + return out +} + +// buildRouterConfigJSON denormalises the account's enabled providers +// into the router middleware's first-match-wins routing table. +// Providers are listed in created_at order so the table is +// deterministic and stable across synth cycles. +// +// AllowedGroupIDs is the union of source group ids across every enabled +// policy that authorises the provider. The router uses it as a hard +// filter — a route whose AllowedGroupIDs has no intersection with the +// caller's user groups is removed from the candidate list before the +// path-prefix tiebreak. Providers no enabled policy authorises +// (orphans) are intentionally OMITTED so the router never observes a +// route with an empty ACL. +func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) { + cfg := routerConfig{Providers: make([]routerProviderRoute, 0, len(providers))} + for _, p := range providers { + groups, hasPolicy := groupIndex[p.ID] + if !hasPolicy { + // Orphan: skip. No enabled policy authorises this + // provider, so it must not be reachable. + continue + } + scheme, host, path, err := parseUpstreamHost(p.UpstreamURL) + if err != nil { + return nil, fmt.Errorf("router config for provider %s: %w", p.ID, err) + } + headerName, headerValue, gcpSAKeyB64, err := providerAuthHeader(p) + if err != nil { + return nil, err + } + cfg.Providers = append(cfg.Providers, routerProviderRoute{ + ID: p.ID, + Vendor: providerVendor(p), + Models: providerModelIDs(p), + UpstreamScheme: scheme, + UpstreamHost: host, + UpstreamPath: path, + AuthHeaderName: headerName, + AuthHeaderValue: headerValue, + AllowedGroupIDs: groups, + Vertex: catalog.IsVertexPathStyle(p.ProviderID), + Bedrock: catalog.IsBedrockPathStyle(p.ProviderID), + GCPServiceAccountKeyB64: gcpSAKeyB64, + }) + } + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal llm_router middleware config: %w", err) + } + return out, nil +} + +// providerVendor returns the parser surface ("openai", "anthropic", …) +// the provider speaks, sourced from its catalog entry's ParserID. The +// router uses it to keep a request the parser tagged with a vendor on a +// route of the same vendor — so e.g. an Anthropic /v1/messages call is +// never sent to an OpenAI-compatible gateway that also claims the model. +// Empty when the catalog entry is unknown or declares no parser surface; +// the router then falls back to model / path routing. +func providerVendor(p *types.Provider) string { + entry, ok := catalog.Lookup(p.ProviderID) + if !ok { + return "" + } + return entry.ParserID +} + +// providerModelIDs returns the model identifiers exposed by the +// provider, deduplicated and in the operator's declared order. Empty +// slice when no models are configured — the router treats that as +// "claim every model" so gateway-style providers (LiteLLM, custom +// OpenAI-compatible endpoints) work without the operator enumerating +// the upstream's full model catalog in NetBird. +func providerModelIDs(p *types.Provider) []string { + if len(p.Models) == 0 { + return []string{} + } + seen := make(map[string]struct{}, len(p.Models)) + out := make([]string, 0, len(p.Models)) + for _, m := range p.Models { + if m.ID == "" { + continue + } + if _, dup := seen[m.ID]; dup { + continue + } + seen[m.ID] = struct{}{} + out = append(out, m.ID) + } + return out +} + +// identityInjectConfig mirrors the on-wire shape llm_identity_inject +// accepts. +type identityInjectConfig struct { + Providers []identityInjectProvider `json:"providers"` +} + +// identityInjectProvider carries one provider's injection rule. +// Identity-stamping uses one of HeaderPair / JSONMetadata (mutually +// exclusive). ExtraHeaders is independent — a list of extra +// per-provider routing/config headers (catalog-declared, value lives +// on the provider record) the middleware stamps with anti-spoof +// (Remove + Add) on every matching request. +type identityInjectProvider struct { + ProviderID string `json:"provider_id"` + HeaderPair *identityInjectHeaderPair `json:"header_pair,omitempty"` + JSONMetadata *identityInjectJSONMetadata `json:"json_metadata,omitempty"` + ExtraHeaders []identityInjectExtraHeader `json:"extra_headers,omitempty"` +} + +type identityInjectExtraHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type identityInjectHeaderPair struct { + EndUserIDHeader string `json:"end_user_id_header,omitempty"` + TagsHeader string `json:"tags_header,omitempty"` + TagsInBody bool `json:"tags_in_body,omitempty"` + EndUserIDInBody bool `json:"end_user_id_in_body,omitempty"` +} + +type identityInjectJSONMetadata struct { + Header string `json:"header"` + UserKey string `json:"user_key,omitempty"` + GroupsKey string `json:"groups_key,omitempty"` + MaxValueLength int `json:"max_value_length,omitempty"` +} + +// buildIdentityInjectConfigJSON walks the enabled providers and emits +// one entry per provider whose catalog entry declares an +// IdentityInjection block. The middleware no-ops for any provider not +// in this list, so the chain is safe to ship to all targets even when +// no identity-stamping provider is configured. +// +// The caller passes groupIndex so we can mirror the synthesiser's own +// "drop orphans" rule — providers no enabled policy authorises don't +// reach the router, so injecting identity for them would never fire. +// We could leave them in for symmetry, but skipping is cheaper and +// clearer. +func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) { + cfg := identityInjectConfig{Providers: make([]identityInjectProvider, 0)} + for _, p := range providers { + if _, hasPolicy := groupIndex[p.ID]; !hasPolicy { + continue + } + entry, ok := catalog.Lookup(p.ProviderID) + if !ok { + continue + } + rule, ok := buildIdentityInjectRule(p, entry) + if !ok { + continue + } + cfg.Providers = append(cfg.Providers, rule) + } + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal llm_identity_inject middleware config: %w", err) + } + return out, nil +} + +// buildIdentityInjectRule assembles the injection rule for one provider +// from its record and catalog entry. The second return is false when the +// provider would emit nothing, so the caller can skip it entirely rather +// than carry an inert rule for it. +func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) { + rule := identityInjectProvider{ProviderID: p.ID} + // Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the + // shape silently when the catalog entry doesn't declare one — extras + // can still apply, see below. + if entry.IdentityInjection != nil { + switch { + case entry.IdentityInjection.HeaderPair != nil: + rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair) + case entry.IdentityInjection.JSONMetadata != nil: + rule.JSONMetadata = buildIdentityJSONMetadata(p, entry.IdentityInjection.JSONMetadata) + } + } + rule.ExtraHeaders = buildIdentityExtraHeaders(p, entry.ExtraHeaders) + if rule.HeaderPair == nil && rule.JSONMetadata == nil && len(rule.ExtraHeaders) == 0 { + return identityInjectProvider{}, false + } + return rule, true +} + +// buildIdentityHeaderPair resolves the header-pair injection shape, +// returning nil when nothing would be stamped. For Customizable shapes +// (Bifrost today) the wire header names come from the provider record +// verbatim; the catalog values are placeholder defaults shown by the +// dashboard, not authoritative. Empty operator value disables stamping +// for that dimension — applyHeaderPair already no-ops on empty header +// names. The body-inject flags stay catalog-owned because Customizable +// today only applies to gateways that read identity from headers (the +// flags would be no-ops anyway). +func buildIdentityHeaderPair(p *types.Provider, hp *catalog.HeaderPairInjection) *identityInjectHeaderPair { + userHeader := hp.EndUserIDHeader + tagsHeader := hp.TagsHeader + if hp.Customizable { + userHeader = p.IdentityHeaderUserID + tagsHeader = p.IdentityHeaderGroups + } + if userHeader == "" && tagsHeader == "" && !hp.TagsInBody && !hp.EndUserIDInBody { + return nil + } + return &identityInjectHeaderPair{ + EndUserIDHeader: userHeader, + TagsHeader: tagsHeader, + TagsInBody: hp.TagsInBody, + EndUserIDInBody: hp.EndUserIDInBody, + } +} + +// buildIdentityJSONMetadata resolves the JSON-metadata injection shape, +// returning nil when the catalog entry carries no header. Customizable +// JSONMetadata reuses the same provider-record fields HeaderPair uses — +// IdentityHeaderUserID becomes the JSON key for the user dimension, and +// IdentityHeaderGroups becomes the JSON key for groups. Empty operator +// value is honored as "skip this key"; applyJSONMetadata already drops +// keys with empty names. Header itself is catalog-owned (e.g. +// cf-aig-metadata) — operators only override the keys inside the JSON, +// not the wire header that carries it. +func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInjection) *identityInjectJSONMetadata { + if jm.Header == "" { + return nil + } + userKey := jm.UserKey + groupsKey := jm.GroupsKey + if jm.Customizable { + userKey = p.IdentityHeaderUserID + groupsKey = p.IdentityHeaderGroups + } + return &identityInjectJSONMetadata{ + Header: jm.Header, + UserKey: userKey, + GroupsKey: groupsKey, + MaxValueLength: jm.MaxValueLength, + } +} + +// buildIdentityExtraHeaders collects catalog-declared static headers (e.g. +// Portkey config id), emitting only entries whose value the operator has +// filled in on the provider record; missing/empty values are no-ops. +func buildIdentityExtraHeaders(p *types.Provider, extras []catalog.ExtraHeader) []identityInjectExtraHeader { + var out []identityInjectExtraHeader + for _, h := range extras { + if h.Name == "" { + continue + } + v := strings.TrimSpace(p.ExtraValues[h.Name]) + if v == "" { + continue + } + out = append(out, identityInjectExtraHeader{Name: h.Name, Value: v}) + } + return out +} + +// buildMiddlewareChain assembles the per-target middleware chain that +// implements the Agent Network behaviour at the proxy. Slot order on +// the request leg is the slice order; on the response leg it runs in +// reverse, so cost_meter must come BEFORE llm_response_parser so the +// parser populates token counts before the cost meter reads them. +// +// Authorisation is fused into llm_router: the router carries +// AllowedGroupIDs per provider and filters candidates by the caller's +// user-groups before the path-prefix tiebreak. Per-policy +// enforcement (token / budget caps) lives in llm_limit_check, which +// runs after the router so it can read the resolved provider id; +// llm_limit_record on the response leg posts deltas back to +// management to keep the consumption counters fresh. +// +// llm_identity_inject runs immediately after the router so the +// resolved provider id is available; it stamps NetBird identity onto +// requests bound for gateways like LiteLLM that key budgets and +// attribution off request headers. CanMutate is required so its +// HeadersAdd / HeadersRemove pass the framework's mutation gate. +func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig { + // Both parsers receive an explicit capture flag derived from the account's + // enable_prompt_collection toggle; nil/unset would default to the legacy + // "always emit" behavior in the middleware, which is precisely what we + // must suppress when the operator hasn't opted in. The flag is duplicated + // across both parsers under distinct field names (capture_prompt / + // capture_completion) to keep each parser's config independently + // auditable. + requestParserCfg := buildParserConfigJSON("capture_prompt", redactPii, capturePromptContent) + responseParserCfg := buildParserConfigJSON("capture_completion", redactPii, capturePromptContent) + return []rpservice.MiddlewareConfig{ + { + ID: middlewareIDLLMRequestParser, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: requestParserCfg, + }, + { + ID: middlewareIDLLMRouter, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: routerCfgJSON, + // llm_router rewrites the request's headers (strip + // client auth + inject provider auth) and the upstream + // target via Mutations.RewriteUpstream. Both gated on + // CanMutate; without this flag the chain framework + // drops every mutation and the reverse proxy dials the + // placeholder noop.invalid host (502). + CanMutate: true, + }, + { + // llm_limit_check runs after the router so it knows the + // resolved provider id, but before identity_inject so a + // cap-deny doesn't pay the cost of stamping headers + // we'll never use. + ID: middlewareIDLLMLimitCheck, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: []byte("{}"), + }, + { + ID: middlewareIDLLMIdentityInject, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: identityInjectJSON, + // CanMutate is required so HeadersAdd / HeadersRemove + // emitted to stamp NetBird identity onto the upstream + // request actually land — without it the framework + // drops every header mutation. + CanMutate: true, + }, + { + ID: middlewareIDLLMGuardrail, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: guardrailJSON, + }, + { + // Response slot runs in reverse slice order at runtime: + // limit_record sits FIRST in the response section so it + // runs LAST, after llm_response_parser stamped tokens + // and cost_meter computed cost — both of which the + // recorder reads from the metadata bag. + ID: middlewareIDLLMLimitRecord, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnResponse, + ConfigJSON: []byte("{}"), + }, + { + ID: middlewareIDCostMeter, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnResponse, + ConfigJSON: []byte("{}"), + }, + { + ID: middlewareIDLLMResponseParser, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnResponse, + ConfigJSON: responseParserCfg, + }, + } +} + +// guardrailConfig is the JSON shape the proxy-side llm_guardrail +// middleware expects. Mirrors the proxy registration documented in +// the management→proxy contract. +type guardrailConfig struct { + ModelAllowlist []string `json:"model_allowlist,omitempty"` + PromptCapture guardrailPromptCapture `json:"prompt_capture"` +} + +type guardrailPromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +// buildParserConfigJSON assembles the request- or response-parser config JSON. +// captureField names the parser-specific gate (capture_prompt for the request +// parser, capture_completion for the response parser); both are sourced from +// settings.EnablePromptCollection. redact_pii is only meaningful when capture +// is on (no content → nothing to redact) but we forward it verbatim so the +// proxy-side parser stays the only place that interprets the combination. +func buildParserConfigJSON(captureField string, redactPii, capture bool) []byte { + payload := map[string]any{ + captureField: capture, + } + if redactPii { + payload["redact_pii"] = true + } + out, err := json.Marshal(payload) + if err != nil { + // json.Marshal on a map[string]any of bools cannot fail; if it + // somehow does, ship the static minimal config so synth keeps + // working instead of panicking. + return []byte(`{}`) + } + return out +} + +// applyAccountCollectionControls folds the account-level collection master +// switches into the merged guardrail set. Prompt capture enablement is sourced +// SOLELY from the account toggle — the account-network setting is the master +// enable, and policies don't need to attach a capture-enabled guardrail to opt +// in. PII redaction is safe-additive: it applies when either the account or a +// policy guardrail enables it (OR). +func applyAccountCollectionControls(merged *MergedGuardrails, settings *types.Settings) { + if settings == nil { + return + } + merged.PromptCapture.Enabled = settings.EnablePromptCollection + merged.PromptCapture.RedactPii = settings.RedactPii || merged.PromptCapture.RedactPii +} + +func marshalGuardrailConfig(merged MergedGuardrails) ([]byte, error) { + cfg := guardrailConfig{ + ModelAllowlist: merged.ModelAllowlist, + PromptCapture: guardrailPromptCapture{ + Enabled: merged.PromptCapture.Enabled, + RedactPii: merged.PromptCapture.RedactPii, + }, + } + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal guardrail middleware config: %w", err) + } + return out, nil +} + +// buildAccountService composes the per-account gateway Service. The +// target carries the noop placeholder URL — the router middleware +// rewrites every request to the matched provider's upstream before the +// proxy dials — alongside the full middleware chain and capture caps. +func buildAccountService( + accountID string, + settings *types.Settings, + enabledPolicies []*types.Policy, + middlewares []rpservice.MiddlewareConfig, + sessionPriv, sessionPub string, +) *rpservice.Service { + cluster := settings.Cluster + domain := settings.Endpoint() + serviceID := SynthesizedServiceIDPrefix + accountID + + return &rpservice.Service{ + ID: serviceID, + AccountID: accountID, + Name: "agent-network-" + accountID, + Domain: domain, + ProxyCluster: cluster, + Mode: rpservice.ModeHTTP, + Enabled: true, + Private: true, + // AccessGroups gates tunnel-peer access (ValidateTunnelPeer) to the + // synthesised agent-network endpoint. Agents reach the gateway over + // the WireGuard tunnel and are authorised by their peer→user group + // membership — the union of every enabled policy's source groups. + AccessGroups: unionSourceGroups(enabledPolicies), + PassHostHeader: false, + RewriteRedirects: false, + SessionPrivateKey: sessionPriv, + SessionPublicKey: sessionPub, + Targets: []*rpservice.Target{ + { + AccountID: accountID, + ServiceID: serviceID, + TargetType: rpservice.TargetTypeCluster, + TargetId: cluster, + Host: noopUpstreamHost, + Port: noopUpstreamPort, + Protocol: noopUpstreamScheme, + Enabled: true, + Options: rpservice.TargetOptions{ + DirectUpstream: true, + AgentNetwork: true, + DisableAccessLog: !settings.EnableLogCollection, + Middlewares: middlewares, + CaptureMaxRequestBytes: agentNetworkRequestCaptureBytes, + CaptureMaxResponseBytes: agentNetworkResponseCaptureBytes, + CaptureContentTypes: append([]string(nil), agentNetworkCaptureContentTypes...), + }, + }, + }, + } +} + +const ( + noopUpstreamScheme = "https" + noopUpstreamHost = "noop.invalid" + noopUpstreamPort = uint16(443) +) + +// providerAuthHeader builds the upstream auth header pair for a +// provider from its catalog entry. The catalog declares which header +// name and template a provider's API expects; the synthesiser +// substitutes the provider's decrypted API key into the template and +// returns the (name, value) pair the router middleware injects after +// stripping the inbound vendor auth headers. +func providerAuthHeader(p *types.Provider) (name, value, gcpSAKeyB64 string, err error) { + entry, ok := catalog.Lookup(p.ProviderID) + if !ok { + return "", "", "", fmt.Errorf("provider %s references unknown catalog id %q", p.ID, p.ProviderID) + } + if entry.AuthHeaderName == "" || entry.AuthHeaderTemplate == "" { + return "", "", "", fmt.Errorf("catalog entry %q has no auth header configured", p.ProviderID) + } + if p.APIKey == "" { + return "", "", "", fmt.Errorf("provider %s has no api key", p.ID) + } + // A "keyfile::" api_key is a GCP service-account key, not a + // static bearer. The proxy mints + refreshes a short-lived OAuth token from + // it at request time, so carry the key material on the route and emit no + // static value. + if rest, isKeyfile := strings.CutPrefix(p.APIKey, gcpKeyfilePrefix); isKeyfile { + return entry.AuthHeaderName, "", strings.TrimSpace(rest), nil + } + value = strings.ReplaceAll(entry.AuthHeaderTemplate, apiKeyPlaceholder, p.APIKey) + return entry.AuthHeaderName, value, "", nil +} + +// parseUpstreamHost splits provider.UpstreamURL into (scheme, host, path) +// where host carries an explicit ":port" suffix when the URL set one +// and path is the URL's path component normalised by stripping a +// trailing slash. The router uses path to disambiguate providers that +// claim the same model. Used by the router config so the rewrite +// carries an authority the reverse proxy can dial verbatim. +func parseUpstreamHost(raw string) (scheme, host, path string, err error) { + parsed, perr := url.Parse(strings.TrimSpace(raw)) + if perr != nil { + return "", "", "", fmt.Errorf("parse upstream_url %q: %w", raw, perr) + } + switch strings.ToLower(parsed.Scheme) { + case "http": + scheme = "http" + case "https": + scheme = "https" + default: + return "", "", "", fmt.Errorf("upstream_url scheme must be http or https, got %q", parsed.Scheme) + } + hostname := parsed.Hostname() + if hostname == "" { + return "", "", "", fmt.Errorf("upstream_url %q has no host", raw) + } + if port := parsed.Port(); port != "" { + host = hostname + ":" + port + } else { + host = hostname + } + path = strings.TrimRight(parsed.Path, "/") + return scheme, host, path, nil +} + +// unionSourceGroups deduplicates source-group IDs across the policies +// pointing at any provider, in deterministic order. +func unionSourceGroups(policies []*types.Policy) []string { + seen := make(map[string]struct{}) + for _, policy := range policies { + for _, group := range policy.SourceGroups { + if group == "" { + continue + } + seen[group] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for group := range seen { + out = append(out, group) + } + sort.Strings(out) + return out +} + +// MergedGuardrails is the JSON shape passed to the proxy via the +// guardrail middleware's config_json. Mirrors the proxy-side +// expectations and is intentionally distinct from +// types.GuardrailChecks so we can evolve either side independently. +type MergedGuardrails struct { + ModelAllowlist []string `json:"model_allowlist,omitempty"` + TokenLimits MergedTokenLimits `json:"token_limits"` + Budget MergedBudget `json:"budget"` + PromptCapture MergedPromptCapture `json:"prompt_capture"` + Retention MergedRetention `json:"retention"` +} + +type MergedTokenLimits struct { + Hourly *MergedTokenWindow `json:"hourly,omitempty"` + Daily *MergedTokenWindow `json:"daily,omitempty"` + Monthly *MergedTokenWindow `json:"monthly,omitempty"` +} + +type MergedTokenWindow struct { + MaxInputTokens int `json:"max_input_tokens,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` +} + +type MergedBudget struct { + Hourly *MergedBudgetWindow `json:"hourly,omitempty"` + Daily *MergedBudgetWindow `json:"daily,omitempty"` + Monthly *MergedBudgetWindow `json:"monthly,omitempty"` +} + +type MergedBudgetWindow struct { + SoftCapUSD float64 `json:"soft_cap_usd,omitempty"` + HardCapUSD float64 `json:"hard_cap_usd,omitempty"` +} + +type MergedPromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +type MergedRetention struct { + Enabled bool `json:"enabled"` + Days int `json:"days"` +} + +// mergeGuardrails computes the effective guardrail spec applied at the +// proxy, given the referencing policies and the account's guardrail +// catalogue. Policy enabled-ness is the caller's responsibility — only +// enabled policies should be passed in. +// +// Merge rules: +// - Model allowlist: union of allowlists across policies that enable it. +// - Token / Budget: most-restrictive (min of non-zero caps) per window. +// - Prompt capture: enabled if any policy enables it; redact_pii sticks +// if any enabling policy turns it on. +// - Retention: enabled if any enables it; smallest non-zero days wins. +func mergeGuardrails(policies []*types.Policy, byID map[string]*types.Guardrail) MergedGuardrails { + merged := MergedGuardrails{} + allowlist := make(map[string]struct{}) + allowlistEnabled := false + + for _, policy := range policies { + for _, gID := range policy.GuardrailIDs { + g, ok := byID[gID] + if !ok || g == nil { + continue + } + mergeGuardrail(g, &merged, allowlist, &allowlistEnabled) + } + } + + if allowlistEnabled { + merged.ModelAllowlist = make([]string, 0, len(allowlist)) + for m := range allowlist { + merged.ModelAllowlist = append(merged.ModelAllowlist, m) + } + sort.Strings(merged.ModelAllowlist) + } + return merged +} + +// mergeGuardrail folds a single guardrail's enabled checks into the +// running merge: model-allowlist models join the shared set (and flip +// allowlistEnabled), and prompt-capture / redact-pii stick once any +// enabling guardrail turns them on. +// +// TokenLimits, Budget, and Retention have moved off guardrails — token +// and budget caps now live on the Policy itself (Policy.Limits) and +// retention moves to account-level Settings — so they are not merged here. +func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails, allowlist map[string]struct{}, allowlistEnabled *bool) { + if g.Checks.ModelAllowlist.Enabled { + *allowlistEnabled = true + for _, m := range g.Checks.ModelAllowlist.Models { + if m != "" { + allowlist[m] = struct{}{} + } + } + } + if g.Checks.PromptCapture.Enabled { + merged.PromptCapture.Enabled = true + if g.Checks.PromptCapture.RedactPii { + merged.PromptCapture.RedactPii = true + } + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go new file mode 100644 index 000000000..8ed4910da --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go @@ -0,0 +1,178 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// decodeServiceGuardrailConfig pulls the llm_guardrail middleware config off the +// synthesised service's single target. +func decodeServiceGuardrailConfig(t *testing.T, svc *rpservice.Service) guardrailConfig { + t.Helper() + require.NotEmpty(t, svc.Targets, "synth service must carry a target") + for _, mw := range svc.Targets[0].Options.Middlewares { + if mw.ID == middlewareIDLLMGuardrail { + var cfg guardrailConfig + require.NoError(t, json.Unmarshal(mw.ConfigJSON, &cfg), "guardrail config must decode") + return cfg + } + } + t.Fatal("llm_guardrail middleware not present on synthesised service") + return guardrailConfig{} +} + +// decodeMiddlewareRawConfig returns the raw ConfigJSON bytes for the named +// middleware on the synth service's target, or fails the test. +func decodeMiddlewareRawConfig(t *testing.T, svc *rpservice.Service, id string) []byte { + t.Helper() + require.NotEmpty(t, svc.Targets, "synth service must carry a target") + for _, mw := range svc.Targets[0].Options.Middlewares { + if mw.ID == id { + return mw.ConfigJSON + } + } + t.Fatalf("middleware %q not present on synthesised service", id) + return nil +} + +// saveGuardrailAndPolicy persists a guardrail with prompt capture + redact + a +// model allowlist, referenced by one enabled policy. Shared by the GC-3 tests. +func saveGuardrailAndPolicy(t *testing.T, ctx context.Context, s store.Store, provider *types.Provider) { + t.Helper() + guardrail := &types.Guardrail{ + ID: "ainguard-1", + AccountID: testAccountID, + Name: "strict", + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4"}}, + PromptCapture: types.GuardrailPromptCapture{Enabled: true, RedactPii: true}, + }, + } + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, guardrail)) + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", guardrail.ID))) +} + +// TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl is the +// GC-3 contract: the account master switch (EnablePromptCollection) is the +// SOLE control for capture enablement. Policy-level guardrail prompt_capture is +// ignored for enablement — operators don't need to attach a capture guardrail +// to a policy just to turn capture on for the account. Off by default. +func TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + // Account collection master switch OFF (default). + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + saveGuardrailAndPolicy(t, ctx, s, newSynthTestProvider()) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.Equal(t, []string{"gpt-5.4"}, cfg.ModelAllowlist, + "model allowlist is a pure policy guardrail and must always reach the config") + assert.False(t, cfg.PromptCapture.Enabled, + "prompt capture must be off when the account toggle is off, even with a capture-enabled guardrail") +} + +// TestSynthesizeServices_RealStore_PromptCaptureFlowsWhenAccountOptsIn proves +// the account toggle is sufficient on its own — even with NO guardrail +// attached to the policy, capture fires when the account opts in. Redact is +// the OR of account + guardrail. +func TestSynthesizeServices_RealStore_PromptCaptureFlowsWhenAccountOptsIn(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnablePromptCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + // Save a provider and a policy with NO guardrails attached — proves the + // account toggle is sufficient on its own. + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.True(t, cfg.PromptCapture.Enabled, + "account toggle alone must enable capture; no guardrail attachment required") +} + +// TestSynthesizeServices_RealStore_AccountRedactWithoutGuardrailRedact proves +// the redact OR-merge from the account side: account RedactPii on, guardrail +// redact off, capture on at both levels. +func TestSynthesizeServices_RealStore_AccountRedactWithoutGuardrailRedact(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnablePromptCollection = true + settings.RedactPii = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + guardrail := &types.Guardrail{ + ID: "ainguard-noredact", + AccountID: testAccountID, + Name: "capture-only", + Checks: types.GuardrailChecks{ + PromptCapture: types.GuardrailPromptCapture{Enabled: true, RedactPii: false}, + }, + } + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, guardrail)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", guardrail.ID))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.True(t, cfg.PromptCapture.Enabled, "capture on (account + guardrail)") + assert.True(t, cfg.PromptCapture.RedactPii, "account RedactPii must apply even when the guardrail leaves it off (OR)") +} + +// TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff pins the default: +// with no guardrail referenced, the synth service's guardrail config has prompt +// capture disabled and an empty allowlist. This is the "off by default" baseline +// the account switch must preserve. +func TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.Empty(t, cfg.ModelAllowlist, "no guardrail → no allowlist") + assert.False(t, cfg.PromptCapture.Enabled, "no guardrail → prompt capture off by default") + assert.False(t, cfg.PromptCapture.RedactPii, "no guardrail → redact off by default") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go new file mode 100644 index 000000000..9aa2a0abe --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go @@ -0,0 +1,70 @@ +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog drives the +// happy default: account settings ship with EnableLogCollection=false, so the +// synthesised target opts out of access-log emission (DisableAccessLog=true) and +// the proto mapping the proxy receives reflects that. +func TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + require.NotEmpty(t, services[0].Targets, "synth service must carry a target") + assert.True(t, services[0].Targets[0].Options.DisableAccessLog, + "EnableLogCollection=false (default) must produce DisableAccessLog=true on the synth target") + + mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}) + require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path") + assert.True(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(), + "proto mapping must propagate DisableAccessLog=true so the proxy suppresses access-log emission") +} + +// TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog asserts the +// inverse: once the account opts in, the synth target leaves DisableAccessLog +// at its default false and the proto wire stays unset. +func TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnableLogCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + require.NotEmpty(t, services[0].Targets, "synth service must carry a target") + assert.False(t, services[0].Targets[0].Options.DisableAccessLog, + "EnableLogCollection=true must leave DisableAccessLog=false on the synth target") + + mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}) + require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path") + assert.False(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(), + "proto mapping must propagate DisableAccessLog=false so access-log emission stays on") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go new file mode 100644 index 000000000..5f42c3b39 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go @@ -0,0 +1,145 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// parserRedactConfig mirrors the on-wire shape of the redact + capture knobs +// that both llm_request_parser and llm_response_parser unmarshal. We don't +// import the proxy-side packages from a management test (cross-module), so we +// decode the JSON directly and assert on the fields that are part of the +// synth contract. +type parserRedactConfig struct { + RedactPii bool `json:"redact_pii,omitempty"` + CapturePrompt *bool `json:"capture_prompt,omitempty"` // present only on the request parser + CaptureCompletion *bool `json:"capture_completion,omitempty"` // present only on the response parser +} + +// TestSynthesizeServices_RealStore_ParserConfigsCarryRedactPii is the +// management-side contract test for the request/response parser redaction +// wiring. When settings.RedactPii is true, the synthesised middleware chain +// MUST stamp redact_pii=true on both llm_request_parser and llm_response_parser +// configs — otherwise the parsers ship raw prompts / completions to the +// access log even though the account has opted in. This is exactly the live +// leak path that motivated the parser-side redaction in the first place. +func TestSynthesizeServices_RealStore_ParserConfigsCarryRedactPii(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.RedactPii = true + settings.EnablePromptCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + + for _, parserID := range []string{middlewareIDLLMRequestParser, middlewareIDLLMResponseParser} { + raw := decodeMiddlewareRawConfig(t, services[0], parserID) + var cfg parserRedactConfig + require.NoError(t, json.Unmarshal(raw, &cfg), "%s config must be valid JSON", parserID) + assert.True(t, cfg.RedactPii, "%s config must carry redact_pii=true when settings.RedactPii is on (otherwise the parser ships raw prompts/completions to the access log)", parserID) + } + // The capture flag is set explicitly to enable_prompt_collection on each + // parser. With it on here, both must allow emission. + reqCfg := decodeParserConfig(t, services[0], middlewareIDLLMRequestParser) + require.NotNil(t, reqCfg.CapturePrompt, "request parser must carry an explicit capture_prompt") + assert.True(t, *reqCfg.CapturePrompt, "capture_prompt=true when EnablePromptCollection=true") + respCfg := decodeParserConfig(t, services[0], middlewareIDLLMResponseParser) + require.NotNil(t, respCfg.CaptureCompletion, "response parser must carry an explicit capture_completion") + assert.True(t, *respCfg.CaptureCompletion, "capture_completion=true when EnablePromptCollection=true") +} + +// decodeParserConfig is a small helper around decodeMiddlewareRawConfig that +// also unmarshals into parserRedactConfig. +func decodeParserConfig(t *testing.T, svc *rpservice.Service, parserID string) parserRedactConfig { + t.Helper() + raw := decodeMiddlewareRawConfig(t, svc, parserID) + var cfg parserRedactConfig + require.NoError(t, json.Unmarshal(raw, &cfg), "%s config must be valid JSON", parserID) + return cfg +} + +// TestSynthesizeServices_RealStore_ParserConfigsSuppressCaptureWhenLogCollectionOnly +// is the contract test for the bug: enable_log_collection=true with +// enable_prompt_collection=false MUST result in capture_prompt=false on the +// request parser AND capture_completion=false on the response parser, so the +// access-log row stays metadata-only (provider, model, tokens, cost) and +// carries NO prompt input nor response output. Without this, operators who +// want billing-style logs end up with raw user prompts and model outputs in +// every access-log entry. +func TestSynthesizeServices_RealStore_ParserConfigsSuppressCaptureWhenLogCollectionOnly(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnableLogCollection = true // operator wants logs ON + settings.EnablePromptCollection = false // but NOT content capture + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + reqCfg := decodeParserConfig(t, services[0], middlewareIDLLMRequestParser) + require.NotNil(t, reqCfg.CapturePrompt, "request parser must carry an explicit capture_prompt gate") + assert.False(t, *reqCfg.CapturePrompt, "capture_prompt MUST be false when EnablePromptCollection is off — otherwise llm.request_prompt_raw leaks user input into the access log") + + respCfg := decodeParserConfig(t, services[0], middlewareIDLLMResponseParser) + require.NotNil(t, respCfg.CaptureCompletion, "response parser must carry an explicit capture_completion gate") + assert.False(t, *respCfg.CaptureCompletion, "capture_completion MUST be false when EnablePromptCollection is off — otherwise llm.response_completion leaks model output into the access log") +} + +// TestSynthesizeServices_RealStore_ParserConfigsOmitRedactPiiWhenOff proves +// the inverse: with the account toggle off, the parser configs stay clean (no +// redact_pii field, which the parsers treat as zero / no redaction). This is +// the operator-opt-out path — the access log keeps raw prompts/completions +// for debugging until the operator opts in. +func TestSynthesizeServices_RealStore_ParserConfigsOmitRedactPiiWhenOff(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + // Default settings: RedactPii = false. + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + for _, parserID := range []string{middlewareIDLLMRequestParser, middlewareIDLLMResponseParser} { + raw := decodeMiddlewareRawConfig(t, services[0], parserID) + // Inspect the decoded JSON directly: a struct decode would also pass + // if redact_pii were present-but-false. The contract is that the key + // is omitted entirely while the account toggle is off. + var rawCfg map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &rawCfg), "%s config must be valid JSON", parserID) + assert.NotContains(t, rawCfg, "redact_pii", + "%s config must omit redact_pii entirely while the account toggle is off", parserID) + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_realstore_test.go new file mode 100644 index 000000000..1e07c0e81 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_realstore_test.go @@ -0,0 +1,174 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// decodeServiceRouterConfig finds the llm_router middleware on the synthesised +// service's single target and decodes its config — the model→provider routing +// table the proxy authorises against. +func decodeServiceRouterConfig(t *testing.T, svc *rpservice.Service) routerConfig { + t.Helper() + require.NotEmpty(t, svc.Targets, "synth service must carry a target") + for _, mw := range svc.Targets[0].Options.Middlewares { + if mw.ID == middlewareIDLLMRouter { + var cfg routerConfig + require.NoError(t, json.Unmarshal(mw.ConfigJSON, &cfg), "router config must decode") + return cfg + } + } + t.Fatal("llm_router middleware not present on synthesised service") + return routerConfig{} +} + +// decodeMappingRouterConfig is the proto-wire equivalent: it pulls the +// llm_router config off the ProxyMapping the proxy actually receives. +func decodeMappingRouterConfig(t *testing.T, m *proto.ProxyMapping) routerConfig { + t.Helper() + require.NotEmpty(t, m.GetPath(), "mapping must carry a path") + for _, mw := range m.GetPath()[0].GetOptions().GetMiddlewares() { + if mw.GetId() == middlewareIDLLMRouter { + var cfg routerConfig + require.NoError(t, json.Unmarshal(mw.GetConfigJson(), &cfg), "wire router config must decode") + return cfg + } + } + t.Fatal("llm_router middleware not present on proxy mapping") + return routerConfig{} +} + +// TestSynthesizeServices_RealStore_SurvivesStatusToggle drives synthesis through +// a REAL sqlite store (Save → gorm/JSON serialize → reload → decrypt) instead of +// a MockStore, so it exercises the field round-trip that a provider/policy edit +// actually hits. Mock-based tests can't catch a field that dies in persistence; +// this one can. It then performs the exact operation that reproduced the live +// 403 — disable then re-enable the provider — and asserts the re-enabled state +// is fully routable again. +func TestSynthesizeServices_RealStore_SurvivesStatusToggle(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + assertRoutable := func(t *testing.T, stage string) { + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err, stage) + require.Len(t, services, 1, "%s: exactly one synth service expected", stage) + svc := services[0] + + assert.True(t, svc.Private, "%s: synth service must be Private after store round-trip", stage) + assert.Equal(t, []string{"grp-eng"}, svc.AccessGroups, "%s: AccessGroups must survive the round-trip", stage) + + m := svc.ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}) + assert.True(t, m.GetPrivate(), "%s: proto mapping Private must be true (proxy gates tunnel-peer auth on it)", stage) + + cfg := decodeServiceRouterConfig(t, svc) + require.Len(t, cfg.Providers, 1, "%s: the enabled+linked provider must appear in the router config", stage) + assert.Equal(t, []string{"gpt-5.4"}, cfg.Providers[0].Models, "%s: provider models must reach the route", stage) + assert.Equal(t, []string{"grp-eng"}, cfg.Providers[0].AllowedGroupIDs, "%s: policy source groups must reach the route", stage) + } + + assertRoutable(t, "initial") + + provider.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + disabled, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err, "synthesis must not error with a disabled provider") + for _, svc := range disabled { + assert.Empty(t, decodeServiceRouterConfig(t, svc).Providers, + "a disabled provider must not appear in the router config (otherwise it would route while off)") + } + + provider.Enabled = true + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + assertRoutable(t, "after disable->enable") +} + +// captureController is a proxy.Controller that records the mappings reconcile +// pushes, so the test can inspect the exact wire payload — Private flag and +// router config included. +type captureController struct { + rpproxy.Controller + pushed []*proto.ProxyMapping +} + +func (c *captureController) GetOIDCValidationConfig() rpproxy.OIDCValidationConfig { + return rpproxy.OIDCValidationConfig{} +} + +func (c *captureController) SendServiceUpdateToCluster(_ context.Context, _ string, update *proto.ProxyMapping, _ string) { + c.pushed = append(c.pushed, update) +} + +// noopAccountManager satisfies the reconcile path's accountManager dependency. +type noopAccountManager struct { + account.Manager +} + +func (noopAccountManager) UpdateAccountPeers(context.Context, string, nbtypes.UpdateReason) {} + +// TestReconcile_RealStore_PushesPrivateAfterStatusToggle reproduces the live +// path end-to-end below the gRPC boundary: a real store + the real +// managerImpl.reconcile + a capturing proxy controller. It runs the operation +// that broke in production — provider disable then re-enable — and asserts the +// mapping reconcile pushes to the cluster after re-enable is Private=true and +// carries the routable provider. If reconcile ever pushes private=false (the +// symptom that left UserGroups empty → no_authorised_provider), this fails. +func TestReconcile_RealStore_PushesPrivateAfterStatusToggle(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + ctrl := &captureController{} + m := &managerImpl{ + store: s, + accountManager: noopAccountManager{}, + proxyController: ctrl, + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + } + + m.reconcile(ctx, testAccountID) // initial, provider enabled + + provider.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + m.reconcile(ctx, testAccountID) // disabled + + provider.Enabled = true + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + m.reconcile(ctx, testAccountID) // re-enabled — the reproduction step + + require.NotEmpty(t, ctrl.pushed, "reconcile must push at least one mapping") + last := ctrl.pushed[len(ctrl.pushed)-1] + + assert.Equal(t, newSynthTestSettings().Endpoint(), last.GetDomain(), "synth domain on the wire") + assert.True(t, last.GetPrivate(), + "reconcile-pushed mapping after re-enable MUST be Private=true; a false here is the exact bug — the proxy skips ValidateTunnelPeer, UserGroups stays empty, and llm_router denies no_authorised_provider") + + cfg := decodeMappingRouterConfig(t, last) + require.Len(t, cfg.Providers, 1, "re-enabled provider must be back in the pushed router config") + assert.Equal(t, []string{"gpt-5.4"}, cfg.Providers[0].Models, "model must be routable again after re-enable") + assert.Equal(t, []string{"grp-eng"}, cfg.Providers[0].AllowedGroupIDs, "authorised groups must be present after re-enable") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go new file mode 100644 index 000000000..0b07f27b3 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -0,0 +1,1098 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testAccountID = "acct-1" + testCluster = "eu.proxy.netbird.io" + testSubdomain = "violet" + testEndpoint = "violet.eu.proxy.netbird.io" +) + +func newSynthTestSettings() *types.Settings { + return &types.Settings{ + AccountID: testAccountID, + Cluster: testCluster, + Subdomain: testSubdomain, + } +} + +func newSynthTestProvider() *types.Provider { + return &types.Provider{ + ID: "prov-1", + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "OpenAI", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015}}, + SessionPrivateKey: "test-priv-key", + SessionPublicKey: "test-pub-key", + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func newSynthTestPolicy(providerID, sourceGroupID, guardrailID string) *types.Policy { + policy := &types.Policy{ + ID: "pol-1", + AccountID: testAccountID, + Name: "engineers", + Enabled: true, + SourceGroups: []string{sourceGroupID}, + DestinationProviderIDs: []string{providerID}, + } + if guardrailID != "" { + policy.GuardrailIDs = []string{guardrailID} + } + return policy +} + +// expectSynthBaseInputs wires the four reads the new synthesiser issues +// in the happy path: settings, providers, policies, guardrails. +func expectSynthBaseInputs(mockStore *store.MockStore, ctx context.Context, settings *types.Settings, providers []*types.Provider, policies []*types.Policy, guardrails []*types.Guardrail) { + if settings == nil { + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, testAccountID). + Return(nil, status.Errorf(status.NotFound, "agent network settings not found")) + return + } + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, testAccountID). + Return(settings, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, testAccountID). + Return(providers, nil) + if hasEnabled(providers) { + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, testAccountID). + Return(policies, nil) + if hasEnabledPolicy(policies) { + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, testAccountID). + Return(guardrails, nil) + } + } +} + +func hasEnabled(providers []*types.Provider) bool { + for _, p := range providers { + if p != nil && p.Enabled { + return true + } + } + return false +} + +func hasEnabledPolicy(policies []*types.Policy) bool { + for _, p := range policies { + if p != nil && p.Enabled { + return true + } + } + return false +} + +func TestSynthesizeServices_HappyPath(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + openai := newSynthTestProvider() + anthropic := &types.Provider{ + ID: "prov-2", + AccountID: testAccountID, + ProviderID: "anthropic_api", + Name: "Anthropic", + UpstreamURL: "https://api.anthropic.com", + APIKey: "sk-ant-secret", + Enabled: true, + Models: []types.ProviderModel{{ID: "claude-opus-4-7"}}, + SessionPrivateKey: "ant-priv", + SessionPublicKey: "ant-pub", + CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + policyEng := newSynthTestPolicy(openai.ID, "grp-eng", "") + policyEng.ID = "pol-eng" + policyOps := newSynthTestPolicy(anthropic.ID, "grp-ops", "") + policyOps.ID = "pol-ops" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{openai, anthropic}, + []*types.Policy{policyEng, policyOps}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err, "synthesis must succeed") + require.Len(t, services, 1, "exactly one service per account") + + svc := services[0] + assert.Equal(t, "agent-net-svc-acct-1", svc.ID, "service id is account-scoped") + assert.Equal(t, testAccountID, svc.AccountID, "service inherits account ID") + assert.Equal(t, testEndpoint, svc.Domain, "domain is settings.Endpoint() (subdomain.cluster)") + assert.Equal(t, testCluster, svc.ProxyCluster, "proxy cluster comes from settings") + assert.Equal(t, rpservice.ModeHTTP, svc.Mode, "synthesised services are HTTP mode") + assert.True(t, svc.Private, "synthesised services are always private") + assert.True(t, svc.Enabled, "synthesised services are enabled when emitted") + assert.Equal(t, []string{"grp-eng", "grp-ops"}, svc.AccessGroups, + "access groups union both policies' source groups (tunnel-peer auth)") + + require.Len(t, svc.Targets, 1, "single cluster target") + target := svc.Targets[0] + assert.Equal(t, rpservice.TargetTypeCluster, target.TargetType, "target type is cluster") + assert.Equal(t, testCluster, target.TargetId, "target id is the cluster address") + assert.Equal(t, "noop.invalid", target.Host, "host is the placeholder; router rewrites at request time") + assert.Equal(t, uint16(443), target.Port, "placeholder port") + assert.Equal(t, "https", target.Protocol, "placeholder scheme") + assert.True(t, target.Options.DirectUpstream, "synth targets imply direct upstream") + assert.True(t, target.Options.AgentNetwork, "synth targets must be flagged as agent_network") + + mws := target.Options.Middlewares + require.Len(t, mws, 8, "eight middlewares: request_parser, router, limit_check, identity_inject, guardrail, limit_record, cost_meter, response_parser") + assert.Equal(t, middlewareIDLLMRequestParser, mws[0].ID, "first middleware is the request parser") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[0].Slot, "request parser runs on_request") + // Request parser carries the capture_prompt gate sourced from + // settings.EnablePromptCollection. The synth-test settings default + // EnablePromptCollection=false, so capture is off and the access-log row + // will not carry prompt content. + assert.JSONEq(t, `{"capture_prompt":false}`, string(mws[0].ConfigJSON), "request parser config must carry capture_prompt from synth") + + assert.Equal(t, middlewareIDLLMRouter, mws[1].ID, "second middleware is the router") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[1].Slot, "router runs on_request") + assert.True(t, mws[1].CanMutate, "router must carry CanMutate=true; without it the framework drops the auth-header strip/inject AND the upstream rewrite, leaving the proxy to dial the placeholder noop.invalid") + require.NotEmpty(t, mws[1].ConfigJSON, "router config JSON must be populated") + + var routerCfg routerConfig + require.NoError(t, json.Unmarshal(mws[1].ConfigJSON, &routerCfg), "router config must unmarshal") + require.Len(t, routerCfg.Providers, 2, "both providers must reach the router") + assert.Equal(t, openai.ID, routerCfg.Providers[0].ID, "openai is first by created_at") + assert.Equal(t, "Bearer sk-test-key", routerCfg.Providers[0].AuthHeaderValue, "openai auth header value substitutes the API key") + assert.Equal(t, "Authorization", routerCfg.Providers[0].AuthHeaderName, "openai uses Authorization header") + assert.Equal(t, "https", routerCfg.Providers[0].UpstreamScheme, "openai scheme") + assert.Equal(t, "api.openai.com", routerCfg.Providers[0].UpstreamHost, "openai host") + assert.Equal(t, []string{"grp-eng"}, routerCfg.Providers[0].AllowedGroupIDs, "openai inherits policyEng's source groups") + assert.Equal(t, []string{"gpt-5.4"}, routerCfg.Providers[0].Models, + "the provider's configured model IDs must reach the router route — otherwise the model never matches and llm_router denies model_not_routable") + assert.Equal(t, anthropic.ID, routerCfg.Providers[1].ID, "anthropic follows openai by created_at") + assert.Equal(t, "sk-ant-secret", routerCfg.Providers[1].AuthHeaderValue, "anthropic value is the raw API key") + assert.Equal(t, "x-api-key", routerCfg.Providers[1].AuthHeaderName, "anthropic uses x-api-key header") + assert.Equal(t, []string{"grp-ops"}, routerCfg.Providers[1].AllowedGroupIDs, "anthropic inherits policyOps' source groups") + assert.Equal(t, []string{"claude-opus-4-7"}, routerCfg.Providers[1].Models, "anthropic's configured model ID must reach its route") + + assert.Equal(t, middlewareIDLLMLimitCheck, mws[2].ID, + "limit_check sits between router and identity_inject so deny paths skip header-stamp work") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[2].Slot, "limit_check runs on_request") + + assert.Equal(t, middlewareIDLLMIdentityInject, mws[3].ID, "fourth middleware is identity inject") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[3].Slot, "identity inject runs on_request") + assert.True(t, mws[3].CanMutate, "identity inject must carry CanMutate=true so its HeadersAdd / HeadersRemove pass the framework's mutation gate") + require.NotEmpty(t, mws[3].ConfigJSON, "identity inject config JSON must be populated even when no provider needs injection") + + assert.Equal(t, middlewareIDLLMGuardrail, mws[4].ID, "fifth middleware is the guardrail") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[4].Slot, "guardrail runs on_request") + require.NotEmpty(t, mws[4].ConfigJSON, "guardrail config JSON must be populated") + + assert.Equal(t, middlewareIDLLMLimitRecord, mws[5].ID, + "limit_record sits FIRST in the response section so it RUNS LAST at runtime — needs cost_meter + response_parser to have stamped tokens / cost first") + assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[5].Slot, "limit_record runs on_response") + + assert.Equal(t, middlewareIDCostMeter, mws[6].ID, "seventh middleware is the cost meter") + assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[6].Slot, "cost meter runs on_response") + assert.Equal(t, []byte("{}"), mws[6].ConfigJSON, "cost meter carries an explicit empty config") + + assert.Equal(t, middlewareIDLLMResponseParser, mws[7].ID, "eighth middleware is the response parser") + assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[7].Slot, "response parser runs on_response") +} + +func TestSynthesizeServices_NoSettings_ReturnsNil(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + expectSynthBaseInputs(mockStore, ctx, nil, nil, nil, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "missing settings row must yield no synth") +} + +func TestSynthesizeServices_NoProviders_ReturnsNil(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), []*types.Provider{}, nil, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "settings present but no providers must yield no synth") +} + +func TestSynthesizeServices_DisabledProvider_NoService(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.Enabled = false + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, nil, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "disabled provider must not synthesise a service") +} + +func TestSynthesizeServices_DisabledPolicy_NoService(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + policy.Enabled = false + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, []*types.Policy{policy}, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "disabled policy must not trigger synthesis") +} + +func TestSynthesizeServices_RouterConfigOrdering(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + first := newSynthTestProvider() + first.ID = "prov-first" + first.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + second := newSynthTestProvider() + second.ID = "prov-second" + second.ProviderID = "anthropic_api" + second.UpstreamURL = "https://api.anthropic.com" + second.APIKey = "sk-ant" + second.CreatedAt = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + third := newSynthTestProvider() + third.ID = "prov-third" + third.ProviderID = "mistral_api" + third.UpstreamURL = "https://api.mistral.ai" + third.APIKey = "sk-mistral" + third.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(first.ID, "grp-eng", "") + policy.DestinationProviderIDs = []string{first.ID, second.ID, third.ID} + + // Pass providers in shuffled order to confirm the synth sorts them. + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{second, first, third}, + []*types.Policy{policy}, []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 3, "all three providers must be in the router config") + assert.Equal(t, first.ID, routerCfg.Providers[0].ID, "providers ordered by created_at; first is earliest") + assert.Equal(t, third.ID, routerCfg.Providers[1].ID, "second is mid") + assert.Equal(t, second.ID, routerCfg.Providers[2].ID, "third is latest") +} + +func TestSynthesizeServices_PolicyCheckConfig_UnionsSourceGroups(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + + policyA := newSynthTestPolicy(provider.ID, "grp-eng", "") + policyA.ID = "pol-a" + policyA.SourceGroups = []string{"grp-eng", "grp-shared"} + policyB := newSynthTestPolicy(provider.ID, "grp-ops", "") + policyB.ID = "pol-b" + policyB.SourceGroups = []string{"grp-ops", "grp-shared"} + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policyA, policyB}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1, "single provider authorised by both policies") + assert.Equal(t, provider.ID, routerCfg.Providers[0].ID) + assert.Equal(t, []string{"grp-eng", "grp-ops", "grp-shared"}, routerCfg.Providers[0].AllowedGroupIDs, + "source groups must be unioned and sorted; the duplicate grp-shared collapses") +} + +func TestSynthesizeServices_OrphanProvider_HasEmptyAllowedGroups(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + authorised := newSynthTestProvider() + authorised.ID = "prov-authed" + + orphan := newSynthTestProvider() + orphan.ID = "prov-orphan" + orphan.ProviderID = "anthropic_api" + orphan.UpstreamURL = "https://api.anthropic.com" + orphan.APIKey = "sk-ant" + orphan.CreatedAt = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + + // Policy authorises the first provider only. + policy := newSynthTestPolicy(authorised.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{authorised, orphan}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + + // Orphan providers are dropped from the router config entirely. + // The router treats an empty AllowedGroupIDs as a catch-all (right + // default for non-agent-network targets, wrong default here), so + // we don't ship them at all. Peers attempting to call models only + // the orphan claims see model_not_routable; peers calling models + // shared with the authorised provider get routed there. + require.Len(t, routerCfg.Providers, 1, "only the authorised provider reaches the router") + assert.Equal(t, authorised.ID, routerCfg.Providers[0].ID, + "authorised provider must be in router config") + assert.Equal(t, []string{"grp-eng"}, routerCfg.Providers[0].AllowedGroupIDs, + "authorised provider inherits the policy's source groups") +} + +// TestSynthesizeServices_IdentityInject_LiteLLM pins that a LiteLLM +// provider lands in the identity-inject middleware's config with the +// catalog-defined LiteLLM headers, while a non-LiteLLM provider does +// not. Together they prove the middleware is a no-op for accounts that +// don't use LiteLLM and stamps identity for those that do. +func TestSynthesizeServices_IdentityInject_LiteLLM(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + openai := newSynthTestProvider() + openai.ID = "prov-openai" + + litellm := newSynthTestProvider() + litellm.ID = "prov-litellm" + litellm.ProviderID = "litellm_proxy" + litellm.UpstreamURL = "https://litellm.acme.example.com" + litellm.APIKey = "sk-llm-master" + litellm.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policyOpenAI := newSynthTestPolicy(openai.ID, "grp-eng", "") + policyOpenAI.ID = "pol-openai" + policyLiteLLM := newSynthTestPolicy(litellm.ID, "grp-eng", "") + policyLiteLLM.ID = "pol-litellm" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{openai, litellm}, + []*types.Policy{policyOpenAI, policyLiteLLM}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1, + "only providers whose catalog entry declares IdentityInjection should appear in the inject config") + entry := injectCfg.Providers[0] + assert.Equal(t, litellm.ID, entry.ProviderID, + "the LiteLLM provider must be the one identity-stamped, not the OpenAI direct provider") + require.NotNil(t, entry.HeaderPair, "LiteLLM uses the HeaderPair shape") + assert.Nil(t, entry.JSONMetadata, "shapes are mutually exclusive — JSONMetadata must be nil for HeaderPair providers") + assert.Equal(t, "x-litellm-end-user-id", entry.HeaderPair.EndUserIDHeader, + "end-user-id header must come from the catalog entry's IdentityInjection block") + assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader) +} + +// TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides +// covers the customizable HeaderPair contract. The Bifrost catalog +// entry sets HeaderPair.Customizable=true with x-bf-dim-* defaults +// (placeholders surfaced by the dashboard, NOT authoritative at +// synth time). The wire header names that actually land on the +// inject middleware config come from the provider record's +// IdentityHeaderUserID / IdentityHeaderGroups fields verbatim. This +// lets operators pick between Bifrost's two attribution paths +// (always-on x-bf-lh-* logs metadata vs. label-declared x-bf-dim-* +// telemetry) per provider record without code changes. +// +// Three sub-cases under one fixture: full override, partial +// override (user kept, groups disabled), and ParserID empty so the +// proxy falls back to URL sniffing. +func TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + bifrost := newSynthTestProvider() + bifrost.ID = "prov-bifrost" + bifrost.ProviderID = "bifrost" + bifrost.UpstreamURL = "https://bifrost.acme.example.com/openai/v1" + bifrost.APIKey = "sk-bf-key" + bifrost.IdentityHeaderUserID = "x-bf-lh-netbird_user_id" + bifrost.IdentityHeaderGroups = "x-bf-lh-netbird_groups" + bifrost.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(bifrost.ID, "grp-eng", "") + policy.ID = "pol-bifrost" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{bifrost}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1, + "single bifrost catalog entry → one inject config target — operator's URL path picks the parser, not the catalog id") + + entry := injectCfg.Providers[0] + assert.Equal(t, bifrost.ID, entry.ProviderID) + require.NotNil(t, entry.HeaderPair, "Bifrost uses HeaderPair shape") + assert.Equal(t, "x-bf-lh-netbird_user_id", entry.HeaderPair.EndUserIDHeader, + "operator-set IdentityHeaderUserID overrides the catalog's x-bf-dim- placeholder — proves the Customizable flag actually swaps the source of truth") + assert.Equal(t, "x-bf-lh-netbird_groups", entry.HeaderPair.TagsHeader, + "operator-set IdentityHeaderGroups overrides the catalog's x-bf-dim- placeholder") + assert.False(t, entry.HeaderPair.TagsInBody, + "body-inject flags stay catalog-owned — Bifrost reads identity from headers, body inject would be a no-op") + assert.False(t, entry.HeaderPair.EndUserIDInBody) +} + +// TestSynthesizeServices_IdentityInject_Bifrost_PartialDisable proves +// that clearing one of the IdentityHeader* fields disables stamping +// for THAT dimension only, leaving the other dimension active. +// Critical because the customizable contract says "empty = disabled +// for that dimension"; if the synth path silently fell back to the +// catalog default for an empty operator value, operators couldn't +// turn off groups while keeping user id (or vice versa). +func TestSynthesizeServices_IdentityInject_Bifrost_PartialDisable(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + bifrost := newSynthTestProvider() + bifrost.ID = "prov-bifrost" + bifrost.ProviderID = "bifrost" + bifrost.UpstreamURL = "https://bifrost.acme.example.com/openai/v1" + bifrost.APIKey = "sk-bf-key" + bifrost.IdentityHeaderUserID = "x-bf-lh-netbird_user_id" + bifrost.IdentityHeaderGroups = "" // operator explicitly disabled groups + bifrost.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(bifrost.ID, "grp-eng", "") + policy.ID = "pol-bifrost" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{bifrost}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair, "user-id header is still set so the rule fires") + assert.Equal(t, "x-bf-lh-netbird_user_id", entry.HeaderPair.EndUserIDHeader) + assert.Empty(t, entry.HeaderPair.TagsHeader, + "groups header must be empty — operator cleared it; the inject middleware no-ops on empty header names so groups are NOT stamped") +} + +// TestSynthesizeServices_IdentityInject_Cloudflare_OperatorOverrides +// covers the JSONMetadata customizable contract: Cloudflare's +// catalog entry sets JSONMetadata.Customizable=true with +// netbird_user_id / netbird_groups defaults that the dashboard +// surfaces as placeholders. The actual JSON keys that land inside +// the cf-aig-metadata header come from the provider record's +// IdentityHeaderUserID / IdentityHeaderGroups fields. Reuses the +// same fields HeaderPair customizable does — the dimensions +// (user identity, groups) match; only the wire encoding (JSON key +// vs HTTP header name) differs. +func TestSynthesizeServices_IdentityInject_Cloudflare_OperatorOverrides(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + cf := newSynthTestProvider() + cf.ID = "prov-cf" + cf.ProviderID = "cloudflare_ai_gateway" + cf.UpstreamURL = "https://gateway.ai.cloudflare.com/v1/acct-xyz/my-gateway/openai" + cf.APIKey = "cf-aig-token" + cf.IdentityHeaderUserID = "team_member" + cf.IdentityHeaderGroups = "team_groups" + cf.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(cf.ID, "grp-eng", "") + policy.ID = "pol-cf" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{cf}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata, "Cloudflare uses JSONMetadata shape — single header carrying a JSON object") + assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive") + assert.Equal(t, "cf-aig-metadata", entry.JSONMetadata.Header, + "the wire header is catalog-owned (cf-aig-metadata) — operator can rename the JSON keys but not the header itself") + assert.Equal(t, "team_member", entry.JSONMetadata.UserKey, + "operator-set IdentityHeaderUserID overrides the catalog's netbird_user_id default — proves the JSONMetadata Customizable flag swaps the source of truth like HeaderPair already does") + assert.Equal(t, "team_groups", entry.JSONMetadata.GroupsKey, + "operator-set IdentityHeaderGroups overrides the catalog's netbird_groups default") +} + +// TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable +// is the JSONMetadata negative case: Portkey's catalog entry leaves +// Customizable=false because Portkey's analytics dashboard reserves +// "_user" and "groups" as fixed JSON keys. An operator-set +// IdentityHeader* on a Portkey provider record must NOT override +// those keys, or Portkey's per-user filters silently break. +func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + portkey := newSynthTestProvider() + portkey.ID = "prov-portkey" + portkey.ProviderID = "portkey" + portkey.UpstreamURL = "https://api.portkey.ai/v1" + portkey.APIKey = "portkey-account-key" + // Operator set these — but portkey's catalog entry has + // JSONMetadata.Customizable=false, so synth must IGNORE them + // and stick with the catalog's _user / groups defaults. + portkey.IdentityHeaderUserID = "should-be-ignored" + portkey.IdentityHeaderGroups = "should-be-ignored-too" + portkey.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(portkey.ID, "grp-eng", "") + policy.ID = "pol-portkey" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{portkey}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata) + assert.Equal(t, "_user", entry.JSONMetadata.UserKey, + "Portkey's reserved JSON key must hold — Customizable=false on the catalog blocks the operator's override fields") + assert.Equal(t, "groups", entry.JSONMetadata.GroupsKey, + "same fixed-schema guarantee for the groups dimension") +} + +// TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI +// Gateway's wiring: HeaderPair shape with fixed wire names dictated +// by Vercel's Custom Reporting API (ai-reporting-user / +// ai-reporting-tags). Customizable=false on the catalog entry, so +// the synth path takes the catalog values verbatim and ignores any +// IdentityHeader* fields the operator might have set. Renaming +// these headers would just silently disable attribution — Vercel's +// reporting endpoint only matches the canonical names — so the +// fixed contract is the right semantic. +func TestSynthesizeServices_IdentityInject_Vercel(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + vercel := newSynthTestProvider() + vercel.ID = "prov-vercel" + vercel.ProviderID = "vercel_ai_gateway" + vercel.UpstreamURL = "https://ai-gateway.vercel.sh/v1" + vercel.APIKey = "vrc-team-key" + // Operator set these — they MUST be ignored because Vercel's + // catalog entry is non-customizable. Renaming the headers on + // the wire would defeat Vercel's reporting endpoint. + vercel.IdentityHeaderUserID = "should-be-ignored" + vercel.IdentityHeaderGroups = "should-be-ignored-too" + vercel.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(vercel.ID, "grp-eng", "") + policy.ID = "pol-vercel" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{vercel}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair, "Vercel uses HeaderPair shape — separate ai-reporting-user / ai-reporting-tags headers, not a JSON blob") + assert.Nil(t, entry.JSONMetadata, "shapes are mutually exclusive") + assert.Equal(t, "ai-reporting-user", entry.HeaderPair.EndUserIDHeader, + "end-user-id header must be Vercel's canonical ai-reporting-user — renaming would silently disable attribution at Vercel's Custom Reporting endpoint") + assert.Equal(t, "ai-reporting-tags", entry.HeaderPair.TagsHeader, + "tags header must be Vercel's canonical ai-reporting-tags for the same reason") + assert.False(t, entry.HeaderPair.TagsInBody, + "Vercel reads from headers — body inject would be a LiteLLM-specific belt-and-suspenders unneeded here") + assert.False(t, entry.HeaderPair.EndUserIDInBody) +} + +// TestSynthesizeServices_IdentityInject_OpenRouter pins OpenRouter's +// wiring: HeaderPair shape with body-only injection. OpenRouter's +// per-user attribution is the OpenAI-standard `user` body field — +// there's no header path and no groups dimension at all. The catalog +// entry sets EndUserIDInBody=true with empty header names; the inject +// middleware writes user identity into the request body but stamps +// nothing on the header surface. Customizable=false so any operator +// IdentityHeader* fields are ignored. +// +// Also asserts the static ExtraHeaders surface: operators provide +// their app URL and display name on the provider record (HTTP-Referer +// and X-OpenRouter-Title), and these land on every upstream request +// so OpenRouter's app rankings / analytics attribute correctly. +func TestSynthesizeServices_IdentityInject_OpenRouter(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + openrouter := newSynthTestProvider() + openrouter.ID = "prov-openrouter" + openrouter.ProviderID = "openrouter" + openrouter.UpstreamURL = "https://openrouter.ai/api/v1" + openrouter.APIKey = "sk-or-v1-acme" + // These would only apply if the catalog entry was Customizable; + // it isn't, so they must be IGNORED. + openrouter.IdentityHeaderUserID = "should-be-ignored" + openrouter.IdentityHeaderGroups = "should-be-ignored-too" + openrouter.ExtraValues = map[string]string{ + "HTTP-Referer": "https://acme.example/agents", + "X-OpenRouter-Title": "Acme Agents", + } + openrouter.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(openrouter.ID, "grp-eng", "") + policy.ID = "pol-openrouter" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{openrouter}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair, "OpenRouter uses HeaderPair shape — body-inject is the only branch active") + assert.Empty(t, entry.HeaderPair.EndUserIDHeader, + "OpenRouter does not document a header path for per-user identity; the inject must NOT stamp a header here. Customizable=false means operator IdentityHeader* fields are ignored.") + assert.Empty(t, entry.HeaderPair.TagsHeader, + "OpenRouter has no per-request groups / tags dimension — the tags header MUST stay empty") + assert.True(t, entry.HeaderPair.EndUserIDInBody, + "OpenRouter's only per-user attribution path is the OpenAI-standard `user` body field — body inject is the load-bearing piece for this provider") + assert.False(t, entry.HeaderPair.TagsInBody, + "no tags dimension at all → no tags-in-body either") + + // ExtraHeaders carry the operator-typed app URL + display name to + // OpenRouter's app rankings. The synth must echo BOTH static + // header values with the operator's typed strings. + require.Len(t, entry.ExtraHeaders, 2, + "both ExtraHeaders the catalog declares should land on the inject config when the operator filled in values") + byName := map[string]string{} + for _, h := range entry.ExtraHeaders { + byName[h.Name] = h.Value + } + assert.Equal(t, "https://acme.example/agents", byName["HTTP-Referer"], + "HTTP-Referer is OpenRouter's primary app identifier — must round-trip the operator-typed URL verbatim") + assert.Equal(t, "Acme Agents", byName["X-OpenRouter-Title"], + "X-OpenRouter-Title surfaces as the app's display name in OpenRouter's rankings — must round-trip operator's chosen string") +} + +// TestSynthesizeServices_IdentityInject_NonCustomizable_UsesCatalog +// is the LiteLLM-style negative case: when the catalog entry does +// NOT flag HeaderPair as Customizable, the catalog defaults are +// authoritative and any IdentityHeader* values on the provider +// record are ignored. Without this guard, an operator who set those +// fields on a non-Bifrost provider could accidentally break the +// gateway's wire protocol (LiteLLM only honours x-litellm-end-user- +// id; renaming it would silently drop spend tracking). +func TestSynthesizeServices_IdentityInject_NonCustomizable_UsesCatalog(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + litellm := newSynthTestProvider() + litellm.ID = "prov-litellm" + litellm.ProviderID = "litellm_proxy" + litellm.UpstreamURL = "https://litellm.acme.example.com" + litellm.APIKey = "sk-llm-master" + // Operator set these — but litellm_proxy's catalog entry has + // HeaderPair.Customizable=false, so the synth path must IGNORE + // these and fall back to the catalog defaults. + litellm.IdentityHeaderUserID = "x-bf-lh-should-be-ignored" + litellm.IdentityHeaderGroups = "x-bf-lh-should-be-ignored-too" + litellm.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(litellm.ID, "grp-eng", "") + policy.ID = "pol-litellm" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{litellm}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair) + assert.Equal(t, "x-litellm-end-user-id", entry.HeaderPair.EndUserIDHeader, + "Customizable=false on the catalog entry must hold — operator IdentityHeader* fields cannot rename a fixed wire protocol's headers") + assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader, + "Customizable=false on the catalog entry must hold for tags too") +} + +func TestSynthesizeServices_GuardrailMerge_AllowlistUnion_LimitsRestrictive(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + + guardrailA := &types.Guardrail{ + ID: "g-a", + AccountID: testAccountID, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4-mini"}}, + }, + } + guardrailB := &types.Guardrail{ + ID: "g-b", + AccountID: testAccountID, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4-pro"}}, + }, + } + + policyA := newSynthTestPolicy(provider.ID, "grp-a", guardrailA.ID) + policyA.ID = "pol-a" + policyB := newSynthTestPolicy(provider.ID, "grp-b", guardrailB.ID) + policyB.ID = "pol-b" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policyA, policyB}, + []*types.Guardrail{guardrailA, guardrailB}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var guardrailJSON []byte + for _, m := range mws { + if m.ID == middlewareIDLLMGuardrail { + guardrailJSON = m.ConfigJSON + break + } + } + require.NotEmpty(t, guardrailJSON, "guardrail middleware config JSON must be present") + + var cfg guardrailConfig + require.NoError(t, json.Unmarshal(guardrailJSON, &cfg), "guardrail config must unmarshal cleanly") + assert.ElementsMatch(t, []string{"gpt-5.4-mini", "gpt-5.4-pro"}, cfg.ModelAllowlist, + "model allowlist union must keep both models") +} + +func TestSynthesizeServices_BackfillsMissingSessionKeys(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.SessionPrivateKey = "" + provider.SessionPublicKey = "" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, testAccountID). + Return(newSynthTestSettings(), nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, testAccountID). + Return([]*types.Provider{provider}, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, testAccountID). + Return([]*types.Policy{policy}, nil) + // Backfill must persist the new keys before synthesising. + mockStore.EXPECT(). + SaveAgentNetworkProvider(ctx, gomock.Any()). + DoAndReturn(func(_ context.Context, p *types.Provider) error { + require.NotEmpty(t, p.SessionPrivateKey, "backfill must populate private key") + require.NotEmpty(t, p.SessionPublicKey, "backfill must populate public key") + return nil + }) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, testAccountID). + Return([]*types.Guardrail{}, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "synthesis must complete after backfill") + assert.NotEmpty(t, services[0].SessionPrivateKey, "synthesised service inherits the freshly-minted private key") + assert.NotEmpty(t, services[0].SessionPublicKey, "synthesised service inherits the freshly-minted public key") +} + +func TestSynthesizeServices_HTTPUpstream_KeepsExplicitPort(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.UpstreamURL = "http://internal-llm.lan:8080" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + assert.Equal(t, "http", routerCfg.Providers[0].UpstreamScheme, "scheme follows the upstream URL") + assert.Equal(t, "internal-llm.lan:8080", routerCfg.Providers[0].UpstreamHost, + "explicit port travels with host so the router rewrite carries an authority the proxy can dial") +} + +func TestSynthesizeServices_UpstreamURLPath_FlowsToRouter(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + // Provider configured with a path-prefixed upstream — common for + // OpenAI-compatible endpoints behind corporate gateways. The path + // is the router's disambiguator when two providers claim the same + // model, so it must round-trip through buildRouterConfigJSON with + // the trailing slash trimmed. + provider := newSynthTestProvider() + provider.UpstreamURL = "https://corp.example.com/openai/" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + assert.Equal(t, "corp.example.com", routerCfg.Providers[0].UpstreamHost, "host should drop the path") + assert.Equal(t, "/openai", routerCfg.Providers[0].UpstreamPath, + "upstream path must be carried so the router can disambiguate same-model providers; trailing slash trimmed for stable string-prefix matching") +} + +func TestSynthesizeServices_UnknownProviderID_FailsClosed(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.ProviderID = "nonexistent_provider" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + _, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.Error(t, err, "synthesis must fail when the catalog can't resolve the provider id") + assert.Contains(t, err.Error(), "unknown catalog id", "error must surface the misconfiguration") +} + +func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.APIKey = "" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + _, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.Error(t, err, "synthesis must refuse a provider with no api key") + assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential") +} diff --git a/management/internals/modules/agentnetwork/types/accesslog.go b/management/internals/modules/agentnetwork/types/accesslog.go new file mode 100644 index 000000000..92b8bc358 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/accesslog.go @@ -0,0 +1,289 @@ +package types + +import ( + "time" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// AgentNetworkAccessLog is the dedicated, flattened agent-network access-log +// row. Unlike the shared reverse-proxy AccessLogEntry (which kept LLM data in +// an opaque metadata JSON blob), the LLM dimensions live in first-class, +// indexed columns so the access-log surface can filter server-side by +// user / group / provider / model / decision. +type AgentNetworkAccessLog struct { + // The composite index idx_anal_acct_session_ts backs the session-grouped + // listing (GROUP BY session_id ORDER BY MAX(timestamp) within an account); + // the single-column indexes still back the flat filters/sorts. + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index;index:idx_anal_acct_session_ts,priority:1"` + ServiceID string `gorm:"index"` + Timestamp time.Time `gorm:"index;index:idx_anal_acct_session_ts,priority:3"` + UserID string `gorm:"index"` + SourceIP string + Method string + Host string + Path string `gorm:"type:text"` + Duration time.Duration + StatusCode int `gorm:"index"` + AuthMethod string + BytesUpload int64 + BytesDownload int64 + + // Flattened LLM dimensions (queryable). Sourced from proxy metadata keys. + Provider string `gorm:"index"` // vendor, e.g. "openai" (llm.provider) + Model string `gorm:"index"` // llm.model + SessionID string `gorm:"index;index:idx_anal_acct_session_ts,priority:2"` // llm.session_id — groups a conversation / coding session + ResolvedProviderID string `gorm:"index"` // llm.resolved_provider_id + SelectedPolicyID string `gorm:"index"` // llm.selected_policy_id + Decision string `gorm:"index"` // llm_policy.decision (allow/deny) + DenyReason string // llm_policy.reason (raw code, mapped in the UI) + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CostUSD float64 + Stream bool + + // Prompt capture. Only populated when prompt collection is enabled + // (account master switch AND policy guardrail). Heavy free text. + RequestPrompt string `gorm:"type:text"` + ResponseCompletion string `gorm:"type:text"` + + CreatedAt time.Time + + // GroupIDs is the authorising group ids for this entry, hydrated from the + // group child table on read. Not a column. + GroupIDs []string `gorm:"-"` +} + +// TableName keeps agent-network access logs in their own table, separate from +// the reverse-proxy AccessLogEntry table. +func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" } + +// ToAPIResponse renders the flattened entry as the API representation. +func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog { + out := api.AgentNetworkAccessLog{ + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + StatusCode: a.StatusCode, + DurationMs: int(a.Duration.Milliseconds()), + InputTokens: a.InputTokens, + OutputTokens: a.OutputTokens, + TotalTokens: a.TotalTokens, + CostUsd: a.CostUSD, + Stream: &a.Stream, + } + + out.UserId = strPtr(a.UserID) + out.SourceIp = strPtr(a.SourceIP) + out.Method = strPtr(a.Method) + out.Host = strPtr(a.Host) + out.Path = strPtr(a.Path) + out.Provider = strPtr(a.Provider) + out.Model = strPtr(a.Model) + out.SessionId = strPtr(a.SessionID) + out.ResolvedProviderId = strPtr(a.ResolvedProviderID) + out.SelectedPolicyId = strPtr(a.SelectedPolicyID) + out.Decision = strPtr(a.Decision) + out.DenyReason = strPtr(a.DenyReason) + out.RequestPrompt = strPtr(a.RequestPrompt) + out.ResponseCompletion = strPtr(a.ResponseCompletion) + + if len(a.GroupIDs) > 0 { + groups := a.GroupIDs + out.GroupIds = &groups + } + return out +} + +// strPtr returns a pointer to s, or nil when s is empty — so empty optional +// fields are omitted from the JSON rather than serialised as "". +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +// AgentNetworkAccessLogSession is a session-grouped view of access-log entries: +// all requests sharing a session id (or, for a request the client sent no +// session id for, that single request keyed by its own row id) folded into one +// summary plus its ordered entries. Assembled in Go from a page of entries — it +// is not a stored table. +type AgentNetworkAccessLogSession struct { + SessionID string // empty for a session-less (singleton) request + UserID string + GroupIDs []string // union of the entries' authorising groups + StartedAt time.Time + EndedAt time.Time + RequestCount int + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CostUSD float64 + Providers []string // distinct vendors seen in the session + Models []string // distinct models seen in the session + Decision string // "deny" if any entry was denied, else "allow" + Entries []*AgentNetworkAccessLog +} + +// sessionKey is the grouping key for an entry: its session id, or — when the +// client sent none — its own row id, so session-less requests each form their +// own singleton group. Must match the SQL group key +// COALESCE(NULLIF(session_id, ”), id). +func sessionKey(e *AgentNetworkAccessLog) string { + if e.SessionID != "" { + return e.SessionID + } + return e.ID +} + +// FoldAccessLogSessions folds a page of entries into per-session summaries, +// preserving the order of orderedKeys (the already-sorted, already-paginated +// session keys from the store). Entries are expected pre-sorted by timestamp +// within each key. Aggregation (sums, distinct providers/models, deny rollup) +// happens here in Go rather than in SQL so the query stays engine-portable. +func FoldAccessLogSessions(orderedKeys []string, entries []*AgentNetworkAccessLog) []*AgentNetworkAccessLogSession { + byKey := make(map[string]*AgentNetworkAccessLogSession, len(orderedKeys)) + order := make([]*AgentNetworkAccessLogSession, 0, len(orderedKeys)) + for _, k := range orderedKeys { + if _, ok := byKey[k]; ok { + continue + } + sess := &AgentNetworkAccessLogSession{Decision: "allow"} + byKey[k] = sess + order = append(order, sess) + } + + seenBy := make(map[string]*sessionSeen, len(orderedKeys)) + + for _, e := range entries { + k := sessionKey(e) + sess, ok := byKey[k] + if !ok { + continue // entry outside the paged set; defensive + } + sk := seenBy[k] + if sk == nil { + sk = newSessionSeen() + seenBy[k] = sk + sess.SessionID = e.SessionID + sess.UserID = e.UserID + sess.StartedAt = e.Timestamp + sess.EndedAt = e.Timestamp + } + sess.foldEntry(sk, e) + } + + out := make([]*AgentNetworkAccessLogSession, 0, len(order)) + for _, sess := range order { + if sess.RequestCount > 0 { + out = append(out, sess) + } + } + return out +} + +// sessionSeen tracks the distinct provider / model / group values already +// recorded for a session so foldEntry can dedupe as it accumulates. +type sessionSeen struct{ providers, models, groups map[string]struct{} } + +func newSessionSeen() *sessionSeen { + return &sessionSeen{ + providers: map[string]struct{}{}, + models: map[string]struct{}{}, + groups: map[string]struct{}{}, + } +} + +// foldEntry accumulates a single entry into the session summary: sums, time +// bounds, first-seen user, deny rollup, distinct provider / model / group +// lists, and the entry itself. +func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNetworkAccessLog) { + sess.RequestCount++ + sess.InputTokens += e.InputTokens + sess.OutputTokens += e.OutputTokens + sess.TotalTokens += e.TotalTokens + sess.CostUSD += e.CostUSD + if e.Timestamp.Before(sess.StartedAt) { + sess.StartedAt = e.Timestamp + } + if e.Timestamp.After(sess.EndedAt) { + sess.EndedAt = e.Timestamp + } + if sess.UserID == "" { + sess.UserID = e.UserID + } + if e.Decision == "deny" { + sess.Decision = "deny" + } + sess.Providers = appendDistinct(sk.providers, sess.Providers, e.Provider) + sess.Models = appendDistinct(sk.models, sess.Models, e.Model) + for _, g := range e.GroupIDs { + sess.GroupIDs = appendDistinct(sk.groups, sess.GroupIDs, g) + } + sess.Entries = append(sess.Entries, e) +} + +// appendDistinct appends v to list when v is non-empty and not already recorded +// in seen, returning the possibly-extended list. +func appendDistinct(seen map[string]struct{}, list []string, v string) []string { + if v == "" { + return list + } + if _, dup := seen[v]; dup { + return list + } + seen[v] = struct{}{} + return append(list, v) +} + +// ToAPIResponse renders the session summary (and its entries) as the API +// representation. +func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccessLogSession { + entries := make([]api.AgentNetworkAccessLog, 0, len(sess.Entries)) + for _, e := range sess.Entries { + entries = append(entries, e.ToAPIResponse()) + } + + out := api.AgentNetworkAccessLogSession{ + StartedAt: sess.StartedAt, + EndedAt: sess.EndedAt, + RequestCount: sess.RequestCount, + InputTokens: sess.InputTokens, + OutputTokens: sess.OutputTokens, + TotalTokens: sess.TotalTokens, + CostUsd: sess.CostUSD, + Decision: sess.Decision, + Entries: entries, + } + out.SessionId = strPtr(sess.SessionID) + out.UserId = strPtr(sess.UserID) + if len(sess.Providers) > 0 { + providers := sess.Providers + out.Providers = &providers + } + if len(sess.Models) > 0 { + models := sess.Models + out.Models = &models + } + if len(sess.GroupIDs) > 0 { + groups := sess.GroupIDs + out.GroupIds = &groups + } + return out +} + +// AgentNetworkAccessLogGroup is the normalised many-to-many row linking a log +// entry to one authorising group, so the access-log endpoint can filter by +// group with a simple `group_id IN (...)` join instead of substring-matching a +// CSV column. +type AgentNetworkAccessLogGroup struct { + LogID string `gorm:"primaryKey"` + GroupID string `gorm:"primaryKey;index"` + AccountID string `gorm:"index"` +} + +// TableName names the access-log group child table. +func (AgentNetworkAccessLogGroup) TableName() string { return "agent_network_access_log_group" } diff --git a/management/internals/modules/agentnetwork/types/accesslogfilter.go b/management/internals/modules/agentnetwork/types/accesslogfilter.go new file mode 100644 index 000000000..d571a87b6 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/accesslogfilter.go @@ -0,0 +1,249 @@ +package types + +import ( + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + // AccessLogDefaultPageSize is the default number of records per page. + AccessLogDefaultPageSize = 50 + // AccessLogMaxPageSize is the maximum number of records allowed per page. + AccessLogMaxPageSize = 100 + + accessLogDefaultSortBy = "timestamp" + accessLogDefaultSortOrder = "desc" + + // usageOverviewDefaultLookback bounds an unbounded usage-overview query so + // it never aggregates an account's entire history into memory. + usageOverviewDefaultLookback = 90 * 24 * time.Hour + // usageOverviewMaxRange caps how far back an explicit range may reach. + usageOverviewMaxRange = 366 * 24 * time.Hour +) + +// ApplyUsageOverviewBounds bounds a missing or over-wide date range so the +// in-memory usage aggregation can't load an account's full usage history. An +// absent range defaults to the last usageOverviewDefaultLookback; a range wider +// than usageOverviewMaxRange is clamped from the (possibly defaulted) end. +func (f *AgentNetworkAccessLogFilter) ApplyUsageOverviewBounds(now time.Time) { + end := now + if f.EndDate != nil { + end = *f.EndDate + } + f.EndDate = &end + if f.StartDate == nil { + start := end.Add(-usageOverviewDefaultLookback) + f.StartDate = &start + return + } + if end.Sub(*f.StartDate) > usageOverviewMaxRange { + start := end.Add(-usageOverviewMaxRange) + f.StartDate = &start + } +} + +// accessLogSortFields maps the API sort_by values to their database columns. +var accessLogSortFields = map[string]string{ + "timestamp": "timestamp", + "model": "model", + "provider": "provider", + "status_code": "status_code", + "duration": "duration", + "cost_usd": "cost_usd", + "total_tokens": "total_tokens", + "user_id": "user_id", + "decision": "decision", +} + +// sessionSortExprs maps the API sort_by values to the aggregate expression a +// session-grouped query sorts on. A session has no single row, so per-row +// columns become aggregates: "timestamp" (the default) is the session's last +// activity, "started_at" its first. Every expression is a plain SQL aggregate +// over the GROUP BY, so the ordering stays portable across SQLite and Postgres. +// Keys absent here (e.g. "model", "provider") fall back to the default — the +// grouped UI only offers the session-level sorts below. +var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential + "timestamp": "MAX(timestamp)", + "started_at": "MIN(timestamp)", + "cost_usd": "SUM(cost_usd)", + "total_tokens": "SUM(total_tokens)", + "duration": "SUM(duration)", + "request_count": "COUNT(*)", + "status_code": "MAX(status_code)", + "user_id": "MIN(user_id)", + "decision": "MAX(decision)", // "deny" > "allow": DESC surfaces denied sessions first +} + +// AgentNetworkAccessLogFilter holds pagination, filtering and sorting +// parameters for the agent-network access-log listing. Group / provider / +// model are multi-valued (the UI uses multi-select; an entry matches when it +// matches any selected value). +type AgentNetworkAccessLogFilter struct { + Page int + PageSize int + + SortBy string + SortOrder string + + Search *string // log id, host, path, model, user email/name + UserID *string // exact user id (the dashboard sends the picked user's id) + SessionID *string // exact session id — groups one conversation / coding session + GroupIDs []string // authorising group ids (match any) + ProviderIDs []string // resolved provider ids (match any) + Models []string // models (match any) + Decision *string // policy decision (allow/deny) + PathPrefix *string // request path prefix (path LIKE 'prefix%') + StartDate *time.Time // timestamp >= start_date + EndDate *time.Time // timestamp <= end_date +} + +// ParseFromRequest fills the filter from the request query parameters. It +// returns a validation error when a supplied start_date / end_date is present +// but not valid RFC3339: silently dropping a malformed date would broaden the +// query (and, for the usage overview, fall back to the default window). +func (f *AgentNetworkAccessLogFilter) ParseFromRequest(r *http.Request) error { + q := r.URL.Query() + + f.Page = parseAccessLogPositiveInt(q.Get("page"), 1) + f.PageSize = min(parseAccessLogPositiveInt(q.Get("page_size"), AccessLogDefaultPageSize), AccessLogMaxPageSize) + + f.SortBy = parseAccessLogSortField(q.Get("sort_by")) + f.SortOrder = parseAccessLogSortOrder(q.Get("sort_order")) + + f.Search = parseAccessLogOptionalString(q.Get("search")) + f.UserID = parseAccessLogOptionalString(q.Get("user_id")) + f.SessionID = parseAccessLogOptionalString(q.Get("session_id")) + f.Decision = parseAccessLogOptionalString(q.Get("decision")) + f.PathPrefix = parseAccessLogOptionalString(q.Get("path")) + // Multi-value filters accept either repeated params (?group_id=a&group_id=b) + // or a single comma-separated value (?group_id=a,b) so both the OpenAPI + // array form and the dashboard's single-value query builder work. + f.GroupIDs = splitMultiValue(q["group_id"]) + f.ProviderIDs = splitMultiValue(q["provider_id"]) + f.Models = splitMultiValue(q["model"]) + + var err error + if f.StartDate, err = parseAccessLogOptionalRFC3339(q.Get("start_date")); err != nil { + return status.Errorf(status.InvalidArgument, "invalid start_date: %v", err) + } + if f.EndDate, err = parseAccessLogOptionalRFC3339(q.Get("end_date")); err != nil { + return status.Errorf(status.InvalidArgument, "invalid end_date: %v", err) + } + return nil +} + +// GetSortColumn returns the database column for the active sort field. +func (f *AgentNetworkAccessLogFilter) GetSortColumn() string { + if col, ok := accessLogSortFields[f.SortBy]; ok { + return col + } + return accessLogSortFields[accessLogDefaultSortBy] +} + +// GetSessionSortExpr returns the aggregate ORDER BY expression for the active +// sort field when listing session-grouped logs. Unknown / non-session sort +// fields fall back to the default (last activity). +func (f *AgentNetworkAccessLogFilter) GetSessionSortExpr() string { + if expr, ok := sessionSortExprs[f.SortBy]; ok { + return expr + } + return sessionSortExprs[accessLogDefaultSortBy] +} + +// GetSortOrder returns the normalised sort order ("ASC"/"DESC"). +func (f *AgentNetworkAccessLogFilter) GetSortOrder() string { + if strings.EqualFold(f.SortOrder, "asc") { + return "ASC" + } + return "DESC" +} + +// GetLimit returns the page size, defaulting/clamping when unset. +func (f *AgentNetworkAccessLogFilter) GetLimit() int { + if f.PageSize <= 0 { + return AccessLogDefaultPageSize + } + return min(f.PageSize, AccessLogMaxPageSize) +} + +// GetOffset returns the zero-based row offset for the active page. Page is +// user-controlled, so the multiplication is guarded against int overflow. +func (f *AgentNetworkAccessLogFilter) GetOffset() int { + limit := f.GetLimit() + if f.Page <= 1 || limit <= 0 { + return 0 + } + if f.Page-1 > math.MaxInt/limit { + return math.MaxInt - (math.MaxInt % limit) + } + return (f.Page - 1) * limit +} + +func parseAccessLogPositiveInt(s string, def int) int { + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v > 0 { + return v + } + return def +} + +func parseAccessLogSortField(s string) string { + if _, ok := accessLogSortFields[s]; ok { + return s + } + // Session-grouped listings sort on aggregates (e.g. request_count, + // started_at) that aren't flat-row columns; accept those too. The flat + // listing maps any unknown field back to the default, so this stays safe + // for the non-grouped endpoint. + if _, ok := sessionSortExprs[s]; ok { + return s + } + return accessLogDefaultSortBy +} + +func parseAccessLogSortOrder(s string) string { + if strings.EqualFold(s, "asc") { + return "asc" + } + return accessLogDefaultSortOrder +} + +func parseAccessLogOptionalString(s string) *string { + if s = strings.TrimSpace(s); s != "" { + return &s + } + return nil +} + +func parseAccessLogOptionalRFC3339(s string) (*time.Time, error) { + if s = strings.TrimSpace(s); s == "" { + return nil, nil //nolint:nilnil // not provided: no value and no error + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil, err + } + return &t, nil +} + +// splitMultiValue flattens repeated query params and comma-separated values +// into a single trimmed, blank-free list. Returns nil when nothing remains so +// callers can skip the filter entirely. +func splitMultiValue(values []string) []string { + out := make([]string, 0, len(values)) + for _, raw := range values { + for _, v := range strings.Split(raw, ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/management/internals/modules/agentnetwork/types/budgetrule.go b/management/internals/modules/agentnetwork/types/budgetrule.go new file mode 100644 index 000000000..6f02a5b92 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/budgetrule.go @@ -0,0 +1,106 @@ +package types + +import ( + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// AccountBudgetRule is an account-level, limit-only rule bound to groups +// and/or users. It mirrors the policy budget experience without any routing: +// it carries the same cap shape as a policy (PolicyLimits) but never selects a +// provider. Rules apply across policies as an always-on ceiling — every +// applicable rule binds (min-wins), so a rule can only tighten a caller's +// effective limit, never loosen it. +// +// TargetGroups matches when it intersects the caller's groups; TargetUsers +// binds a specific user directly. Empty TargetGroups and TargetUsers means the +// rule applies to every caller (the account-wide default). +type AccountBudgetRule struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Enabled bool + TargetGroups []string `gorm:"serializer:json;column:target_groups"` + TargetUsers []string `gorm:"serializer:json;column:target_users"` + Limits PolicyLimits `gorm:"serializer:json;column:limits"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName puts budget rules in their own table. +func (AccountBudgetRule) TableName() string { return "agent_network_budget_rules" } + +// NewAccountBudgetRule returns a new rule with a freshly minted ID. +func NewAccountBudgetRule(accountID string) *AccountBudgetRule { + now := time.Now().UTC() + return &AccountBudgetRule{ + ID: "ainbud_" + xid.New().String(), + AccountID: accountID, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } +} + +// Copy returns a deep copy of the rule, including its target slices. +func (r *AccountBudgetRule) Copy() *AccountBudgetRule { + c := *r + c.TargetGroups = append([]string(nil), r.TargetGroups...) + c.TargetUsers = append([]string(nil), r.TargetUsers...) + return &c +} + +// EventMeta renders the rule for the activity log. +func (r *AccountBudgetRule) EventMeta() map[string]any { + return map[string]any{ + "name": r.Name, + "enabled": r.Enabled, + } +} + +// FromAPIRequest applies the request payload onto the receiver. +func (r *AccountBudgetRule) FromAPIRequest(req *api.AgentNetworkBudgetRuleRequest) { + r.Name = req.Name + if req.Enabled != nil { + r.Enabled = *req.Enabled + } + if req.TargetGroups != nil { + r.TargetGroups = append([]string(nil), (*req.TargetGroups)...) + } else { + r.TargetGroups = []string{} + } + if req.TargetUsers != nil { + r.TargetUsers = append([]string(nil), (*req.TargetUsers)...) + } else { + r.TargetUsers = []string{} + } + r.Limits = limitsFromAPI(req.Limits) +} + +// ToAPIResponse renders the rule as the API representation. +func (r *AccountBudgetRule) ToAPIResponse() *api.AgentNetworkBudgetRule { + groups := r.TargetGroups + if groups == nil { + groups = []string{} + } + users := r.TargetUsers + if users == nil { + users = []string{} + } + created := r.CreatedAt + updated := r.UpdatedAt + return &api.AgentNetworkBudgetRule{ + Id: r.ID, + Name: r.Name, + Enabled: r.Enabled, + TargetGroups: groups, + TargetUsers: users, + Limits: limitsToAPI(r.Limits), + CreatedAt: &created, + UpdatedAt: &updated, + } +} diff --git a/management/internals/modules/agentnetwork/types/consumption.go b/management/internals/modules/agentnetwork/types/consumption.go new file mode 100644 index 000000000..5295b5570 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/consumption.go @@ -0,0 +1,69 @@ +package types + +import "time" + +// ConsumptionDimension classifies which kind of identity a consumption +// row counts against. The proxy-side enforcement layer ticks one row +// per dimension per request — typically one user row plus one group +// row. +type ConsumptionDimension string + +const ( + // DimensionUser counts tokens / spend for a single end user. The + // dim_id column carries the netbird user id (or peer.ID when the + // caller is a tunnel-peer principal). + DimensionUser ConsumptionDimension = "user" + // DimensionGroup counts tokens / spend for a single source group + // across every member of that group. The dim_id column carries + // the netbird group id. + DimensionGroup ConsumptionDimension = "group" +) + +// Consumption is a per-dimension token + USD counter for a fixed +// aligned window. The (account, dim_kind, dim_id, window_seconds, +// window_start) tuple is the primary key; rows are rolled forward by +// the proxy's post-flight RecordLLMUsage path on every request. +// +// The same dim_id (e.g. a group id) gets one row per distinct +// window_seconds length in scope across the account's policies, +// because two policies with different window lengths read independent +// counters even though they share the dimension. Two policies with +// identical window_seconds on the same dimension share one counter +// (correct: their caps are checked against the same shared bucket). +type Consumption struct { + AccountID string `gorm:"primaryKey;type:varchar(255)"` + DimensionKind ConsumptionDimension `gorm:"primaryKey;type:varchar(16);column:dim_kind"` + DimensionID string `gorm:"primaryKey;type:varchar(255);column:dim_id"` + WindowSeconds int64 `gorm:"primaryKey;column:window_seconds"` + WindowStartUTC time.Time `gorm:"primaryKey;column:window_start_utc"` + TokensInput int64 `gorm:"column:tokens_input"` + TokensOutput int64 `gorm:"column:tokens_output"` + CostUSD float64 `gorm:"column:cost_usd"` + UpdatedAt time.Time +} + +// TableName forces a stable name independent of GORM's pluraliser. +func (Consumption) TableName() string { return "agent_network_consumption" } + +// ConsumptionKey identifies a single consumption counter within an account: +// the (dim_kind, dim_id, window_seconds, window_start) part of the row's +// primary key. Used to batch-read and batch-increment many counters for one +// request in a single store round-trip / transaction. +type ConsumptionKey struct { + Kind ConsumptionDimension + DimID string + WindowSeconds int64 + WindowStartUTC time.Time +} + +// WindowStart returns the aligned UTC start of the window of length +// windowSeconds that contains t. Aligned to the unix epoch so the +// same bucket boundary is computed deterministically across processes. +func WindowStart(t time.Time, windowSeconds int64) time.Time { + if windowSeconds <= 0 { + return t.UTC() + } + step := windowSeconds * int64(time.Second) + bucketed := t.UTC().UnixNano() / step * step + return time.Unix(0, bucketed).UTC() +} diff --git a/management/internals/modules/agentnetwork/types/consumption_test.go b/management/internals/modules/agentnetwork/types/consumption_test.go new file mode 100644 index 000000000..596cc158c --- /dev/null +++ b/management/internals/modules/agentnetwork/types/consumption_test.go @@ -0,0 +1,141 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestWindowStart_AlignedToUnixEpoch is the multi-node-convergence +// guarantee: any two proxies computing WindowStart(now, s) for the +// same s must land on the same boundary. The implementation aligns +// to the unix epoch (UTC) rather than local time, calendar weeks, or +// process start time — none of which are shared across nodes. +// +// Table covers the load-bearing window lengths (5m, 1h, 24h, 30d) +// plus a few odd values that still need to align cleanly. +func TestWindowStart_AlignedToUnixEpoch(t *testing.T) { + cases := []struct { + name string + instant time.Time + windowSeconds int64 + want time.Time + }{ + { + name: "5m window — drops seconds inside the bucket", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC), + windowSeconds: 300, + want: time.Date(2026, 5, 6, 13, 45, 0, 0, time.UTC), + }, + { + name: "1h window — drops minutes / seconds, keeps the hour", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC), + windowSeconds: 3600, + want: time.Date(2026, 5, 6, 13, 0, 0, 0, time.UTC), + }, + { + name: "24h window aligns to UTC midnight", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC), + windowSeconds: 86_400, + want: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC), + }, + { + name: "30d (2_592_000s) window aligns to the 30d epoch grid, not month boundaries", + instant: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC), + windowSeconds: 2_592_000, + // 2026-05-06 UTC = 1778025600s; 1778025600 / 2592000 = 685 + // 685 * 2592000 = 1775520000s = 2026-04-07 00:00:00 UTC + want: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), + }, + { + name: "non-UTC input still anchors on UTC epoch boundaries", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.FixedZone("CEST", 2*3600)), + windowSeconds: 86_400, + // 2026-05-06 13:47:23 CEST = 11:47:23 UTC → bucket 2026-05-06 00:00:00 UTC + want: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := WindowStart(tc.instant, tc.windowSeconds) + assert.True(t, got.Equal(tc.want), + "WindowStart(%v, %ds) = %v, want %v", tc.instant, tc.windowSeconds, got, tc.want) + }) + } +} + +// TestWindowStart_WithinWindowConverges proves the determinism +// contract: any two timestamps inside the same window land on the +// exact same boundary. Two proxy nodes serving requests 7s apart +// must agree on which counter row to upsert. +func TestWindowStart_WithinWindowConverges(t *testing.T) { + t1 := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC) + t2 := t1.Add(7 * time.Second) + t3 := t1.Add(59*time.Minute + 59*time.Second) + + a := WindowStart(t1, 3600) + b := WindowStart(t2, 3600) + c := WindowStart(t3, 3600) + + assert.True(t, a.Equal(b), "two timestamps 7s apart in the same 1h window must align to the same boundary") + assert.True(t, a.Equal(c), "the very last second of a 1h window still lands on the SAME bucket as the first second") +} + +// TestWindowStart_AcrossWindowsDiverges is the symmetric guarantee: +// two timestamps separated by a window's worth of time MUST land on +// different boundaries. Without this, a 24h window's "rollover" +// would never reset the counter. +func TestWindowStart_AcrossWindowsDiverges(t *testing.T) { + t1 := time.Date(2026, 5, 6, 23, 59, 59, 0, time.UTC) + t2 := t1.Add(2 * time.Second) // 2026-05-07 00:00:01 + + a := WindowStart(t1, 86_400) + b := WindowStart(t2, 86_400) + assert.False(t, a.Equal(b), + "timestamps straddling a 24h-window boundary must land on different buckets — otherwise daily caps never reset") +} + +// TestWindowStart_DifferentWindowsHaveDifferentBuckets locks the +// design fork "two policies with different window_seconds on the same +// group produce independent counters". A 24h boundary at noon is NOT +// the same as the 30d boundary that contains it. +func TestWindowStart_DifferentWindowsHaveDifferentBuckets(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + short := WindowStart(now, 86_400) + long := WindowStart(now, 2_592_000) + assert.False(t, short.Equal(long), + "the 24h bucket and 30d bucket containing the same instant must differ — independent counters require independent keys") +} + +// TestWindowStart_SubMinuteAndMinuteAlignment locks sub-hour windows. +// A 5-minute window must align to multiples of 300s from the unix +// epoch — minute marks 0/5/10/.../55 within an hour, deterministic +// across nodes regardless of clock drift. +func TestWindowStart_SubMinuteAndMinuteAlignment(t *testing.T) { + t1 := time.Date(2026, 5, 6, 14, 12, 30, 0, time.UTC) + t2 := time.Date(2026, 5, 6, 14, 14, 59, 0, time.UTC) + t3 := time.Date(2026, 5, 6, 14, 15, 0, 0, time.UTC) + + a := WindowStart(t1, 300) + b := WindowStart(t2, 300) + c := WindowStart(t3, 300) + + assert.True(t, a.Equal(b), + "14:12:30 and 14:14:59 fall in the same 5m bucket starting at 14:10:00") + assert.True(t, a.Equal(time.Date(2026, 5, 6, 14, 10, 0, 0, time.UTC)), + "5m bucket containing 14:12 starts at 14:10 — aligned to multiples of 300s from unix epoch") + assert.False(t, a.Equal(c), + "14:15:00 is the start of the next 5m bucket — must not fold into the previous one") +} + +// TestWindowStart_ZeroWindowReturnsInputUTC covers the defensive +// path: caller hands a zero / negative window (shouldn't happen, but +// might mid-refactor). The function returns the input as UTC rather +// than dividing by zero. +func TestWindowStart_ZeroWindowReturnsInputUTC(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 30, 45, 0, time.FixedZone("CEST", 2*3600)) + got := WindowStart(now, 0) + assert.True(t, got.Equal(now.UTC()), "zero window must not panic — return input as UTC") +} diff --git a/management/internals/modules/agentnetwork/types/guardrail.go b/management/internals/modules/agentnetwork/types/guardrail.go new file mode 100644 index 000000000..12edd815c --- /dev/null +++ b/management/internals/modules/agentnetwork/types/guardrail.go @@ -0,0 +1,120 @@ +package types + +import ( + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// GuardrailChecks is the configurable parameter set persisted with each +// guardrail. Stored as a JSON blob to keep the table flat. +type GuardrailChecks struct { + ModelAllowlist GuardrailModelAllowlist `json:"model_allowlist"` + PromptCapture GuardrailPromptCapture `json:"prompt_capture"` +} + +type GuardrailModelAllowlist struct { + Enabled bool `json:"enabled"` + Models []string `json:"models"` +} + +type GuardrailPromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +// Guardrail is an Agent Network reusable guardrail set persisted per account. +type Guardrail struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Description string + Checks GuardrailChecks `gorm:"serializer:json"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName uses an explicit name so guardrail rows live in their own +// table. +func (Guardrail) TableName() string { return "agent_network_guardrails" } + +// NewGuardrail returns a new Guardrail with a freshly minted ID. +func NewGuardrail(accountID string) *Guardrail { + now := time.Now().UTC() + return &Guardrail{ + ID: "ainguard_" + xid.New().String(), + AccountID: accountID, + Checks: GuardrailChecks{ModelAllowlist: GuardrailModelAllowlist{Models: []string{}}}, + CreatedAt: now, + UpdatedAt: now, + } +} + +// FromAPIRequest applies the request payload onto the receiver. +func (g *Guardrail) FromAPIRequest(req *api.AgentNetworkGuardrailRequest) { + g.Name = req.Name + if req.Description != nil { + g.Description = *req.Description + } + g.Checks = checksFromAPI(req.Checks) +} + +// ToAPIResponse renders the guardrail as the API representation. +func (g *Guardrail) ToAPIResponse() *api.AgentNetworkGuardrail { + created := g.CreatedAt + updated := g.UpdatedAt + return &api.AgentNetworkGuardrail{ + Id: g.ID, + Name: g.Name, + Description: g.Description, + Checks: checksToAPI(g.Checks), + CreatedAt: &created, + UpdatedAt: &updated, + } +} + +// Copy returns a deep copy of the guardrail. +func (g *Guardrail) Copy() *Guardrail { + clone := *g + if g.Checks.ModelAllowlist.Models != nil { + clone.Checks.ModelAllowlist.Models = append([]string(nil), g.Checks.ModelAllowlist.Models...) + } + return &clone +} + +// EventMeta is the audit-log payload for activity events. +func (g *Guardrail) EventMeta() map[string]any { + return map[string]any{"name": g.Name} +} + +func checksFromAPI(c api.AgentNetworkGuardrailChecks) GuardrailChecks { + models := append([]string(nil), c.ModelAllowlist.Models...) + if models == nil { + models = []string{} + } + return GuardrailChecks{ + ModelAllowlist: GuardrailModelAllowlist{ + Enabled: c.ModelAllowlist.Enabled, + Models: models, + }, + PromptCapture: GuardrailPromptCapture{ + Enabled: c.PromptCapture.Enabled, + RedactPii: c.PromptCapture.RedactPii, + }, + } +} + +func checksToAPI(c GuardrailChecks) api.AgentNetworkGuardrailChecks { + models := c.ModelAllowlist.Models + if models == nil { + models = []string{} + } + out := api.AgentNetworkGuardrailChecks{} + out.ModelAllowlist.Enabled = c.ModelAllowlist.Enabled + out.ModelAllowlist.Models = models + out.PromptCapture.Enabled = c.PromptCapture.Enabled + out.PromptCapture.RedactPii = c.PromptCapture.RedactPii + return out +} diff --git a/management/internals/modules/agentnetwork/types/policy.go b/management/internals/modules/agentnetwork/types/policy.go new file mode 100644 index 000000000..709b8149d --- /dev/null +++ b/management/internals/modules/agentnetwork/types/policy.go @@ -0,0 +1,192 @@ +package types + +import ( + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Policy is an Agent Network policy persisted per account. A policy +// authorises members of SourceGroups to reach the listed +// DestinationProviderIDs under the attached GuardrailIDs and Limits. +// +// Token and budget limits live on the Policy itself (Limits field); +// guardrails carry only model allowlist and prompt capture. +type Policy struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Description string + Enabled bool + SourceGroups []string `gorm:"serializer:json;column:source_groups"` + DestinationProviderIDs []string `gorm:"serializer:json;column:destination_provider_ids"` + GuardrailIDs []string `gorm:"serializer:json;column:guardrail_ids"` + Limits PolicyLimits `gorm:"serializer:json;column:limits"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +// PolicyLimits aggregates the token and budget caps attached directly +// to a policy. Both halves are always present; their Enabled flags +// control whether the proxy enforces them. +type PolicyLimits struct { + TokenLimit PolicyTokenLimit `json:"token_limit"` + BudgetLimit PolicyBudgetLimit `json:"budget_limit"` +} + +// PolicyTokenLimit is a token-count cap evaluated over an aligned +// window of WindowSeconds seconds. GroupCap is applied to each +// source group independently — every group in the policy's +// SourceGroups gets its own bucket of GroupCap tokens. UserCap +// applies independently to each individual user. A zero cap means +// uncapped. WindowSeconds must be at least 60 (one minute) when the +// limit is enabled. +type PolicyTokenLimit struct { + Enabled bool `json:"enabled"` + GroupCap int64 `json:"group_cap"` + UserCap int64 `json:"user_cap"` + WindowSeconds int64 `json:"window_seconds"` +} + +// PolicyBudgetLimit is a USD spend cap evaluated over an aligned +// window of WindowSeconds seconds. GroupCapUsd is applied to each +// source group independently — every group in the policy's +// SourceGroups gets its own bucket of GroupCapUsd USD. UserCapUsd +// applies independently to each individual user. A zero cap means +// uncapped. WindowSeconds must be at least 60 (one minute) when the +// limit is enabled. +type PolicyBudgetLimit struct { + Enabled bool `json:"enabled"` + GroupCapUsd float64 `json:"group_cap_usd"` + UserCapUsd float64 `json:"user_cap_usd"` + WindowSeconds int64 `json:"window_seconds"` +} + +// TableName forces a unique GORM table to avoid collision with the access +// control Policy type, which also resolves to "policies" by default. +func (Policy) TableName() string { return "agent_network_policies" } + +// NewPolicy returns a new Policy with a freshly minted ID. +func NewPolicy(accountID string) *Policy { + now := time.Now().UTC() + return &Policy{ + ID: "ainpol_" + xid.New().String(), + AccountID: accountID, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } +} + +// FromAPIRequest applies the request payload onto the receiver. +func (p *Policy) FromAPIRequest(req *api.AgentNetworkPolicyRequest) { + p.Name = req.Name + if req.Description != nil { + p.Description = *req.Description + } + if req.Enabled != nil { + p.Enabled = *req.Enabled + } + p.SourceGroups = append([]string(nil), req.SourceGroups...) + p.DestinationProviderIDs = append([]string(nil), req.DestinationProviderIds...) + if req.GuardrailIds != nil { + p.GuardrailIDs = append([]string(nil), (*req.GuardrailIds)...) + } else { + p.GuardrailIDs = []string{} + } + if req.Limits != nil { + p.Limits = limitsFromAPI(*req.Limits) + } else { + p.Limits = PolicyLimits{} + } +} + +// ToAPIResponse renders the policy as the API representation. +func (p *Policy) ToAPIResponse() *api.AgentNetworkPolicy { + src := p.SourceGroups + if src == nil { + src = []string{} + } + dst := p.DestinationProviderIDs + if dst == nil { + dst = []string{} + } + guardrails := p.GuardrailIDs + if guardrails == nil { + guardrails = []string{} + } + created := p.CreatedAt + updated := p.UpdatedAt + return &api.AgentNetworkPolicy{ + Id: p.ID, + Name: p.Name, + Description: p.Description, + Enabled: p.Enabled, + SourceGroups: src, + DestinationProviderIds: dst, + GuardrailIds: guardrails, + Limits: limitsToAPI(p.Limits), + CreatedAt: &created, + UpdatedAt: &updated, + } +} + +// Copy returns a deep copy of the policy. +func (p *Policy) Copy() *Policy { + clone := *p + if p.SourceGroups != nil { + clone.SourceGroups = append([]string(nil), p.SourceGroups...) + } + if p.DestinationProviderIDs != nil { + clone.DestinationProviderIDs = append([]string(nil), p.DestinationProviderIDs...) + } + if p.GuardrailIDs != nil { + clone.GuardrailIDs = append([]string(nil), p.GuardrailIDs...) + } + return &clone +} + +// EventMeta is the audit-log payload for activity events. +func (p *Policy) EventMeta() map[string]any { + return map[string]any{ + "name": p.Name, + "enabled": p.Enabled, + } +} + +func limitsFromAPI(in api.AgentNetworkPolicyLimits) PolicyLimits { + return PolicyLimits{ + TokenLimit: PolicyTokenLimit{ + Enabled: in.TokenLimit.Enabled, + GroupCap: in.TokenLimit.GroupCap, + UserCap: in.TokenLimit.UserCap, + WindowSeconds: in.TokenLimit.WindowSeconds, + }, + BudgetLimit: PolicyBudgetLimit{ + Enabled: in.BudgetLimit.Enabled, + GroupCapUsd: in.BudgetLimit.GroupCapUsd, + UserCapUsd: in.BudgetLimit.UserCapUsd, + WindowSeconds: in.BudgetLimit.WindowSeconds, + }, + } +} + +func limitsToAPI(in PolicyLimits) api.AgentNetworkPolicyLimits { + return api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: in.TokenLimit.Enabled, + GroupCap: in.TokenLimit.GroupCap, + UserCap: in.TokenLimit.UserCap, + WindowSeconds: in.TokenLimit.WindowSeconds, + }, + BudgetLimit: api.AgentNetworkPolicyBudgetLimit{ + Enabled: in.BudgetLimit.Enabled, + GroupCapUsd: in.BudgetLimit.GroupCapUsd, + UserCapUsd: in.BudgetLimit.UserCapUsd, + WindowSeconds: in.BudgetLimit.WindowSeconds, + }, + } +} diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go new file mode 100644 index 000000000..28c8a94e2 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -0,0 +1,252 @@ +package types + +import ( + "fmt" + "strings" + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/util/crypt" +) + +// ProviderModel is one row in the provider's models list. The operator +// pins the per-1k input/output price for cost tracking; ID is the +// model identifier the upstream provider expects on the wire. +type ProviderModel struct { + ID string `json:"id"` + InputPer1k float64 `json:"input_per_1k"` + OutputPer1k float64 `json:"output_per_1k"` +} + +// Provider is an Agent Network AI provider record persisted per account. +// The proxy cluster fronting the account lives on the per-account +// agent-network Settings row, not on the Provider — every provider in +// an account routes through the same cluster. +type Provider struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + ProviderID string `gorm:"index:idx_agent_network_provider"` + Name string + // UpstreamURL is the full upstream URL (e.g. https://api.openai.com) + // the operator selected. + UpstreamURL string `gorm:"column:upstream_url"` + APIKey string `gorm:"column:api_key"` + // ExtraValues holds operator-typed values for catalog-declared + // ExtraHeaders (see catalog.Provider.ExtraHeaders). Keyed by + // header name (e.g. "x-portkey-config"); a non-empty value is + // stamped on every upstream request to this provider via the + // proxy's identity-inject middleware (anti-spoof Remove + Add). + // Empty / missing keys = no header stamped. Stored as a JSON + // blob so the schema doesn't grow per-catalog-entry. + ExtraValues map[string]string `gorm:"serializer:json;column:extra_values"` + // Models is the operator's curated list of models exposed by this + // provider together with their per-1k input/output prices (USD). + // Empty means all catalog models are allowed at catalog prices. + Models []ProviderModel `gorm:"serializer:json"` + Enabled bool + // SessionPrivateKey + SessionPublicKey are the ed25519 keypair the + // synthesised reverse-proxy service uses to sign / verify session + // JWTs after a successful OIDC handshake. Generated once on + // provider create and never rotated by the manager so existing + // session cookies survive provider edits. SessionPrivateKey is + // encrypted at rest via EncryptSensitiveData / + // DecryptSensitiveData; SessionPublicKey is plain. + SessionPrivateKey string `gorm:"column:session_private_key"` + SessionPublicKey string `gorm:"column:session_public_key"` + // IdentityHeaderUserID + IdentityHeaderGroups are the operator- + // chosen wire header names for HeaderPair-style identity + // injection on catalog entries that flag the shape as + // Customizable (e.g. Bifrost, where the operator picks between + // the always-on x-bf-lh- log-metadata family and the + // label-declared x-bf-dim- telemetry family). Empty value + // disables stamping for that dimension; the inject middleware + // already no-ops on empty header names. Catalog entries with + // Customizable=false ignore these fields and use the static + // header names defined in their HeaderPairInjection block. + IdentityHeaderUserID string `gorm:"column:identity_header_user_id"` + IdentityHeaderGroups string `gorm:"column:identity_header_groups"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName uses an explicit name so the Agent Network provider rows live +// in their own table, separate from any future "providers"-named entity. +func (Provider) TableName() string { return "agent_network_providers" } + +// NewProvider returns a new Provider with a freshly minted ID. +func NewProvider(accountID string) *Provider { + now := time.Now().UTC() + return &Provider{ + ID: xid.New().String(), + AccountID: accountID, + CreatedAt: now, + UpdatedAt: now, + } +} + +// FromAPIRequest applies the request payload onto the receiver. The api_key +// is only overwritten when the caller provided one — empty/nil leaves the +// existing key intact, so updates can omit it. +func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { + p.ProviderID = req.ProviderId + p.Name = req.Name + p.UpstreamURL = req.UpstreamUrl + if req.ApiKey != nil && strings.TrimSpace(*req.ApiKey) != "" { + p.APIKey = *req.ApiKey + } + if req.ExtraValues != nil { + // Replace the whole map (rather than merge) so unsetting a + // value on the dashboard actually clears it. Empty strings + // are dropped so we don't waste a row on no-op values. + next := make(map[string]string, len(*req.ExtraValues)) + for k, v := range *req.ExtraValues { + v = strings.TrimSpace(v) + if v != "" { + next[k] = v + } + } + if len(next) == 0 { + p.ExtraValues = nil + } else { + p.ExtraValues = next + } + } + p.Models = p.Models[:0] + if req.Models != nil { + for _, m := range *req.Models { + p.Models = append(p.Models, ProviderModel{ + ID: m.Id, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + }) + } + } + if p.Models == nil { + p.Models = []ProviderModel{} + } + if req.Enabled != nil { + p.Enabled = *req.Enabled + } + // Identity-header overrides for catalogs flagged Customizable. + // nil pointer = "field omitted on the wire" → leave the stored + // value untouched (per the openapi description). Empty string is + // an explicit clear that disables stamping for this dimension. + if req.IdentityHeaderUserId != nil { + p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId) + } + if req.IdentityHeaderGroups != nil { + p.IdentityHeaderGroups = strings.TrimSpace(*req.IdentityHeaderGroups) + } +} + +// ToAPIResponse renders the provider as the API representation. The API +// key is intentionally never surfaced. +func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { + models := make([]api.AgentNetworkProviderModel, 0, len(p.Models)) + for _, m := range p.Models { + models = append(models, api.AgentNetworkProviderModel{ + Id: m.ID, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + }) + } + created := p.CreatedAt + updated := p.UpdatedAt + resp := &api.AgentNetworkProvider{ + Id: p.ID, + ProviderId: p.ProviderID, + Name: p.Name, + UpstreamUrl: p.UpstreamURL, + Models: models, + Enabled: p.Enabled, + CreatedAt: &created, + UpdatedAt: &updated, + } + if len(p.ExtraValues) > 0 { + out := make(map[string]string, len(p.ExtraValues)) + for k, v := range p.ExtraValues { + out[k] = v + } + resp.ExtraValues = &out + } + if p.IdentityHeaderUserID != "" { + v := p.IdentityHeaderUserID + resp.IdentityHeaderUserId = &v + } + if p.IdentityHeaderGroups != "" { + v := p.IdentityHeaderGroups + resp.IdentityHeaderGroups = &v + } + return resp +} + +// Copy returns a deep copy of the provider. +func (p *Provider) Copy() *Provider { + clone := *p + if p.Models != nil { + clone.Models = append([]ProviderModel(nil), p.Models...) + } + if p.ExtraValues != nil { + clone.ExtraValues = make(map[string]string, len(p.ExtraValues)) + for k, v := range p.ExtraValues { + clone.ExtraValues[k] = v + } + } + return &clone +} + +// EventMeta is the audit-log payload for activity events. +func (p *Provider) EventMeta() map[string]any { + return map[string]any{ + "name": p.Name, + "provider_id": p.ProviderID, + } +} + +// EncryptSensitiveData encrypts the upstream API key and the session +// signing key in place. +func (p *Provider) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + if p.APIKey != "" { + encrypted, err := enc.Encrypt(p.APIKey) + if err != nil { + return fmt.Errorf("encrypt agent network provider api key: %w", err) + } + p.APIKey = encrypted + } + if p.SessionPrivateKey != "" { + encrypted, err := enc.Encrypt(p.SessionPrivateKey) + if err != nil { + return fmt.Errorf("encrypt agent network provider session key: %w", err) + } + p.SessionPrivateKey = encrypted + } + return nil +} + +// DecryptSensitiveData decrypts the upstream API key and the session +// signing key in place. +func (p *Provider) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + if p.APIKey != "" { + decrypted, err := enc.Decrypt(p.APIKey) + if err != nil { + return fmt.Errorf("decrypt agent network provider api key: %w", err) + } + p.APIKey = decrypted + } + if p.SessionPrivateKey != "" { + decrypted, err := enc.Decrypt(p.SessionPrivateKey) + if err != nil { + return fmt.Errorf("decrypt agent network provider session key: %w", err) + } + p.SessionPrivateKey = decrypted + } + return nil +} diff --git a/management/internals/modules/agentnetwork/types/settings.go b/management/internals/modules/agentnetwork/types/settings.go new file mode 100644 index 000000000..d61d9deff --- /dev/null +++ b/management/internals/modules/agentnetwork/types/settings.go @@ -0,0 +1,78 @@ +package types + +import ( + "time" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// DefaultAccessLogRetentionDays is the retention applied to new accounts' +// agent-network access logs. Usage records are not subject to this — they are +// the long-term aggregate and are retained independently. +const DefaultAccessLogRetentionDays = 30 + +// Settings is the per-account agent-network configuration row. One +// row per account. Cluster + Subdomain are immutable once written and +// produce the public endpoint agents call (`.`). +type Settings struct { + AccountID string `gorm:"primaryKey"` + Cluster string + Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"` + + // Account-level collection controls sourced by the synthesizer. + // EnableLogCollection gates the per-request access-log trail and defaults + // ON for new accounts. EnablePromptCollection is the master gate for + // request/response prompt capture (AND-gated with the policy-level + // guardrail). RedactPii enables PII redaction on captured prompts; + // effective redaction is account OR policy. + EnableLogCollection bool + EnablePromptCollection bool + RedactPii bool + + // AccessLogRetentionDays bounds how long full access-log rows are kept; a + // periodic sweep deletes older rows. <= 0 means keep indefinitely. Usage + // records are unaffected. + AccessLogRetentionDays int + + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName puts the rows in their own table to keep the agent-network +// schema cohesive. +func (Settings) TableName() string { return "agent_network_settings" } + +// Endpoint returns the bare hostname agents reach this account at: +// `.`. +func (s *Settings) Endpoint() string { + return s.Subdomain + "." + s.Cluster +} + +// ToAPIResponse renders the settings as the API representation. +func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings { + created := s.CreatedAt + updated := s.UpdatedAt + retention := s.AccessLogRetentionDays + return &api.AgentNetworkSettings{ + Cluster: s.Cluster, + Subdomain: s.Subdomain, + Endpoint: s.Endpoint(), + EnableLogCollection: s.EnableLogCollection, + EnablePromptCollection: s.EnablePromptCollection, + RedactPii: s.RedactPii, + AccessLogRetentionDays: &retention, + CreatedAt: &created, + UpdatedAt: &updated, + } +} + +// FromAPIRequest applies the mutable settings fields from the request. Cluster +// and Subdomain are immutable and intentionally not touched here. +func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) { + s.EnableLogCollection = req.EnableLogCollection + s.EnablePromptCollection = req.EnablePromptCollection + s.RedactPii = req.RedactPii + if req.AccessLogRetentionDays != nil { + s.AccessLogRetentionDays = *req.AccessLogRetentionDays + } +} diff --git a/management/internals/modules/agentnetwork/types/usage.go b/management/internals/modules/agentnetwork/types/usage.go new file mode 100644 index 000000000..dd01d4300 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/usage.go @@ -0,0 +1,47 @@ +package types + +import ( + "time" +) + +// AgentNetworkUsage is the stripped, always-collected per-request usage record +// powering the Usage overview. Unlike AgentNetworkAccessLog it carries no +// request detail (host/path/source IP/prompt) — only the dimensions needed to +// aggregate and filter spend by user / group / provider / model over time. +// +// It is written unconditionally on every served agent-network request, +// independent of the account's EnableLogCollection toggle: when log collection +// is off the proxy ships a stripped, usage-only entry and management still +// records the usage row (but skips the full AgentNetworkAccessLog row). +type AgentNetworkUsage struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Timestamp time.Time `gorm:"index"` + UserID string `gorm:"index"` + ResolvedProviderID string `gorm:"index"` + Provider string // vendor, e.g. "openai" + Model string `gorm:"index"` + SessionID string `gorm:"index"` // llm.session_id — groups a conversation / coding session + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CostUSD float64 + CreatedAt time.Time +} + +// TableName keeps usage records in their own stripped table. Named +// distinctly (…_request_usage) to avoid colliding with any pre-existing +// agent_network_usage table in a shared database. +func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" } + +// AgentNetworkUsageGroup is the normalised many-to-many row linking a usage +// record to one authorising group, mirroring AgentNetworkAccessLogGroup so the +// usage overview can filter by group with a `group_id IN (...)` join. +type AgentNetworkUsageGroup struct { + UsageID string `gorm:"primaryKey"` + GroupID string `gorm:"primaryKey;index"` + AccountID string `gorm:"index"` +} + +// TableName names the usage group child table. +func (AgentNetworkUsageGroup) TableName() string { return "agent_network_request_usage_group" } diff --git a/management/internals/modules/agentnetwork/types/usageoverview.go b/management/internals/modules/agentnetwork/types/usageoverview.go new file mode 100644 index 000000000..658832bec --- /dev/null +++ b/management/internals/modules/agentnetwork/types/usageoverview.go @@ -0,0 +1,96 @@ +package types + +import ( + "sort" + "time" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// UsageGranularity is the time-bucket width for the usage overview. New values +// can be added here and handled in bucketStart without touching the store. +type UsageGranularity string + +const ( + UsageGranularityDay UsageGranularity = "day" + UsageGranularityWeek UsageGranularity = "week" + UsageGranularityMonth UsageGranularity = "month" +) + +// ParseUsageGranularity maps the API query value to a granularity, defaulting +// to day for empty/unknown input. +func ParseUsageGranularity(s string) UsageGranularity { + switch UsageGranularity(s) { + case UsageGranularityWeek: + return UsageGranularityWeek + case UsageGranularityMonth: + return UsageGranularityMonth + default: + return UsageGranularityDay + } +} + +// AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is +// the UTC start of the bucket as YYYY-MM-DD. +type AgentNetworkUsageBucket struct { + PeriodStart string + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CostUSD float64 +} + +// ToAPIResponse renders the bucket as the API representation. +func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket { + return api.AgentNetworkUsageBucket{ + PeriodStart: b.PeriodStart, + InputTokens: b.InputTokens, + OutputTokens: b.OutputTokens, + TotalTokens: b.TotalTokens, + CostUsd: b.CostUSD, + } +} + +// bucketStart truncates t (in UTC) to the start of its bucket for the given +// granularity. Week buckets start on Monday (ISO week). +func bucketStart(t time.Time, g UsageGranularity) time.Time { + t = t.UTC() + switch g { + case UsageGranularityWeek: + // Monday-start week. time.Weekday: Sunday=0..Saturday=6. + offset := (int(t.Weekday()) + 6) % 7 + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + return day.AddDate(0, 0, -offset) + case UsageGranularityMonth: + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + default: // day + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + } +} + +// AggregateUsageByGranularity buckets the usage rows by the requested +// granularity and returns the buckets ordered oldest-first. Aggregation is done +// in Go (rather than per-engine SQL date_trunc) so granularities stay portable +// across SQLite/Postgres/MySQL and easy to extend. +func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket { + byPeriod := make(map[string]*AgentNetworkUsageBucket) + for _, r := range rows { + key := bucketStart(r.Timestamp, g).Format("2006-01-02") + b := byPeriod[key] + if b == nil { + b = &AgentNetworkUsageBucket{PeriodStart: key} + byPeriod[key] = b + } + b.InputTokens += r.InputTokens + b.OutputTokens += r.OutputTokens + b.TotalTokens += r.TotalTokens + b.CostUSD += r.CostUSD + } + + out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod)) + for _, b := range byPeriod { + out = append(out, b) + } + sort.Slice(out, func(i, j int) bool { return out[i].PeriodStart < out[j].PeriodStart }) + return out +} diff --git a/management/internals/modules/agentnetwork/wire_shape_test.go b/management/internals/modules/agentnetwork/wire_shape_test.go new file mode 100644 index 000000000..b574ab3e1 --- /dev/null +++ b/management/internals/modules/agentnetwork/wire_shape_test.go @@ -0,0 +1,109 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestSynthesizedService_WireShape locks down the proto shape that +// flows from the synthesizer through ToProtoMapping to the proxy. +// Drift between this test and what the proxy expects manifests as +// "service not matching" — the proxy receives a mapping but can't +// register an SNI/HTTP route from it. +func TestSynthesizedService_WireShape(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + svc := services[0] + mapping := svc.ToProtoMapping(rpservice.Create, "test-token", proxy.OIDCValidationConfig{}) + + // Identifiers — account-scoped service ID, settings-derived domain. + assert.Equal(t, "agent-net-svc-acct-1", mapping.GetId(), "stable account-scoped virtual service ID") + assert.Equal(t, testAccountID, mapping.GetAccountId(), "account id round-trips") + assert.Equal(t, testEndpoint, mapping.GetDomain(), "domain matches settings.Endpoint() output") + + // Mode + listen port — addMapping at proxy/server.go switches on Mode. + assert.Equal(t, "http", mapping.GetMode(), "synthesised services are HTTP mode") + assert.Equal(t, int32(0), mapping.GetListenPort(), "no custom listen port for HTTP services") + + // Auth token + private/tunnel shape: agent-network endpoints authenticate + // inbound agents via ValidateTunnelPeer against AccessGroups, not OIDC. + assert.Equal(t, "test-token", mapping.GetAuthToken(), "auth token round-trips for proxy CreateProxyPeer") + assert.True(t, mapping.GetPrivate(), "synthesised services are private (tunnel-peer auth via AccessGroups)") + require.NotNil(t, mapping.GetAuth(), "auth payload carries the session key") + assert.False(t, mapping.GetAuth().GetOidc(), "OIDC is off for tunnel-auth agent-network services") + + // Path mappings — proxy/server.go::setupHTTPMapping early-returns when + // len(mapping.GetPath()) == 0, so this is a critical assertion. + require.Len(t, mapping.GetPath(), 1, "exactly one path mapping for the cluster target") + pm := mapping.GetPath()[0] + assert.Equal(t, "/", pm.GetPath(), "default path is '/'") + assert.Equal(t, "https://noop.invalid/", pm.GetTarget(), + "target URL is the placeholder; the router middleware rewrites it per request") + require.NotNil(t, pm.GetOptions(), "target options must be populated so direct_upstream + middleware chain reach the proxy") + assert.True(t, pm.GetOptions().GetDirectUpstream(), "synth targets imply direct_upstream so the proxy dials via the host stack") + assert.True(t, pm.GetOptions().GetAgentNetwork(), "agent_network flag must travel on the wire so the proxy can tag access logs") + + mws := pm.GetOptions().GetMiddlewares() + require.Len(t, mws, 8, "eight middlewares reach the proxy: request_parser, router, limit_check, identity_inject, guardrail, limit_record, cost_meter, response_parser") + + assert.Equal(t, middlewareIDLLMRequestParser, mws[0].GetId(), "first middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[0].GetSlot(), "request parser slot") + + assert.Equal(t, middlewareIDLLMRouter, mws[1].GetId(), "second middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[1].GetSlot(), "router slot") + require.NotEmpty(t, mws[1].GetConfigJson(), "router config must travel on the wire") + var routerCfg routerConfig + require.NoError(t, json.Unmarshal(mws[1].GetConfigJson(), &routerCfg), "router config decodes") + require.Len(t, routerCfg.Providers, 1, "the only enabled provider reaches the router") + assert.Equal(t, provider.ID, routerCfg.Providers[0].ID, "router provider id matches synth provider") + assert.Equal(t, "Bearer sk-test-key", routerCfg.Providers[0].AuthHeaderValue, + "openai catalog template substitutes the API key on the wire") + + assert.Equal(t, middlewareIDLLMLimitCheck, mws[2].GetId(), + "limit_check runs after the router so the resolved provider id is available, before identity_inject so a deny doesn't pay the header-stamp cost") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[2].GetSlot()) + + assert.Equal(t, middlewareIDLLMIdentityInject, mws[3].GetId(), "fourth middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[3].GetSlot(), "identity inject slot") + require.NotEmpty(t, mws[3].GetConfigJson(), "identity inject config JSON must travel on the wire") + + assert.Equal(t, middlewareIDLLMGuardrail, mws[4].GetId(), "fifth middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[4].GetSlot(), "guardrail slot") + require.NotEmpty(t, mws[4].GetConfigJson(), "guardrail middleware config JSON must travel on the wire") + + assert.Equal(t, middlewareIDLLMLimitRecord, mws[5].GetId(), + "limit_record sits FIRST in the response section so it RUNS LAST at runtime — slot order on the response leg is reverse-of-slice") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[5].GetSlot()) + + assert.Equal(t, middlewareIDCostMeter, mws[6].GetId(), "seventh middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[6].GetSlot(), "cost meter slot") + + assert.Equal(t, middlewareIDLLMResponseParser, mws[7].GetId(), "eighth middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[7].GetSlot(), "response parser slot") +} diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index e22d1e6e0..239d6b09c 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -220,12 +220,36 @@ func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, er func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error { existingPeerID, err := m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) if err == nil && existingPeerID != "" { - // Peer already exists + // Same pubkey already registered — idempotent. return nil } + // Dedupe stale embedded peer records for the same (account, cluster). + // The proxy generates a fresh WireGuard keypair on every startup + // (proxy/internal/roundtrip/netbird.go), so without this sweep the + // prior embedded peer would linger forever — holding its CGNAT IP + // allocation, polluting other peers' rosters, and (most visibly) + // leaving the synth DNS pointing at the dead address. The + // (account, cluster) tuple identifies "the embedded peer for this + // proxy instance at this cluster"; any record matching that tuple + // with a different pubkey is by definition stale and must go. + staleIDs, err := m.findStaleEmbeddedProxyPeers(ctx, accountID, cluster, peerKey) + if err != nil { + return fmt.Errorf("scan for stale embedded proxy peers: %w", err) + } + if len(staleIDs) > 0 { + // userID="" + checkConnected=false: the deletion is initiated + // by management itself on behalf of the freshly-registering + // proxy, not by an end user; the stale peer may still be + // marked Connected from its prior session, but its session is + // dead by definition (its key no longer exists). + if err := m.DeletePeers(ctx, accountID, staleIDs, "", false); err != nil { + return fmt.Errorf("delete stale embedded proxy peers %v: %w", staleIDs, err) + } + } + name := fmt.Sprintf("proxy-%s", xid.New().String()) - peer := &peer.Peer{ + newPeer := &peer.Peer{ Ephemeral: true, ProxyMeta: peer.ProxyMeta{ Cluster: cluster, @@ -242,10 +266,36 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee }, } - _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true) + _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", newPeer, true) if err != nil { return fmt.Errorf("failed to create proxy peer: %w", err) } return nil } + +// findStaleEmbeddedProxyPeers returns the peer IDs of embedded proxy peer +// records in accountID that target the same cluster but carry a different +// WireGuard pubkey than the freshly-registering one. Used by CreateProxyPeer +// to garbage-collect stale records left behind when the proxy restarts with a +// regenerated keypair. +func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID, cluster, newKey string) ([]string, error) { + account, err := m.store.GetAccount(ctx, accountID) + if err != nil { + return nil, err + } + var stale []string + for _, p := range account.Peers { + if p == nil || !p.ProxyMeta.Embedded { + continue + } + if p.ProxyMeta.Cluster != cluster { + continue + } + if p.Key == newKey { + continue + } + stale = append(stale, p.ID) + } + return stale, nil +} diff --git a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go index f2ecfd5f9..6705eab1a 100644 --- a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go +++ b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go @@ -39,6 +39,10 @@ type AccessLogEntry struct { BytesDownload int64 `gorm:"index"` Protocol AccessLogProtocol `gorm:"index"` Metadata map[string]string `gorm:"serializer:json"` + // AgentNetwork marks the entry as emitted by a synthesised agent-network + // service. Sourced from proto.AccessLog.AgentNetwork the proxy stamps + // before shipping. Indexed so the agent-network log surface filters cheaply. + AgentNetwork bool `gorm:"index"` } // FromProto creates an AccessLogEntry from a proto.AccessLog @@ -58,6 +62,7 @@ func (a *AccessLogEntry) FromProto(serviceLog *proto.AccessLog) { a.BytesDownload = serviceLog.GetBytesDownload() a.Protocol = AccessLogProtocol(serviceLog.GetProtocol()) a.Metadata = maps.Clone(serviceLog.GetMetadata()) + a.AgentNetwork = serviceLog.GetAgentNetwork() if sourceIP := serviceLog.GetSourceIp(); sourceIP != "" { if addr, err := netip.ParseAddr(sourceIP); err == nil { diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go index ced2ec4d1..4b24adaa1 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -7,6 +7,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/permissions" @@ -31,8 +32,14 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, geo g } } -// SaveAccessLog saves an access log entry to the database after enriching it +// SaveAccessLog saves an access log entry to the database after enriching it. +// Agent-network entries are flattened into their own dedicated table (queryable +// LLM columns + group child rows) instead of the shared reverse-proxy table. func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { + if logEntry.AgentNetwork { + return agentnetwork.IngestAccessLog(ctx, m.store, logEntry) + } + if m.geo != nil && logEntry.GeoLocation.ConnectionIP != nil { location, err := m.geo.Lookup(logEntry.GeoLocation.ConnectionIP) if err != nil { diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go index ee1e3c8b2..b6438abde 100644 --- a/management/internals/modules/reverseproxy/service/service.go +++ b/management/internals/modules/reverseproxy/service/service.go @@ -66,6 +66,51 @@ type TargetOptions struct { // reachable without WireGuard (public APIs, LAN services, localhost // sidecars). Default false. DirectUpstream bool `json:"direct_upstream,omitempty"` + // Middlewares carries per-target agent-network middleware configs. Empty + // for private and operator-defined services; populated only by the + // agent-network synthesizer. + Middlewares []MiddlewareConfig `gorm:"serializer:json" json:"middlewares,omitempty"` + CaptureMaxRequestBytes int64 `json:"capture_max_request_bytes,omitempty"` + CaptureMaxResponseBytes int64 `json:"capture_max_response_bytes,omitempty"` + CaptureContentTypes []string `gorm:"serializer:json" json:"capture_content_types,omitempty"` + // AgentNetwork marks targets synthesised from Agent Network state. The + // proxy uses it to gate agent-network-specific behaviour (access log + // tagging, observability, etc.). + AgentNetwork bool `json:"agent_network,omitempty"` + // DisableAccessLog suppresses the per-request access-log emission for this + // target. Defaults false to preserve access-log behaviour for every + // non-agent-network target. The agent-network synthesizer sets this true + // only when the account's EnableLogCollection toggle is off. + DisableAccessLog bool `json:"disable_access_log,omitempty"` +} + +// MiddlewareSlot mirrors proto.MiddlewareSlot / middleware.Slot. +type MiddlewareSlot string + +const ( + MiddlewareSlotOnRequest MiddlewareSlot = "on_request" + MiddlewareSlotOnResponse MiddlewareSlot = "on_response" + MiddlewareSlotTerminal MiddlewareSlot = "terminal" +) + +// MiddlewareFailMode mirrors proto.MiddlewareConfig_FailMode. +type MiddlewareFailMode string + +const ( + MiddlewareFailOpen MiddlewareFailMode = "fail_open" + MiddlewareFailClosed MiddlewareFailMode = "fail_closed" +) + +// MiddlewareConfig is the per-target configuration for a single +// middleware instance. Mirrors proto.MiddlewareConfig. +type MiddlewareConfig struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Slot MiddlewareSlot `json:"slot"` + ConfigJSON []byte `json:"config_json,omitempty"` + FailMode MiddlewareFailMode `json:"fail_mode,omitempty"` + TimeoutMs int32 `json:"timeout_ms,omitempty"` + CanMutate bool `json:"can_mutate"` } type Target struct { @@ -504,21 +549,75 @@ func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions { func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions { if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 && - len(opts.CustomHeaders) == 0 && !opts.DirectUpstream { + len(opts.CustomHeaders) == 0 && !opts.DirectUpstream && + len(opts.Middlewares) == 0 && opts.CaptureMaxRequestBytes == 0 && + opts.CaptureMaxResponseBytes == 0 && len(opts.CaptureContentTypes) == 0 && + !opts.AgentNetwork && !opts.DisableAccessLog { return nil } popts := &proto.PathTargetOptions{ - SkipTlsVerify: opts.SkipTLSVerify, - PathRewrite: pathRewriteToProto(opts.PathRewrite), - CustomHeaders: opts.CustomHeaders, - DirectUpstream: opts.DirectUpstream, + SkipTlsVerify: opts.SkipTLSVerify, + PathRewrite: pathRewriteToProto(opts.PathRewrite), + CustomHeaders: opts.CustomHeaders, + DirectUpstream: opts.DirectUpstream, + AgentNetwork: opts.AgentNetwork, + DisableAccessLog: opts.DisableAccessLog, } if opts.RequestTimeout != 0 { popts.RequestTimeout = durationpb.New(opts.RequestTimeout) } + if len(opts.Middlewares) > 0 { + popts.Middlewares = middlewaresToProto(opts.Middlewares) + } + popts.CaptureMaxRequestBytes = opts.CaptureMaxRequestBytes + popts.CaptureMaxResponseBytes = opts.CaptureMaxResponseBytes + if len(opts.CaptureContentTypes) > 0 { + popts.CaptureContentTypes = append([]string(nil), opts.CaptureContentTypes...) + } return popts } +// middlewaresToProto converts the internal middleware slice to the proto +// representation sent to the proxy via the mapping stream. +func middlewaresToProto(in []MiddlewareConfig) []*proto.MiddlewareConfig { + out := make([]*proto.MiddlewareConfig, 0, len(in)) + for _, m := range in { + pm := &proto.MiddlewareConfig{ + Id: m.ID, + Enabled: m.Enabled, + Slot: middlewareSlotToProto(m.Slot), + ConfigJson: append([]byte(nil), m.ConfigJSON...), + CanMutate: m.CanMutate, + FailMode: middlewareFailModeToProto(m.FailMode), + } + if m.TimeoutMs > 0 { + pm.Timeout = durationpb.New(time.Duration(m.TimeoutMs) * time.Millisecond) + } + out = append(out, pm) + } + return out +} + +func middlewareSlotToProto(s MiddlewareSlot) proto.MiddlewareSlot { + switch s { + case MiddlewareSlotOnRequest: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST + case MiddlewareSlotOnResponse: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE + case MiddlewareSlotTerminal: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL + default: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED + } +} + +func middlewareFailModeToProto(m MiddlewareFailMode) proto.MiddlewareConfig_FailMode { + if m == MiddlewareFailClosed { + return proto.MiddlewareConfig_FAIL_CLOSED + } + return proto.MiddlewareConfig_FAIL_OPEN +} + // l4TargetOptionsToProto converts L4-relevant target options to proto. func l4TargetOptionsToProto(target *Target) *proto.PathTargetOptions { if !target.ProxyProtocol && target.Options.RequestTimeout == 0 && target.Options.SessionIdleTimeout == 0 { diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index ae82b60fe..1c78af9d0 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -26,9 +26,11 @@ import ( "github.com/netbirdio/netbird/formatter/hook" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" nbcache "github.com/netbirdio/netbird/management/server/cache" nbContext "github.com/netbirdio/netbird/management/server/context" nbhttp "github.com/netbirdio/netbird/management/server/http" @@ -120,7 +122,7 @@ func (s *BaseServer) EventStore() activity.Store { func (s *BaseServer) APIHandler() http.Handler { return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount) + httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager()) if err != nil { log.Fatalf("failed to create API handler: %v", err) } @@ -223,11 +225,35 @@ func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { s.AfterInit(func(s *BaseServer) { proxyService.SetServiceManager(s.ServiceManager()) proxyService.SetProxyController(s.ServiceProxyController()) + proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store())) + proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager()) }) return proxyService }) } +// agentNetworkSynthesizerAdapter implements nbgrpc.AgentNetworkSynthesizer by +// delegating to the agentnetwork package's store-backed synthesiser. +type agentNetworkSynthesizerAdapter struct { + store store.Store +} + +func newAgentNetworkSynthesizer(s store.Store) *agentNetworkSynthesizerAdapter { + return &agentNetworkSynthesizerAdapter{store: s} +} + +func (a *agentNetworkSynthesizerAdapter) SynthesizeServicesForCluster(ctx context.Context, clusterAddr string) ([]*rpservice.Service, error) { + return agentnetwork.SynthesizeServicesForCluster(ctx, a.store, clusterAddr) +} + +func (a *agentNetworkSynthesizerAdapter) SynthesizeServicesForAccount(ctx context.Context, accountID string) ([]*rpservice.Service, error) { + return agentnetwork.SynthesizeServices(ctx, a.store, accountID) +} + +func (a *agentNetworkSynthesizerAdapter) SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + return agentnetwork.SynthesizeServiceForDomain(ctx, a.store, domain) +} + func (s *BaseServer) proxyOIDCConfig() nbgrpc.ProxyOIDCConfig { return Create(s, func() nbgrpc.ProxyOIDCConfig { return nbgrpc.ProxyOIDCConfig{ diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index a70da855a..6b1365f3b 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -20,6 +20,7 @@ import ( recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/idp" @@ -194,6 +195,24 @@ func (s *BaseServer) NetworksManager() networks.Manager { }) } +func (s *BaseServer) AgentNetworkManager() agentnetwork.Manager { + return Create(s, func() agentnetwork.Manager { + mgr := agentnetwork.NewManager( + s.Store(), + s.PermissionsManager(), + s.AccountManager(), + s.ServiceProxyController(), + ) + // Sweep expired agent-network access logs per account retention, + // reusing the reverse-proxy cleanup interval config. + mgr.StartAccessLogCleanup( + context.Background(), + s.Config.ReverseProxy.AccessLogCleanupIntervalHours, + ) + return mgr + }) +} + func (s *BaseServer) ZonesManager() zones.Manager { return Create(s, func() zones.Manager { return zonesManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.DNSDomain()) diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 76663f898..0dfa24bc4 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "math" "net" "net/http" "net/url" @@ -35,6 +36,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/users" proxyauth "github.com/netbirdio/netbird/proxy/auth" @@ -60,6 +62,23 @@ type ProxyTokenChecker interface { } // ProxyServiceServer implements the ProxyService gRPC server +// AgentNetworkSynthesizer produces in-memory reverse-proxy services from +// Agent Network provider/policy state for the proxy snapshot path; synthesised +// services never appear in the reverseproxy_services table. +type AgentNetworkSynthesizer interface { + SynthesizeServicesForCluster(ctx context.Context, clusterAddr string) ([]*rpservice.Service, error) + SynthesizeServicesForAccount(ctx context.Context, accountID string) ([]*rpservice.Service, error) + SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error) +} + +// AgentNetworkLimitsService is the minimal slice of agentnetwork.Manager the +// gRPC layer needs for CheckLLMPolicyLimits + RecordLLMUsage — kept narrow so +// the grpc package doesn't take a hard import on the full manager. +type AgentNetworkLimitsService interface { + SelectPolicyForRequest(ctx context.Context, in agentnetwork.PolicySelectionInput) (*agentnetwork.PolicySelectionResult, error) + RecordUsage(ctx context.Context, in agentnetwork.RecordUsageInput) error +} + type ProxyServiceServer struct { proto.UnimplementedProxyServiceServer @@ -72,6 +91,14 @@ type ProxyServiceServer struct { mu sync.RWMutex // Manager for reverse proxy operations serviceManager rpservice.Manager + // agentNetworkSynth produces synthesised reverse-proxy services from + // Agent Network state. Optional — when nil the snapshot path only ships + // persisted services. + agentNetworkSynth AgentNetworkSynthesizer + // agentNetworkLimits handles the pre-flight selection (CheckLLMPolicyLimits) + // and the post-flight consumption write (RecordLLMUsage). Optional — when + // nil both RPCs return Unimplemented. + agentNetworkLimits AgentNetworkLimitsService // ProxyController for service updates and cluster management proxyController proxy.Controller @@ -209,6 +236,127 @@ func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) { s.serviceManager = manager } +// SetAgentNetworkSynthesizer wires the agent-network service synthesiser. +// Optional — when nil the snapshot path skips agent-network synthesis. The +// modules layer injects this after both the proxy server and the agent-network +// manager are constructed. +func (s *ProxyServiceServer) SetAgentNetworkSynthesizer(synth AgentNetworkSynthesizer) { + s.mu.Lock() + s.agentNetworkSynth = synth + s.mu.Unlock() +} + +// SetAgentNetworkLimitsService wires the policy-selection + post-flight +// consumption sink. Pass nil to disable; both RPCs return Unimplemented while +// unset so partial wiring surfaces during integration. +func (s *ProxyServiceServer) SetAgentNetworkLimitsService(svc AgentNetworkLimitsService) { + s.mu.Lock() + s.agentNetworkLimits = svc + s.mu.Unlock() +} + +// agentNetworkSynthesizer returns the synthesiser under read lock. +func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer { + s.mu.RLock() + defer s.mu.RUnlock() + return s.agentNetworkSynth +} + +// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before +// forwarding an LLM request upstream. Delegates to the agent-network selector, +// which scores applicable policies by remaining headroom and returns the +// policy that pays for this request (or a deny when all are exhausted). +func (s *ProxyServiceServer) CheckLLMPolicyLimits(ctx context.Context, req *proto.CheckLLMPolicyLimitsRequest) (*proto.CheckLLMPolicyLimitsResponse, error) { + s.mu.RLock() + svc := s.agentNetworkLimits + s.mu.RUnlock() + if svc == nil { + return nil, status.Errorf(codes.Unimplemented, "agent-network limits service not configured on management") + } + if req.GetAccountId() == "" { + return nil, status.Errorf(codes.InvalidArgument, "account_id is required") + } + if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil { + return nil, err + } + + res, err := svc.SelectPolicyForRequest(ctx, agentnetwork.PolicySelectionInput{ + AccountID: req.GetAccountId(), + UserID: req.GetUserId(), + GroupIDs: req.GetGroupIds(), + ProviderID: req.GetProviderId(), + }) + if err != nil { + log.WithContext(ctx).Errorf("select policy for request: %v", err) + return nil, status.Error(codes.Internal, "select policy failed") + } + + if !res.Allow { + return &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + SelectedPolicyId: res.SelectedPolicyID, + AttributionGroupId: res.AttributionGroupID, + WindowSeconds: res.WindowSeconds, + DenyCode: res.DenyCode, + DenyReason: res.DenyReason, + }, nil + } + return &proto.CheckLLMPolicyLimitsResponse{ + Decision: "allow", + SelectedPolicyId: res.SelectedPolicyID, + AttributionGroupId: res.AttributionGroupID, + WindowSeconds: res.WindowSeconds, + }, nil +} + +// RecordLLMUsage increments the per-(dimension, window) consumption counter for +// the user and optional attribution group after a served request. Returns +// Unimplemented when the agent-network limits service hasn't been wired. +func (s *ProxyServiceServer) RecordLLMUsage(ctx context.Context, req *proto.RecordLLMUsageRequest) (*proto.RecordLLMUsageResponse, error) { + s.mu.RLock() + svc := s.agentNetworkLimits + s.mu.RUnlock() + if svc == nil { + return nil, status.Errorf(codes.Unimplemented, "agent-network limits service not configured on management") + } + + accountID := req.GetAccountId() + if accountID == "" { + return nil, status.Errorf(codes.InvalidArgument, "account_id is required") + } + if err := enforceAccountScope(ctx, accountID); err != nil { + return nil, err + } + tokensIn := req.GetTokensInput() + tokensOut := req.GetTokensOutput() + costUSD := req.GetCostUsd() + + // Reject impossible counters at the boundary instead of recording them: + // a negative window, negative tokens, or a negative / non-finite cost + // would otherwise decrement or poison the persisted consumption totals. + if req.GetWindowSeconds() < 0 || tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return nil, status.Errorf(codes.InvalidArgument, "usage counters must be non-negative and finite") + } + + // Book the policy-window dimensions (when a policy cap bound this request) + // and every applicable account budget rule's window in a single batched + // transaction. + if err := svc.RecordUsage(ctx, agentnetwork.RecordUsageInput{ + AccountID: accountID, + UserID: req.GetUserId(), + AttributionGroupID: req.GetGroupId(), + GroupIDs: req.GetGroupIds(), + WindowSeconds: req.GetWindowSeconds(), + TokensIn: tokensIn, + TokensOut: tokensOut, + CostUSD: costUSD, + }); err != nil { + log.WithContext(ctx).Errorf("record usage: %v", err) + return nil, status.Error(codes.Internal, "record usage failed") + } + return &proto.RecordLLMUsageResponse{}, nil +} + // SetProxyController sets the proxy controller. Must be called before serving. func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller) { s.mu.Lock() @@ -623,12 +771,40 @@ func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn * return nil, fmt.Errorf("get services from store: %w", err) } + if synth := s.agentNetworkSynthesizer(); synth != nil { + var synthesised []*rpservice.Service + var serr error + // Account-scoped connections synthesise only their own account, so the + // snapshot can never carry another tenant's mappings (which embed the + // upstream auth header derived from that tenant's provider API key). + // Global connections still see the whole cluster. + if conn.accountID != nil { + synthesised, serr = synth.SynthesizeServicesForAccount(ctx, *conn.accountID) + } else { + synthesised, serr = synth.SynthesizeServicesForCluster(ctx, conn.address) + } + if serr != nil { + // Surface a real synthesis failure instead of silently shipping an + // incomplete snapshot (which would drop the account's agent-network + // routes). Consistent with the persisted-services error above; the + // proxy retries the snapshot on connection error. + return nil, fmt.Errorf("synthesise agent-network services: %w", serr) + } + services = append(services, synthesised...) + } + oidcCfg := s.GetOIDCValidationConfig() var mappings []*proto.ProxyMapping for _, service := range services { if !service.Enabled || service.ProxyCluster == "" || service.ProxyCluster != conn.address { continue } + // Defense in depth: an account-scoped proxy must never receive another + // account's mapping, matching the per-account filtering the incremental + // update path already applies. + if conn.accountID != nil && service.AccountID != *conn.accountID { + continue + } m := service.ToProtoMapping(rpservice.Create, "", oidcCfg) if !proxyAcceptsMapping(conn, m) { @@ -1617,7 +1793,29 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val } func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { - return s.serviceManager.GetServiceByDomain(ctx, domain) + service, err := s.serviceManager.GetServiceByDomain(ctx, domain) + if err == nil { + return service, nil + } + + // Fall back to the Agent Network synthesiser scoped directly to the domain's + // account. Synthesised services are never persisted, so they must resolve + // here for OIDC / session / tunnel-peer flows against agent-network + // endpoints. Resolving by domain synthesises only the owning account rather + // than every tenant on the cluster. + if synth := s.agentNetworkSynthesizer(); synth != nil { + svc, serr := synth.SynthesizeServiceForDomain(ctx, domain) + if serr != nil { + // A real synthesis failure must surface, not be masked by the + // original store miss — otherwise a transient DB error looks like + // "no such service". + return nil, fmt.Errorf("synthesize agent-network service for %s: %w", domain, serr) + } + if svc != nil { + return svc, nil + } + } + return nil, err } func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *types.User) error { diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index 852193a3b..cfd809871 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -245,6 +245,37 @@ const ( // tunnel. Distinct from UserLoggedInPeer (full interactive login). UserExtendedPeerSession Activity = 125 + // AgentNetworkProviderCreated indicates that a user created an Agent Network provider + AgentNetworkProviderCreated Activity = 126 + // AgentNetworkProviderUpdated indicates that a user updated an Agent Network provider + AgentNetworkProviderUpdated Activity = 127 + // AgentNetworkProviderDeleted indicates that a user deleted an Agent Network provider + AgentNetworkProviderDeleted Activity = 128 + + // AgentNetworkPolicyCreated indicates that a user created an Agent Network policy + AgentNetworkPolicyCreated Activity = 129 + // AgentNetworkPolicyUpdated indicates that a user updated an Agent Network policy + AgentNetworkPolicyUpdated Activity = 130 + // AgentNetworkPolicyDeleted indicates that a user deleted an Agent Network policy + AgentNetworkPolicyDeleted Activity = 131 + + // AgentNetworkGuardrailCreated indicates that a user created an Agent Network guardrail + AgentNetworkGuardrailCreated Activity = 132 + // AgentNetworkGuardrailUpdated indicates that a user updated an Agent Network guardrail + AgentNetworkGuardrailUpdated Activity = 133 + // AgentNetworkGuardrailDeleted indicates that a user deleted an Agent Network guardrail + AgentNetworkGuardrailDeleted Activity = 134 + + // AgentNetworkBudgetRuleCreated indicates that a user created an Agent Network budget rule + AgentNetworkBudgetRuleCreated Activity = 135 + // AgentNetworkBudgetRuleUpdated indicates that a user updated an Agent Network budget rule + AgentNetworkBudgetRuleUpdated Activity = 136 + // AgentNetworkBudgetRuleDeleted indicates that a user deleted an Agent Network budget rule + AgentNetworkBudgetRuleDeleted Activity = 137 + + // AgentNetworkSettingsUpdated indicates that a user updated Agent Network account settings + AgentNetworkSettingsUpdated Activity = 139 + AccountDeleted Activity = 99999 ) @@ -400,6 +431,24 @@ var activityMap = map[Activity]Code{ UserExtendedPeerSession: {"User extended peer session", "user.peer.session.extend"}, + AgentNetworkProviderCreated: {"Agent Network provider created", "agent_network.provider.create"}, + AgentNetworkProviderUpdated: {"Agent Network provider updated", "agent_network.provider.update"}, + AgentNetworkProviderDeleted: {"Agent Network provider deleted", "agent_network.provider.delete"}, + + AgentNetworkPolicyCreated: {"Agent Network policy created", "agent_network.policy.create"}, + AgentNetworkPolicyUpdated: {"Agent Network policy updated", "agent_network.policy.update"}, + AgentNetworkPolicyDeleted: {"Agent Network policy deleted", "agent_network.policy.delete"}, + + AgentNetworkGuardrailCreated: {"Agent Network guardrail created", "agent_network.guardrail.create"}, + AgentNetworkGuardrailUpdated: {"Agent Network guardrail updated", "agent_network.guardrail.update"}, + AgentNetworkGuardrailDeleted: {"Agent Network guardrail deleted", "agent_network.guardrail.delete"}, + + AgentNetworkBudgetRuleCreated: {"Agent Network budget rule created", "agent_network.budget_rule.create"}, + AgentNetworkBudgetRuleUpdated: {"Agent Network budget rule updated", "agent_network.budget_rule.update"}, + AgentNetworkBudgetRuleDeleted: {"Agent Network budget rule deleted", "agent_network.budget_rule.delete"}, + + AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"}, + DomainAdded: {"Domain added", "domain.add"}, DomainDeleted: {"Domain deleted", "domain.delete"}, DomainValidated: {"Domain validated", "domain.validate"}, diff --git a/management/server/affectedpeers/proxy_synth_test.go b/management/server/affectedpeers/proxy_synth_test.go new file mode 100644 index 000000000..07273b18b --- /dev/null +++ b/management/server/affectedpeers/proxy_synth_test.go @@ -0,0 +1,95 @@ +package affectedpeers + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// fakeProxyStore implements only the two store methods loadProxyServices calls; +// the embedded nil store.Store panics if anything else is invoked, which keeps +// the test honest about the surface under test. +type fakeProxyStore struct { + store.Store + proxyByCluster map[string][]string + persisted []*rpservice.Service +} + +func (f *fakeProxyStore) GetEmbeddedProxyPeerIDsByCluster(_ context.Context, _ string) (map[string][]string, error) { + return f.proxyByCluster, nil +} + +func (f *fakeProxyStore) GetAccountServices(_ context.Context, _ store.LockingStrength, _ string) ([]*rpservice.Service, error) { + return f.persisted, nil +} + +func serviceIDs(svcs []*rpservice.Service) []string { + ids := make([]string, 0, len(svcs)) + for _, s := range svcs { + ids = append(ids, s.ID) + } + return ids +} + +// loadProxyServices must merge the synthesised agent-network services (which are +// never persisted) with the persisted ones, so the proxy-affected expansion can +// see agent-network AccessGroups. Without this the embedded proxy peer is never +// flagged on a client group change and only a full resync (restart) recovers. +func TestLoadProxyServices_MergesSynthesizedAgentNetworkServices(t *testing.T) { + prev := agentNetworkSynthesizer + t.Cleanup(func() { agentNetworkSynthesizer = prev }) + SetAgentNetworkSynthesizer(func(_ context.Context, _ store.Store, _ string) ([]*rpservice.Service, error) { + return []*rpservice.Service{ + {ID: "agent-net-svc-acc", ProxyCluster: "proxy.netbird.local", Private: true, AccessGroups: []string{"gB"}}, + }, nil + }) + + s := &fakeProxyStore{ + proxyByCluster: map[string][]string{"proxy.netbird.local": {"proxy-peer-1"}}, + persisted: []*rpservice.Service{{ID: "persisted-rp-svc", ProxyCluster: "proxy.netbird.local"}}, + } + snap := &Snapshot{} + require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc")) + + ids := serviceIDs(snap.services) + assert.Contains(t, ids, "persisted-rp-svc", "persisted services must be kept") + assert.Contains(t, ids, "agent-net-svc-acc", "synthesised agent-network service must be merged in") +} + +// With no synthesiser registered, loadProxyServices falls back to persisted +// services only (no panic, no behaviour change for non-agent-network builds). +func TestLoadProxyServices_NoSynthesizerRegistered(t *testing.T) { + prev := agentNetworkSynthesizer + t.Cleanup(func() { agentNetworkSynthesizer = prev }) + agentNetworkSynthesizer = nil + + s := &fakeProxyStore{ + proxyByCluster: map[string][]string{"c": {"proxy-1"}}, + persisted: []*rpservice.Service{{ID: "persisted"}}, + } + snap := &Snapshot{} + require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc")) + assert.Equal(t, []string{"persisted"}, serviceIDs(snap.services)) +} + +// No embedded proxy peers → skip entirely (don't even call the synthesiser). +func TestLoadProxyServices_NoEmbeddedProxyPeersSkips(t *testing.T) { + prev := agentNetworkSynthesizer + t.Cleanup(func() { agentNetworkSynthesizer = prev }) + called := false + SetAgentNetworkSynthesizer(func(_ context.Context, _ store.Store, _ string) ([]*rpservice.Service, error) { + called = true + return nil, nil + }) + + s := &fakeProxyStore{proxyByCluster: map[string][]string{}} + snap := &Snapshot{} + require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc")) + assert.False(t, called, "synthesiser must not run for accounts without embedded proxy peers") + assert.Empty(t, snap.services) +} diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index 94e24ced6..16a795539 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -29,6 +29,19 @@ import ( "github.com/netbirdio/netbird/route" ) +// agentNetworkSynthesizer returns the account's synthesised (never-persisted) +// agent-network reverse-proxy services. It is registered at boot via +// SetAgentNetworkSynthesizer to avoid an import cycle (agentnetwork → account → +// affectedpeers). nil when agent-network is not wired, in which case only +// persisted services are considered. +var agentNetworkSynthesizer func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error) + +// SetAgentNetworkSynthesizer registers the agent-network service synthesiser. +// Called once during boot, before any request is served. +func SetAgentNetworkSynthesizer(fn func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)) { + agentNetworkSynthesizer = fn +} + // Snapshot is an in-memory view of the collections needed to expand a Change. // Loaded in-tx, walked by Expand after commit. Only the collections the Change // can touch are loaded; the rest stay nil (see Load). @@ -124,7 +137,12 @@ func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID stri } // loadProxyServices loads the embedded-proxy cluster index, and the services only -// when the account actually has embedded proxy peers. +// when the account actually has embedded proxy peers. Both the persisted +// reverse-proxy services and the synthesised agent-network services are loaded: +// agent-network services are never persisted, so without synthesising them here +// collectFromProxyServices can't fold the embedded proxy peer into the affected +// set when a client's group changes, and the proxy never learns a newly +// authorised client until it reconnects (full network-map resync). func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error { var err error if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil { @@ -133,8 +151,21 @@ func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, acco if len(snap.proxyByCluster) == 0 { return nil } - snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID) - return err + if snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + if agentNetworkSynthesizer == nil { + return nil + } + synth, serr := agentNetworkSynthesizer(ctx, s, accountID) + if serr != nil { + // Non-fatal: fall back to persisted services. The next full + // network-map resync still converges the proxy. + log.WithContext(ctx).Warnf("affectedpeers: synthesise agent-network services for account %s: %v", accountID, serr) + return nil + } + snap.services = append(snap.services, synth...) + return nil } // loadGroupIndex loads all groups (for group.Resources) and builds the diff --git a/management/server/agentnetwork_budgetrule_realstack_test.go b/management/server/agentnetwork_budgetrule_realstack_test.go new file mode 100644 index 000000000..d17f2e26a --- /dev/null +++ b/management/server/agentnetwork_budgetrule_realstack_test.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" +) + +// TestAgentNetwork_BudgetRuleCRUD_RealManager is the GC-1 no-mock guard for the +// account budget-rule manager surface: real DefaultAccountManager, real store, +// real permissions. It exercises create/get/list/update/delete through the +// permission-gated manager (not the store directly) and asserts the reused +// PolicyLimits cap shape and targets survive each step. +func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "agent-net-budget-acct" + adminUserID = "agent-net-budget-admin" + ) + account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed") + + mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil) + + created, err := mgr.CreateBudgetRule(ctx, adminUserID, &agenttypes.AccountBudgetRule{ + AccountID: accountID, + Name: "eng-monthly", + Enabled: true, + TargetGroups: []string{"grp-eng"}, + TargetUsers: []string{"user-alice"}, + Limits: agenttypes.PolicyLimits{ + TokenLimit: agenttypes.PolicyTokenLimit{Enabled: true, GroupCap: 100_000, UserCap: 10_000, WindowSeconds: 2_592_000}, + BudgetLimit: agenttypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 500, WindowSeconds: 2_592_000}, + }, + }) + require.NoError(t, err, "CreateBudgetRule must succeed") + require.NotEmpty(t, created.ID, "create must mint an ID") + + got, err := mgr.GetBudgetRule(ctx, accountID, adminUserID, created.ID) + require.NoError(t, err, "GetBudgetRule must succeed") + assert.Equal(t, "eng-monthly", got.Name, "name round-trips through the manager") + assert.Equal(t, []string{"grp-eng"}, got.TargetGroups, "target groups round-trip") + assert.Equal(t, int64(100_000), got.Limits.TokenLimit.GroupCap, "token group cap round-trips") + + list, err := mgr.GetAllBudgetRules(ctx, accountID, adminUserID) + require.NoError(t, err, "GetAllBudgetRules must succeed") + require.Len(t, list, 1, "exactly the one created rule must be listed") + + created.Limits.TokenLimit.GroupCap = 200_000 + updated, err := mgr.UpdateBudgetRule(ctx, adminUserID, created) + require.NoError(t, err, "UpdateBudgetRule must succeed") + assert.Equal(t, int64(200_000), updated.Limits.TokenLimit.GroupCap, "updated cap must persist") + + require.NoError(t, mgr.DeleteBudgetRule(ctx, accountID, adminUserID, created.ID), "DeleteBudgetRule must succeed") + _, err = mgr.GetBudgetRule(ctx, accountID, adminUserID, created.ID) + assert.Error(t, err, "get after delete must fail") +} + +// TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection is the +// GC-1 guard for UpdateSettings: it must apply the collection toggles while +// preserving the immutable Cluster/Subdomain pinned at bootstrap. +func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "agent-net-settings-acct" + adminUserID = "agent-net-settings-admin" + clusterAddr = "eu.proxy.netbird.io" + ) + account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed") + + mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil) + + // Creating a provider bootstraps the settings row (cluster + subdomain). + _, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ + AccountID: accountID, + ProviderID: "openai_api", + Name: "openai", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + Enabled: true, + Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, + }, clusterAddr) + require.NoError(t, err, "CreateProvider must bootstrap settings") + + before, err := mgr.GetSettings(ctx, accountID, adminUserID) + require.NoError(t, err, "GetSettings must succeed after bootstrap") + require.Equal(t, clusterAddr, before.Cluster, "cluster pinned at bootstrap") + require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap") + assert.False(t, before.EnablePromptCollection, "prompt collection defaults off") + + // Attempt to flip toggles AND smuggle a different cluster/subdomain — the + // immutable fields must be ignored. + updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{ + AccountID: accountID, + Cluster: "attacker.cluster", + Subdomain: "evil", + EnableLogCollection: true, + EnablePromptCollection: true, + RedactPii: true, + }) + require.NoError(t, err, "UpdateSettings must succeed") + assert.Equal(t, before.Cluster, updated.Cluster, "cluster is immutable and must be preserved") + assert.Equal(t, before.Subdomain, updated.Subdomain, "subdomain is immutable and must be preserved") + assert.True(t, updated.EnableLogCollection, "log collection toggle must apply") + assert.True(t, updated.EnablePromptCollection, "prompt collection toggle must apply") + assert.True(t, updated.RedactPii, "redact toggle must apply") + + reloaded, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + assert.Equal(t, before.Cluster, reloaded.Cluster, "persisted cluster unchanged") + assert.True(t, reloaded.EnablePromptCollection, "persisted prompt collection toggled on") +} diff --git a/management/server/agentnetwork_proxypeer_restart_test.go b/management/server/agentnetwork_proxypeer_restart_test.go new file mode 100644 index 000000000..1e4b8d016 --- /dev/null +++ b/management/server/agentnetwork_proxypeer_restart_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale is the no-mock +// regression guard for the bug the user reported: restarting the proxy creates +// a fresh embedded peer with a NEW WireGuard public key (the proxy generates +// the keypair on every startup at proxy/internal/roundtrip/netbird.go:312). +// The PRIOR embedded peer record is never deleted on management, so the +// account accumulates a stale peer holding a stale CGNAT IP. Other peers +// in the account either keep routing to the dead IP, or — if synth DNS +// picks the wrong record — never see the new IP at all. +// +// What this test exercises (no mocks): +// - real SQLite test store +// - real DefaultAccountManager, network-map controller, peer-update channels +// - real peers.Manager.CreateProxyPeer path (the very method the proxy +// invokes over gRPC on every startup) +// - real agentnetwork.Manager + synth chain so the client receives a +// concrete DNS record that must point at the LATEST proxy peer. +// +// Pre-fix expected behavior (red): two embedded peers exist after the +// "restart"; the synth DNS record points at the stale one; the client +// receives an update reflecting the new peer but the old one lingers. +// Post-fix expected behavior (green): exactly one embedded peer exists +// after restart (with the new key) AND the client's network map carries +// the synth DNS pointing at that new peer's CGNAT IP. +func TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale(t *testing.T) { + am, updateManager, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "an-restart-acct" + adminUserID = "an-restart-admin" + groupAID = "an-restart-grp-A" + clusterAddr = "eu.proxy.netbird.io" + clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" + // Two different proxy pubkeys — the "before" and "after" of a + // proxy-process restart with fresh-keypair generation. + proxyKey1 = "Aaaaa1aaaaYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" + proxyKey2 = "Bbbbb2bbbbYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" + ) + + // --- Account scaffold --- + account := newAccountWithId(ctx, accountID, adminUserID, "an-restart.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account)) + + clientPeer := &nbpeer.Peer{ + Key: clientKey, + Name: "an-restart-client", + DNSLabel: "an-restart-client", + Meta: nbpeer.PeerSystemMeta{Hostname: "an-restart-client", GoOS: "linux", WtVersion: "development"}, + } + addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false) + require.NoError(t, err, "AddPeer for client must succeed") + require.NoError(t, am.MarkPeerConnected(ctx, clientKey, accountID, time.Now().UnixNano(), &types.NetworkMap{}), + "MarkPeerConnected for the client peer must succeed (affected-peer fan-out skips disconnected peers)") + + // Place the client in group A so the synth policy reaches it. + account, err = am.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}} + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist group A") + + // --- Real peers + agent-network managers --- + permMgr := permissions.NewManager(am.Store) + peersMgr := peers.NewManager(am.Store, permMgr) + peersMgr.SetAccountManager(am) + peersMgr.SetNetworkMapController(am.networkMapController) + agentMgr := agentnetwork.NewManager(am.Store, permMgr, am, nil) + + // Subscribe BEFORE any state-mutating call so we don't lose the update + // that contains the synth DNS record. + clientCh := updateManager.CreateChannel(ctx, addedClient.ID) + t.Cleanup(func() { updateManager.CloseChannel(ctx, addedClient.ID) }) + drain(clientCh) + + // --- First proxy startup: register peer key K1, then mark it + // connected. In production the proxy follows CreateProxyPeer with the + // regular sync stream which lands on MarkPeerConnected; the synth DNS + // path filters out peers that aren't Connected (types/account.go:323), + // so without this step no DNS record would be emitted. + require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey1, clusterAddr), + "first CreateProxyPeer (proxy startup) must succeed") + + peer1ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1) + require.NoError(t, err, "proxy peer for K1 must be persisted after CreateProxyPeer") + require.NotEmpty(t, peer1ID) + + require.NoError(t, am.MarkPeerConnected(ctx, proxyKey1, accountID, time.Now().UnixNano(), &types.NetworkMap{}), + "MarkPeerConnected for K1 must succeed") + + account, err = am.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + proxyIP1 := account.Peers[peer1ID].IP.String() + require.NotEmpty(t, proxyIP1, "K1 must have an assigned overlay IP") + + // --- Provider + policy. CreateProvider / CreatePolicy trigger the + // agentnetwork reconcile which runs UpdateAccountPeers; the resulting + // NetworkMap delivered to the client carries the synth DNS record + // pointing at K1's IP. --- + provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ + AccountID: accountID, + ProviderID: "openai_api", + Name: "openai-test", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, + }, clusterAddr) + require.NoError(t, err, "CreateProvider must succeed") + + _, err = agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{ + AccountID: accountID, + Name: "p1", + Enabled: true, + SourceGroups: []string{groupAID}, + DestinationProviderIDs: []string{provider.ID}, + }) + require.NoError(t, err, "CreatePolicy must succeed") + + settings, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + fqdn := settings.Endpoint() + + rdata1 := awaitZoneRData(clientCh, clusterAddr, fqdn, true) + require.Equal(t, proxyIP1, rdata1, + "client must receive a synth DNS record pointing at K1's overlay IP after the synth path runs") + drain(clientCh) + + // --- Proxy restart: NEW keypair K2, same account, same cluster --- + require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey2, clusterAddr), + "second CreateProxyPeer (proxy restart with fresh keypair) must succeed") + + peer2ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey2) + require.NoError(t, err, "proxy peer for K2 must be persisted after restart") + require.NotEmpty(t, peer2ID) + + require.NoError(t, am.MarkPeerConnected(ctx, proxyKey2, accountID, time.Now().UnixNano(), &types.NetworkMap{}), + "MarkPeerConnected for K2 must succeed") + + // In production the agent's sync stream pulls a fresh NetworkMap as + // part of its normal reconcile cadence; in this isolated test + // MarkPeerConnected's affected-peer fan-out can race the channel-side + // buffer in a way that swallows the synth-DNS-bearing update before + // our await reads it. Trigger an explicit account-wide fan-out so the + // assertion below tests what production actually delivers, not the + // in-test buffer race. + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) + + account, err = am.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + proxyIP2 := account.Peers[peer2ID].IP.String() + require.NotEmpty(t, proxyIP2, "K2 must have an assigned overlay IP") + require.NotEqual(t, proxyIP1, proxyIP2, "K2 must get a different overlay IP than K1 (sanity)") + + // CRITICAL ASSERTION 1: K1 must no longer be in the store. The SqlStore + // returns ("", nil) for a missing key rather than NotFound, so assert + // on the returned ID being empty. + staleID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1) + require.NoError(t, err, "GetPeerIDByKey for a missing peer must not error") + assert.Empty(t, staleID, + "stale embedded proxy peer K1 must be removed when a new embedded peer registers for the same (account, cluster); pre-fix this assertion fails because management never cleans up the prior peer record") + + // CRITICAL ASSERTION 2: exactly one embedded proxy peer remains, and it + // is K2. + account, err = am.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + embeddedKeys := []string{} + for _, p := range account.Peers { + if p.ProxyMeta.Embedded { + embeddedKeys = append(embeddedKeys, p.Key) + } + } + assert.Equal(t, []string{proxyKey2}, embeddedKeys, + "after a proxy restart exactly one embedded proxy peer should remain — the one with the new key K2") + + // CRITICAL ASSERTION 3: the synth DNS record the client receives now + // points at K2's IP, not K1's. + rdata2 := awaitZoneRData(clientCh, clusterAddr, fqdn, true) + assert.Equal(t, proxyIP2, rdata2, + "after proxy restart, the client's synth DNS record must point at the NEW embedded peer's IP, not the stale K1 IP") +} diff --git a/management/server/agentnetwork_realstack_test.go b/management/server/agentnetwork_realstack_test.go new file mode 100644 index 000000000..e7855c575 --- /dev/null +++ b/management/server/agentnetwork_realstack_test.go @@ -0,0 +1,212 @@ +package server + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + networkmap "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + nbproto "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers is the no-mock +// integration test for the live propagation path: a provider/policy mutation +// through the real agentnetwork.Manager triggers the real +// DefaultAccountManager.UpdateAccountPeers, which runs the real network-map +// controller (including AN-2b's injectAllProxyPolicies), and a network map is +// computed and fanned out to BOTH the embedded proxy peer and the client peer. +// +// Unlike the synthesizer/reconcile unit tests, nothing here is mocked: real +// SQLite store, real account manager + network-map controller, real +// agentnetwork manager, real peer update channels. The client peer's delivered +// map is asserted to actually carry the synth DNS surface, and provider +// create/delete are exercised end to end. +func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) { + am, updateManager, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "agent-net-acct-1" + adminUserID = "agent-net-admin-1" + groupAID = "agent-net-grp-A" + clusterAddr = "eu.proxy.netbird.io" + clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" + proxyPeerID = "agent-net-proxy-peer-1" + proxyPeerKey = "/yF0+vCfv+mRR5k0dca0TrGdO/oiNeAI58gToZm5NyI=" + proxyIP = "100.64.0.99" + ) + + account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed") + + // Real client peer through the production AddPeer path. + clientPeer := &nbpeer.Peer{ + Key: clientKey, + Name: "agent-net-client", + DNSLabel: "agent-net-client", + Meta: nbpeer.PeerSystemMeta{Hostname: "agent-net-client", GoOS: "linux", WtVersion: "development"}, + } + addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false) + require.NoError(t, err, "AddPeer must add the client peer") + + // Inject a connected embedded proxy peer + put the client in the source group. + account, err = am.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Peers[proxyPeerID] = &nbpeer.Peer{ + ID: proxyPeerID, + AccountID: accountID, + Key: proxyPeerKey, + IP: netip.MustParseAddr(proxyIP), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: clusterAddr}, + DNSLabel: "agent-net-proxy", + } + account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}} + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist proxy peer + group") + + // Subscribe to BOTH peers' update channels — this is how we observe the + // real fan-out. + clientCh := updateManager.CreateChannel(ctx, addedClient.ID) + proxyCh := updateManager.CreateChannel(ctx, proxyPeerID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, addedClient.ID) + updateManager.CloseChannel(ctx, proxyPeerID) + }) + drain(clientCh) + drain(proxyCh) + + // Real agentnetwork manager wired to the real account manager. proxyController + // is nil (no gRPC cluster fan-out here) — the reconcile still fires + // UpdateAccountPeers, which is the path under test. + agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil) + + provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ + AccountID: accountID, + ProviderID: "openai_api", + Name: "openai-test", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, + }, clusterAddr) + require.NoError(t, err, "CreateProvider must succeed") + + policy, err := agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{ + AccountID: accountID, + Name: "p1", + Enabled: true, + SourceGroups: []string{groupAID}, + DestinationProviderIDs: []string{provider.ID}, + }) + require.NoError(t, err, "CreatePolicy must succeed") + + settings, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + fqdn := settings.Endpoint() + + // Both peers must receive a fan-out. The provider-create reconcile fires + // before the policy exists (synth service then has no AccessGroups, so no + // zone), and the async update buffer can collapse/reorder updates — so we + // poll until the client's delivered map actually carries the synth record. + rdata := awaitZoneRData(clientCh, clusterAddr, fqdn, true) + assert.Equal(t, proxyIP, rdata, + "client peer's delivered network map must contain the synth DNS record pointing at the embedded proxy peer") + require.True(t, awaitUpdate(proxyCh), "embedded proxy peer must also receive a netmap update after create") + + // UPDATE the provider — a new model on the existing service must still + // reconcile and keep the private surface routable (the live MODIFIED path). + provider.Models = append(provider.Models, agenttypes.ProviderModel{ID: "gpt-5.4-mini"}) + _, err = agentMgr.UpdateProvider(ctx, adminUserID, provider) + require.NoError(t, err, "UpdateProvider must succeed") + assert.Equal(t, proxyIP, awaitZoneRData(clientCh, clusterAddr, fqdn, true), + "client peer must still resolve the synth record after the provider is updated") + require.True(t, awaitUpdate(proxyCh), "embedded proxy peer must also receive a netmap update after update") + + // DELETE: detach the policy first (provider is in use), then drop the + // provider. Both peers update again and the synth surface disappears. + require.NoError(t, agentMgr.DeletePolicy(ctx, accountID, adminUserID, policy.ID), "DeletePolicy must succeed") + require.NoError(t, agentMgr.DeleteProvider(ctx, accountID, adminUserID, provider.ID), "DeleteProvider must succeed") + + require.True(t, awaitUpdate(proxyCh), "embedded proxy peer must also receive a netmap update after delete") + assert.Empty(t, awaitZoneRData(clientCh, clusterAddr, fqdn, false), + "synth DNS record must be gone from the client's map after the provider is deleted") +} + +// awaitZoneRData drains the channel for up to 8s. When wantPresent is true it +// returns as soon as the synth record appears (its RData). When false it drains +// to quiescence and returns the RData of the last delivered map (expected empty +// once the provider is gone), tolerating stale buffered updates that still +// carry the zone. +func awaitZoneRData(ch <-chan *networkmap.UpdateMessage, clusterAddr, fqdn string, wantPresent bool) string { + deadline := time.After(8 * time.Second) + last := "" + for { + select { + case m := <-ch: + if m == nil { + continue + } + last = synthZoneRData(m.Update, clusterAddr, fqdn) + if wantPresent && last != "" { + return last + } + case <-time.After(750 * time.Millisecond): + return last + case <-deadline: + return last + } + } +} + +// awaitUpdate reports whether at least one update arrives within the window. +func awaitUpdate(ch <-chan *networkmap.UpdateMessage) bool { + select { + case m := <-ch: + return m != nil + case <-time.After(5 * time.Second): + return false + } +} + +// drain empties any buffered updates (e.g. from AddPeer/SaveAccount) so the +// next observation reflects the operation under test. +func drain(ch <-chan *networkmap.UpdateMessage) { + for { + select { + case <-ch: + case <-time.After(200 * time.Millisecond): + return + } + } +} + +// synthZoneRData returns the RData of the synth A record (record name == fqdn) +// inside the cluster's custom zone, or "" when absent. +func synthZoneRData(sync *nbproto.SyncResponse, clusterAddr, fqdn string) string { + if sync == nil { + return "" + } + for _, zone := range sync.GetNetworkMap().GetDNSConfig().GetCustomZones() { + if zone.GetDomain() != dns.Fqdn(clusterAddr) { + continue + } + for _, rec := range zone.GetRecords() { + if rec.GetName() == dns.Fqdn(fqdn) { + return rec.GetRData() + } + } + } + return "" +} diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 0abdb854d..a57f44b3c 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -23,6 +23,8 @@ import ( idpmanager "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentnetworkhandlers "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/handlers" "github.com/netbirdio/netbird/management/internals/modules/zones" zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" "github.com/netbirdio/netbird/management/internals/modules/zones/records" @@ -59,7 +61,7 @@ import ( ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -124,6 +126,9 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou zonesManager.RegisterEndpoints(router, zManager) recordsManager.RegisterEndpoints(router, rManager) idp.AddEndpoints(accountManager, router) + if agentNetworkManager != nil { + agentnetworkhandlers.RegisterEndpoints(agentNetworkManager, router) + } instance.AddEndpoints(instanceManager, accountManager, router) instance.AddVersionEndpoint(instanceManager, router) if serviceManager != nil && reverseProxyDomainManager != nil { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 61584a615..8b05b2ddf 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -137,7 +137,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } @@ -267,7 +267,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go index efe50c88f..43fbec15d 100644 --- a/management/server/metrics/selfhosted.go +++ b/management/server/metrics/selfhosted.go @@ -55,6 +55,7 @@ type DataSource interface { GetStoreEngine() types.Engine GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) GetProxyMetrics(ctx context.Context) (store.ProxyMetrics, error) + GetAgentNetworkMetrics(ctx context.Context) (store.AgentNetworkMetrics, error) } // ConnManager peer connection manager that holds state for current active connections @@ -413,6 +414,13 @@ func (w *Worker) generateProperties(ctx context.Context) properties { log.WithContext(ctx).Debugf("collect proxy metrics: %v", err) } + // Agent-network adoption + usage, aggregated across all accounts in a few + // cheap queries; nil on FileStore. + agentNetworkMetrics, err := w.dataSource.GetAgentNetworkMetrics(ctx) + if err != nil { + log.WithContext(ctx).Debugf("collect agent network metrics: %v", err) + } + minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions) metricsProperties["uptime"] = uptime metricsProperties["accounts"] = accounts @@ -471,6 +479,14 @@ func (w *Worker) generateProperties(ctx context.Context) properties { metricsProperties["proxies_connected"] = proxyMetrics.ProxiesConnected metricsProperties["custom_domains"] = customDomains metricsProperties["custom_domains_validated"] = customDomainsValidated + metricsProperties["agent_network_accounts"] = agentNetworkMetrics.Accounts + metricsProperties["agent_network_providers"] = agentNetworkMetrics.Providers + metricsProperties["agent_network_policies"] = agentNetworkMetrics.Policies + metricsProperties["agent_network_budget_rules"] = agentNetworkMetrics.BudgetRules + metricsProperties["agent_network_log_collection_enabled"] = agentNetworkMetrics.LogCollectionEnabled + metricsProperties["agent_network_input_tokens"] = agentNetworkMetrics.InputTokens + metricsProperties["agent_network_output_tokens"] = agentNetworkMetrics.OutputTokens + metricsProperties["agent_network_cost_usd"] = agentNetworkMetrics.CostUSD for targetType, count := range servicesTargetType { metricsProperties["services_target_type_"+string(targetType)] = count diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go index ca9e10262..1fef89a2c 100644 --- a/management/server/metrics/selfhosted_test.go +++ b/management/server/metrics/selfhosted_test.go @@ -277,6 +277,21 @@ func (mockDatasource) GetProxyMetrics(_ context.Context) (store.ProxyMetrics, er }, nil } +// GetAgentNetworkMetrics returns canned agent-network counts so the +// generateProperties test can assert the adoption/usage signals end-to-end. +func (mockDatasource) GetAgentNetworkMetrics(_ context.Context) (store.AgentNetworkMetrics, error) { + return store.AgentNetworkMetrics{ + Accounts: 2, + Providers: 5, + Policies: 3, + BudgetRules: 1, + LogCollectionEnabled: 2, + InputTokens: 1000, + OutputTokens: 500, + CostUSD: 1.25, + }, nil +} + // TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties func TestGenerateProperties(t *testing.T) { ds := mockDatasource{} diff --git a/management/server/permissions/modules/module.go b/management/server/permissions/modules/module.go index 93007d4c1..a3a9c554d 100644 --- a/management/server/permissions/modules/module.go +++ b/management/server/permissions/modules/module.go @@ -19,6 +19,7 @@ const ( Pats Module = "pats" IdentityProviders Module = "identity_providers" Services Module = "services" + AgentNetwork Module = "agent_network" ) var All = map[Module]struct{}{ @@ -38,4 +39,5 @@ var All = map[Module]struct{}{ Pats: {}, IdentityProviders: {}, Services: {}, + AgentNetwork: {}, } diff --git a/management/server/store/file_store.go b/management/server/store/file_store.go index bcf563cd0..a776a8b42 100644 --- a/management/server/store/file_store.go +++ b/management/server/store/file_store.go @@ -280,3 +280,9 @@ func (s *FileStore) GetCustomDomainsCounts(_ context.Context) (int64, int64, err func (s *FileStore) GetProxyMetrics(_ context.Context) (ProxyMetrics, error) { return ProxyMetrics{}, nil } + +// GetAgentNetworkMetrics is a no-op for FileStore — agent-network state isn't +// persisted in the JSON file format. +func (s *FileStore) GetAgentNetworkMetrics(_ context.Context) (AgentNetworkMetrics, error) { + return AgentNetworkMetrics{}, nil +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 8bc4bcd7d..18be1b6ed 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -33,6 +33,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" @@ -137,6 +138,10 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{}, &types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &rpservice.Service{}, &rpservice.Target{}, &domain.Domain{}, &accesslogs.AccessLogEntry{}, &proxy.Proxy{}, + &agentNetworkTypes.Provider{}, &agentNetworkTypes.Policy{}, &agentNetworkTypes.Guardrail{}, &agentNetworkTypes.Settings{}, + &agentNetworkTypes.Consumption{}, &agentNetworkTypes.AccountBudgetRule{}, + &agentNetworkTypes.AgentNetworkAccessLog{}, &agentNetworkTypes.AgentNetworkAccessLogGroup{}, + &agentNetworkTypes.AgentNetworkUsage{}, &agentNetworkTypes.AgentNetworkUsageGroup{}, ) if err != nil { return nil, fmt.Errorf("auto migratePreAuto: %w", err) @@ -5573,6 +5578,340 @@ func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.Acc return nil } +// CreateAgentNetworkAccessLog persists a flattened agent-network access-log +// entry together with its authorising-group child rows in a single +// transaction. +func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + // Idempotent on the log id / (log_id, group_id) so a proxy resend of the + // same entry can't fail the request. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil { + return err + } + if len(groups) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "service_id": entry.ServiceID, + "model": entry.Model, + }).Errorf("failed to create agent-network access log entry in store: %v", err) + return status.Errorf(status.Internal, "failed to create agent-network access log entry in store") + } + return nil +} + +// CreateAgentNetworkUsage persists a stripped agent-network usage record +// together with its authorising-group child rows in a single transaction. +func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + // Idempotent on the usage id / (usage_id, group_id) so a proxy resend of + // the same entry can't fail the request. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil { + return err + } + if len(groups) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": usage.AccountID, + "model": usage.Model, + }).Errorf("failed to create agent-network usage record in store: %v", err) + return status.Errorf(status.Internal, "failed to create agent-network usage record in store") + } + return nil +} + +// DeleteOldAgentNetworkAccessLogs deletes an account's access-log rows (and +// their authorising-group child rows) older than the cutoff. Usage records are +// untouched — they are the long-term aggregate. Returns the number of log rows +// deleted. +func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { + var deleted int64 + err := s.db.Transaction(func(tx *gorm.DB) error { + // Remove group child rows for the soon-to-be-deleted logs first. + if err := tx.Exec( + "DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)", + accountID, accountID, olderThan, + ).Error; err != nil { + return err + } + res := tx.Where("account_id = ? AND timestamp < ?", accountID, olderThan). + Delete(&agentNetworkTypes.AgentNetworkAccessLog{}) + if res.Error != nil { + return res.Error + } + deleted = res.RowsAffected + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete old agent-network access logs for account %s: %v", accountID, err) + return 0, status.Errorf(status.Internal, "failed to delete old agent-network access logs") + } + return deleted, nil +} + +// GetAgentNetworkUsageRows returns the stripped usage rows for an account that +// match the filter (date / user / group / provider / model). Aggregation into +// time buckets happens in the manager so granularities stay engine-portable. +func (s *SqlStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { + var rows []*agentNetworkTypes.AgentNetworkUsage + + query := s.applyAgentNetworkUsageFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ).Order("timestamp ASC") + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := query.Find(&rows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network usage rows from store: %v", err) + return nil, status.Errorf(status.Internal, "failed to get agent-network usage rows from store") + } + return rows, nil +} + +// applyAgentNetworkUsageFilters applies the shared access-log filter's +// date/user/group/provider/model conditions to a usage-table query. Pagination, +// sort and free-text search are ignored — the overview is an aggregate. +func (s *SqlStore) applyAgentNetworkUsageFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + if filter.SessionID != nil { + query = query.Where("session_id = ?", *filter.SessionID) + } + if len(filter.ProviderIDs) > 0 { + query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) + } + if len(filter.Models) > 0 { + query = query.Where("model IN ?", filter.Models) + } + if len(filter.GroupIDs) > 0 { + query = query.Where( + "id IN (SELECT usage_id FROM agent_network_request_usage_group WHERE group_id IN ?)", + filter.GroupIDs, + ) + } + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + return query +} + +// GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for +// an account with server-side pagination, filtering and sorting. Authorising +// group ids are hydrated from the group child table for the returned page. +func (s *SqlStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { + var logs []*agentNetworkTypes.AgentNetworkAccessLog + var totalCount int64 + + countQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ) + if err := countQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access logs") + } + + query := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Order(filter.GetSortColumn() + " " + filter.GetSortOrder()). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := query.Find(&logs).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access logs from store: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access logs from store") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, logs); err != nil { + return nil, 0, err + } + + return logs, totalCount, nil +} + +// applyAgentNetworkAccessLogFilters applies the filter conditions to a query. +func (s *SqlStore) applyAgentNetworkAccessLogFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { + if filter.Search != nil { + p := "%" + *filter.Search + "%" + query = query.Where( + "id LIKE ? OR host LIKE ? OR path LIKE ? OR model LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + p, p, p, p, p, p, + ) + } + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + if filter.SessionID != nil { + query = query.Where("session_id = ?", *filter.SessionID) + } + if filter.Decision != nil { + query = query.Where("decision = ?", *filter.Decision) + } + if filter.PathPrefix != nil { + query = query.Where("path LIKE ?", *filter.PathPrefix+"%") + } + if len(filter.ProviderIDs) > 0 { + query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) + } + if len(filter.Models) > 0 { + query = query.Where("model IN ?", filter.Models) + } + if len(filter.GroupIDs) > 0 { + query = query.Where( + "id IN (SELECT log_id FROM agent_network_access_log_group WHERE group_id IN ?)", + filter.GroupIDs, + ) + } + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + return query +} + +// hydrateAgentNetworkAccessLogGroups loads the authorising group ids for the +// given page of entries and assigns them onto each entry's GroupIDs field. +func (s *SqlStore) hydrateAgentNetworkAccessLogGroups(ctx context.Context, accountID string, logs []*agentNetworkTypes.AgentNetworkAccessLog) error { + if len(logs) == 0 { + return nil + } + + ids := make([]string, 0, len(logs)) + for _, l := range logs { + ids = append(ids, l.ID) + } + + var rows []agentNetworkTypes.AgentNetworkAccessLogGroup + if err := s.db. + Where(accountIDCondition, accountID). + Where("log_id IN ?", ids). + Find(&rows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to hydrate agent-network access log groups: %v", err) + return status.Errorf(status.Internal, "failed to hydrate agent-network access log groups") + } + + byLog := make(map[string][]string, len(logs)) + for _, r := range rows { + byLog[r.LogID] = append(byLog[r.LogID], r.GroupID) + } + for _, l := range logs { + l.GroupIDs = byLog[l.ID] + } + return nil +} + +// agentNetworkSessionKeyExpr is the SQL group key for session-grouped access +// logs: the row's session id, or — when the client sent none — the row id, so +// session-less requests each form their own singleton group. COALESCE/NULLIF +// are standard SQL, so this stays portable across SQLite and Postgres. +const agentNetworkSessionKeyExpr = "COALESCE(NULLIF(session_id, ''), id)" + +// GetAgentNetworkAccessLogSessions retrieves agent-network access logs grouped +// by session, with server-side pagination, filtering and sorting at the session +// level. It paginates over the distinct session keys (ordered by the requested +// session-level aggregate), fetches every entry for the page's sessions, and +// folds them into per-session summaries. The returned count is the number of +// matching sessions. Filters apply to the entries, so a session's summary +// reflects only its filter-matching requests. +func (s *SqlStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { + // Count distinct sessions via a grouped subquery — portable and avoids + // relying on COUNT(DISTINCT ) quoting quirks. + sessionsSubquery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr) + + var totalCount int64 + if err := s.db.Table("(?) AS sessions", sessionsSubquery).Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access-log sessions: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access-log sessions") + } + + // The page of session keys, ordered by the session-level aggregate. The + // session-key tiebreaker keeps pagination deterministic when the primary + // aggregate ties. + type sessionKeyRow struct { + SessionKey string + } + var keyRows []sessionKeyRow + keyQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr). + Order(filter.GetSessionSortExpr() + " " + filter.GetSortOrder()). + Order("session_key ASC"). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + if err := keyQuery.Scan(&keyRows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to list agent-network access-log session keys: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to list agent-network access-log session keys") + } + if len(keyRows) == 0 { + return nil, totalCount, nil + } + + keys := make([]string, 0, len(keyRows)) + for _, r := range keyRows { + keys = append(keys, r.SessionKey) + } + + // All entries for the page's sessions, contiguous per session and oldest + // first within each — the fold relies on that ordering. + var entries []*agentNetworkTypes.AgentNetworkAccessLog + entriesQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Where(agentNetworkSessionKeyExpr+" IN ?", keys). + Order(agentNetworkSessionKeyExpr + ", timestamp ASC") + + if lockStrength != LockingStrengthNone { + entriesQuery = entriesQuery.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := entriesQuery.Find(&entries).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access-log session entries: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access-log session entries") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, entries); err != nil { + return nil, 0, err + } + + return agentNetworkTypes.FoldAccessLogSessions(keys, entries), totalCount, nil +} + // GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { var logs []*accesslogs.AccessLogEntry diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go new file mode 100644 index 000000000..b0df0cd2a --- /dev/null +++ b/management/server/store/sql_store_agentnetwork.go @@ -0,0 +1,664 @@ +package store + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// GetAllAgentNetworkProviders returns Agent Network providers across +// every account. Used by the synthesizer to build the global service map. +func (s *SqlStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var providers []*agentNetworkTypes.Provider + if result := tx.Find(&providers); result.Error != nil { + log.WithContext(ctx).Errorf("failed to get all agent network providers from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get all agent network providers from store") + } + + for _, provider := range providers { + if err := provider.DecryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to decrypt agent network provider %s: %v", provider.ID, err) + return nil, status.Errorf(status.Internal, "failed to decrypt agent network provider") + } + } + + return providers, nil +} + +// GetAgentNetworkMetrics returns aggregated agent-network adoption + usage +// counts for the self-hosted metrics worker. Each value is a single cheap +// aggregate; token/cost are summed over the always-collected per-request usage +// ledger (independent of the log-collection toggle) so they reflect real usage. +func (s *SqlStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { + var m AgentNetworkMetrics + db := s.db.WithContext(ctx) + + // Providers + distinct adopting accounts in one round-trip. + provRow := db.Model(&agentNetworkTypes.Provider{}). + Select("COUNT(*) AS providers, COUNT(DISTINCT account_id) AS accounts").Row() + if err := provRow.Scan(&m.Providers, &m.Accounts); err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("scan agent network provider metrics: %w", err) + } + + if err := db.Model(&agentNetworkTypes.Policy{}).Count(&m.Policies).Error; err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("count agent network policies: %w", err) + } + + if err := db.Model(&agentNetworkTypes.AccountBudgetRule{}).Count(&m.BudgetRules).Error; err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("count agent network budget rules: %w", err) + } + + if err := db.Model(&agentNetworkTypes.Settings{}). + Where("enable_log_collection = ?", true).Count(&m.LogCollectionEnabled).Error; err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("count agent network log-collection accounts: %w", err) + } + + // COALESCE so an empty ledger scans as 0 instead of NULL. + usageRow := db.Model(&agentNetworkTypes.AgentNetworkUsage{}). + Select("COALESCE(SUM(input_tokens), 0) AS input_tokens, " + + "COALESCE(SUM(output_tokens), 0) AS output_tokens, " + + "COALESCE(SUM(cost_usd), 0) AS cost_usd").Row() + if err := usageRow.Scan(&m.InputTokens, &m.OutputTokens, &m.CostUSD); err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("scan agent network usage metrics: %w", err) + } + + return m, nil +} + +func (s *SqlStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var providers []*agentNetworkTypes.Provider + result := tx.Find(&providers, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network providers from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network providers from store") + } + + for _, provider := range providers { + if err := provider.DecryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to decrypt agent network provider %s: %v", provider.ID, err) + return nil, status.Errorf(status.Internal, "failed to decrypt agent network provider") + } + } + + return providers, nil +} + +func (s *SqlStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var provider *agentNetworkTypes.Provider + result := tx.Take(&provider, accountAndIDQueryCondition, accountID, providerID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkProviderNotFoundError(providerID) + } + + log.WithContext(ctx).Errorf("failed to get agent network provider from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network provider from store") + } + + if err := provider.DecryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to decrypt agent network provider %s: %v", provider.ID, err) + return nil, status.Errorf(status.Internal, "failed to decrypt agent network provider") + } + + return provider, nil +} + +func (s *SqlStore) SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error { + providerCopy := provider.Copy() + if err := providerCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to encrypt agent network provider %s: %v", provider.ID, err) + return status.Errorf(status.Internal, "failed to encrypt agent network provider") + } + + result := s.db.Save(providerCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network provider to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network provider to store") + } + + return nil +} + +func (s *SqlStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { + result := s.db.Delete(&agentNetworkTypes.Provider{}, accountAndIDQueryCondition, accountID, providerID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network provider from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network provider from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkProviderNotFoundError(providerID) + } + + return nil +} + +func (s *SqlStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policies []*agentNetworkTypes.Policy + result := tx.Find(&policies, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network policies from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network policies from store") + } + + return policies, nil +} + +func (s *SqlStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policy *agentNetworkTypes.Policy + result := tx.Take(&policy, accountAndIDQueryCondition, accountID, policyID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkPolicyNotFoundError(policyID) + } + + log.WithContext(ctx).Errorf("failed to get agent network policy from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network policy from store") + } + + return policy, nil +} + +func (s *SqlStore) SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error { + result := s.db.Save(policy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network policy to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network policy to store") + } + + return nil +} + +func (s *SqlStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { + result := s.db.Delete(&agentNetworkTypes.Policy{}, accountAndIDQueryCondition, accountID, policyID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network policy from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network policy from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkPolicyNotFoundError(policyID) + } + + return nil +} + +func (s *SqlStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var guardrails []*agentNetworkTypes.Guardrail + result := tx.Find(&guardrails, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network guardrails from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network guardrails from store") + } + + return guardrails, nil +} + +func (s *SqlStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var guardrail *agentNetworkTypes.Guardrail + result := tx.Take(&guardrail, accountAndIDQueryCondition, accountID, guardrailID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkGuardrailNotFoundError(guardrailID) + } + + log.WithContext(ctx).Errorf("failed to get agent network guardrail from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network guardrail from store") + } + + return guardrail, nil +} + +func (s *SqlStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error { + result := s.db.Save(guardrail) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network guardrail to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network guardrail to store") + } + + return nil +} + +func (s *SqlStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { + result := s.db.Delete(&agentNetworkTypes.Guardrail{}, accountAndIDQueryCondition, accountID, guardrailID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network guardrail from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network guardrail from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkGuardrailNotFoundError(guardrailID) + } + + return nil +} + +// GetAgentNetworkSettings returns the per-account Agent Network +// settings row. Returns status.NotFound when no row exists. +func (s *SqlStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var settings agentNetworkTypes.Settings + result := tx.Take(&settings, "account_id = ?", accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID) + } + + log.WithContext(ctx).Errorf("failed to get agent network settings from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network settings from store") + } + + return &settings, nil +} + +// GetAllAgentNetworkSettings returns every account's settings row. Used by the +// access-log retention sweep to learn each account's retention window. +func (s *SqlStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var settings []*agentNetworkTypes.Settings + if err := tx.Find(&settings).Error; err != nil { + log.WithContext(ctx).Errorf("failed to list agent network settings: %v", err) + return nil, status.Errorf(status.Internal, "failed to list agent network settings") + } + return settings, nil +} + +// GetAgentNetworkSettingsByCluster returns every Settings row pinned to +// the given proxy cluster. Used by the bootstrap label generator to +// build the set of subdomains already taken on a cluster. +func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var settings []*agentNetworkTypes.Settings + result := tx.Find(&settings, "cluster = ?", cluster) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network settings by cluster from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network settings by cluster from store") + } + + return settings, nil +} + +// SaveAgentNetworkSettings upserts the per-account Agent Network +// settings row. +func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error { + result := s.db.Save(settings) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network settings to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network settings to store") + } + + return nil +} + +// IncrementAgentNetworkConsumption atomically upserts the consumption +// row keyed on (account, dim_kind, dim_id, window_seconds, window_start) +// and adds the supplied deltas. Concurrent calls from multiple proxy +// nodes converge — the database performs the increment server-side via +// ON CONFLICT DO UPDATE so no read-modify-write race exists. +func (s *SqlStore) IncrementAgentNetworkConsumption( + ctx context.Context, + accountID string, + kind agentNetworkTypes.ConsumptionDimension, + dimID string, + windowSeconds int64, + windowStart time.Time, + tokensIn, tokensOut int64, + costUSD float64, +) error { + if accountID == "" || dimID == "" || windowSeconds <= 0 { + return status.Errorf(status.InvalidArgument, "account_id, dim_id and window_seconds must be set") + } + // Deltas are added server-side via ON CONFLICT; a negative or non-finite + // value would silently decrement / poison the persisted totals. + if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return status.Errorf(status.InvalidArgument, "consumption deltas must be non-negative and finite") + } + row := agentNetworkTypes.Consumption{ + AccountID: accountID, + DimensionKind: kind, + DimensionID: dimID, + WindowSeconds: windowSeconds, + WindowStartUTC: windowStart.UTC(), + TokensInput: tokensIn, + TokensOutput: tokensOut, + CostUSD: costUSD, + UpdatedAt: time.Now().UTC(), + } + const tbl = "agent_network_consumption" + err := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "account_id"}, + {Name: "dim_kind"}, + {Name: "dim_id"}, + {Name: "window_seconds"}, + {Name: "window_start_utc"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "tokens_input": gorm.Expr(tbl+".tokens_input + ?", tokensIn), + "tokens_output": gorm.Expr(tbl+".tokens_output + ?", tokensOut), + "cost_usd": gorm.Expr(tbl+".cost_usd + ?", costUSD), + "updated_at": time.Now().UTC(), + }), + }).Create(&row).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to increment agent network consumption: %v", err) + return status.Errorf(status.Internal, "failed to increment agent network consumption") + } + return nil +} + +// GetAgentNetworkConsumption returns the consumption row for the exact +// window key. Returns a zero-valued row (not found mapped to zero) so +// callers can use the result as the headroom basis without nil checks. +func (s *SqlStore) GetAgentNetworkConsumption( + ctx context.Context, + lockStrength LockingStrength, + accountID string, + kind agentNetworkTypes.ConsumptionDimension, + dimID string, + windowSeconds int64, + windowStart time.Time, +) (*agentNetworkTypes.Consumption, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + var row agentNetworkTypes.Consumption + result := tx.Take(&row, + "account_id = ? AND dim_kind = ? AND dim_id = ? AND window_seconds = ? AND window_start_utc = ?", + accountID, kind, dimID, windowSeconds, windowStart.UTC()) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return &agentNetworkTypes.Consumption{ + AccountID: accountID, + DimensionKind: kind, + DimensionID: dimID, + WindowSeconds: windowSeconds, + WindowStartUTC: windowStart.UTC(), + }, nil + } + log.WithContext(ctx).Errorf("failed to get agent network consumption: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network consumption") + } + return &row, nil +} + +// GetAgentNetworkConsumptionBatch reads many consumption counters for one +// account in a single query, returning a map keyed by the exact +// ConsumptionKey. Missing counters are simply absent from the map (callers +// treat absence as a zero counter). Replaces the per-cap point reads the +// policy selector previously issued one at a time. +func (s *SqlStore) GetAgentNetworkConsumptionBatch( + ctx context.Context, + lockStrength LockingStrength, + accountID string, + keys []agentNetworkTypes.ConsumptionKey, +) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) { + out := make(map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, len(keys)) + if len(keys) == 0 { + return out, nil + } + + // Collect the distinct dim ids, windows and window starts so a single + // query scopes to exactly the current windows in play, then filter the + // returned rows down to the exact requested keys. + wanted := make(map[agentNetworkTypes.ConsumptionKey]struct{}, len(keys)) + dimSet := make(map[string]struct{}) + winSet := make(map[int64]struct{}) + startSet := make(map[time.Time]struct{}) + for _, k := range keys { + k.WindowStartUTC = k.WindowStartUTC.UTC() + wanted[k] = struct{}{} + dimSet[k.DimID] = struct{}{} + winSet[k.WindowSeconds] = struct{}{} + startSet[k.WindowStartUTC] = struct{}{} + } + dimIDs := make([]string, 0, len(dimSet)) + for d := range dimSet { + dimIDs = append(dimIDs, d) + } + windows := make([]int64, 0, len(winSet)) + for w := range winSet { + windows = append(windows, w) + } + starts := make([]time.Time, 0, len(startSet)) + for t := range startSet { + starts = append(starts, t) + } + + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + var rows []*agentNetworkTypes.Consumption + result := tx.Find(&rows, + "account_id = ? AND dim_id IN ? AND window_seconds IN ? AND window_start_utc IN ?", + accountID, dimIDs, windows, starts) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to batch-get agent network consumption: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network consumption") + } + for _, row := range rows { + k := agentNetworkTypes.ConsumptionKey{ + Kind: row.DimensionKind, + DimID: row.DimensionID, + WindowSeconds: row.WindowSeconds, + WindowStartUTC: row.WindowStartUTC.UTC(), + } + if _, ok := wanted[k]; ok { + out[k] = row + } + } + return out, nil +} + +// IncrementAgentNetworkConsumptionBatch applies the same usage delta to every +// supplied counter inside a single transaction, so all per-(dimension, window) +// counters a served request books are written atomically in one round-trip +// instead of one upsert per counter. Keys are deduplicated by the caller. +func (s *SqlStore) IncrementAgentNetworkConsumptionBatch( + ctx context.Context, + accountID string, + keys []agentNetworkTypes.ConsumptionKey, + tokensIn, tokensOut int64, + costUSD float64, +) error { + if accountID == "" { + return status.Errorf(status.InvalidArgument, "account_id must be set") + } + if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return status.Errorf(status.InvalidArgument, "consumption deltas must be non-negative and finite") + } + if len(keys) == 0 { + return nil + } + + const tbl = "agent_network_consumption" + err := s.db.Transaction(func(tx *gorm.DB) error { + for _, k := range keys { + if k.DimID == "" || k.WindowSeconds <= 0 { + return status.Errorf(status.InvalidArgument, "dim_id and window_seconds must be set") + } + now := time.Now().UTC() + row := agentNetworkTypes.Consumption{ + AccountID: accountID, + DimensionKind: k.Kind, + DimensionID: k.DimID, + WindowSeconds: k.WindowSeconds, + WindowStartUTC: k.WindowStartUTC.UTC(), + TokensInput: tokensIn, + TokensOutput: tokensOut, + CostUSD: costUSD, + UpdatedAt: now, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "account_id"}, + {Name: "dim_kind"}, + {Name: "dim_id"}, + {Name: "window_seconds"}, + {Name: "window_start_utc"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "tokens_input": gorm.Expr(tbl+".tokens_input + ?", tokensIn), + "tokens_output": gorm.Expr(tbl+".tokens_output + ?", tokensOut), + "cost_usd": gorm.Expr(tbl+".cost_usd + ?", costUSD), + "updated_at": now, + }), + }).Create(&row).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to batch-increment agent network consumption: %v", err) + return status.Errorf(status.Internal, "failed to increment agent network consumption") + } + return nil +} + +// ListAgentNetworkConsumption returns every consumption row recorded +// for the account, ordered by window_start descending. Backs the +// dashboard's basic counter view. +func (s *SqlStore) ListAgentNetworkConsumption( + ctx context.Context, + lockStrength LockingStrength, + accountID string, +) ([]*agentNetworkTypes.Consumption, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + var rows []*agentNetworkTypes.Consumption + result := tx. + Order("window_start_utc DESC"). + Find(&rows, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to list agent network consumption: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to list agent network consumption") + } + return rows, nil +} + +// GetAccountAgentNetworkBudgetRules returns every account-level budget rule for +// the account. +func (s *SqlStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var rules []*agentNetworkTypes.AccountBudgetRule + result := tx.Find(&rules, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network budget rules from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network budget rules from store") + } + + return rules, nil +} + +// GetAgentNetworkBudgetRuleByID returns a single budget rule scoped to the +// account, or a NotFound error. +func (s *SqlStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var rule *agentNetworkTypes.AccountBudgetRule + result := tx.Take(&rule, accountAndIDQueryCondition, accountID, ruleID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkBudgetRuleNotFoundError(ruleID) + } + + log.WithContext(ctx).Errorf("failed to get agent network budget rule from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network budget rule from store") + } + + return rule, nil +} + +// SaveAgentNetworkBudgetRule upserts a budget rule. +func (s *SqlStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error { + result := s.db.Save(rule) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network budget rule to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network budget rule to store") + } + + return nil +} + +// DeleteAgentNetworkBudgetRule removes a budget rule scoped to the account. +func (s *SqlStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { + result := s.db.Delete(&agentNetworkTypes.AccountBudgetRule{}, accountAndIDQueryCondition, accountID, ruleID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network budget rule from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network budget rule from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkBudgetRuleNotFoundError(ruleID) + } + + return nil +} diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go new file mode 100644 index 000000000..793c82d79 --- /dev/null +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -0,0 +1,302 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// TestAgentNetworkUsage_RealStore_RoundTrip drives CreateAgentNetworkUsage and +// CreateAgentNetworkAccessLog through a real sqlite store to prove the schema +// migrates and the inserts succeed for both a populated (allowed) entry and a +// stripped (denied) entry. +func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-usage-1" + now := time.Now().UTC() + + // Populated (allowed) usage row with two authorising groups. + usage := &agentNetworkTypes.AgentNetworkUsage{ + ID: "log-allowed-1", + AccountID: accountID, + Timestamp: now, + UserID: "user-alice", + ResolvedProviderID: "prov-openai-1", + Provider: "openai", + Model: "gpt-4o", + SessionID: "sess-round-trip-1", + InputTokens: 1200, + OutputTokens: 640, + TotalTokens: 1840, + CostUSD: 0.0231, + } + usageGroups := []agentNetworkTypes.AgentNetworkUsageGroup{ + {UsageID: usage.ID, GroupID: "grp-eng", AccountID: accountID}, + {UsageID: usage.ID, GroupID: "grp-oncall", AccountID: accountID}, + } + require.NoError(t, s.CreateAgentNetworkUsage(ctx, usage, usageGroups), "populated usage insert must succeed") + + // Stripped (denied / 403) usage row: no provider/model/tokens, no groups. + denied := &agentNetworkTypes.AgentNetworkUsage{ + ID: "log-denied-1", + AccountID: accountID, + Timestamp: now, + UserID: "user-bob", + } + require.NoError(t, s.CreateAgentNetworkUsage(ctx, denied, nil), "stripped usage insert must succeed") + + // Idempotency: re-inserting the same id must not error. + require.NoError(t, s.CreateAgentNetworkUsage(ctx, usage, usageGroups), "duplicate usage insert must be idempotent") + + // Access-log row + group children. + entry := &agentNetworkTypes.AgentNetworkAccessLog{ + ID: "log-allowed-1", + AccountID: accountID, + ServiceID: "agent-net-svc-1", + Timestamp: now, + UserID: "user-alice", + StatusCode: 200, + Provider: "openai", + Model: "gpt-4o", + SessionID: "sess-round-trip-1", + InputTokens: 1200, + OutputTokens: 640, + TotalTokens: 1840, + CostUSD: 0.0231, + } + entryGroups := []agentNetworkTypes.AgentNetworkAccessLogGroup{ + {LogID: entry.ID, GroupID: "grp-eng", AccountID: accountID}, + {LogID: entry.ID, GroupID: "grp-oncall", AccountID: accountID}, + } + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, entry, entryGroups), "access-log insert must succeed") + + // Read back through the filtered list + verify group hydration. + logs, total, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err, "list must succeed") + assert.Equal(t, int64(1), total, "one access-log row expected") + require.Len(t, logs, 1) + assert.ElementsMatch(t, []string{"grp-eng", "grp-oncall"}, logs[0].GroupIDs, "group ids must hydrate") + assert.Equal(t, "sess-round-trip-1", logs[0].SessionID, "session id must persist and read back on the access-log row") + + // Session filter narrows the access-log listing to one conversation. + sessionID := "sess-round-trip-1" + sessLogs, sessTotal, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SessionID: &sessionID}) + require.NoError(t, err) + assert.Equal(t, int64(1), sessTotal, "session filter must match the one row with that session id") + require.Len(t, sessLogs, 1) + assert.Equal(t, entry.ID, sessLogs[0].ID, "session filter must return the matching log row") + + bogus := "no-such-session" + _, emptyTotal, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SessionID: &bogus}) + require.NoError(t, err) + assert.Equal(t, int64(0), emptyTotal, "unknown session id must match nothing") + + // Session filter also narrows the always-on usage rows. + sessUsage, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{SessionID: &sessionID}) + require.NoError(t, err) + require.Len(t, sessUsage, 1, "session filter must narrow usage rows to the matching session") + assert.Equal(t, "sess-round-trip-1", sessUsage[0].SessionID, "usage row must carry the session id") +} + +// TestAgentNetworkUsageOverview_DailyAggregation drives GetAgentNetworkUsageRows +// + AggregateUsageByGranularity end-to-end against a real sqlite store, with +// two rows on the same day and one on another, plus a model filter. +func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-overview-1" + day1 := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + day1b := time.Date(2026, 5, 5, 22, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC) + + mk := func(id string, ts time.Time, model string, in, out int64, cost float64) *agentNetworkTypes.AgentNetworkUsage { + return &agentNetworkTypes.AgentNetworkUsage{ + ID: id, AccountID: accountID, Timestamp: ts, Model: model, + InputTokens: in, OutputTokens: out, TotalTokens: in + out, CostUSD: cost, + } + } + require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u1", day1, "gpt-4o", 100, 50, 0.10), nil)) + require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u2", day1b, "gpt-4o", 200, 80, 0.20), nil)) + require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u3", day2, "claude-3", 10, 5, 0.01), nil)) + + rows, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, rows, 3, "all three usage rows expected") + + buckets := agentNetworkTypes.AggregateUsageByGranularity(rows, agentNetworkTypes.UsageGranularityDay) + require.Len(t, buckets, 2, "two distinct days expected") + assert.Equal(t, "2026-05-05", buckets[0].PeriodStart, "oldest-first ordering") + assert.Equal(t, int64(300), buckets[0].InputTokens, "same-day input tokens summed") + assert.Equal(t, int64(130), buckets[0].OutputTokens) + assert.InDelta(t, 0.30, buckets[0].CostUSD, 1e-9, "same-day cost summed") + assert.Equal(t, "2026-05-06", buckets[1].PeriodStart) + assert.Equal(t, int64(15), buckets[1].TotalTokens) + + // Model filter narrows to a single day. + model := "claude-3" + filtered, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{Models: []string{model}}) + require.NoError(t, err) + require.Len(t, filtered, 1, "model filter must narrow rows") + assert.Equal(t, "u3", filtered[0].ID) +} + +// TestAgentNetworkAccessLogSessions_RealStore drives GetAgentNetworkAccessLogSessions +// against a real sqlite store: session grouping + aggregation, recency ordering, +// singleton groups for session-less requests, session pagination, the model +// filter narrowing sessions, and aggregate sorting. +func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-sessions-1" + base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + at := func(h int) time.Time { return base.Add(time.Duration(h) * time.Hour) } + + mk := func(id, session, user, provider, model, decision string, ts time.Time, cost float64) *agentNetworkTypes.AgentNetworkAccessLog { + return &agentNetworkTypes.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, + UserID: user, StatusCode: 200, Provider: provider, Model: model, + SessionID: session, Decision: decision, + InputTokens: 100, OutputTokens: 50, TotalTokens: 150, CostUSD: cost, + } + } + + // Two-request session s1 (alice), a one-request denied session s2 (bob), and + // two session-less requests (empty session id) that must each form their own + // singleton group. + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s1-a", "s1", "alice", "openai", "gpt-4o", "allow", at(1), 0.10), + []agentNetworkTypes.AgentNetworkAccessLogGroup{{LogID: "s1-a", GroupID: "grp-eng", AccountID: accountID}})) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s1-b", "s1", "alice", "openai", "gpt-4o", "allow", at(2), 0.20), + []agentNetworkTypes.AgentNetworkAccessLogGroup{{LogID: "s1-b", GroupID: "grp-oncall", AccountID: accountID}})) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s2-a", "s2", "bob", "anthropic", "claude-3", "deny", at(3), 0.05), nil)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("se-old", "", "carol", "openai", "o1", "allow", at(0), 0.01), nil)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("se-new", "", "dave", "mistral", "mistral-large", "allow", at(4), 0.02), nil)) + + // Default sort: last activity (MAX timestamp) descending. + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "four sessions: s1, s2, and two singletons") + require.Len(t, sessions, 4) + + // se-new(t4) > s2(t3) > s1(t2) > se-old(t0) + assert.Equal(t, "", sessions[0].SessionID, "newest is a session-less singleton") + assert.Equal(t, "se-new", sessions[0].Entries[0].ID) + assert.Equal(t, "s2", sessions[1].SessionID) + assert.Equal(t, "s1", sessions[2].SessionID) + assert.Equal(t, "se-old", sessions[3].Entries[0].ID) + + // s1 aggregation. + s1 := sessions[2] + assert.Equal(t, 2, s1.RequestCount, "s1 has two requests") + assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session") + assert.InDelta(t, 0.30, s1.CostUSD, 1e-9, "cost summed across the session") + assert.Equal(t, "alice", s1.UserID) + assert.Equal(t, "allow", s1.Decision) + // SQLite hands times back in time.Local; normalise to UTC so the instant is + // compared, not the (differing) *Location pointer. + assert.Equal(t, at(1), s1.StartedAt.UTC(), "started = earliest entry") + assert.Equal(t, at(2), s1.EndedAt.UTC(), "ended = latest entry") + assert.ElementsMatch(t, []string{"openai"}, s1.Providers) + assert.ElementsMatch(t, []string{"gpt-4o"}, s1.Models) + assert.ElementsMatch(t, []string{"grp-eng", "grp-oncall"}, s1.GroupIDs, "union of the entries' authorising groups") + + // Denied session rolls up to deny. + assert.Equal(t, "deny", sessions[1].Decision, "any denied request makes the session deny") + + // Pagination over sessions: 2 per page. + page1, total, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 2}) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "total still counts all sessions") + require.Len(t, page1, 2) + assert.Equal(t, "se-new", page1[0].Entries[0].ID) + assert.Equal(t, "s2", page1[1].SessionID) + + page2, _, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 2, PageSize: 2}) + require.NoError(t, err) + require.Len(t, page2, 2) + assert.Equal(t, "s1", page2[0].SessionID) + assert.Equal(t, "se-old", page2[1].Entries[0].ID) + + // Model filter narrows to the session(s) with matching entries. + model := "claude-3" + filtered, fTotal, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, Models: []string{model}}) + require.NoError(t, err) + assert.Equal(t, int64(1), fTotal, "only s2 has a claude-3 request") + require.Len(t, filtered, 1) + assert.Equal(t, "s2", filtered[0].SessionID) + + // Sort by total session cost, descending: s1 (0.30) leads despite not being + // the most recent. + byCost, _, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SortBy: "cost_usd", SortOrder: "desc"}) + require.NoError(t, err) + require.Len(t, byCost, 4) + assert.Equal(t, "s1", byCost[0].SessionID, "highest-cost session sorts first") +} + +// TestDeleteOldAgentNetworkAccessLogs verifies the retention sweep removes only +// access-log rows (and their group children) older than the cutoff, leaving +// recent rows — and never touching usage records. +func TestDeleteOldAgentNetworkAccessLogs(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-retention-1" + old := time.Now().UTC().AddDate(0, 0, -40) + recent := time.Now().UTC().AddDate(0, 0, -1) + + mkLog := func(id string, ts time.Time) (*agentNetworkTypes.AgentNetworkAccessLog, []agentNetworkTypes.AgentNetworkAccessLogGroup) { + return &agentNetworkTypes.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, StatusCode: 200, Model: "gpt-4o", + }, []agentNetworkTypes.AgentNetworkAccessLogGroup{ + {LogID: id, GroupID: "grp-eng", AccountID: accountID}, + } + } + oldEntry, oldGroups := mkLog("old-1", old) + recentEntry, recentGroups := mkLog("recent-1", recent) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, oldEntry, oldGroups)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, recentEntry, recentGroups)) + // A usage row for the old request must survive the access-log sweep. + require.NoError(t, s.CreateAgentNetworkUsage(ctx, &agentNetworkTypes.AgentNetworkUsage{ + ID: "old-1", AccountID: accountID, Timestamp: old, Model: "gpt-4o", InputTokens: 10, TotalTokens: 10, + }, nil)) + + cutoff := time.Now().UTC().AddDate(0, 0, -30) + deleted, err := s.DeleteOldAgentNetworkAccessLogs(ctx, accountID, cutoff) + require.NoError(t, err) + assert.Equal(t, int64(1), deleted, "only the 40-day-old log is deleted") + + logs, total, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + assert.Equal(t, int64(1), total, "the recent log remains") + require.Len(t, logs, 1) + assert.Equal(t, "recent-1", logs[0].ID) + + // Usage is untouched by the access-log retention sweep. + usage, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, usage, 1, "usage record for the deleted log must survive") +} diff --git a/management/server/store/sql_store_agentnetwork_budgetrule_test.go b/management/server/store/sql_store_agentnetwork_budgetrule_test.go new file mode 100644 index 000000000..3bf7b797d --- /dev/null +++ b/management/server/store/sql_store_agentnetwork_budgetrule_test.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// TestAgentNetworkBudgetRule_RealStore_RoundTrip is the GC-0 no-mock guard: it +// drives the budget-rule CRUD through a real sqlite store and asserts the full +// object — targets and the reused PolicyLimits cap shape — survives the +// save → gorm/JSON serialize → reload round-trip, then that delete removes it +// and a second delete reports NotFound. +func TestAgentNetworkBudgetRule_RealStore_RoundTrip(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-budgetrule-1" + rule := agentNetworkTypes.NewAccountBudgetRule(accountID) + rule.Name = "eng-monthly" + rule.TargetGroups = []string{"grp-eng", "grp-oncall"} + rule.TargetUsers = []string{"user-alice"} + rule.Limits = agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{ + Enabled: true, GroupCap: 100_000, UserCap: 10_000, WindowSeconds: 2_592_000, + }, + BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{ + Enabled: true, GroupCapUsd: 500, UserCapUsd: 50, WindowSeconds: 2_592_000, + }, + } + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule), "save must succeed") + + got, err := s.GetAgentNetworkBudgetRuleByID(ctx, LockingStrengthNone, accountID, rule.ID) + require.NoError(t, err, "get by id must succeed after save") + assert.Equal(t, rule.Name, got.Name, "name must round-trip") + assert.Equal(t, []string{"grp-eng", "grp-oncall"}, got.TargetGroups, "target groups must round-trip") + assert.Equal(t, []string{"user-alice"}, got.TargetUsers, "target users must round-trip") + assert.Equal(t, rule.Limits, got.Limits, "the reused PolicyLimits cap shape must round-trip intact") + assert.True(t, got.Enabled, "enabled must round-trip") + + list, err := s.GetAccountAgentNetworkBudgetRules(ctx, LockingStrengthNone, accountID) + require.NoError(t, err, "list must succeed") + require.Len(t, list, 1, "exactly the one saved rule must be listed") + assert.Equal(t, rule.ID, list[0].ID, "listed rule id must match") + + require.NoError(t, s.DeleteAgentNetworkBudgetRule(ctx, accountID, rule.ID), "delete must succeed") + + _, err = s.GetAgentNetworkBudgetRuleByID(ctx, LockingStrengthNone, accountID, rule.ID) + assert.Error(t, err, "get after delete must report not found") + + err = s.DeleteAgentNetworkBudgetRule(ctx, accountID, rule.ID) + assert.Error(t, err, "deleting an absent rule must report not found") +} + +// TestAgentNetworkBudgetRule_RealStore_ScopedByAccount pins that rules are +// account-scoped: a rule under one account is invisible to another. +func TestAgentNetworkBudgetRule_RealStore_ScopedByAccount(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + ruleA := agentNetworkTypes.NewAccountBudgetRule("acc-A") + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, ruleA)) + + list, err := s.GetAccountAgentNetworkBudgetRules(ctx, LockingStrengthNone, "acc-B") + require.NoError(t, err) + assert.Empty(t, list, "account B must not see account A's budget rule") + + _, err = s.GetAgentNetworkBudgetRuleByID(ctx, LockingStrengthNone, "acc-B", ruleA.ID) + assert.Error(t, err, "cross-account get by id must not resolve") +} + +// TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip pins the GC-0 +// additive settings columns: the three collection toggles default off on a +// fresh row and survive a save/reload at their set values. +func TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + const accountID = "acc-settings-toggles" + require.NoError(t, s.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{ + AccountID: accountID, + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + })) + + got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, accountID) + require.NoError(t, err) + assert.False(t, got.EnableLogCollection, "log collection must default off") + assert.False(t, got.EnablePromptCollection, "prompt collection must default off") + assert.False(t, got.RedactPii, "redact pii must default off") + + got.EnableLogCollection = true + got.EnablePromptCollection = true + got.RedactPii = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, got)) + + reloaded, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, accountID) + require.NoError(t, err) + assert.True(t, reloaded.EnableLogCollection, "log collection must round-trip on") + assert.True(t, reloaded.EnablePromptCollection, "prompt collection must round-trip on") + assert.True(t, reloaded.RedactPii, "redact pii must round-trip on") +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 066ab285d..908c199f5 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -37,6 +37,7 @@ import ( "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/server/migration" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -300,6 +301,12 @@ type Store interface { CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) + CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error + CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error + GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) + GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) + GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) + DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error @@ -328,7 +335,40 @@ type Store interface { // return a zero-valued struct. GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) + // GetAgentNetworkMetrics returns aggregated agent-network adoption + usage + // counts for the self-hosted metrics worker. Self-hosted only — file-based + // stores return a zero-valued struct. + GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) + GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error) + + // Agent Network persistence (providers, policies, guardrails, settings). + GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) + GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) + GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) + SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error + DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error + GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) + GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) + SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error + DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error + GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) + GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) + SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error + DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error + GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) + GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) + GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) + SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error + IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error + IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error + GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) + GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) + ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) + GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) + GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) + SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error + DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error } // ProxyMetrics aggregates self-hosted proxy + cluster usage signals @@ -355,6 +395,32 @@ type ProxyMetrics struct { ProxiesConnected int64 } +// AgentNetworkMetrics aggregates self-hosted agent-network adoption + usage +// signals surfaced to the telemetry payload. Each field is best-effort: when a +// store cannot answer (e.g. FileStore) all fields are zero. +type AgentNetworkMetrics struct { + // Accounts is the number of distinct accounts with at least one provider + // configured (agent-network adoption). + Accounts int64 + // Providers is the total number of configured providers across all accounts. + Providers int64 + // Policies is the total number of agent-network policies across all accounts. + Policies int64 + // BudgetRules is the total number of account-level budget rules ("budget + // limits") across all accounts. + BudgetRules int64 + // LogCollectionEnabled is the number of accounts that have agent-network + // log collection turned on. + LogCollectionEnabled int64 + // InputTokens / OutputTokens / CostUSD are summed over the always-collected + // per-request usage ledger (agent_network_request_usage), independent of the + // log-collection toggle. They reflect total metered LLM usage served through + // agent networks. + InputTokens int64 + OutputTokens int64 + CostUSD float64 +} + const ( postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" diff --git a/management/server/store/store_mock_agentnetwork.go b/management/server/store/store_mock_agentnetwork.go new file mode 100644 index 000000000..18adf20f0 --- /dev/null +++ b/management/server/store/store_mock_agentnetwork.go @@ -0,0 +1,495 @@ +package store + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// GetAllAgentNetworkProviders mocks base method. +func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkProviders", ctx, lockStrength) + ret0, _ := ret[0].([]*agentNetworkTypes.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) +} + +// GetAgentNetworkMetrics mocks base method. +func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkMetrics", ctx) + ret0, _ := ret[0].(AgentNetworkMetrics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. +func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) +} + +// GetAccountAgentNetworkProviders mocks base method. +func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkProviders", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*agentNetworkTypes.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) +} + +// GetAgentNetworkProviderByID mocks base method. +func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkProviderByID", ctx, lockStrength, accountID, providerID) + ret0, _ := ret[0].(*agentNetworkTypes.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) +} + +// SaveAgentNetworkProvider mocks base method. +func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkProvider", ctx, provider) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. +func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) +} + +// DeleteAgentNetworkProvider mocks base method. +func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkProvider", ctx, accountID, providerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) +} + +// GetAccountAgentNetworkPolicies mocks base method. +func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkPolicies", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*agentNetworkTypes.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) +} + +// GetAgentNetworkPolicyByID mocks base method. +func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkPolicyByID", ctx, lockStrength, accountID, policyID) + ret0, _ := ret[0].(*agentNetworkTypes.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) +} + +// SaveAgentNetworkPolicy mocks base method. +func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkPolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) +} + +// DeleteAgentNetworkPolicy mocks base method. +func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkPolicy", ctx, accountID, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) +} + +// GetAccountAgentNetworkGuardrails mocks base method. +func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkGuardrails", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*agentNetworkTypes.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) +} + +// GetAgentNetworkGuardrailByID mocks base method. +func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkGuardrailByID", ctx, lockStrength, accountID, guardrailID) + ret0, _ := ret[0].(*agentNetworkTypes.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) +} + +// SaveAgentNetworkGuardrail mocks base method. +func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkGuardrail", ctx, guardrail) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) +} + +// DeleteAgentNetworkGuardrail mocks base method. +func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkGuardrail", ctx, accountID, guardrailID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) +} + +// GetAgentNetworkSettings mocks base method. +func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*agentNetworkTypes.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) +} + +// GetAgentNetworkSettingsByCluster mocks base method. +func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster) + ret0, _ := ret[0].([]*agentNetworkTypes.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster) +} + +// SaveAgentNetworkSettings mocks base method. +func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkSettings", ctx, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. +func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) +} + +// IncrementAgentNetworkConsumption mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumption", ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumption), ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) +} + +// GetAgentNetworkConsumption mocks base method. +func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumption", ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) + ret0, _ := ret[0].(*agentNetworkTypes.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumption), ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) +} + +// GetAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumptionBatch", ctx, lockStrength, accountID, keys) + ret0, _ := ret[0].(map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) +} + +// IncrementAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumptionBatch", ctx, accountID, keys, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumptionBatch), ctx, accountID, keys, tokensIn, tokensOut, costUSD) +} + +// ListAgentNetworkConsumption mocks base method. +func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAgentNetworkConsumption", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*agentNetworkTypes.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkBudgetRules mocks base method. +func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkBudgetRules", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*agentNetworkTypes.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) +} + +// GetAgentNetworkBudgetRuleByID mocks base method. +func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkBudgetRuleByID", ctx, lockStrength, accountID, ruleID) + ret0, _ := ret[0].(*agentNetworkTypes.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) +} + +// SaveAgentNetworkBudgetRule mocks base method. +func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkBudgetRule", ctx, rule) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) +} + +// DeleteAgentNetworkBudgetRule mocks base method. +func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkBudgetRule", ctx, accountID, ruleID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) +} + +// CreateAgentNetworkAccessLog mocks base method. +func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkAccessLog", ctx, entry, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. +func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) +} + +// CreateAgentNetworkUsage mocks base method. +func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkUsage", ctx, usage, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. +func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) +} + +// GetAgentNetworkAccessLogs mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogs", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLog) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkAccessLogSessions mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLogSession) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkUsageRows mocks base method. +func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkUsageRows", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkUsage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. +func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) +} + +// DeleteOldAgentNetworkAccessLogs mocks base method. +func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldAgentNetworkAccessLogs", ctx, accountID, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) +} + +// GetAllAgentNetworkSettings mocks base method. +func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkSettings", ctx, lockStrength) + ret0, _ := ret[0].([]*agentNetworkTypes.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) +} diff --git a/proxy/inbound.go b/proxy/inbound.go index d729ba9ae..e8f93fbe2 100644 --- a/proxy/inbound.go +++ b/proxy/inbound.go @@ -466,15 +466,20 @@ func feedRouterFromListener(ctx context.Context, ln net.Listener, router *nbtcp. _ = ln.Close() }() + var backoff nbtcp.AcceptBackoff for { conn, err := ln.Accept() if err != nil { - if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + if ctx.Err() != nil || nbtcp.IsClosedListenerErr(err) { + return + } + logger.WithField("account_id", accountID).Debugf("plain inbound accept: %v; backing off", err) + if !backoff.Backoff(ctx) { return } - logger.WithField("account_id", accountID).Debugf("plain inbound accept: %v", err) continue } + backoff.Reset() router.HandleConn(ctx, conn) } } diff --git a/proxy/inbound_test.go b/proxy/inbound_test.go index 584a04238..0e6081802 100644 --- a/proxy/inbound_test.go +++ b/proxy/inbound_test.go @@ -533,3 +533,125 @@ MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== -----END EC PRIVATE KEY-----`) + +// scriptedAcceptListener returns pre-scripted errors from Accept(). Used +// to drive the feedRouterFromListener tests without binding a real +// socket — the production code path is a netstack-backed listener that +// returns gVisor's "endpoint is in invalid state" forever after its +// endpoint is destroyed. +type scriptedAcceptListener struct { + errs chan error + closed chan struct{} +} + +func newScriptedAcceptListener(errs ...error) *scriptedAcceptListener { + s := &scriptedAcceptListener{ + errs: make(chan error, len(errs)+1), + closed: make(chan struct{}), + } + for _, e := range errs { + s.errs <- e + } + return s +} + +func (s *scriptedAcceptListener) Accept() (net.Conn, error) { + select { + case <-s.closed: + return nil, net.ErrClosed + case err := <-s.errs: + return nil, err + } +} + +func (s *scriptedAcceptListener) Close() error { + select { + case <-s.closed: + default: + close(s.closed) + } + return nil +} + +func (s *scriptedAcceptListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +// errSentinel carries a literal error message so tests can synthesise +// the exact gVisor text without importing the netstack package. +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } + +// TestFeedRouterFromListener_ExitsOnGVisorInvalidEndpoint is the +// regression guard for the inbound side of the tight-loop bug. The +// per-account plain-HTTP feeder must recognise gVisor's "endpoint is in +// invalid state" and exit, otherwise it pegs a CPU core and floods the +// account-scoped log with the same accept error every iteration. +func TestFeedRouterFromListener_ExitsOnGVisorInvalidEndpoint(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80} + router := nbtcp.NewRouter(logger, nil, addr) + + gvisorErr := &net.OpError{ + Op: "accept", + Net: "tcp", + Addr: addr, + Err: errSentinel("endpoint is in invalid state"), + } + ln := newScriptedAcceptListener(gvisorErr) + defer ln.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + feedRouterFromListener(context.Background(), ln, router, logger, "acct-1") + }() + + select { + case <-done: + // Expected: loop recognised the gVisor error and returned. + case <-time.After(2 * time.Second): + t.Fatal("feedRouterFromListener did not exit on gVisor 'endpoint is in invalid state' — accept loop is spinning") + } +} + +// TestFeedRouterFromListener_BacksOffOnTransientError asserts the +// defence-in-depth path: an unknown sticky Accept error must NOT cause +// CPU spin. The loop backs off and exits cleanly when ctx is cancelled. +func TestFeedRouterFromListener_BacksOffOnTransientError(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80} + router := nbtcp.NewRouter(logger, nil, addr) + + const transientCount = 5 + errs := make([]error, transientCount) + for i := range errs { + errs[i] = errSentinel("transient: temporary network error") + } + ln := newScriptedAcceptListener(errs...) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + start := time.Now() + done := make(chan struct{}) + go func() { + defer close(done) + feedRouterFromListener(ctx, ln, router, logger, "acct-1") + }() + time.AfterFunc(150*time.Millisecond, cancel) + + select { + case <-done: + // Expected. + case <-time.After(2 * time.Second): + t.Fatal("feedRouterFromListener did not exit on ctx cancellation — backoff or exit path broken") + } + + // Without backoff the 5 scripted errors would burn in microseconds. + // With backoff the first delay alone is 5ms, so the loop must take + // at least that long even though ctx fires at 150ms. + elapsed := time.Since(start) + assert.GreaterOrEqual(t, elapsed, 5*time.Millisecond, + "loop ran without backing off — would burn CPU in production") +} diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go index 3283f61db..db868b4e0 100644 --- a/proxy/internal/accesslog/logger.go +++ b/proxy/internal/accesslog/logger.go @@ -128,6 +128,7 @@ type logEntry struct { BytesDownload int64 Protocol Protocol Metadata map[string]string + AgentNetwork bool } // Protocol identifies the transport protocol of an access log entry. @@ -214,6 +215,54 @@ func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool { return false } +// usageMetadataKeys is the allowlist of metadata retained on a stripped, +// usage-only agent-network entry. Mirrors the llm.* / cost.* keys in +// proxy/internal/middleware/keys.go — only the dimensions management needs to +// record a usage row (provider / model / tokens / cost / groups). +var usageMetadataKeys = map[string]struct{}{ + "llm.provider": {}, + "llm.model": {}, + "llm.resolved_provider_id": {}, + "llm.input_tokens": {}, + "llm.output_tokens": {}, + "llm.total_tokens": {}, + "cost.usd_total": {}, + "llm.authorising_groups": {}, +} + +// stripAgentNetworkEntryForUsage returns the entry reduced to what's needed to +// record usage/cost: it drops request detail (host / path / source IP) and any +// prompt capture, keeping the LLM usage metadata plus the caller identity +// (user / auth mechanism) needed for attribution. Shipped when an +// agent-network account has log collection disabled but usage must still be +// collected. logEntry is passed by value, so mutating it here is safe; Metadata +// is replaced with a fresh map rather than mutated in place. +func stripAgentNetworkEntryForUsage(entry logEntry) logEntry { + entry.Host = "" + entry.Path = "" + entry.SourceIP = netip.Addr{} + // Drop the rest of the per-request telemetry too — a usage-only entry + // must carry the LLM usage metadata and caller identity, nothing that + // describes the individual request. + entry.Method = "" + entry.ResponseCode = 0 + entry.DurationMs = 0 + entry.BytesUpload = 0 + entry.BytesDownload = 0 + entry.Protocol = "" + + if len(entry.Metadata) > 0 { + stripped := make(map[string]string, len(usageMetadataKeys)) + for k := range usageMetadataKeys { + if v, ok := entry.Metadata[k]; ok { + stripped[k] = v + } + } + entry.Metadata = stripped + } + return entry +} + func (l *Logger) log(entry logEntry) { // Fire off the log request in a separate routine. // This increases the possibility of losing a log message @@ -264,6 +313,7 @@ func (l *Logger) log(entry logEntry) { BytesDownload: entry.BytesDownload, Protocol: string(entry.Protocol), Metadata: entry.Metadata, + AgentNetwork: entry.AgentNetwork, }, }); err != nil { l.logger.WithFields(log.Fields{ diff --git a/proxy/internal/accesslog/middleware.go b/proxy/internal/accesslog/middleware.go index 5a0684c19..9c644418e 100644 --- a/proxy/internal/accesslog/middleware.go +++ b/proxy/internal/accesslog/middleware.go @@ -83,11 +83,23 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { BytesDownload: bytesDownload, Protocol: ProtocolHTTP, Metadata: capturedData.GetMetadata(), + AgentNetwork: capturedData.GetAgentNetwork(), } l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s", requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceID(), capturedData.GetAccountID()) - l.log(entry) + // Emit the access log unless the matched target opted out + // (agent-network synth targets do this when the account's + // EnableLogCollection toggle is off). For agent-network entries we + // still ship a stripped, usage-only record even when suppressed, so + // usage/cost is collected regardless of the log-collection toggle; + // request detail and prompt capture are dropped before sending. + switch { + case !capturedData.GetSuppressAccessLog(): + l.log(entry) + case entry.AgentNetwork: + l.log(stripAgentNetworkEntryForUsage(entry)) + } // Track usage for cost monitoring (upload + download) by domain l.trackUsage(host, bytesUpload+bytesDownload) diff --git a/proxy/internal/accesslog/middleware_test.go b/proxy/internal/accesslog/middleware_test.go new file mode 100644 index 000000000..cf91957a8 --- /dev/null +++ b/proxy/internal/accesslog/middleware_test.go @@ -0,0 +1,185 @@ +package accesslog + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// recorderClient is a minimal stub for the access-log gRPCClient interface. It +// counts SendAccessLog invocations and signals on every call so tests can +// deterministically wait for the goroutine inside Logger.log without sleeping. +type recorderClient struct { + mu sync.Mutex + calls int64 + lastEntry *proto.AccessLog + called chan struct{} +} + +func newRecorderClient() *recorderClient { + return &recorderClient{called: make(chan struct{}, 16)} +} + +func (r *recorderClient) SendAccessLog(_ context.Context, in *proto.SendAccessLogRequest, _ ...grpc.CallOption) (*proto.SendAccessLogResponse, error) { + r.mu.Lock() + r.calls++ + r.lastEntry = in.GetLog() + r.mu.Unlock() + select { + case r.called <- struct{}{}: + default: + } + return &proto.SendAccessLogResponse{}, nil +} + +func (r *recorderClient) callCount() int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.calls +} + +// newTestLogger builds a Logger backed by the supplied recorderClient. It is +// the same constructor production uses, just with a stub gRPC client — no +// mocks, no interface re-implementations. +func newTestLogger(t *testing.T, client *recorderClient) *Logger { + t.Helper() + logger := NewLogger(client, nil, nil) + t.Cleanup(logger.Close) + return logger +} + +// TestMiddleware_SuppressAccessLog_SkipsLogSink asserts the suppression gate. +// When the inner handler stamps SuppressAccessLog=true on CapturedData (mirrors +// what reverseproxy does when the matched target's DisableAccessLog flag is +// set), the middleware must NOT invoke the access-log sink. Bandwidth telemetry +// (trackUsage) keeps running — it's the call to SendAccessLog that we gate. +func TestMiddleware_SuppressAccessLog_SkipsLogSink(t *testing.T) { + client := newRecorderClient() + l := newTestLogger(t, client) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd, "middleware must inject CapturedData into the request context") + cd.SetSuppressAccessLog(true) + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(l.Middleware(inner)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/agent-network/v1/chat/completions") + require.NoError(t, err, "GET against suppressed target must succeed") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, "inner handler must run normally") + + // Give the goroutine fence a beat (Logger.log dispatches in a goroutine). + // The negative assertion needs a small window: if a send is going to + // happen, it happens promptly. + select { + case <-client.called: + t.Fatalf("access-log sink must not be invoked when SuppressAccessLog=true (got %d call(s))", client.callCount()) + case <-time.After(150 * time.Millisecond): + } + + assert.Equal(t, int64(0), client.callCount(), + "SendAccessLog must not be called for suppressed requests") +} + +// TestMiddleware_SuppressAccessLog_DefaultEmitsLog is the regression sanity: +// when nothing sets SuppressAccessLog (the universal default for every +// non-agent-network target), the middleware MUST still emit the access-log +// entry. This is the guarantee that wires-through to the EnableLogCollection +// gate without breaking anyone who isn't opted in. +func TestMiddleware_SuppressAccessLog_DefaultEmitsLog(t *testing.T) { + client := newRecorderClient() + l := newTestLogger(t, client) + + var innerRan atomic.Bool + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + innerRan.Store(true) + // Intentionally DO NOT touch SuppressAccessLog — mirrors every + // non-agent-network target. + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(l.Middleware(inner)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/service/healthz") + require.NoError(t, err, "GET against default target must succeed") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, "inner handler must run normally") + require.True(t, innerRan.Load(), "inner handler must have run") + + select { + case <-client.called: + case <-time.After(2 * time.Second): + t.Fatalf("SendAccessLog must be invoked for non-suppressed requests, none observed (calls=%d)", client.callCount()) + } + + assert.Equal(t, int64(1), client.callCount(), + "non-suppressed request must produce exactly one access-log send") +} + +// TestMiddleware_SuppressAccessLog_PreservesUsageTracking proves the gate is +// surgical: with SuppressAccessLog=true the access-log send is skipped, but +// the per-domain usage tracker still records the bytes transferred. This is +// the cost-monitoring guarantee called out in the gate's comment. +func TestMiddleware_SuppressAccessLog_PreservesUsageTracking(t *testing.T) { + client := newRecorderClient() + l := newTestLogger(t, client) + + payload := []byte("ok") + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd, "middleware must inject CapturedData") + cd.SetSuppressAccessLog(true) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + }) + + srv := httptest.NewServer(l.Middleware(inner)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/agent-network/v1/chat/completions") + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // Allow trackUsage to land — it runs synchronously after l.log(entry) is + // (would have been) called. + time.Sleep(50 * time.Millisecond) + + l.usageMux.Lock() + usage, present := l.domainUsage[hostNoPort(srv.URL)] + l.usageMux.Unlock() + require.True(t, present, "domain usage must be tracked even when the access-log is suppressed") + assert.Greater(t, usage.bytesTransferred, int64(0), "bytesTransferred must include the response payload") + assert.Equal(t, int64(0), client.callCount(), + "SendAccessLog must remain suppressed across the response write") +} + +// hostNoPort extracts the host name from an httptest server URL. The +// middleware strips the port before keying domain usage, so the test mirrors +// that to look the entry up. +func hostNoPort(url string) string { + // httptest URLs are always "http://127.0.0.1:PORT". + const prefix = "http://" + host := url[len(prefix):] + for i := 0; i < len(host); i++ { + if host[i] == ':' || host[i] == '/' { + return host[:i] + } + } + return host +} diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index c0ec5c94c..6608c2b22 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -297,6 +297,109 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) { assert.Equal(t, groups, capturedData.GetUserGroups(), "CapturedData groups must be retained after handler completes") } +// stubTunnelValidator implements SessionValidator for the tunnel-peer +// path. ValidateTunnelPeer returns a fixed response so tests can assert +// how the proxy maps it onto CapturedData, and records whether the +// fast-path actually reached management. +type stubTunnelValidator struct { + called bool + resp *proto.ValidateTunnelPeerResponse +} + +func (s *stubTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) { + return nil, errors.New("not used in this test") +} + +func (s *stubTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) { + s.called = true + return s.resp, nil +} + +// TestProtect_PrivateService_TunnelPeerGroupsPropagate locks the agent-network +// auth path end-to-end at the proxy edge: a Private service must route through +// ValidateTunnelPeer and lift the returned peer_group_ids onto CapturedData so +// the llm_router group-authorisation pass can see them. Regression guard for +// the failure that surfaces downstream as llm_policy.no_authorised_provider — +// i.e. a synthesised service that reaches the proxy without private=true (so +// this path is skipped) leaves UserGroups empty and every request is denied. +func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) { + groups := []string{"grp-admins", "grp-users"} + names := []string{"Admins", "Users"} + validator := &stubTunnelValidator{resp: &proto.ValidateTunnelPeerResponse{ + Valid: true, + UserId: "user-1", + UserEmail: "user@example.com", + SessionToken: "tunnel-session-token", + PeerGroupIds: groups, + PeerGroupNames: names, + }} + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + + // Private service: no operator schemes — auth gates solely on the tunnel peer. + require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true)) + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source + + var seenGroups []string + var seenUser string + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, c, "captured data must be present in request context") + seenGroups = c.GetUserGroups() + seenUser = c.GetUserID() + w.WriteHeader(http.StatusOK) + })) + + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + req := httptest.NewRequest(http.MethodPost, "http://agent.example.com/v1/chat/completions", nil) + req.RemoteAddr = "100.90.1.14:5000" + req = req.WithContext(WithTunnelLookup(proxy.WithCapturedData(req.Context(), cd), lookup)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "private service must authorise a tunnel peer the validator accepts") + assert.Equal(t, groups, seenGroups, "ValidateTunnelPeer peer_group_ids must reach CapturedData.UserGroups for llm_router authorisation") + assert.Equal(t, "user-1", seenUser, "tunnel-peer principal must reach CapturedData") + assert.Equal(t, groups, cd.GetUserGroups(), "groups must persist on CapturedData after the handler returns") +} + +// TestProtect_PrivateService_TunnelPeerDenied verifies the deny path: when +// ValidateTunnelPeer rejects the peer, a Private service 403s and never reaches +// the upstream handler (no fall-through to unauthenticated pass-through). +func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) { + validator := &stubTunnelValidator{resp: &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "not_in_group", + }} + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true)) + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) + + reached := false + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + req := httptest.NewRequest(http.MethodPost, "http://agent.example.com/v1/chat/completions", nil) + req.RemoteAddr = "100.90.1.14:5000" + req = req.WithContext(WithTunnelLookup(proxy.WithCapturedData(req.Context(), cd), lookup)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "private service must 403 when the tunnel peer is rejected") + assert.False(t, reached, "denied private request must not reach the upstream handler") +} + func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) kp := generateTestKeyPair(t) @@ -1228,22 +1331,6 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP") } -// stubTunnelValidator records ValidateTunnelPeer calls so a test can -// assert whether the fast-path reached management. -type stubTunnelValidator struct { - called bool - resp *proto.ValidateTunnelPeerResponse -} - -func (s *stubTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) { - return nil, errors.New("not used in this test") -} - -func (s *stubTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) { - s.called = true - return s.resp, nil -} - // TestProtect_TunnelPeerFastPath_RequiresInboundMarker guards the // anti-spoof gate: a request with an RFC1918 source IP arriving on the // public listener (no TunnelLookupFromContext attached) must not be diff --git a/proxy/internal/llm/anthropic.go b/proxy/internal/llm/anthropic.go new file mode 100644 index 000000000..523731fbd --- /dev/null +++ b/proxy/internal/llm/anthropic.go @@ -0,0 +1,196 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// AnthropicParser implements the Parser interface for the Anthropic Messages +// and Completions APIs. Detection is substring-based to tolerate upstream +// path rewrites. +type AnthropicParser struct{} + +var anthropicPathHints = []string{ + "/v1/messages", + "/v1/complete", +} + +// Provider returns ProviderAnthropic. +func (AnthropicParser) Provider() Provider { return ProviderAnthropic } + +// ProviderName returns the stable label used for metrics and metadata. +func (AnthropicParser) ProviderName() string { return "anthropic" } + +// DetectFromURL reports whether the given request path looks like an +// Anthropic API endpoint. The match is case-insensitive and substring-based. +func (AnthropicParser) DetectFromURL(path string) bool { + lower := strings.ToLower(path) + for _, hint := range anthropicPathHints { + if strings.Contains(lower, hint) { + return true + } + } + return false +} + +type anthropicRequest struct { + Model string `json:"model"` + Stream *bool `json:"stream"` + System json.RawMessage `json:"system"` + Messages []anthropicMessage `json:"messages"` + // Legacy /v1/complete endpoint. + Prompt string `json:"prompt"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// ParseRequest extracts the model name and streaming flag from an Anthropic +// request body. Unknown or missing fields leave the corresponding struct +// members zero-valued. +func (AnthropicParser) ParseRequest(body []byte) (RequestFacts, error) { + var req anthropicRequest + if err := json.Unmarshal(body, &req); err != nil { + return RequestFacts{}, fmt.Errorf("decode anthropic request: %w: %v", ErrMalformedRequest, err) + } + return RequestFacts{ + Model: req.Model, + Stream: ptrDeref(req.Stream), + }, nil +} + +type anthropicResponse struct { + Usage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + // CacheReadInputTokens and CacheCreationInputTokens are + // ADDITIVE to InputTokens (not subset), each billed at its + // own rate by the cost meter. cache_read is the cheaper + // read-from-cache rate, cache_creation is the more + // expensive write-to-cache rate. + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + } `json:"usage"` +} + +// ParseResponse decodes the non-streaming Anthropic response envelope. Status +// codes other than 200 are treated as non-LLM responses so the caller can +// skip cost accounting without aborting the request. +func (AnthropicParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) { + if status != 200 { + return Usage{}, fmt.Errorf("anthropic status %d: %w", status, ErrNotLLMResponse) + } + if isEventStream(contentType) { + return Usage{}, ErrStreamingUnsupported + } + if !isJSON(contentType) { + return Usage{}, fmt.Errorf("anthropic content-type %q: %w", contentType, ErrNotLLMResponse) + } + + var resp anthropicResponse + if err := json.Unmarshal(body, &resp); err != nil { + return Usage{}, fmt.Errorf("decode anthropic response: %w: %v", ErrMalformedResponse, err) + } + return Usage{ + InputTokens: resp.Usage.InputTokens, + OutputTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens, + CachedInputTokens: resp.Usage.CacheReadInputTokens, + CacheCreationTokens: resp.Usage.CacheCreationInputTokens, + }, nil +} + +// ExtractPrompt returns the user-visible prompt text from an Anthropic +// request body. Handles the Messages API (system + messages[]) and the +// legacy /v1/complete prompt string. Returns "" on any decode failure. +func (AnthropicParser) ExtractPrompt(body []byte) string { + var req anthropicRequest + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + var b strings.Builder + if len(req.System) > 0 { + if s := decodeStringOrJoin(req.System); s != "" { + b.WriteString("system: ") + b.WriteString(s) + } + } + for _, m := range req.Messages { + if b.Len() > 0 { + b.WriteByte('\n') + } + if m.Role != "" { + b.WriteString(m.Role) + b.WriteString(": ") + } + b.WriteString(decodeStringOrJoin(m.Content)) + } + if b.Len() == 0 && req.Prompt != "" { + b.WriteString(req.Prompt) + } + return b.String() +} + +// ExtractSessionID is the body-side fallback for Anthropic. Claude Code's +// authoritative session marker is the X-Claude-Code-Session-Id request +// header (handled by the request-parser middleware); this only mines the +// optional metadata.user_id for an embedded "...session_" marker. +// metadata.user_id on its own is a USER identifier, not a session, so the +// whole value is deliberately NOT used — returning it would mislabel every +// request from a user as one session. Returns "" when no session marker is +// present. +func (AnthropicParser) ExtractSessionID(body []byte) string { + var req struct { + Metadata struct { + UserID string `json:"user_id"` + } `json:"metadata"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + if idx := strings.LastIndex(req.Metadata.UserID, "session_"); idx >= 0 { + if session := req.Metadata.UserID[idx+len("session_"):]; session != "" { + return session + } + } + return "" +} + +type anthropicMessageResponse struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + // Legacy /v1/complete response. + Completion string `json:"completion"` +} + +// ExtractCompletion returns the assistant text from a non-streaming Anthropic +// Messages or Completions response. Returns "" when status/content-type +// indicate the body is not parseable or no text part is present. +func (AnthropicParser) ExtractCompletion(status int, contentType string, body []byte) string { + if status != 200 || isEventStream(contentType) || !isJSON(contentType) { + return "" + } + var resp anthropicMessageResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + var b strings.Builder + for _, part := range resp.Content { + if part.Text == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(part.Text) + } + if b.Len() == 0 { + return resp.Completion + } + return b.String() +} diff --git a/proxy/internal/llm/anthropic_test.go b/proxy/internal/llm/anthropic_test.go new file mode 100644 index 000000000..a0c1f7896 --- /dev/null +++ b/proxy/internal/llm/anthropic_test.go @@ -0,0 +1,169 @@ +package llm + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnthropicDetectFromURL(t *testing.T) { + p := AnthropicParser{} + + cases := map[string]bool{ + "/v1/messages": true, + "/v1/complete": true, + "/V1/Messages": true, + "/proxy/v1/messages?x": true, + "/v1/chat/completions": false, + "": false, + } + for path, want := range cases { + assert.Equal(t, want, p.DetectFromURL(path), "DetectFromURL(%q)", path) + } +} + +func TestAnthropicParseRequest(t *testing.T) { + p := AnthropicParser{} + + t.Run("stream true", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"claude-sonnet-4-5","stream":true}`)) + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-5", facts.Model, "model extracted") + assert.True(t, facts.Stream, "stream flag honoured") + }) + + t.Run("stream default", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"claude-sonnet-4-5"}`)) + require.NoError(t, err) + assert.False(t, facts.Stream, "missing stream flag defaults to false") + }) + + t.Run("malformed", func(t *testing.T) { + _, err := p.ParseRequest([]byte(`{"model":`)) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMalformedRequest), "sentinel wrapped") + }) +} + +func TestAnthropicParseResponse(t *testing.T) { + p := AnthropicParser{} + + t.Run("happy fixture", func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "anthropic_messages.json")) + require.NoError(t, err, "fixture must be readable") + + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(123), usage.InputTokens, "input tokens extracted") + assert.Equal(t, int64(45), usage.OutputTokens, "output tokens extracted") + assert.Equal(t, int64(168), usage.TotalTokens, "total computed as sum") + }) + + t.Run("streaming rejected", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/event-stream", []byte("")) + require.ErrorIs(t, err, ErrStreamingUnsupported, "SSE responses must use the scanner") + }) + + t.Run("non-200", func(t *testing.T) { + _, err := p.ParseResponse(429, "application/json", []byte(`{}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 rejected as non-LLM") + }) + + t.Run("non-json content type", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/html", []byte(`{}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "text/html treated as non-LLM") + }) + + t.Run("malformed body", func(t *testing.T) { + _, err := p.ParseResponse(200, "application/json", []byte(`{`)) + require.ErrorIs(t, err, ErrMalformedResponse, "bad JSON yields malformed error") + }) + + // Anthropic's two cache fields are ADDITIVE to input_tokens (not + // subset). The parser must surface them so the cost meter can + // bill each bucket at its own configured rate. Total includes + // every bucket so downstream attribution sees the full token + // volume the request consumed. + t.Run("cache_read_input_tokens surfaces as CachedInputTokens (additive)", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(256), usage.InputTokens, "regular input remains separate from cache buckets") + assert.Equal(t, int64(768), usage.CachedInputTokens, "cache_read maps onto CachedInputTokens — same field carries OpenAI cached subset and Anthropic cache reads") + assert.Zero(t, usage.CacheCreationTokens) + assert.Equal(t, int64(256+200+768), usage.TotalTokens, "total includes every input bucket plus output — cache reads are billable tokens") + }) + + t.Run("cache_creation_input_tokens surfaces as CacheCreationTokens (additive)", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_creation_input_tokens":512}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(256), usage.InputTokens) + assert.Zero(t, usage.CachedInputTokens) + assert.Equal(t, int64(512), usage.CacheCreationTokens, "cache_creation surfaces — meter applies the write-rate multiplier") + assert.Equal(t, int64(256+200+512), usage.TotalTokens) + }) + + t.Run("both cache buckets present", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(768), usage.CachedInputTokens) + assert.Equal(t, int64(512), usage.CacheCreationTokens) + assert.Equal(t, int64(256+200+768+512), usage.TotalTokens, "all four buckets sum into total") + }) + + t.Run("absent cache fields leave counts at zero", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":100,"output_tokens":50}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Zero(t, usage.CachedInputTokens, "no cache_read field = no cached count") + assert.Zero(t, usage.CacheCreationTokens, "no cache_creation field = no creation count") + assert.Equal(t, int64(150), usage.TotalTokens, "back to the simple in+out total when no cache buckets present") + }) +} + +func TestAnthropicExtractPrompt_Messages(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-7","system":"be brief","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"yes"}]}`) + got := AnthropicParser{}.ExtractPrompt(body) + require.Contains(t, got, "system: be brief", "system surfaces with role label") + require.Contains(t, got, "user: hi", "user message surfaces") + require.Contains(t, got, "assistant: yes", "assistant message surfaces") +} + +func TestAnthropicExtractPrompt_LegacyComplete(t *testing.T) { + body := []byte(`{"model":"claude-2","prompt":"\n\nHuman: hi\n\nAssistant:"}`) + got := AnthropicParser{}.ExtractPrompt(body) + require.Contains(t, got, "Human: hi", "legacy prompt string surfaces") +} + +func TestAnthropicExtractSessionID(t *testing.T) { + t.Run("claude code session suffix", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","metadata":{"user_id":"user_abc123_account_def456_session_9f8e7d6c"},"messages":[]}`) + assert.Equal(t, "9f8e7d6c", AnthropicParser{}.ExtractSessionID(body), "session_ suffix must be extracted from metadata.user_id") + }) + t.Run("plain user_id is not treated as a session", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","metadata":{"user_id":"acme-team"},"messages":[]}`) + assert.Equal(t, "", AnthropicParser{}.ExtractSessionID(body), "a user identifier without a session marker must NOT be used as a session id") + }) + t.Run("no metadata yields empty", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","messages":[{"role":"user","content":"hi"}]}`) + assert.Equal(t, "", AnthropicParser{}.ExtractSessionID(body), "absent metadata.user_id yields no session id") + }) +} + +func TestAnthropicExtractCompletion_Messages(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "anthropic_messages.json")) + require.NoError(t, err) + got := AnthropicParser{}.ExtractCompletion(200, "application/json", body) + require.NotEmpty(t, got, "anthropic fixture has assistant text") +} + +func TestAnthropicExtractCompletion_Streaming(t *testing.T) { + got := AnthropicParser{}.ExtractCompletion(200, "text/event-stream", []byte("")) + require.Empty(t, got, "streaming responses are skipped") +} diff --git a/proxy/internal/llm/bedrock.go b/proxy/internal/llm/bedrock.go new file mode 100644 index 000000000..f7802beb2 --- /dev/null +++ b/proxy/internal/llm/bedrock.go @@ -0,0 +1,189 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ProviderNameBedrock is the stable label for the AWS Bedrock parser, used as +// the llm.provider metadata value and the cost-meter formula selector. +const ProviderNameBedrock = "bedrock" + +// BedrockParser implements the Parser interface for the AWS Bedrock runtime. +// Bedrock carries the model in the URL path (/model/{id}/{action}); the request +// middleware extracts it there, so this parser focuses on the response shapes: +// the vendor-native InvokeModel body (e.g. Anthropic's snake_case usage) and the +// unified Converse body (camelCase usage). +type BedrockParser struct{} + +var bedrockPathHints = []string{"/invoke", "/converse"} + +// Provider returns ProviderBedrock. +func (BedrockParser) Provider() Provider { return ProviderBedrock } + +// ProviderName returns the stable label used for metrics and metadata. +func (BedrockParser) ProviderName() string { return ProviderNameBedrock } + +// DetectFromURL reports whether the path is a Bedrock runtime model endpoint. +func (BedrockParser) DetectFromURL(path string) bool { + lower := strings.ToLower(path) + if !strings.HasPrefix(lower, "/model/") { + return false + } + for _, hint := range bedrockPathHints { + if strings.Contains(lower, hint) { + return true + } + } + return false +} + +// ParseRequest is a no-op for Bedrock: the model lives in the URL path, not the +// body, and the streaming flag is derived from the path action. The request +// middleware handles both via parseBedrockPath, so this returns empty facts. +func (BedrockParser) ParseRequest([]byte) (RequestFacts, error) { + return RequestFacts{}, nil +} + +// bedrockResponse captures token usage from both Bedrock response shapes: +// InvokeModel (vendor-native; Anthropic uses snake_case + additive cache +// buckets) and Converse (camelCase, with a precomputed total). +type bedrockResponse struct { + Usage struct { + // InvokeModel (Anthropic-on-Bedrock) — snake_case. + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + // Converse — camelCase. + InputTokensCamel int64 `json:"inputTokens"` + OutputTokensCamel int64 `json:"outputTokens"` + TotalTokensCamel int64 `json:"totalTokens"` + } `json:"usage"` +} + +// ParseResponse decodes the non-streaming Bedrock response envelope, handling +// both the InvokeModel and Converse usage shapes. Non-200 / non-JSON bodies are +// treated as non-LLM responses so the caller skips cost accounting. +func (BedrockParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) { + if status != 200 { + return Usage{}, fmt.Errorf("bedrock status %d: %w", status, ErrNotLLMResponse) + } + if isAWSEventStream(contentType) || isEventStream(contentType) { + return Usage{}, ErrStreamingUnsupported + } + if !isJSON(contentType) { + return Usage{}, fmt.Errorf("bedrock content-type %q: %w", contentType, ErrNotLLMResponse) + } + + var resp bedrockResponse + if err := json.Unmarshal(body, &resp); err != nil { + return Usage{}, fmt.Errorf("decode bedrock response: %w: %v", ErrMalformedResponse, err) + } + inTok := firstNonZero(resp.Usage.InputTokens, resp.Usage.InputTokensCamel) + outTok := firstNonZero(resp.Usage.OutputTokens, resp.Usage.OutputTokensCamel) + total := resp.Usage.TotalTokensCamel + if total == 0 { + total = inTok + outTok + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens + } + return Usage{ + InputTokens: inTok, + OutputTokens: outTok, + TotalTokens: total, + CachedInputTokens: resp.Usage.CacheReadInputTokens, + CacheCreationTokens: resp.Usage.CacheCreationInputTokens, + }, nil +} + +// ExtractPrompt returns the user-visible prompt from a Bedrock request body, +// handling both the InvokeModel (Anthropic Messages: system + messages[]) and +// Converse (messages[].content[].text) shapes. Returns "" on decode failure. +func (BedrockParser) ExtractPrompt(body []byte) string { + var req struct { + System json.RawMessage `json:"system"` + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + var b strings.Builder + if s := decodeStringOrJoin(req.System); s != "" { + b.WriteString("system: ") + b.WriteString(s) + } + for _, m := range req.Messages { + if b.Len() > 0 { + b.WriteByte('\n') + } + if m.Role != "" { + b.WriteString(m.Role) + b.WriteString(": ") + } + b.WriteString(decodeStringOrJoin(m.Content)) + } + return b.String() +} + +// ExtractCompletion returns the assistant text from a non-streaming Bedrock +// response, handling InvokeModel (Anthropic content[].text) and Converse +// (output.message.content[].text). +func (BedrockParser) ExtractCompletion(status int, contentType string, body []byte) string { + if status != 200 || isAWSEventStream(contentType) || !isJSON(contentType) { + return "" + } + var resp struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Output struct { + Message struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } `json:"output"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + var b strings.Builder + appendText := func(text string) { + if text == "" { + return + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(text) + } + for _, p := range resp.Content { + appendText(p.Text) + } + for _, p := range resp.Output.Message.Content { + appendText(p.Text) + } + return b.String() +} + +// ExtractSessionID has no Bedrock-native marker; session grouping relies on the +// request headers handled by the middleware. Returns "". +func (BedrockParser) ExtractSessionID([]byte) string { return "" } + +// firstNonZero returns a when non-zero, else b. Folds the snake_case and +// camelCase usage variants into a single value. +func firstNonZero(a, b int64) int64 { + if a != 0 { + return a + } + return b +} + +// isAWSEventStream reports whether contentType is the AWS binary event-stream +// framing used by Bedrock's streaming endpoints. +func isAWSEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "application/vnd.amazon.eventstream") +} diff --git a/proxy/internal/llm/bedrock_test.go b/proxy/internal/llm/bedrock_test.go new file mode 100644 index 000000000..ca6f092f3 --- /dev/null +++ b/proxy/internal/llm/bedrock_test.go @@ -0,0 +1,65 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBedrockParser_ParseResponse_Invoke(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":13,"output_tokens":5,"cache_read_input_tokens":2,"cache_creation_input_tokens":4}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(13), u.InputTokens, "invoke input tokens") + require.Equal(t, int64(5), u.OutputTokens, "invoke output tokens") + require.Equal(t, int64(2), u.CachedInputTokens, "invoke cache-read tokens") + require.Equal(t, int64(4), u.CacheCreationTokens, "invoke cache-creation tokens") + require.Equal(t, int64(13+5+2+4), u.TotalTokens, "invoke total is additive") +} + +func TestBedrockParser_ParseResponse_Converse(t *testing.T) { + body := []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":11,"outputTokens":3,"totalTokens":14}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(11), u.InputTokens, "converse camelCase input tokens") + require.Equal(t, int64(3), u.OutputTokens, "converse camelCase output tokens") + require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total") +} + +func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) { + _, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary")) + require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator") +} + +func TestBedrockParser_ParseResponse_NonSuccess(t *testing.T) { + _, err := BedrockParser{}.ParseResponse(404, "application/json", []byte(`{"message":"gated"}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 is not an LLM response") +} + +func TestBedrockParser_ExtractCompletion(t *testing.T) { + invoke := BedrockParser{}.ExtractCompletion(200, "application/json", []byte(`{"content":[{"text":"a"},{"text":"b"}]}`)) + require.Equal(t, "a\nb", invoke, "invoke completion joins content parts") + + converse := BedrockParser{}.ExtractCompletion(200, "application/json", []byte(`{"output":{"message":{"content":[{"text":"x"}]}}}`)) + require.Equal(t, "x", converse, "converse completion reads output.message.content") +} + +func TestBedrockParser_ExtractPrompt(t *testing.T) { + invoke := BedrockParser{}.ExtractPrompt([]byte(`{"messages":[{"role":"user","content":"hi"}]}`)) + require.Equal(t, "user: hi", invoke, "invoke prompt reads anthropic content string") + + converse := BedrockParser{}.ExtractPrompt([]byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`)) + require.Equal(t, "user: hello", converse, "converse prompt reads content parts") +} + +func TestBedrockParser_DetectFromURL(t *testing.T) { + require.True(t, BedrockParser{}.DetectFromURL("/model/eu.anthropic.claude/invoke"), "invoke path") + require.True(t, BedrockParser{}.DetectFromURL("/model/x/converse-stream"), "converse-stream path") + require.False(t, BedrockParser{}.DetectFromURL("/v1/chat/completions"), "openai path is not bedrock") +} + +func TestBedrockParser_RegisteredByName(t *testing.T) { + p, ok := ParserByName(ProviderNameBedrock) + require.True(t, ok, "bedrock parser is registered") + require.Equal(t, ProviderNameBedrock, p.ProviderName()) +} diff --git a/proxy/internal/llm/errors.go b/proxy/internal/llm/errors.go new file mode 100644 index 000000000..09019fbbe --- /dev/null +++ b/proxy/internal/llm/errors.go @@ -0,0 +1,31 @@ +package llm + +import "errors" + +// Sentinel errors returned by parsers and the pricing loader. Callers use +// errors.Is to branch on a condition without coupling to parser internals. +var ( + // ErrUnknownProvider indicates no parser claimed the request path. + ErrUnknownProvider = errors.New("llmobs: unknown provider") + + // ErrUnsupportedModel indicates the response parsed successfully but the + // model is absent from the pricing table. Token counts are still valid. + ErrUnsupportedModel = errors.New("llmobs: unsupported model") + + // ErrNotLLMResponse indicates the response is not a JSON success body + // that a non-streaming parser can consume (non-200 or wrong content type). + ErrNotLLMResponse = errors.New("llmobs: not an LLM response") + + // ErrStreamingUnsupported indicates the caller passed an SSE response to + // a non-streaming parser. Streaming is handled separately via the SSE + // scanner. + ErrStreamingUnsupported = errors.New("llmobs: streaming response requires SSE scanner") + + // ErrMalformedResponse indicates the response body could not be decoded + // as the provider-specific JSON schema. + ErrMalformedResponse = errors.New("llmobs: malformed response body") + + // ErrMalformedRequest indicates the request body could not be decoded as + // the provider-specific JSON schema. + ErrMalformedRequest = errors.New("llmobs: malformed request body") +) diff --git a/proxy/internal/llm/fixtures/anthropic_messages.json b/proxy/internal/llm/fixtures/anthropic_messages.json new file mode 100644 index 000000000..2c9bb663a --- /dev/null +++ b/proxy/internal/llm/fixtures/anthropic_messages.json @@ -0,0 +1,17 @@ +{ + "id": "msg_abc", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 123, + "output_tokens": 45 + } +} diff --git a/proxy/internal/llm/fixtures/anthropic_stream.txt b/proxy/internal/llm/fixtures/anthropic_stream.txt new file mode 100644 index 000000000..2b8bb889c --- /dev/null +++ b/proxy/internal/llm/fixtures/anthropic_stream.txt @@ -0,0 +1,21 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[],"stop_reason":null,"usage":{"input_tokens":123,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world!"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":45}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/proxy/internal/llm/fixtures/openai_chat_completion.json b/proxy/internal/llm/fixtures/openai_chat_completion.json new file mode 100644 index 000000000..d0e25337b --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_chat_completion.json @@ -0,0 +1,21 @@ +{ + "id": "chatcmpl-abc", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, world!" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 123, + "completion_tokens": 45, + "total_tokens": 168 + } +} diff --git a/proxy/internal/llm/fixtures/openai_responses.json b/proxy/internal/llm/fixtures/openai_responses.json new file mode 100644 index 000000000..f998fcd33 --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_responses.json @@ -0,0 +1,24 @@ +{ + "id": "resp_abc", + "object": "response", + "created_at": 1700000000, + "model": "gpt-5.4", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok"}] + } + ], + "usage": { + "input_tokens": 15, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 414, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 429 + } +} diff --git a/proxy/internal/llm/fixtures/openai_responses_stream.txt b/proxy/internal/llm/fixtures/openai_responses_stream.txt new file mode 100644 index 000000000..2801fa99d --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_responses_stream.txt @@ -0,0 +1,24 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_abc","object":"response","model":"gpt-5.5","usage":null}} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_abc","usage":null}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"type":"response.content_part.added","item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":", world!"} + +event: response.output_text.done +data: {"type":"response.output_text.done","item_id":"msg_1","output_index":0,"content_index":0,"text":"Hello, world!"} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_abc","object":"response","model":"gpt-5.5","usage":{"input_tokens":123,"input_tokens_details":{"cached_tokens":40},"output_tokens":45,"output_tokens_details":{"reasoning_tokens":12},"total_tokens":168}}} + diff --git a/proxy/internal/llm/fixtures/openai_stream.txt b/proxy/internal/llm/fixtures/openai_stream.txt new file mode 100644 index 000000000..058b7ce22 --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_stream.txt @@ -0,0 +1,8 @@ +data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":", world!"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":123,"completion_tokens":45,"total_tokens":168}} + +data: [DONE] + diff --git a/proxy/internal/llm/fixtures/pricing.yaml b/proxy/internal/llm/fixtures/pricing.yaml new file mode 100644 index 000000000..3d26ff803 --- /dev/null +++ b/proxy/internal/llm/fixtures/pricing.yaml @@ -0,0 +1,59 @@ +# Realistic-pricing starter for llm_observability. Drop this into the +# directory you point the proxy at via --plugin-data-dir, then reference it +# from the target's plugin config: +# +# plugins: +# - id: llm_observability +# enabled: true +# params: +# pricing_path: pricing.yaml +# +# Values are USD per 1_000 tokens. Public list prices drift; treat this as a +# starting point and keep your production copy current. + +openai: + # GPT-5 family + gpt-5: + input_per_1k: 0.00125 + output_per_1k: 0.01 + gpt-5-mini: + input_per_1k: 0.00025 + output_per_1k: 0.002 + gpt-5-nano: + input_per_1k: 0.00005 + output_per_1k: 0.0004 + gpt-5.4: + input_per_1k: 0.00125 + output_per_1k: 0.01 + # GPT-4o family + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + gpt-4o-mini: + input_per_1k: 0.00015 + output_per_1k: 0.0006 + # Embeddings + text-embedding-3-large: + input_per_1k: 0.00013 + output_per_1k: 0 + text-embedding-3-small: + input_per_1k: 0.00002 + output_per_1k: 0 + +anthropic: + # Claude 4.x family + claude-opus-4-7: + input_per_1k: 0.015 + output_per_1k: 0.075 + claude-sonnet-4-7: + input_per_1k: 0.003 + output_per_1k: 0.015 + claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + claude-haiku-4-5: + input_per_1k: 0.0008 + output_per_1k: 0.004 diff --git a/proxy/internal/llm/openai.go b/proxy/internal/llm/openai.go new file mode 100644 index 000000000..86ee30797 --- /dev/null +++ b/proxy/internal/llm/openai.go @@ -0,0 +1,412 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// OpenAIParser implements the Parser interface for OpenAI-compatible APIs. +// It recognizes chat.completions, completions, embeddings, and the newer +// responses endpoint; any proxy path-prefix stripping is tolerated by the +// substring match in DetectFromURL. +type OpenAIParser struct{} + +// openAIPathHints are substring patterns that mark a request as +// OpenAI-shaped. The bare `/chat/completions` is listed alongside +// `/v1/chat/completions` because gateways like Cloudflare AI +// Gateway place their own version segment before the provider +// slug (gateway/v1/{account}/{gateway}/openai/chat/completions) — +// the canonical `/v1/` ends up nowhere near `/chat/completions`, +// so the `/v1/chat/completions` hint misses. `/chat/completions` +// is OpenAI's API contract: any service accepting OpenAI bodies +// serves at this path, so false-positive risk is negligible. +// `/completions` (legacy), `/embeddings`, and `/responses` are +// kept on the canonical-only path because their bare forms are +// too generic to be safe substrings. +var openAIPathHints = []string{ + "/v1/chat/completions", + "/v1/completions", + "/v1/embeddings", + "/v1/responses", + "/chat/completions", +} + +// Provider returns ProviderOpenAI. +func (OpenAIParser) Provider() Provider { return ProviderOpenAI } + +// ProviderName returns the stable label used for metrics and metadata. +func (OpenAIParser) ProviderName() string { return "openai" } + +// DetectFromURL reports whether the given request path looks like an OpenAI +// API endpoint. The match is case-insensitive and substring-based so that a +// reverse proxy prefix strip or rewrite does not defeat detection. +func (OpenAIParser) DetectFromURL(path string) bool { + lower := strings.ToLower(path) + for _, hint := range openAIPathHints { + if strings.Contains(lower, hint) { + return true + } + } + return false +} + +type openAIRequest struct { + Model string `json:"model"` + Stream *bool `json:"stream"` + StreamOptions *struct { + IncludeUsage *bool `json:"include_usage"` + } `json:"stream_options"` + // Chat Completions / Completions: messages[].content (string or array of + // content parts). Responses API: input is either a string or an array of + // items with content parts. We use json.RawMessage to defer parsing each + // shape independently. + Messages []openAIMessage `json:"messages"` + Prompt json.RawMessage `json:"prompt"` + Input json.RawMessage `json:"input"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// ParseRequest extracts the model name and streaming flag from an OpenAI +// request body. Unknown or missing fields leave the corresponding struct +// members zero-valued. +func (OpenAIParser) ParseRequest(body []byte) (RequestFacts, error) { + var req openAIRequest + if err := json.Unmarshal(body, &req); err != nil { + return RequestFacts{}, fmt.Errorf("decode openai request: %w: %v", ErrMalformedRequest, err) + } + return RequestFacts{ + Model: req.Model, + Stream: ptrDeref(req.Stream), + }, nil +} + +// openAIResponse accepts both naming conventions in a single struct because +// OpenAI's older Chat Completions API uses prompt_tokens/completion_tokens +// while the newer Responses API (/v1/responses) uses input_tokens/output_tokens +// (aligned with Anthropic). Pointer fields let us tell "absent" from "zero". +// +// PromptTokensDetails.CachedTokens (Chat Completions) and +// InputTokensDetails.CachedTokens (Responses API) carry the SUBSET of +// prompt/input tokens that hit the prompt cache. Cost-meter applies the +// discount rate to that subset and the regular rate to the remainder so +// we never double-bill the cached portion. +type openAIResponse struct { + Usage struct { + PromptTokens *int64 `json:"prompt_tokens"` + CompletionTokens *int64 `json:"completion_tokens"` + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + TotalTokens *int64 `json:"total_tokens"` + PromptTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + InputTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"input_tokens_details"` + } `json:"usage"` +} + +// ParseResponse decodes the non-streaming OpenAI response envelope. Status +// codes other than 200 are treated as non-LLM responses so the caller can +// skip cost accounting without aborting the request. +func (OpenAIParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) { + if status != 200 { + return Usage{}, fmt.Errorf("openai status %d: %w", status, ErrNotLLMResponse) + } + if isEventStream(contentType) { + return Usage{}, ErrStreamingUnsupported + } + if !isJSON(contentType) { + return Usage{}, fmt.Errorf("openai content-type %q: %w", contentType, ErrNotLLMResponse) + } + + var resp openAIResponse + if err := json.Unmarshal(body, &resp); err != nil { + return Usage{}, fmt.Errorf("decode openai response: %w: %v", ErrMalformedResponse, err) + } + + // Responses-API names take precedence when present; fall back to the older + // Chat Completions names. This handles both endpoints transparently + // without forcing a per-route configuration. + u := Usage{ + InputTokens: pickInt64(resp.Usage.InputTokens, resp.Usage.PromptTokens), + OutputTokens: pickInt64(resp.Usage.OutputTokens, resp.Usage.CompletionTokens), + TotalTokens: derefInt64(resp.Usage.TotalTokens), + CachedInputTokens: openAICachedTokens(resp), + } + if u.TotalTokens == 0 && (u.InputTokens > 0 || u.OutputTokens > 0) { + u.TotalTokens = u.InputTokens + u.OutputTokens + } + return u, nil +} + +// openAICachedTokens returns the cached-prompt subset reported by +// either the Responses-API (input_tokens_details.cached_tokens) or +// the Chat-Completions API (prompt_tokens_details.cached_tokens). +// Responses-API takes precedence when both are populated. +func openAICachedTokens(resp openAIResponse) int64 { + // Responses-API details are authoritative when present: an explicit + // cached_tokens of 0 must be honored, not treated as missing and + // overridden by the Chat-Completions field (which would overstate cache). + if resp.Usage.InputTokensDetails != nil && resp.Usage.InputTokensDetails.CachedTokens != nil { + return derefInt64(resp.Usage.InputTokensDetails.CachedTokens) + } + if resp.Usage.PromptTokensDetails != nil { + return derefInt64(resp.Usage.PromptTokensDetails.CachedTokens) + } + return 0 +} + +// ExtractPrompt returns the user-visible prompt text from an OpenAI request. +// Handles chat.completions (messages[].content), legacy completions (prompt +// string), and the Responses API (input as string or content-part array). +// Returns "" when nothing extractable is found. +func (OpenAIParser) ExtractPrompt(body []byte) string { + var req openAIRequest + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + if len(req.Messages) > 0 { + return joinMessages(req.Messages) + } + if len(req.Input) > 0 { + return extractResponsesInput(req.Input) + } + if len(req.Prompt) > 0 { + return decodeStringOrJoin(req.Prompt) + } + return "" +} + +// extractResponsesInput handles the Responses API `input` field. It is one +// of three shapes: a plain string, an array of message items +// ({role, content: string | [parts]}) as sent by Codex and the Responses +// SDK, or a flat array of content parts ({type, text/input_text}). Message +// items are flattened to "role: text" lines; items without extractable text +// (reasoning blocks, tool calls) are skipped. +func extractResponsesInput(raw json.RawMessage) string { + if s, ok := tryDecodeString(raw); ok { + return s + } + var items []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Text string `json:"text"` + InputText string `json:"input_text"` + } + if err := json.Unmarshal(raw, &items); err != nil { + return extractContentParts(raw) + } + var b strings.Builder + for _, it := range items { + var text string + switch { + case len(it.Content) > 0: + text = decodeStringOrJoin(it.Content) + case it.Text != "": + text = it.Text + case it.InputText != "": + text = it.InputText + } + if text == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + if it.Role != "" { + b.WriteString(it.Role) + b.WriteString(": ") + } + b.WriteString(text) + } + return b.String() +} + +// ExtractSessionID reads the OpenAI session marker. Codex (the Responses +// API client) stamps client_metadata.session_id on every request body; +// plain chat.completions traffic carries no session id and yields "". +func (OpenAIParser) ExtractSessionID(body []byte) string { + var req struct { + ClientMetadata struct { + SessionID string `json:"session_id"` + } `json:"client_metadata"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + return req.ClientMetadata.SessionID +} + +type openAIChatChoice struct { + Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"message"` + Text string `json:"text"` +} + +type openAIChatResponse struct { + Choices []openAIChatChoice `json:"choices"` + // Responses API: output[].content[].text + Output []struct { + Type string `json:"type"` + Content json.RawMessage `json:"content"` + Text string `json:"text"` + } `json:"output"` + OutputText string `json:"output_text"` +} + +// ExtractCompletion returns the assistant text from a non-streaming OpenAI +// response. Handles chat.completions (choices[].message.content), legacy +// completions (choices[].text), and Responses API (output[].content[].text +// or the convenience output_text field). +func (OpenAIParser) ExtractCompletion(status int, contentType string, body []byte) string { + if status != 200 || isEventStream(contentType) || !isJSON(contentType) { + return "" + } + var resp openAIChatResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + if resp.OutputText != "" { + return resp.OutputText + } + for _, c := range resp.Choices { + if len(c.Message.Content) > 0 { + if s := decodeStringOrJoin(c.Message.Content); s != "" { + return s + } + } + if c.Text != "" { + return c.Text + } + } + for _, o := range resp.Output { + if o.Text != "" { + return o.Text + } + if len(o.Content) > 0 { + if s := extractContentParts(o.Content); s != "" { + return s + } + } + } + return "" +} + +// joinMessages flattens a chat.completions messages array into a single +// "role: content" string per message, separated by newlines. Roles surface +// system/user/assistant context which is useful for log review. +func joinMessages(msgs []openAIMessage) string { + var b strings.Builder + for i, m := range msgs { + if i > 0 { + b.WriteByte('\n') + } + if m.Role != "" { + b.WriteString(m.Role) + b.WriteString(": ") + } + b.WriteString(decodeStringOrJoin(m.Content)) + } + return b.String() +} + +// extractContentParts handles the Responses-API content shape, which is +// either a single string or an array of {type, text} parts. text and +// input_text both carry user-facing content. +func extractContentParts(raw json.RawMessage) string { + if s, ok := tryDecodeString(raw); ok { + return s + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + InputText string `json:"input_text"` + } + if err := json.Unmarshal(raw, &parts); err != nil { + // Last-ditch: array of strings. + var arr []string + if json.Unmarshal(raw, &arr) == nil { + return strings.Join(arr, "\n") + } + return "" + } + var b strings.Builder + for _, p := range parts { + var text string + switch { + case p.Text != "": + text = p.Text + case p.InputText != "": + text = p.InputText + } + if text == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(text) + } + return b.String() +} + +// decodeStringOrJoin accepts either a JSON string or a content-parts array +// (chat.completions multimodal) and returns a flat string. Multimodal parts +// are separated by newlines; non-text parts are skipped. +func decodeStringOrJoin(raw json.RawMessage) string { + if s, ok := tryDecodeString(raw); ok { + return s + } + return extractContentParts(raw) +} + +func tryDecodeString(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s, true + } + return "", false +} + +// pickInt64 returns the first non-nil pointer's value. Used to prefer one +// naming convention while transparently falling back to another. +func pickInt64(preferred, fallback *int64) int64 { + if preferred != nil { + return *preferred + } + return derefInt64(fallback) +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +func ptrDeref(b *bool) bool { + if b == nil { + return false + } + return *b +} + +func isEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/event-stream") +} + +func isJSON(contentType string) bool { + lower := strings.ToLower(contentType) + return strings.Contains(lower, "application/json") || strings.Contains(lower, "+json") +} diff --git a/proxy/internal/llm/openai_test.go b/proxy/internal/llm/openai_test.go new file mode 100644 index 000000000..7a5fca4fb --- /dev/null +++ b/proxy/internal/llm/openai_test.go @@ -0,0 +1,255 @@ +package llm + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIDetectFromURL(t *testing.T) { + p := OpenAIParser{} + + cases := map[string]bool{ + "/v1/chat/completions": true, + "/v1/completions": true, + "/v1/embeddings": true, + "/v1/responses": true, + "/API/V1/Chat/Completions": true, + "/upstream/v1/chat/completions?trace=1": true, + // Cloudflare AI Gateway puts its own /v1/{account}/{gateway} + // segment between the canonical /v1/ and the provider slug, + // so the /v1/chat/completions substring no longer appears + // adjacent in the path. The bare /chat/completions hint + // catches Cloudflare's OpenAI direct path + // (/v1/{account}/{gateway}/openai/chat/completions) and + // compat path (/v1/{account}/{gateway}/compat/chat/completions). + "/v1/{account}/{gateway}/openai/chat/completions": true, + "/v1/{account}/{gateway}/compat/chat/completions": true, + "/chat/completions": true, + "/v1/messages": false, + "/healthz": false, + "": false, + } + for path, want := range cases { + assert.Equal(t, want, p.DetectFromURL(path), "DetectFromURL(%q)", path) + } +} + +func TestOpenAIParseRequest(t *testing.T) { + p := OpenAIParser{} + + t.Run("stream true", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"gpt-4o","stream":true,"stream_options":{"include_usage":true}}`)) + require.NoError(t, err) + assert.Equal(t, "gpt-4o", facts.Model, "request model extracted") + assert.True(t, facts.Stream, "request marked as streaming") + }) + + t.Run("stream default", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"gpt-4o-mini"}`)) + require.NoError(t, err) + assert.Equal(t, "gpt-4o-mini", facts.Model, "request model extracted") + assert.False(t, facts.Stream, "missing stream flag defaults to false") + }) + + t.Run("malformed", func(t *testing.T) { + _, err := p.ParseRequest([]byte(`{not json}`)) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMalformedRequest), "sentinel error wrapped") + }) +} + +func TestOpenAIParseResponse(t *testing.T) { + p := OpenAIParser{} + + t.Run("happy fixture", func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_chat_completion.json")) + require.NoError(t, err, "fixture must be readable") + + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(123), usage.InputTokens, "prompt tokens become input") + assert.Equal(t, int64(45), usage.OutputTokens, "completion tokens become output") + assert.Equal(t, int64(168), usage.TotalTokens, "total_tokens carried through") + }) + + t.Run("total computed when missing", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":10,"completion_tokens":5}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.TotalTokens, "total computed from in+out") + }) + + t.Run("streaming rejected", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/event-stream", []byte("")) + require.ErrorIs(t, err, ErrStreamingUnsupported, "SSE responses must use the scanner") + }) + + t.Run("non-200", func(t *testing.T) { + _, err := p.ParseResponse(500, "application/json", []byte(`{"error":"x"}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 rejected as non-LLM") + }) + + t.Run("non-json content type", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/plain", []byte(`{}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "text/plain treated as non-LLM") + }) + + t.Run("malformed body", func(t *testing.T) { + _, err := p.ParseResponse(200, "application/json", []byte(`{not json`)) + require.ErrorIs(t, err, ErrMalformedResponse, "bad JSON yields malformed error") + }) + + // Responses-API fixture: /v1/responses returns input_tokens/output_tokens + // (Anthropic-style) instead of prompt_tokens/completion_tokens. The parser + // must accept both. + t.Run("responses api fixture", func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_responses.json")) + require.NoError(t, err, "fixture must be readable") + + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.InputTokens, "input_tokens should map directly") + assert.Equal(t, int64(414), usage.OutputTokens, "output_tokens should map directly") + assert.Equal(t, int64(429), usage.TotalTokens, "total_tokens carried through") + }) + + t.Run("responses api naming preferred over chat-completions when both present", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":1,"completion_tokens":2,"input_tokens":15,"output_tokens":414,"total_tokens":429}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.InputTokens, "responses-api names take precedence") + assert.Equal(t, int64(414), usage.OutputTokens, "responses-api names take precedence") + }) + + t.Run("chat-completions naming still works alone", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":15,"completion_tokens":414,"total_tokens":429}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.InputTokens, "prompt_tokens fallback") + assert.Equal(t, int64(414), usage.OutputTokens, "completion_tokens fallback") + }) + + // Cached-prompt accounting. cached_tokens is a SUBSET of + // prompt_tokens — input_tokens carries the full prompt count and + // the cached subset is reported separately so the cost meter can + // apply the discount rate to that portion. + t.Run("chat-completions cached_tokens subset surfaces", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":1024,"completion_tokens":200,"total_tokens":1224,"prompt_tokens_details":{"cached_tokens":768}}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(1024), usage.InputTokens, "input remains the full prompt count — cached is a subset, not a separate bucket") + assert.Equal(t, int64(768), usage.CachedInputTokens, "cached_tokens must surface so cost meter can discount the cached subset") + assert.Zero(t, usage.CacheCreationTokens, "OpenAI has no cache_creation analogue") + }) + + t.Run("responses-api input_tokens_details.cached_tokens surfaces", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":2048,"output_tokens":100,"total_tokens":2148,"input_tokens_details":{"cached_tokens":1500}}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(2048), usage.InputTokens) + assert.Equal(t, int64(1500), usage.CachedInputTokens, "Responses-API input_tokens_details.cached_tokens path must surface too") + }) + + t.Run("responses-api cached takes precedence over chat-completions when both present", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":1,"input_tokens":2,"output_tokens":3,"prompt_tokens_details":{"cached_tokens":50},"input_tokens_details":{"cached_tokens":99}}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(99), usage.CachedInputTokens, "Responses-API field wins when both naming conventions are present") + }) + + t.Run("absent cached_tokens leaves cached counts at zero", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":15,"completion_tokens":414,"total_tokens":429}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Zero(t, usage.CachedInputTokens, "no prompt_tokens_details = no cached subset") + }) +} + +func TestOpenAIExtractPrompt_ChatCompletions(t *testing.T) { + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"system","content":"be brief"},{"role":"user","content":"ping"}]}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.NotEmpty(t, got, "messages array must extract") + require.Contains(t, got, "system: be brief", "system role and content surface") + require.Contains(t, got, "user: ping", "user role and content surface") +} + +func TestOpenAIExtractPrompt_ResponsesAPIStringInput(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"Hello there"}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Equal(t, "Hello there", got, "string input field should pass through") +} + +func TestOpenAIExtractPrompt_ResponsesAPIInputParts(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":[{"type":"input_text","input_text":"first"},{"type":"input_text","input_text":"second"}]}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Contains(t, got, "first", "first content part surfaces") + require.Contains(t, got, "second", "second content part surfaces") +} + +// TestOpenAIExtractPrompt_ResponsesAPIMessageItems guards the live Codex +// shape: input is an array of message items whose text is nested under +// content[].text, not flat content parts. The old code fed the outer array +// to the content-part decoder and extracted nothing, so the stored prompt +// was empty. +func TestOpenAIExtractPrompt_ResponsesAPIMessageItems(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":[` + + `{"type":"message","role":"developer","content":[{"type":"input_text","text":"system rules"}]},` + + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hello there"}]},` + + `{"type":"reasoning","encrypted_content":"opaque","summary":[]},` + + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"prior reply"}]}` + + `]}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Contains(t, got, "system rules", "developer message content must surface") + require.Contains(t, got, "hello there", "user message content must surface") + require.Contains(t, got, "developer:", "role labels must prefix each message") + require.NotContains(t, got, "opaque", "reasoning items without text must be skipped") +} + +func TestOpenAIExtractPrompt_LegacyCompletion(t *testing.T) { + body := []byte(`{"model":"text-davinci-003","prompt":"once upon a time"}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Equal(t, "once upon a time", got, "string prompt field should pass through") +} + +func TestOpenAIExtractSessionID(t *testing.T) { + t.Run("codex client_metadata.session_id", func(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","client_metadata":{"session_id":"019eeb72-ab7c-7cd2","thread_id":"t1"},"input":[]}`) + assert.Equal(t, "019eeb72-ab7c-7cd2", OpenAIParser{}.ExtractSessionID(body), "Codex session id must come from client_metadata.session_id") + }) + t.Run("plain chat has no session", func(t *testing.T) { + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + assert.Equal(t, "", OpenAIParser{}.ExtractSessionID(body), "plain chat.completions carries no session id") + }) + t.Run("non-JSON yields empty", func(t *testing.T) { + assert.Equal(t, "", OpenAIParser{}.ExtractSessionID([]byte("not json")), "malformed body must not error") + }) +} + +func TestOpenAIExtractCompletion_ChatCompletions(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_chat_completion.json")) + require.NoError(t, err) + got := OpenAIParser{}.ExtractCompletion(200, "application/json", body) + require.NotEmpty(t, got, "fixture has assistant content") +} + +func TestOpenAIExtractCompletion_ResponsesAPI(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_responses.json")) + require.NoError(t, err) + got := OpenAIParser{}.ExtractCompletion(200, "application/json", body) + require.NotEmpty(t, got, "responses-api fixture has output content") +} + +func TestOpenAIExtractCompletion_Streaming(t *testing.T) { + got := OpenAIParser{}.ExtractCompletion(200, "text/event-stream", []byte("")) + require.Empty(t, got, "streaming responses are skipped") +} + +func TestOpenAIExtractCompletion_NonOK(t *testing.T) { + got := OpenAIParser{}.ExtractCompletion(500, "application/json", []byte(`{"choices":[{"message":{"content":"x"}}]}`)) + require.Empty(t, got, "non-200 returns empty") +} diff --git a/proxy/internal/llm/parser.go b/proxy/internal/llm/parser.go new file mode 100644 index 000000000..81fa11f97 --- /dev/null +++ b/proxy/internal/llm/parser.go @@ -0,0 +1,112 @@ +// Package llm provides the shared LLM request and response parsing +// library consumed by proxy middleware. It is runtime agnostic: the same +// package is used by the native built-in executor now and will be reused +// by the WASM adapter later. +package llm + +// Provider identifies an LLM API provider. +type Provider int + +const ( + // ProviderUnknown signals that no parser matched the request. + ProviderUnknown Provider = 0 + // ProviderOpenAI identifies the OpenAI API surface. + ProviderOpenAI Provider = 1 + // ProviderAnthropic identifies the Anthropic Messages API surface. + ProviderAnthropic Provider = 2 + // ProviderBedrock identifies the AWS Bedrock runtime surface. + ProviderBedrock Provider = 3 +) + +// RequestFacts captures the subset of the LLM request body that the +// middleware annotates as metadata (model, streaming flag). Additional +// fields are added as parsers grow. +type RequestFacts struct { + Model string + Stream bool +} + +// Usage is the provider-agnostic token accounting emitted to metrics and +// access logs. Downstream consumers map InputTokens/OutputTokens to the +// plg.llm.* metadata allowlist entries. +// +// CachedInputTokens carries OpenAI's prompt_tokens_details.cached_tokens +// (a SUBSET of InputTokens) when the response is from OpenAI, or +// Anthropic's cache_read_input_tokens (ADDITIVE to InputTokens) when from +// Anthropic. The cost meter switches formula on KeyLLMProvider so the +// two shapes are billed correctly without double-counting. +// +// CacheCreationTokens carries Anthropic's cache_creation_input_tokens +// (ADDITIVE; not present in the OpenAI shape). +type Usage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 +} + +// Parser is the per-provider interface implemented in this package. The +// dispatcher selects a parser by calling DetectFromURL against the incoming +// request path; ties break by registration order (see Parsers). +type Parser interface { + Provider() Provider + ProviderName() string + DetectFromURL(path string) bool + ParseRequest(body []byte) (RequestFacts, error) + ParseResponse(status int, contentType string, body []byte) (Usage, error) + // ExtractPrompt returns the user-facing prompt text from a request body. + // Different endpoint shapes (chat.completions, responses, messages) are + // handled by the per-provider implementation. Returns "" when no prompt + // can be extracted; never returns an error — extraction is best-effort + // because callers use the result for observability, not authorization. + ExtractPrompt(body []byte) string + // ExtractCompletion returns the assistant-facing completion text from a + // non-streaming response body. status and contentType match the + // ParseResponse arguments so implementations can fast-fail uniformly. + ExtractCompletion(status int, contentType string, body []byte) string + // ExtractSessionID returns a stable identifier that groups requests of + // the same conversation / coding session, read from the per-provider + // location clients populate (e.g. OpenAI Codex's client_metadata.session_id, + // Claude Code's metadata.user_id). Returns "" when the body carries no + // recognised session marker; extraction is best-effort and never errors. + ExtractSessionID(body []byte) string +} + +// Parsers returns the built-in parser set in a stable order. The order is +// deterministic so that DetectFromURL ties produce consistent routing. +func Parsers() []Parser { + return []Parser{ + OpenAIParser{}, + AnthropicParser{}, + BedrockParser{}, + } +} + +// DetectParser returns the first parser whose DetectFromURL matches the given +// request path. ok=false means no parser claimed the path. +func DetectParser(path string) (Parser, bool) { + for _, p := range Parsers() { + if p.DetectFromURL(path) { + return p, true + } + } + return nil, false +} + +// ParserByName returns the parser whose ProviderName matches id. Used by +// callers that already know which provider surface a request will hit +// (e.g. the agent-network middleware chain configured per synthesised +// service) so they can skip URL sniffing. ok=false when no parser is +// registered under that name. +func ParserByName(id string) (Parser, bool) { + if id == "" { + return nil, false + } + for _, p := range Parsers() { + if p.ProviderName() == id { + return p, true + } + } + return nil, false +} diff --git a/proxy/internal/llm/parser_test.go b/proxy/internal/llm/parser_test.go new file mode 100644 index 000000000..b3052ce68 --- /dev/null +++ b/proxy/internal/llm/parser_test.go @@ -0,0 +1,54 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsers_ProviderNames(t *testing.T) { + parsers := Parsers() + require.Len(t, parsers, 3, "three built-in parsers expected") + + names := make([]string, 0, len(parsers)) + for _, p := range parsers { + names = append(names, p.ProviderName()) + } + assert.Contains(t, names, "openai", "OpenAI parser should be registered") + assert.Contains(t, names, "anthropic", "Anthropic parser should be registered") + assert.Contains(t, names, "bedrock", "Bedrock parser should be registered") +} + +func TestDetectParser(t *testing.T) { + cases := []struct { + name string + path string + expectedName string + expectOK bool + }{ + {"openai chat", "/v1/chat/completions", "openai", true}, + {"openai prefixed", "/api/v1/chat/completions", "openai", true}, + {"openai responses", "/v1/responses", "openai", true}, + {"anthropic messages", "/v1/messages", "anthropic", true}, + {"anthropic prefixed", "/proxy/v1/messages?query", "anthropic", true}, + {"unknown path", "/healthz", "", false}, + {"empty path", "", "", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, ok := DetectParser(tc.path) + require.Equal(t, tc.expectOK, ok, "detection success mismatch for %q", tc.path) + if ok { + assert.Equal(t, tc.expectedName, p.ProviderName(), "provider name mismatch") + } + }) + } +} + +func TestProviderValues(t *testing.T) { + assert.Equal(t, Provider(0), ProviderUnknown, "unknown provider is the zero value") + assert.Equal(t, ProviderOpenAI, OpenAIParser{}.Provider(), "OpenAI parser reports its provider enum") + assert.Equal(t, ProviderAnthropic, AnthropicParser{}.Provider(), "Anthropic parser reports its provider enum") +} diff --git a/proxy/internal/llm/pricing/defaults_coverage_test.go b/proxy/internal/llm/pricing/defaults_coverage_test.go new file mode 100644 index 000000000..be23682da --- /dev/null +++ b/proxy/internal/llm/pricing/defaults_coverage_test.go @@ -0,0 +1,65 @@ +package pricing + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDefaultTable_FirstPartyModelCoverage guards the embedded defaults against +// silent drift/gaps: every metered first-party model the management catalog +// enumerates must resolve to a price, and a few rates that previously drifted +// are pinned to their LiteLLM-validated values. Keep this list in step with the +// catalog (management/server/agentnetwork/catalog) when adding models. +func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) { + tbl := DefaultTable() + require.NotNil(t, tbl, "embedded default pricing table must load") + + mustPrice := map[string][]string{ + // openai parser covers openai_api, azure_openai_api, and mistral_api. + "openai": { + "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", + "gpt-5.3-codex", "gpt-5.3-chat-latest", "o4-mini", + "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", + "gpt-4-turbo", "gpt-3.5-turbo", "gpt-35-turbo", + "text-embedding-3-large", "text-embedding-3-small", + "mistral-large-latest", "mistral-medium-3-5", "codestral-2508", + "ministral-8b-latest", "mistral-embed", + }, + "anthropic": { + "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", + "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5", + }, + // bedrock keys are the normalized ids the request parser emits. + "bedrock": { + "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", + "anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5", + "anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct", + "amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite", + }, + } + for provider, models := range mustPrice { + for _, m := range models { + _, ok := tbl.Cost(provider, m, 1000, 1000, 0, 0) + assert.True(t, ok, "%s/%s must be priced in the embedded defaults", provider, m) + } + } + + // Pin per-direction rates independently (input-only then output-only) so a + // swap or skew of input<->output that preserves the combined total is still + // caught — these are rates that previously drifted or are easy to mis-enter. + in, ok := tbl.Cost("openai", "gpt-5.4", 1000, 0, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.0025, in, 1e-9, "gpt-5.4 input = 0.0025 per 1k") + out, ok := tbl.Cost("openai", "gpt-5.4", 0, 1000, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.015, out, 1e-9, "gpt-5.4 output = 0.015 per 1k") + + in, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 1000, 0, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.003, in, 1e-9, "bedrock sonnet-4-5 input = 0.003 per 1k") + out, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 0, 1000, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.015, out, 1e-9, "bedrock sonnet-4-5 output = 0.015 per 1k") +} diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml new file mode 100644 index 000000000..cd5c64fbf --- /dev/null +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -0,0 +1,264 @@ +# Embedded default pricing for llm_observability. Compiled into the proxy +# binary via go:embed in pricing.go; cost annotation works out of the box +# without any operator action. +# +# Operators override entries by dropping a pricing.yaml into --plugin-data-dir +# (or whichever basename is given via params.pricing_path). The override file +# only needs entries the operator wants to change; missing entries fall +# through to these defaults. +# +# Values are USD per 1_000 tokens. Public list prices drift; ship a fresh +# binary or override individual entries via the override file as needed. +# +# Optional cache fields: +# cached_input_per_1k OpenAI: rate for prompt_tokens_details.cached_tokens +# (a SUBSET of prompt_tokens). Typically 0.5x input. +# Absent → cached portion bills at input_per_1k. +# cache_read_per_1k Anthropic: rate for cache_read_input_tokens +# (ADDITIVE to input_tokens). Typically 0.1x input. +# Absent → cache reads bill at input_per_1k. +# cache_creation_per_1k Anthropic: rate for cache_creation_input_tokens +# (ADDITIVE to input_tokens). Typically 1.25x input. +# Absent → cache writes bill at input_per_1k. + +openai: + # OpenAI + OpenAI-compatible providers (openai_api, azure_openai_api, + # mistral_api, and the openai-parser gateways) all emit llm.provider="openai", + # so their models are priced here. Kept in sync with the management catalog; + # rates cross-checked against LiteLLM model_prices_and_context_window.json. + + # GPT-5.x family — cache reads 10% of input (0.1x). + gpt-5.5: + input_per_1k: 0.005 + output_per_1k: 0.03 + cached_input_per_1k: 0.0005 + gpt-5.5-pro: + input_per_1k: 0.03 + output_per_1k: 0.18 + cached_input_per_1k: 0.003 + gpt-5.4: + input_per_1k: 0.0025 + output_per_1k: 0.015 + cached_input_per_1k: 0.00025 + gpt-5.4-pro: + input_per_1k: 0.03 + output_per_1k: 0.18 + cached_input_per_1k: 0.003 + gpt-5.4-mini: + input_per_1k: 0.00075 + output_per_1k: 0.0045 + cached_input_per_1k: 0.000075 + gpt-5.4-nano: + input_per_1k: 0.0002 + output_per_1k: 0.00125 + cached_input_per_1k: 0.00002 + gpt-5.3-codex: + input_per_1k: 0.00175 + output_per_1k: 0.014 + cached_input_per_1k: 0.000175 + gpt-5.3-chat-latest: + input_per_1k: 0.00175 + output_per_1k: 0.014 + cached_input_per_1k: 0.000175 + # GPT-5 (2025) family — kept for gateway requests using the unsuffixed ids. + gpt-5: + input_per_1k: 0.00125 + output_per_1k: 0.01 + cached_input_per_1k: 0.000125 + gpt-5-mini: + input_per_1k: 0.00025 + output_per_1k: 0.002 + cached_input_per_1k: 0.000025 + gpt-5-nano: + input_per_1k: 0.00005 + output_per_1k: 0.0004 + cached_input_per_1k: 0.000005 + o4-mini: + input_per_1k: 0.0011 + output_per_1k: 0.0044 + cached_input_per_1k: 0.000275 + # GPT-4.1 family — cache reads 25% of input. + gpt-4.1: + input_per_1k: 0.002 + output_per_1k: 0.008 + cached_input_per_1k: 0.0005 + gpt-4.1-mini: + input_per_1k: 0.0004 + output_per_1k: 0.0016 + cached_input_per_1k: 0.0001 + gpt-4.1-nano: + input_per_1k: 0.0001 + output_per_1k: 0.0004 + cached_input_per_1k: 0.000025 + # GPT-4o family — cache reads 50% of input (0.5x). + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + cached_input_per_1k: 0.00125 + gpt-4o-mini: + input_per_1k: 0.00015 + output_per_1k: 0.0006 + cached_input_per_1k: 0.000075 + # Older GPT — no prompt caching. + gpt-4-turbo: + input_per_1k: 0.01 + output_per_1k: 0.03 + gpt-3.5-turbo: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + gpt-35-turbo: # Azure deployment alias of gpt-3.5-turbo + input_per_1k: 0.0005 + output_per_1k: 0.0015 + # Embeddings — no caching, no output tokens. + text-embedding-3-large: + input_per_1k: 0.00013 + output_per_1k: 0 + text-embedding-3-small: + input_per_1k: 0.00002 + output_per_1k: 0 + + # Mistral (mistral_api) — routed via the openai parser; no prompt caching. + mistral-large-latest: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + mistral-medium-latest: + input_per_1k: 0.0004 + output_per_1k: 0.002 + mistral-medium-3-5: + input_per_1k: 0.0015 + output_per_1k: 0.0075 + mistral-small-latest: + input_per_1k: 0.00006 + output_per_1k: 0.00018 + magistral-medium-latest: + input_per_1k: 0.002 + output_per_1k: 0.005 + magistral-small-latest: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + devstral-medium-latest: + input_per_1k: 0.0004 + output_per_1k: 0.002 + devstral-small-latest: + input_per_1k: 0.0001 + output_per_1k: 0.0003 + codestral-2508: + input_per_1k: 0.0003 + output_per_1k: 0.0009 + codestral-latest: + input_per_1k: 0.001 + output_per_1k: 0.003 + ministral-3-14b-2512: + input_per_1k: 0.0002 + output_per_1k: 0.0002 + ministral-8b-latest: + input_per_1k: 0.00015 + output_per_1k: 0.00015 + ministral-3-3b-2512: + input_per_1k: 0.0001 + output_per_1k: 0.0001 + mistral-embed: + input_per_1k: 0.0001 + output_per_1k: 0 + +anthropic: + # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. + # Pricing source: Anthropic's current published rates per million tokens, + # divided by 1000 for the per-1k figures stored here. + claude-fable-5: + input_per_1k: 0.010 + output_per_1k: 0.050 + cache_read_per_1k: 0.001 + cache_creation_per_1k: 0.0125 + claude-opus-4-8: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-7: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-6: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-1: + input_per_1k: 0.015 + output_per_1k: 0.075 + cache_read_per_1k: 0.0015 + cache_creation_per_1k: 0.01875 + claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + claude-haiku-4-5: + input_per_1k: 0.001 + output_per_1k: 0.005 + cache_read_per_1k: 0.0001 + cache_creation_per_1k: 0.00125 + +bedrock: + # AWS Bedrock model ids, normalised by the request parser (cross-region + # inference-profile prefix + version/throughput suffix stripped), e.g. + # eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5. + # Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input, + # write ≈1.25x input); Nova / Llama report no cache, so cost is input+output. + anthropic.claude-opus-4-8: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-7: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-6: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-1: + input_per_1k: 0.015 + output_per_1k: 0.075 + cache_read_per_1k: 0.0015 + cache_creation_per_1k: 0.01875 + anthropic.claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + anthropic.claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + anthropic.claude-haiku-4-5: + input_per_1k: 0.001 + output_per_1k: 0.005 + cache_read_per_1k: 0.0001 + cache_creation_per_1k: 0.00125 + meta.llama3-3-70b-instruct: + input_per_1k: 0.00072 + output_per_1k: 0.00072 + amazon.nova-2-lite: + input_per_1k: 0.0003 + output_per_1k: 0.0025 + amazon.nova-pro: + input_per_1k: 0.0008 + output_per_1k: 0.0032 + amazon.nova-lite: + input_per_1k: 0.00006 + output_per_1k: 0.00024 + amazon.nova-micro: + input_per_1k: 0.000035 + output_per_1k: 0.00014 diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go new file mode 100644 index 000000000..09afec5ff --- /dev/null +++ b/proxy/internal/llm/pricing/pricing.go @@ -0,0 +1,449 @@ +// Package pricing implements the embedded-default + override pricing table +// shared by middleware that converts LLM token usage into a USD cost +// estimate. The table is hot-reloadable from a basename under the proxy +// data directory; missing override files keep the embedded defaults so +// cost annotation works without operator action. +package pricing + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "io" + "io/fs" + "math" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "gopkg.in/yaml.v3" +) + +//go:embed defaults_pricing.yaml +var defaultPricingYAML []byte + +var ( + defaultTableOnce sync.Once + defaultTablePtr *Table +) + +// DefaultTable returns the pricing table embedded in the binary. The result +// is parsed once and shared; callers must not mutate the returned value. +// Cost annotation works without any operator action because every loader +// starts with this table. +func DefaultTable() *Table { + defaultTableOnce.Do(func() { + t, err := parsePricingBytes(defaultPricingYAML) + if err != nil { + panic(fmt.Sprintf("llmobs: embedded default pricing failed to parse: %v", err)) + } + defaultTablePtr = t + }) + return defaultTablePtr +} + +// mergeOver returns a new Table containing every entry from base, with any +// matching entry from overlay replacing the base value. Either argument may +// be nil. Result is a fresh allocation so callers can mutate / Store safely. +func mergeOver(base, overlay *Table) *Table { + if overlay == nil || len(overlay.entries) == 0 { + return base + } + if base == nil || len(base.entries) == 0 { + return overlay + } + out := make(map[string]map[string]Entry, len(base.entries)) + for provider, models := range base.entries { + inner := make(map[string]Entry, len(models)) + for model, e := range models { + inner[model] = e + } + out[provider] = inner + } + for provider, models := range overlay.entries { + inner, ok := out[provider] + if !ok { + inner = make(map[string]Entry, len(models)) + out[provider] = inner + } + for model, e := range models { + inner[model] = e + } + } + return &Table{entries: out} +} + +// Entry is a single model's input and output pricing, expressed in USD per +// 1000 tokens. +// +// CachedInputPer1K applies to OpenAI's cached prompt tokens, which are a +// subset of input_tokens — when set, the cached portion is billed at this +// rate and the non-cached remainder at InputPer1K. Zero means "no discount +// configured", and cached tokens are billed at InputPer1K (matches current +// behaviour where cached counts weren't extracted at all). +// +// CacheReadPer1K and CacheCreationPer1K apply to Anthropic's two prompt- +// cache fields, which are additive to input_tokens: cache_read is the +// cheaper read-from-cache rate, cache_creation is the more expensive +// write-to-cache rate. Zero means "no rate configured" and the +// corresponding token bucket is billed at InputPer1K. This is more +// accurate than today's behaviour, where Anthropic's cache tokens are +// ignored and not charged at all. +type Entry struct { + InputPer1K float64 + OutputPer1K float64 + CachedInputPer1K float64 + CacheReadPer1K float64 + CacheCreationPer1K float64 +} + +// Table is a provider-to-model pricing lookup. Instances are immutable once +// built and are swapped atomically by Loader. +type Table struct { + entries map[string]map[string]Entry +} + +// Cost returns the estimated USD cost for the given token counts. ok is +// false when the provider or model is not present in the table; the caller +// can still emit token metrics with a model=unknown label. +// +// Provider-shape semantics for cached / cache-creation counts: +// +// - OpenAI: cachedInput is a SUBSET of inTokens. The cached portion is +// billed at CachedInputPer1K (or InputPer1K when no override), and the +// non-cached remainder of inTokens at InputPer1K. cacheCreation is +// ignored (OpenAI has no analogue). +// - Anthropic: cachedInput (cache_read) and cacheCreation are ADDITIVE to +// inTokens. The three buckets are billed at CacheReadPer1K, +// CacheCreationPer1K, and InputPer1K respectively, each falling back +// to InputPer1K when the corresponding rate is zero. +// - Other providers: cached and cacheCreation are ignored; cost is +// inTokens*InputPer1K + outTokens*OutputPer1K. +func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) { + // Clamp negatives to zero before any pricing math so a malformed + // upstream count can never produce a negative cost. + if inTokens < 0 { + inTokens = 0 + } + if outTokens < 0 { + outTokens = 0 + } + if cachedInput < 0 { + cachedInput = 0 + } + if cacheCreation < 0 { + cacheCreation = 0 + } + if t == nil { + return 0, false + } + byModel, ok := t.entries[provider] + if !ok { + return 0, false + } + entry, ok := byModel[model] + if !ok { + return 0, false + } + output := (float64(outTokens) / 1000.0) * entry.OutputPer1K + switch provider { + case "openai": + // cachedInput is a subset of inTokens; clamp so a malformed + // upstream (cached > total) can't produce a negative remainder. + clamped := cachedInput + if clamped > inTokens { + clamped = inTokens + } + cachedRate := entry.CachedInputPer1K + if cachedRate <= 0 { + cachedRate = entry.InputPer1K + } + nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K + cached := float64(clamped) / 1000.0 * cachedRate + return nonCached + cached + output, true + case "anthropic", "bedrock": + // Bedrock-Anthropic returns the same additive cache buckets as + // first-party Anthropic; non-Anthropic Bedrock models simply report + // zero cache tokens, so this formula degrades to input + output. + readRate := entry.CacheReadPer1K + if readRate <= 0 { + readRate = entry.InputPer1K + } + createRate := entry.CacheCreationPer1K + if createRate <= 0 { + createRate = entry.InputPer1K + } + input := float64(inTokens) / 1000.0 * entry.InputPer1K + read := float64(cachedInput) / 1000.0 * readRate + create := float64(cacheCreation) / 1000.0 * createRate + return input + read + create + output, true + default: + input := float64(inTokens) / 1000.0 * entry.InputPer1K + return input + output, true + } +} + +// Has reports whether the provider/model pair is present in the table. +func (t *Table) Has(provider, model string) bool { + if t == nil { + return false + } + byModel, ok := t.entries[provider] + if !ok { + return false + } + _, ok = byModel[model] + return ok +} + +// pricingFile mirrors the on-disk YAML schema. Keys are provider names; the +// nested map keys are model names. +type pricingFile map[string]map[string]struct { + InputPer1K float64 `yaml:"input_per_1k"` + OutputPer1K float64 `yaml:"output_per_1k"` + CachedInputPer1K float64 `yaml:"cached_input_per_1k"` + CacheReadPer1K float64 `yaml:"cache_read_per_1k"` + CacheCreationPer1K float64 `yaml:"cache_creation_per_1k"` +} + +const ( + // ReloadInterval is the mtime-poll cadence for the background reloader. + ReloadInterval = 30 * time.Second + + // errorBackoff bounds how often the loader logs a repeated parse error. + errorBackoff = 5 * time.Minute +) + +var basenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + +// Loader is a confined, hot-reloadable pricing table reader. Construction +// must succeed against the target file; subsequent reload failures keep the +// previously-loaded table so callers never observe a blank price list. +type Loader struct { + baseDir string + fullPath string + pluginID string + table atomic.Pointer[Table] + mtime atomic.Int64 + failures metric.Int64Counter + interval time.Duration +} + +// NewLoader returns a pricing loader that overlays an optional file-based +// table on top of the embedded defaults. Missing override file, baseDir, or +// relPath is not an error: the loader keeps the embedded defaults so cost +// metadata is still emitted for known models. +// +// Errors: +// - bad basename, traversal segment, or absolute relPath are rejected so a +// misconfigured target surfaces immediately. +// - permission errors and YAML parse errors keep the defaults but log a +// warning; cost annotation does not silently break. +// +// failures is optional; pass nil in tests that do not care about +// reload-failure telemetry. +func NewLoader(baseDir, relPath, pluginID string, failures metric.Int64Counter) (*Loader, error) { + defaults := DefaultTable() + l := &Loader{ + baseDir: baseDir, + pluginID: pluginID, + failures: failures, + } + l.table.Store(defaults) + + if strings.TrimSpace(baseDir) == "" || strings.TrimSpace(relPath) == "" { + return l, nil + } + + full, err := resolveMiddlewareDataPath(baseDir, relPath) + if err != nil { + return nil, err + } + l.fullPath = full + + overlay, mtime, err := loadPricing(full) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Override file is optional. Defaults already stored. + return l, nil + } + // Symlink rejection, oversize file, parse failure, permission errors + // — surface so a misconfigured operator sees the problem instead of + // silently running with stale defaults. + return nil, fmt.Errorf("load pricing %s: %w", full, err) + } + l.table.Store(mergeOver(defaults, overlay)) + l.mtime.Store(mtime.UnixNano()) + return l, nil +} + +// Get returns the current pricing table. The returned pointer is immutable; +// callers must not mutate its contents. +func (l *Loader) Get() *Table { + if l == nil { + return nil + } + return l.table.Load() +} + +// WatchesFile reports whether this loader is bound to an override file on +// disk. False for defaults-only loaders (no operator override given). +// Callers use this to decide whether to spawn the mtime-poll goroutine. +func (l *Loader) WatchesFile() bool { + if l == nil { + return false + } + return l.fullPath != "" +} + +// SetReloadInterval overrides the mtime-poll cadence used by Reload. Calls +// after Reload has started have no effect on the running loop. Intended for +// tests; production code uses the default ReloadInterval. +func (l *Loader) SetReloadInterval(d time.Duration) { + if l == nil || d <= 0 { + return + } + l.interval = d +} + +// Reload runs a polling loop that checks the pricing file mtime every +// ReloadInterval (or the value passed to SetReloadInterval). Returns when +// ctx is cancelled. +func (l *Loader) Reload(ctx context.Context) { + if l == nil { + return + } + interval := l.interval + if interval <= 0 { + interval = ReloadInterval + } + t := time.NewTicker(interval) + defer t.Stop() + + var lastErrAt time.Time + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := l.reload(); err != nil { + if l.failures != nil { + l.failures.Add(ctx, 1, metric.WithAttributes( + attribute.String("plugin", l.pluginID), + )) + } + now := time.Now() + if now.Sub(lastErrAt) >= errorBackoff { + log.Warnf("llmobs: pricing reload failed for %s: %v", l.fullPath, err) + lastErrAt = now + } + } + } + } +} + +// reload performs a single-shot mtime check and reload. The reloaded +// override file is merged on top of the embedded defaults; missing override +// (e.g. operator deleted the file) is not an error and reverts to defaults. +func (l *Loader) reload() error { + if l.fullPath == "" { + // Defaults-only loader; nothing on disk to reload. + return nil + } + mtime, err := statMtime(l.fullPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // File was removed since startup. Drop back to defaults and + // reset mtime so a future re-creation triggers a reload. + l.table.Store(DefaultTable()) + l.mtime.Store(0) + return nil + } + return err + } + if mtime.UnixNano() == l.mtime.Load() { + return nil + } + + overlay, newMtime, err := loadPricing(l.fullPath) + if err != nil { + return err + } + l.table.Store(mergeOver(DefaultTable(), overlay)) + l.mtime.Store(newMtime.UnixNano()) + return nil +} + +// resolveMiddlewareDataPath validates relPath is a safe basename and resolves +// it under baseDir. An additional cleaned-prefix check guards against +// CVE-style edge cases where Join is used with trailing path segments. +func resolveMiddlewareDataPath(baseDir, relPath string) (string, error) { + if strings.TrimSpace(baseDir) == "" { + return "", errors.New("middleware-data-dir is not configured") + } + if relPath == "" { + return "", errors.New("pricing path is empty") + } + if !basenameRegex.MatchString(relPath) { + return "", fmt.Errorf("pricing path %q is not a safe basename", relPath) + } + if filepath.IsAbs(relPath) { + return "", fmt.Errorf("pricing path %q must be a basename, not absolute", relPath) + } + + cleanBase, err := filepath.Abs(filepath.Clean(baseDir)) + if err != nil { + return "", fmt.Errorf("resolve middleware-data-dir: %w", err) + } + full := filepath.Join(cleanBase, relPath) + cleanedFull := filepath.Clean(full) + if !strings.HasPrefix(cleanedFull, cleanBase+string(filepath.Separator)) && cleanedFull != cleanBase { + return "", fmt.Errorf("pricing path %q escapes middleware-data-dir", relPath) + } + return cleanedFull, nil +} + +func parsePricingBytes(data []byte) (*Table, error) { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + + var raw pricingFile + if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("decode pricing yaml: %w", err) + } + + out := make(map[string]map[string]Entry, len(raw)) + for provider, models := range raw { + inner := make(map[string]Entry, len(models)) + for model, entry := range models { + for field, v := range map[string]float64{ + "input_per_1k": entry.InputPer1K, + "output_per_1k": entry.OutputPer1K, + "cached_input_per_1k": entry.CachedInputPer1K, + "cache_read_per_1k": entry.CacheReadPer1K, + "cache_creation_per_1k": entry.CacheCreationPer1K, + } { + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return nil, fmt.Errorf("pricing %s/%s: %s must be a finite, non-negative rate, got %v", provider, model, field, v) + } + } + inner[model] = Entry{ + InputPer1K: entry.InputPer1K, + OutputPer1K: entry.OutputPer1K, + CachedInputPer1K: entry.CachedInputPer1K, + CacheReadPer1K: entry.CacheReadPer1K, + CacheCreationPer1K: entry.CacheCreationPer1K, + } + } + out[provider] = inner + } + return &Table{entries: out}, nil +} diff --git a/proxy/internal/llm/pricing/pricing_other.go b/proxy/internal/llm/pricing/pricing_other.go new file mode 100644 index 000000000..e65fffff1 --- /dev/null +++ b/proxy/internal/llm/pricing/pricing_other.go @@ -0,0 +1,20 @@ +//go:build !unix + +package pricing + +import ( + "fmt" + "time" +) + +// loadPricing is unavailable on non-Unix platforms because O_NOFOLLOW and +// fstat-from-FD are required to honour the spec's symlink-safety rules. The +// proxy is only deployed on Linux today; a Windows port would need an +// equivalent path-as-handle implementation. +func loadPricing(path string) (*Table, time.Time, error) { + return nil, time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path) +} + +func statMtime(path string) (time.Time, error) { + return time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path) +} diff --git a/proxy/internal/llm/pricing/pricing_test.go b/proxy/internal/llm/pricing/pricing_test.go new file mode 100644 index 000000000..7ac2a85dc --- /dev/null +++ b/proxy/internal/llm/pricing/pricing_test.go @@ -0,0 +1,432 @@ +//go:build unix + +package pricing + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func copyFixture(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + require.NoError(t, err, "read source fixture") + require.NoError(t, os.WriteFile(dst, data, 0o600), "write target fixture") +} + +func TestNewLoader_HappyPath(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err, "NewLoader must succeed with a valid fixture") + table := l.Get() + require.NotNil(t, table, "table populated after load") + + cost, ok := table.Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "known provider/model resolves") + assert.InDelta(t, 0.00075, cost, 1e-9, "cost = 0.00015 + 0.0006 per 1k tokens") + + cost, ok = table.Cost("openai", "gpt-4o", 2000, 1000, 0, 0) + require.True(t, ok, "second known model resolves") + assert.InDelta(t, 0.015, cost, 1e-9, "cost for gpt-4o: 2*0.0025 + 1*0.01") + + cost, ok = table.Cost("anthropic", "claude-sonnet-4-5", 1000, 1000, 0, 0) + require.True(t, ok, "anthropic model resolves") + assert.InDelta(t, 0.018, cost, 1e-9, "cost for claude-sonnet-4-5: 0.003 + 0.015") +} + +// TestCost_OpenAICachedSubsetDiscount proves OpenAI's cached input +// tokens are billed at the configured cached_input_per_1k rate while +// the non-cached remainder of input_tokens is billed at the regular +// rate. Critical because OpenAI returns cached_tokens as a SUBSET of +// prompt_tokens — naïvely charging the cached count on top of +// prompt_tokens would double-bill that portion. +func TestCost_OpenAICachedSubsetDiscount(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "openai": {"gpt-4o": { + InputPer1K: 0.0025, // 0.0025 USD per 1k input tokens + OutputPer1K: 0.01, + CachedInputPer1K: 0.00125, // 0.5x discount on cached + }}, + }} + // 1000 prompt tokens, 750 of which were cached. 250 non-cached + // at regular rate, 750 cached at the discount rate, 500 output. + cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 500, 750, 0) + require.True(t, ok, "known model resolves") + want := (250.0/1000.0)*0.0025 + (750.0/1000.0)*0.00125 + (500.0/1000.0)*0.01 + assert.InDelta(t, want, cost, 1e-12, + "cached subset must bill at the discount rate; non-cached remainder at regular rate") +} + +// TestCost_OpenAICachedFallsBackToInputRate covers the operator +// opt-in contract: when CachedInputPer1K is unset (zero), cached +// tokens bill at the regular input rate. This matches today's +// behaviour (cached counts weren't extracted at all so they +// implicitly billed at the input rate via prompt_tokens). +func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}}, + }} + cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 500, 750, 0) + require.True(t, ok) + want := 0.0025 + (500.0/1000.0)*0.01 + assert.InDelta(t, want, cost, 1e-12, + "absent cached_input_per_1k rate must fall back to input_per_1k — same as pre-feature behaviour") +} + +// TestCost_OpenAIClampsCachedToInputCount is the defensive guard +// against malformed upstream responses that report cached_tokens > +// prompt_tokens. We clamp so the formula never produces a negative +// "non-cached remainder" multiplied by the input rate. +func TestCost_OpenAIClampsCachedToInputCount(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}}, + }} + cost, ok := tbl.Cost("openai", "gpt-4o", 100, 0, 9999, 0) + require.True(t, ok) + // All 100 cached, 0 non-cached. Output is 0. + want := (100.0 / 1000.0) * 0.00125 + assert.InDelta(t, want, cost, 1e-12, + "cached count > input count must clamp to input — never bill negative non-cached tokens") +} + +// TestCost_AnthropicCacheReadAndCreationAreAdditive proves the +// Anthropic shape: cache_read and cache_creation tokens are +// ADDITIVE to input_tokens (not subset), each billed at its own +// configured rate. The two rates pull in opposite directions — +// cache_read is the cheaper read-from-cache rate (≈0.1× input), +// cache_creation is the more expensive write-to-cache rate +// (≈1.25× input). +func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "anthropic": {"claude-sonnet": { + InputPer1K: 0.003, + OutputPer1K: 0.015, + CacheReadPer1K: 0.0003, // 0.1x of input + CacheCreationPer1K: 0.00375, // 1.25x of input + }}, + }} + // 256 regular input + 768 cache_read + 512 cache_creation + + // 200 output. Each input bucket bills at its own rate. + cost, ok := tbl.Cost("anthropic", "claude-sonnet", 256, 200, 768, 512) + require.True(t, ok, "known model resolves") + want := (256.0/1000.0)*0.003 + + (768.0/1000.0)*0.0003 + + (512.0/1000.0)*0.00375 + + (200.0/1000.0)*0.015 + assert.InDelta(t, want, cost, 1e-12, + "each Anthropic input bucket must bill at its own configured rate") +} + +// TestCost_AnthropicCacheRatesFallBackToInput covers the no-opt-in +// path: when neither CacheReadPer1K nor CacheCreationPer1K is set, +// cache tokens bill at the regular input rate. This is more +// accurate than today's behaviour (cache tokens ignored entirely) +// without requiring operators to opt in via YAML. +func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "anthropic": {"claude-sonnet": {InputPer1K: 0.003, OutputPer1K: 0.015}}, + }} + cost, ok := tbl.Cost("anthropic", "claude-sonnet", 256, 200, 768, 512) + require.True(t, ok) + // Without overrides: every input bucket at input_per_1k. + want := ((256.0+768.0+512.0)/1000.0)*0.003 + (200.0/1000.0)*0.015 + assert.InDelta(t, want, cost, 1e-12, + "absent cache rates must fall back to input_per_1k — Anthropic cache tokens were ignored before this change, billing at input rate is more accurate as a default") +} + +func TestNewLoader_UnknownModel(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + + _, ok := l.Get().Cost("openai", "fantasy-model", 10, 10, 0, 0) + assert.False(t, ok, "unknown model returns ok=false") + + _, ok = l.Get().Cost("cohere", "anything", 10, 10, 0, 0) + assert.False(t, ok, "unknown provider returns ok=false") +} + +func TestNewLoader_InvalidYAMLRejected(t *testing.T) { + base := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(base, "pricing.yaml"), []byte("\t- this is not: valid: yaml: :["), 0o600)) + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "invalid YAML must surface as construction error") +} + +func TestLoader_ReloadKeepsPreviousOnParseError(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + require.NotNil(t, l.Get(), "initial table populated") + + // Overwrite with content that violates the strict schema (extra field) + // plus a bumped mtime to trigger reload. + require.NoError(t, os.WriteFile(target, []byte("openai:\n gpt-4o:\n input_per_1k: 1.0\n output_per_1k: 2.0\n bogus_field: nope\n"), 0o600)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(target, future, future)) + + err = l.reload() + require.Error(t, err, "parse error surfaced by reload()") + + cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "previous table still available after parse failure") + assert.InDelta(t, 0.00075, cost, 1e-9, "previous cost preserved") +} + +func TestLoader_ReloadNoChangeIsNoOp(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + ptrBefore := l.Get() + + require.NoError(t, l.reload(), "no-change reload must not error") + ptrAfter := l.Get() + assert.Same(t, ptrBefore, ptrAfter, "table pointer unchanged when mtime unchanged") +} + +func TestLoader_ReloadDetectsChange(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + + updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n") + require.NoError(t, os.WriteFile(target, updated, 0o600)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(target, future, future)) + + require.NoError(t, l.reload(), "reload must succeed on valid new content") + + cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "updated model still present") + assert.InDelta(t, 3.0, cost, 0.0001, "new prices are applied: 1 + 2 per 1k") +} + +// TestLoader_ReloadGoroutinePicksUpChanges proves the background goroutine +// started via Reload actually swaps the pricing table when the file changes +// on disk. Without that goroutine running, pricing edits would never reach +// requests until a proxy restart. +func TestLoader_ReloadGoroutinePicksUpChanges(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + l.SetReloadInterval(20 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + l.Reload(ctx) + close(done) + }() + + // Before any rewrite, the loader holds the fixture's prices. + costBefore, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "fixture model must resolve initially") + assert.InDelta(t, 0.00075, costBefore, 1e-9, "fixture prices apply before rewrite") + + updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n") + require.NoError(t, os.WriteFile(target, updated, 0o600)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(target, future, future)) + + deadline := time.Now().Add(2 * time.Second) + for { + cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + if ok && cost > 2.5 { + break + } + if time.Now().After(deadline) { + t.Fatalf("background reloader did not pick up rewrite within deadline") + } + time.Sleep(10 * time.Millisecond) + } + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Reload loop did not exit after cancel") + } +} + +func TestLoader_ReloadBackgroundLoopCancellation(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + l.Reload(ctx) + close(done) + }() + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Reload loop did not exit on context cancel") + } +} + +func TestNewLoader_PathValidation(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + cases := []struct { + name string + relPath string + }{ + {"traversal", "../../etc/passwd"}, + {"absolute", "/etc/passwd"}, + {"slash in basename", "sub/pricing.yaml"}, + {"control chars", "pricing\x00.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewLoader(base, tc.relPath, "llm_observability", nil) + require.Error(t, err, "NewLoader must reject %q", tc.relPath) + }) + } + + // Empty relPath is no longer a validation error: the loader treats it + // as "no override file, defaults only" so cost metadata is still + // emitted for the embedded models out of the box. + t.Run("empty falls back to defaults", func(t *testing.T) { + l, err := NewLoader(base, "", "llm_observability", nil) + require.NoError(t, err, "empty relPath should yield a defaults-only loader") + require.NotNil(t, l, "loader must be returned") + require.False(t, l.WatchesFile(), "no file watching when no override is given") + _, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + assert.True(t, ok, "embedded defaults should still resolve gpt-4o-mini") + }) +} + +// TestNewLoader_PathValidation_Extended covers the remaining attack shapes +// called out in C2: dot references, embedded traversal segments, and a +// newline in the basename. The basename regex must reject each one even +// though filepath.Clean would otherwise collapse them. +func TestNewLoader_PathValidation_Extended(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + cases := []struct { + name string + relPath string + }{ + {"dot", "."}, + {"dotdot", ".."}, + {"relative traversal", "../pricing.yaml"}, + {"embedded slash", "pri/cing.yaml"}, + {"newline", "pricing\n.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewLoader(base, tc.relPath, "llm_observability", nil) + require.Error(t, err, "NewLoader must reject %q", tc.relPath) + }) + } +} + +// TestNewLoader_ValidBasenameLoads proves the allowlist is exclusive: a +// basename containing only safe characters under baseDir loads. Without this +// a regression that over-tightened the regex would silently break valid +// deployments. +func TestNewLoader_ValidBasenameLoads(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing-v2_prod.yaml")) + + l, err := NewLoader(base, "pricing-v2_prod.yaml", "llm_observability", nil) + require.NoError(t, err, "basename with _, -, . must load") + require.NotNil(t, l.Get(), "table populated") +} + +// TestNewLoader_SymlinkOutsideBaseDirRejected constructs a symlink under +// baseDir that points to a file outside it. O_NOFOLLOW must refuse to open +// the symlink even though the symlink path itself is a valid basename under +// baseDir. +func TestNewLoader_SymlinkOutsideBaseDirRejected(t *testing.T) { + outside := t.TempDir() + target := filepath.Join(outside, "evil.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + base := t.TempDir() + link := filepath.Join(base, "pricing.yaml") + require.NoError(t, os.Symlink(target, link), "symlink setup") + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "O_NOFOLLOW must reject symlink even when it points outside baseDir") +} + +func TestNewLoader_SymlinkRejected(t *testing.T) { + base := t.TempDir() + concrete := filepath.Join(base, "real.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), concrete) + + link := filepath.Join(base, "pricing.yaml") + require.NoError(t, os.Symlink(concrete, link), "symlink setup") + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "O_NOFOLLOW must reject symlinked targets") +} + +func TestTableCost_NilSafe(t *testing.T) { + var t1 *Table + cost, ok := t1.Cost("x", "y", 1, 1, 0, 0) + assert.False(t, ok, "nil table reports unknown") + assert.Zero(t, cost, "nil table returns zero cost") + assert.False(t, t1.Has("x", "y"), "nil table has nothing") +} + +func TestLoaderGet_NilSafe(t *testing.T) { + var l *Loader + assert.Nil(t, l.Get(), "nil loader returns nil table") +} + +// TestNewLoader_RejectsOversizedFile_FixesM4 proves the loader bounds reads +// at maxPricingBytes so a hostile file cannot exhaust process memory. +func TestNewLoader_RejectsOversizedFile_FixesM4(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + + // Build a YAML payload larger than the cap. We pad with valid YAML + // comments so a partial read would still fail the size check rather + // than the parser. + header := "openai:\n" + bigComment := make([]byte, maxPricingBytes+1024) + for i := range bigComment { + bigComment[i] = ' ' + } + bigComment[0] = '#' + bigComment[len(bigComment)-1] = '\n' + payload := append([]byte(header), bigComment...) + require.NoError(t, os.WriteFile(target, payload, 0o600)) + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "oversized pricing file must be rejected") + assert.Contains(t, err.Error(), "exceeds", "rejection must reference the byte cap") +} diff --git a/proxy/internal/llm/pricing/pricing_unix.go b/proxy/internal/llm/pricing/pricing_unix.go new file mode 100644 index 000000000..4f3ea33a2 --- /dev/null +++ b/proxy/internal/llm/pricing/pricing_unix.go @@ -0,0 +1,68 @@ +//go:build unix + +package pricing + +import ( + "fmt" + "io" + "os" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +// maxPricingBytes caps the size of the pricing YAML on read so a hostile or +// runaway file cannot exhaust process memory during reload. 1 MiB is several +// orders of magnitude larger than any reasonable pricing table. +const maxPricingBytes int64 = 1 << 20 + +// loadPricing opens the file with O_NOFOLLOW, fstats the open descriptor, +// and parses from that same descriptor. Never re-opens by path so a +// mid-read rename or symlink swap cannot substitute content. Bytes are +// capped at maxPricingBytes so the loader cannot be coerced into reading an +// unbounded file. +func loadPricing(path string) (*Table, time.Time, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return nil, time.Time{}, fmt.Errorf("open %s: %w", path, err) + } + defer func() { + if cerr := f.Close(); cerr != nil { + log.Debugf("close pricing file %s: %v", path, cerr) + } + }() + + info, err := f.Stat() + if err != nil { + return nil, time.Time{}, fmt.Errorf("fstat %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, time.Time{}, fmt.Errorf("pricing file %s is not a regular file", path) + } + + data, err := io.ReadAll(io.LimitReader(f, maxPricingBytes+1)) + if err != nil { + return nil, time.Time{}, fmt.Errorf("read %s: %w", path, err) + } + if int64(len(data)) > maxPricingBytes { + return nil, time.Time{}, fmt.Errorf("pricing file %s exceeds %d bytes", path, maxPricingBytes) + } + + table, err := parsePricingBytes(data) + if err != nil { + return nil, time.Time{}, err + } + return table, info.ModTime(), nil +} + +// statMtime returns the mtime of the file at path. It uses lstat semantics +// via os.Lstat so a symlink swap is detected even though O_NOFOLLOW will +// later reject the open. +func statMtime(path string) (time.Time, error) { + info, err := os.Lstat(path) + if err != nil { + return time.Time{}, fmt.Errorf("lstat %s: %w", path, err) + } + return info.ModTime(), nil +} diff --git a/proxy/internal/llm/sse.go b/proxy/internal/llm/sse.go new file mode 100644 index 000000000..3d33ab577 --- /dev/null +++ b/proxy/internal/llm/sse.go @@ -0,0 +1,117 @@ +package llm + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" +) + +// Event represents a single server-sent event. Type is the dispatch name +// carried on an "event:" line (empty when the stream uses only "data:" +// lines). Data is the concatenation of every "data:" line that made up the +// event, joined by a single newline. +type Event struct { + Type string + Data string +} + +// Scanner reads SSE events from an underlying byte stream. Events are +// delimited by a blank line ("\n\n"). CRLF line endings are normalized to LF +// transparently so fixtures captured from live servers can be replayed. +// +// Scanner is not safe for concurrent use. +type Scanner struct { + r *bufio.Reader + maxLine int +} + +// NewScanner wraps the given reader. The default underlying buffer size is +// large enough for typical provider events (~64 KiB); callers needing +// larger events can wrap the reader in their own bufio.Reader beforehand. +func NewScanner(r io.Reader) *Scanner { + return &Scanner{ + r: bufio.NewReaderSize(r, 64*1024), + maxLine: 1 << 20, + } +} + +// Next returns the next event. It returns io.EOF after the final event has +// been consumed. A trailing event that is not terminated by a blank line is +// still returned before io.EOF so that servers which close the connection +// without a trailing newline are handled correctly. +func (s *Scanner) Next() (Event, error) { + var ( + event Event + dataBuf strings.Builder + hasData bool + hasAny bool + ) + + for { + line, err := s.readLine() + if err != nil { + if errors.Is(err, io.EOF) && hasAny { + event.Data = dataBuf.String() + return event, nil + } + return Event{}, err + } + + if line == "" { + if !hasAny { + continue + } + event.Data = dataBuf.String() + return event, nil + } + + hasAny = true + if strings.HasPrefix(line, ":") { + continue + } + + field, value := splitField(line) + switch field { + case "event": + event.Type = value + case "data": + if hasData { + dataBuf.WriteByte('\n') + } + dataBuf.WriteString(value) + hasData = true + } + } +} + +func (s *Scanner) readLine() (string, error) { + line, err := s.r.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) && line != "" { + return trimEOL(line), nil + } + return "", err + } + if len(line) > s.maxLine { + return "", fmt.Errorf("sse line exceeds %d bytes", s.maxLine) + } + return trimEOL(line), nil +} + +func trimEOL(line string) string { + line = strings.TrimRight(line, "\n") + line = strings.TrimRight(line, "\r") + return line +} + +func splitField(line string) (string, string) { + idx := strings.IndexByte(line, ':') + if idx < 0 { + return line, "" + } + field := line[:idx] + value := strings.TrimPrefix(line[idx+1:], " ") + return field, value +} diff --git a/proxy/internal/llm/sse_test.go b/proxy/internal/llm/sse_test.go new file mode 100644 index 000000000..96cecc111 --- /dev/null +++ b/proxy/internal/llm/sse_test.go @@ -0,0 +1,175 @@ +package llm + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func collectEvents(t *testing.T, r io.Reader) []Event { + t.Helper() + s := NewScanner(r) + var out []Event + for { + ev, err := s.Next() + if errors.Is(err, io.EOF) { + return out + } + require.NoError(t, err, "unexpected error scanning SSE") + out = append(out, ev) + } +} + +func TestSSEScanner_OpenAIFixture(t *testing.T) { + f, err := os.Open(filepath.Join("fixtures", "openai_stream.txt")) + require.NoError(t, err, "fixture must be openable") + defer f.Close() + + events := collectEvents(t, f) + require.Len(t, events, 4, "expected 4 data frames (3 chunks + [DONE])") + + for _, ev := range events { + assert.Empty(t, ev.Type, "OpenAI stream uses data-only frames") + } + assert.Contains(t, events[2].Data, `"usage"`, "third chunk carries usage block") + assert.Equal(t, "[DONE]", events[3].Data, "final frame is the OpenAI DONE sentinel") +} + +func TestSSEScanner_AnthropicFixture(t *testing.T) { + f, err := os.Open(filepath.Join("fixtures", "anthropic_stream.txt")) + require.NoError(t, err, "fixture must be openable") + defer f.Close() + + events := collectEvents(t, f) + require.Len(t, events, 7, "expected 7 Anthropic events") + + types := make([]string, 0, len(events)) + for _, ev := range events { + types = append(types, ev.Type) + } + assert.Equal(t, []string{ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + }, types, "Anthropic event ordering matches fixture") + + var deltaUsage Event + for _, ev := range events { + if ev.Type == "message_delta" { + deltaUsage = ev + break + } + } + assert.Contains(t, deltaUsage.Data, `"output_tokens":45`, "message_delta carries partial usage") +} + +func TestSSEScanner_MultilineData(t *testing.T) { + raw := "event: ping\ndata: line1\ndata: line2\ndata: line3\n\n" + events := collectEvents(t, strings.NewReader(raw)) + + require.Len(t, events, 1, "one logical event from three data lines") + assert.Equal(t, "ping", events[0].Type, "event name honored") + assert.Equal(t, "line1\nline2\nline3", events[0].Data, "data lines joined with newline") +} + +func TestSSEScanner_CRLF(t *testing.T) { + raw := "event: foo\r\ndata: bar\r\n\r\ndata: baz\r\n\r\n" + events := collectEvents(t, strings.NewReader(raw)) + + require.Len(t, events, 2, "CRLF-delimited events recognized") + assert.Equal(t, "foo", events[0].Type, "first event type preserved") + assert.Equal(t, "bar", events[0].Data, "first event data preserved") + assert.Empty(t, events[1].Type, "second event has no event name") + assert.Equal(t, "baz", events[1].Data, "second event data preserved") +} + +func TestSSEScanner_EmptyInput(t *testing.T) { + s := NewScanner(strings.NewReader("")) + _, err := s.Next() + require.ErrorIs(t, err, io.EOF, "empty input yields immediate EOF") +} + +func TestSSEScanner_CommentIgnored(t *testing.T) { + raw := ": this is a comment\ndata: hi\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "comment line does not emit an event") + assert.Equal(t, "hi", events[0].Data, "data line honoured after comment") +} + +func TestSSEScanner_TrailingWithoutBlankLine(t *testing.T) { + raw := "event: foo\ndata: bar\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "trailing event without blank line still emitted") + assert.Equal(t, "foo", events[0].Type) + assert.Equal(t, "bar", events[0].Data) +} + +// TestSSEScanner_ManyConsecutiveEmptyLines feeds a stream that is nothing +// but empty lines. The scanner must terminate without panic — empty lines +// alone do not constitute an event and must yield io.EOF. +func TestSSEScanner_ManyConsecutiveEmptyLines(t *testing.T) { + raw := strings.Repeat("\n", 100) + s := NewScanner(strings.NewReader(raw)) + _, err := s.Next() + require.ErrorIs(t, err, io.EOF, "100 empty lines must terminate as EOF without panic") +} + +// TestSSEScanner_InterleavedCRLFAndLF mixes \r\n and \n terminators within +// the same event. The scanner normalizes both and must still recover a +// coherent event. +func TestSSEScanner_InterleavedCRLFAndLF(t *testing.T) { + raw := "event: mix\r\ndata: first\ndata: second\r\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "mixed line endings must still produce one event") + assert.Equal(t, "mix", events[0].Type) + assert.Equal(t, "first\nsecond", events[0].Data, "both data lines joined") +} + +// TestSSEScanner_LongSingleDataLine constructs a single data line that +// exceeds the default bufio buffer (64 KiB) but stays under the scanner +// maxLine. The scanner must round-trip the value intact without panicking +// or truncating silently. +func TestSSEScanner_LongSingleDataLine(t *testing.T) { + big := strings.Repeat("x", 80<<10) + raw := "data: " + big + "\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "long single-line event must be emitted") + assert.Equal(t, big, events[0].Data, "long data preserved") +} + +// TestSSEScanner_BinaryGarbageInData validates that non-printable bytes +// inside a data line do not crash the parser. The scanner should either +// round-trip them or return a well-formed error — never panic. +func TestSSEScanner_BinaryGarbageInData(t *testing.T) { + raw := "data: \x00\x01\x02\xff\xfe\n\n" + defer func() { + if r := recover(); r != nil { + t.Fatalf("scanner panicked on binary garbage: %v", r) + } + }() + s := NewScanner(strings.NewReader(raw)) + ev, err := s.Next() + require.NoError(t, err, "binary bytes in data should not surface as error") + assert.Equal(t, "\x00\x01\x02\xff\xfe", ev.Data, "binary payload round-trips") +} + +// TestSSEScanner_UnknownFieldsIgnored stresses the field parser by sending +// unrecognized field names ("id:", "retry:", "custom:"). They must be +// silently ignored per the SSE spec; the scanner must not panic or emit +// spurious events. +func TestSSEScanner_UnknownFieldsIgnored(t *testing.T) { + raw := "id: 1\nretry: 5000\ncustom: value\ndata: payload\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "unknown fields must not spawn extra events") + assert.Equal(t, "payload", events[0].Data, "data field survives amid unknown fields") +} diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go index 41a6b0dd4..5fd23d934 100644 --- a/proxy/internal/metrics/metrics.go +++ b/proxy/internal/metrics/metrics.go @@ -17,6 +17,7 @@ import ( // Metrics collects OpenTelemetry metrics for the proxy. type Metrics struct { ctx context.Context + meter metric.Meter requestsTotal metric.Int64Counter activeRequests metric.Int64UpDownCounter configuredDomains metric.Int64UpDownCounter @@ -49,10 +50,18 @@ type Metrics struct { mappingPaths map[string]int } +// Meter returns the OpenTelemetry meter the bundle was built with, so other +// subsystems (e.g. the middleware manager) register instruments on the same +// meter. +func (m *Metrics) Meter() metric.Meter { + return m.meter +} + // New creates a Metrics instance using the given OpenTelemetry meter. func New(ctx context.Context, meter metric.Meter) (*Metrics, error) { m := &Metrics{ ctx: ctx, + meter: meter, mappingPaths: make(map[string]int), } diff --git a/proxy/internal/middleware/bodypolicy.go b/proxy/internal/middleware/bodypolicy.go new file mode 100644 index 000000000..f31486fc8 --- /dev/null +++ b/proxy/internal/middleware/bodypolicy.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// ErrExpectContinue is returned when a middleware attempts to replace +// the body of a request that advertised Expect: 100-continue. +var ErrExpectContinue = errors.New("body replace rejected: request has Expect: 100-continue") + +// ErrOriginalNotDrained is returned when the original body was not +// fully consumed before replacement. This prevents the backend from +// seeing a mix of original bytes and the replacement. +var ErrOriginalNotDrained = errors.New("body replace rejected: original body not drained") + +// ErrContentLengthMismatch is returned when the client-advertised +// Content-Length disagrees with the number of bytes actually read from +// the body (short-read). +var ErrContentLengthMismatch = errors.New("body replace rejected: content-length mismatch (short read)") + +// ValidateBodyReplace runs the smuggling-prevention rules before a +// body replacement is applied. Callers must pass originalDrained=true +// once they have read r.Body to EOF. +func ValidateBodyReplace(r *http.Request, newBody []byte, originalDrained bool) error { + if r == nil { + return errors.New("body replace rejected: nil request") + } + if strings.EqualFold(r.Header.Get("Expect"), "100-continue") { + return ErrExpectContinue + } + if !originalDrained { + return ErrOriginalNotDrained + } + if cl := r.Header.Get("Content-Length"); cl != "" && r.ContentLength > 0 { + parsed, err := strconv.ParseInt(cl, 10, 64) + if err == nil && parsed != r.ContentLength { + return fmt.Errorf("%w: header=%d actual=%d", ErrContentLengthMismatch, parsed, r.ContentLength) + } + } + return nil +} + +// ApplyBodyReplace swaps r.Body for a reader over newBody, recomputes +// Content-Length, and strips Transfer-Encoding and Trailer so no stale +// framing reaches the backend. +func ApplyBodyReplace(r *http.Request, newBody []byte) { + if r == nil { + return + } + r.Body = io.NopCloser(bytes.NewReader(newBody)) + r.ContentLength = int64(len(newBody)) + r.Header.Set("Content-Length", strconv.Itoa(len(newBody))) + r.Header.Del("Transfer-Encoding") + r.Header.Del("Trailer") + r.TransferEncoding = nil + r.Trailer = nil +} diff --git a/proxy/internal/middleware/bodytap/request.go b/proxy/internal/middleware/bodytap/request.go new file mode 100644 index 000000000..826883a96 --- /dev/null +++ b/proxy/internal/middleware/bodytap/request.go @@ -0,0 +1,344 @@ +// Package bodytap owns the framework-side body capture used by the +// middleware chain. Request capture buffers up to N bytes of the +// request body for middleware inspection while replaying the original +// stream to the upstream. Response capture tees up to N bytes off the +// streaming response while every byte continues to flow to the client +// untouched. +// +// The package is the single owner of body access — middlewares never +// read req.Body or hijack the response writer. All inspection happens +// against the buffer surfaced by the tap, so streaming remains +// transparent to the client even when middlewares need access to the +// payload. +package bodytap + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "sync" +) + +// MaxRoutingScanBytes bounds how far ScanRoutingFields will read into a +// request body to recover routing fields when the normal capture is +// bypassed for size. Sized to comfortably hold a 1M-token context +// request (whose `model` field a client may place after a multi-MB +// `messages` array) while still capping pathological inputs. +const MaxRoutingScanBytes int64 = 32 << 20 + +// Request bypass reasons emitted as the `mw.capture.bypass_reason` +// metadata key by the chain when a request body is not surfaced. +const ( + BypassUpgradeHeader = "upgrade_header" + BypassConnectionUpgrd = "connection_upgrade" + BypassContentType = "content_type_not_allowed" + BypassBudget = "capture_budget_exhausted" + BypassNoConfig = "no_capture_config" + BypassNoMiddlewares = "no_middlewares" + BypassCapZero = "cap_zero" + BypassContentLengthCap = "content_length_over_cap" +) + +// DefaultCaptureBudgetBytes is the default global capture-budget size. +const DefaultCaptureBudgetBytes int64 = 256 << 20 + +// Config holds per-target body capture limits after clamp validation. +// A zero MaxRequestBytes / MaxResponseBytes disables capture in that +// direction. +type Config struct { + MaxRequestBytes int64 + MaxResponseBytes int64 + ContentTypes []string +} + +// Budget is the global token-bucket semaphore shared across all +// in-flight captures so a single misbehaving target cannot exhaust the +// proxy. +type Budget interface { + Acquire(n int64) bool + Release(n int64) +} + +// NewBudget returns a Budget with the given total byte cap. A zero or +// negative total disables the budget check. +func NewBudget(total int64) Budget { + return &budget{total: total} +} + +type budget struct { + mu sync.Mutex + used int64 + total int64 +} + +func (b *budget) Acquire(n int64) bool { + if n <= 0 { + return true + } + b.mu.Lock() + defer b.mu.Unlock() + if b.total <= 0 { + return true + } + if b.used+n > b.total { + return false + } + b.used += n + return true +} + +func (b *budget) Release(n int64) { + if n <= 0 { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if b.total <= 0 { + return + } + b.used -= n + if b.used < 0 { + b.used = 0 + } +} + +// CaptureRequest reads up to cfg.MaxRequestBytes from r.Body into a +// buffer suitable for middleware inspection, replacing r.Body with a +// replay reader so the upstream still sees the original bytes. When +// bypass != "" no body is read and r.Body is left untouched. The +// returned release function must be invoked once the request is fully +// processed; it returns the acquired budget tokens to the shared pool. +// release is always non-nil and is safe to defer immediately after the +// call. +func CaptureRequest(r *http.Request, cfg *Config, b Budget) (body []byte, truncated bool, originalSize int64, bypass string, release func(), err error) { + release = func() {} + if r == nil { + return nil, false, 0, BypassNoConfig, release, nil + } + if cfg == nil { + return nil, false, 0, BypassNoConfig, release, nil + } + if cfg.MaxRequestBytes <= 0 { + return nil, false, 0, BypassCapZero, release, nil + } + if r.Header.Get("Upgrade") != "" { + return nil, false, 0, BypassUpgradeHeader, release, nil + } + if strings.EqualFold(r.Header.Get("Connection"), "upgrade") { + return nil, false, 0, BypassConnectionUpgrd, release, nil + } + if !contentTypeAllowed(r.Header.Get("Content-Type"), cfg.ContentTypes) { + return nil, false, 0, BypassContentType, release, nil + } + + originalSize = parseContentLength(r.Header.Get("Content-Length")) + if originalSize > cfg.MaxRequestBytes { + return nil, true, originalSize, BypassContentLengthCap, release, nil + } + + limit := cfg.MaxRequestBytes + if b != nil && !b.Acquire(limit) { + return nil, false, originalSize, BypassBudget, release, nil + } + if b != nil { + var released sync.Once + release = func() { + released.Do(func() { b.Release(limit) }) + } + } + + if r.Body == nil || r.Body == http.NoBody { + release() + release = func() {} + return nil, false, originalSize, "", release, nil + } + + limited := io.LimitReader(r.Body, limit+1) + buf, readErr := io.ReadAll(limited) + if readErr != nil && !errors.Is(readErr, io.EOF) { + release() + release = func() {} + return nil, false, originalSize, "", release, readErr + } + + truncated = int64(len(buf)) > limit + if truncated { + replay := append([]byte(nil), buf...) + viewable := buf[:limit] + r.Body = &replayReadCloser{replay: bytes.NewReader(replay), tail: r.Body} + return viewable, true, originalSize, "", release, nil + } + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(buf)) + if originalSize <= 0 { + originalSize = int64(len(buf)) + } + return buf, false, originalSize, "", release, nil +} + +// replayReadCloser replays the captured prefix and then forwards the +// remaining bytes from the original body so the upstream sees the +// full request stream even when capture truncates. +type replayReadCloser struct { + replay *bytes.Reader + tail io.ReadCloser + drained bool +} + +func (r *replayReadCloser) Read(p []byte) (int, error) { + if !r.drained { + n, err := r.replay.Read(p) + if n > 0 { + return n, nil + } + if errors.Is(err, io.EOF) { + r.drained = true + } else if err != nil { + return 0, err + } + } + return r.tail.Read(p) +} + +func (r *replayReadCloser) Close() error { + return r.tail.Close() +} + +// ScanRoutingFields recovers the LLM routing fields ("model" and +// "stream") from a request whose normal capture was bypassed or +// truncated for size. It reads up to maxScan bytes of r.Body to locate +// the top-level keys — clients (e.g. Claude Code) may place `model` +// after a multi-MB `messages` array — then restores r.Body so the +// upstream still receives the full, untouched stream. Only the small +// routing fields are extracted; the prompt is never buffered for +// capture, keeping memory bounded. Returns ok=false when the body isn't +// a JSON object, the model field isn't found within maxScan, or on a +// read error. +func ScanRoutingFields(r *http.Request, maxScan int64) (model string, stream bool, ok bool) { + if r == nil || r.Body == nil || r.Body == http.NoBody || maxScan <= 0 { + return "", false, false + } + limited := io.LimitReader(r.Body, maxScan+1) + buf, readErr := io.ReadAll(limited) + if readErr != nil && !errors.Is(readErr, io.EOF) { + // Mid-stream read error (e.g. client disconnect): restore the bytes + // read so far plus the untouched tail and abort, rather than + // forwarding only the partial prefix as if it were the whole body. + r.Body = &replayReadCloser{replay: bytes.NewReader(append([]byte(nil), buf...)), tail: r.Body} + return "", false, false + } + if int64(len(buf)) > maxScan { + // Body exceeds the scan ceiling: restore the read prefix plus the + // untouched tail so the upstream still gets every byte. + r.Body = &replayReadCloser{replay: bytes.NewReader(append([]byte(nil), buf...)), tail: r.Body} + } else { + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(buf)) + } + return scanTopLevelModelStream(buf) +} + +// scanTopLevelModelStream walks the top level of a JSON object via a +// streaming token reader, extracting the "model" string and "stream" +// bool without materialising large values (each non-target value is +// skipped as a RawMessage). Tolerant of truncation: returns whatever was +// found before a malformed/short tail. +func scanTopLevelModelStream(body []byte) (model string, stream bool, ok bool) { + dec := json.NewDecoder(bytes.NewReader(body)) + tok, err := dec.Token() + if err != nil { + return "", false, false + } + if d, isDelim := tok.(json.Delim); !isDelim || d != '{' { + return "", false, false + } + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return model, stream, ok + } + key, _ := keyTok.(string) + switch key { + case "model": + var v string + if dec.Decode(&v) == nil { + model, ok = v, true + } + case "stream": + var v bool + if dec.Decode(&v) == nil { + stream = v + } + default: + // Skip the value by walking tokens instead of decoding it into + // a json.RawMessage — a multi-MB messages array would otherwise + // be materialised in full just to be discarded. + if err := skipValue(dec); err != nil { + return model, stream, ok + } + } + } + return model, stream, ok +} + +// skipValue consumes one JSON value from dec without materialising it. +// Scalars are a single token; objects/arrays are walked to their matching +// close delimiter so nested structures are skipped in bounded memory. +func skipValue(dec *json.Decoder) error { + tok, err := dec.Token() + if err != nil { + return err + } + d, isDelim := tok.(json.Delim) + if !isDelim || (d != '{' && d != '[') { + return nil + } + depth := 1 + for depth > 0 { + tok, err := dec.Token() + if err != nil { + return err + } + if d, ok := tok.(json.Delim); ok { + switch d { + case '{', '[': + depth++ + case '}', ']': + depth-- + } + } + } + return nil +} + +func contentTypeAllowed(ct string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + media := ct + if idx := strings.Index(ct, ";"); idx >= 0 { + media = ct[:idx] + } + media = strings.TrimSpace(strings.ToLower(media)) + for _, a := range allowed { + if strings.EqualFold(strings.TrimSpace(a), media) { + return true + } + } + return false +} + +func parseContentLength(v string) int64 { + if v == "" { + return 0 + } + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} diff --git a/proxy/internal/middleware/bodytap/response.go b/proxy/internal/middleware/bodytap/response.go new file mode 100644 index 000000000..c23e35b34 --- /dev/null +++ b/proxy/internal/middleware/bodytap/response.go @@ -0,0 +1,189 @@ +package bodytap + +import ( + "bytes" + "net/http" + "sync" + + "github.com/netbirdio/netbird/proxy/internal/responsewriter" +) + +// CapturingResponseWriter wraps an http.ResponseWriter, forwards bytes +// immediately to the client, and tees a bounded copy into an internal +// buffer for middleware inspection. Streaming-aware in the sense that +// every byte the upstream emits flows to the client without queuing +// — the tee just sees a bounded prefix. SSE-aware parsing happens in +// the response middleware against the buffered prefix; this writer +// makes no attempt to demux event boundaries. +// +// Flusher and Hijacker are preserved via responsewriter.PassthroughWriter. +type CapturingResponseWriter struct { + *responsewriter.PassthroughWriter + mu sync.Mutex + buf bytes.Buffer + cap int64 + status int + statusSet bool + written int64 + truncated bool + stopped bool + releaseBuf func() + released sync.Once + bypassed bool + bypassReas string + acquiredCap int64 +} + +// NewCapturingResponseWriter returns a writer that tees up to maxBytes +// into a capped buffer while forwarding bytes to the underlying writer +// immediately. When budget is non-nil the writer pre-acquires maxBytes +// from it and the returned wrapper must be released by calling +// Release() once the response is fully forwarded. If the budget cannot +// be acquired the writer falls back to forwarding the response +// unmodified, exposes Bypassed()=true with reason BypassBudget, and +// releases nothing. +func NewCapturingResponseWriter(w http.ResponseWriter, maxBytes int64, b Budget) *CapturingResponseWriter { + cw := &CapturingResponseWriter{ + PassthroughWriter: responsewriter.New(w), + cap: maxBytes, + status: http.StatusOK, + releaseBuf: func() {}, + } + if maxBytes <= 0 { + // Capture disabled: mark stopped so Write never tees and never + // flags truncation (a zero cap means "don't capture", not + // "captured nothing"). + cw.stopped = true + return cw + } + if b == nil { + return cw + } + if !b.Acquire(maxBytes) { + cw.bypassed = true + cw.bypassReas = BypassBudget + cw.cap = 0 + cw.stopped = true + return cw + } + cw.acquiredCap = maxBytes + cw.releaseBuf = func() { b.Release(maxBytes) } + return cw +} + +// Release returns the response capture budget acquired at construction +// back to the shared pool. Idempotent. Safe to call from a defer +// immediately after construction even when the writer ended up +// bypassing the budget. +func (c *CapturingResponseWriter) Release() { + if c == nil { + return + } + c.released.Do(func() { + if c.releaseBuf != nil { + c.releaseBuf() + } + }) +} + +// Bypassed reports whether the writer fell through to a no-tee +// passthrough because the response capture budget could not be +// acquired. +func (c *CapturingResponseWriter) Bypassed() bool { + if c == nil { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + return c.bypassed +} + +// BypassReason returns the bypass code recorded by the budget check. +// Empty when capture proceeded normally. +func (c *CapturingResponseWriter) BypassReason() string { + if c == nil { + return "" + } + c.mu.Lock() + defer c.mu.Unlock() + return c.bypassReas +} + +// WriteHeader records the status code and forwards it to the underlying +// writer. Only the first call commits the status — matching HTTP semantics, +// where superfluous WriteHeader calls (and any call after the body has +// started) are ignored — so Status() reflects the code actually sent. +func (c *CapturingResponseWriter) WriteHeader(status int) { + c.mu.Lock() + if c.statusSet { + c.mu.Unlock() + return + } + c.status = status + c.statusSet = true + c.mu.Unlock() + c.PassthroughWriter.WriteHeader(status) +} + +// Write forwards p to the underlying writer unmodified and copies up +// to the remaining buffer capacity into the tee buffer. +func (c *CapturingResponseWriter) Write(p []byte) (int, error) { + n, err := c.PassthroughWriter.Write(p) + if n > 0 { + c.mu.Lock() + // The first byte commits the status (implicit 200 if WriteHeader was + // never called); a later WriteHeader must not change Status(). + c.statusSet = true + c.written += int64(n) + if !c.stopped { + remaining := c.cap - int64(c.buf.Len()) + if remaining <= 0 { + c.truncated = true + c.stopped = true + } else { + take := int64(n) + if take > remaining { + take = remaining + c.truncated = true + c.stopped = true + } + c.buf.Write(p[:take]) + } + } + c.mu.Unlock() + } + return n, err +} + +// Status returns the captured status code (defaults to 200 when +// WriteHeader has not been called). +func (c *CapturingResponseWriter) Status() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} + +// Body returns a copy of the buffered response prefix. +func (c *CapturingResponseWriter) Body() []byte { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]byte, c.buf.Len()) + copy(out, c.buf.Bytes()) + return out +} + +// Truncated reports whether the buffered prefix stopped short of the +// full response stream. +func (c *CapturingResponseWriter) Truncated() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.truncated +} + +// BytesWritten returns the total number of bytes forwarded to the +// underlying writer. +func (c *CapturingResponseWriter) BytesWritten() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.written +} diff --git a/proxy/internal/middleware/bodytap/routing_scan_test.go b/proxy/internal/middleware/bodytap/routing_scan_test.go new file mode 100644 index 000000000..1748c989e --- /dev/null +++ b/proxy/internal/middleware/bodytap/routing_scan_test.go @@ -0,0 +1,86 @@ +package bodytap + +import ( + "fmt" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeBigAnthropicBody builds a request body shaped like Claude Code's: +// a multi-MB "messages" array with the routing fields (model, stream) +// placed AFTER it, which is the ordering that defeats a prefix-only +// capture. +func makeBigAnthropicBody(t *testing.T, model string, stream bool, messagesBytes int) string { + t.Helper() + filler := strings.Repeat("x", messagesBytes) + return fmt.Sprintf( + `{"max_tokens":64000,"messages":[{"role":"user","content":%q}],"model":%q,"stream":%t}`, + filler, model, stream, + ) +} + +func TestScanRoutingFields_ModelAfterLargeMessages(t *testing.T) { + body := makeBigAnthropicBody(t, "claude-opus-4-8", true, 3<<20) // 3 MiB messages + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + model, stream, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + require.True(t, ok, "model must be recovered even when it follows a multi-MB messages array") + assert.Equal(t, "claude-opus-4-8", model, "model field must be extracted") + assert.True(t, stream, "stream field must be extracted") + + // Body must be fully restored for the upstream. + got, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, body, string(got), "the full request body must be replayed to upstream after scanning") +} + +func TestScanRoutingFields_SmallBody(t *testing.T) { + body := `{"model":"claude-opus-4-8","stream":false,"messages":[]}` + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + model, stream, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + require.True(t, ok) + assert.Equal(t, "claude-opus-4-8", model) + assert.False(t, stream) + + got, _ := io.ReadAll(req.Body) + assert.Equal(t, body, string(got), "small bodies must also be restored intact") +} + +func TestScanRoutingFields_NoModel(t *testing.T) { + body := `{"stream":true,"messages":[]}` + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + _, _, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + assert.False(t, ok, "ok must be false when no model field is present") + + got, _ := io.ReadAll(req.Body) + assert.Equal(t, body, string(got), "body must be restored even when model is absent") +} + +func TestScanRoutingFields_NotJSON(t *testing.T) { + body := "this is not json at all" + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + _, _, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + assert.False(t, ok, "ok must be false for a non-JSON body") +} + +func TestScanRoutingFields_ModelBeyondScanCeiling(t *testing.T) { + // model sits after 4 MiB of messages but the scan ceiling is 1 MiB: + // model can't be recovered, yet the full body must still replay. + body := makeBigAnthropicBody(t, "claude-opus-4-8", true, 4<<20) + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + _, _, ok := ScanRoutingFields(req, 1<<20) + assert.False(t, ok, "model beyond the scan ceiling is not recoverable") + + got, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, body, string(got), "the full body must still replay to upstream even when the scan gives up") +} diff --git a/proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go b/proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go new file mode 100644 index 000000000..96777025c --- /dev/null +++ b/proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go @@ -0,0 +1,318 @@ +package builtin_test + +import ( + "context" + "net" + "runtime" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// chainIntegrationFixture wires the BOTH new agent-network +// middlewares against a live in-process management stack: real +// sqlite store + real Manager + real gRPC server. The proxy chain +// framework itself isn't constructed (its dispatcher / accumulator / +// metadata gate are tested separately); we exercise the middleware +// pair as the proxy runtime would, by invoking each with a crafted +// Input and asserting the wire path between them. +// +// This is the regression cover for item 16 in the design review: +// real LLM request → cost stamped → consumption row in the table. +type chainIntegrationFixture struct { + store store.Store + manager agentnetwork.Manager + gatecase *llm_limit_check.Middleware + recorder *llm_limit_record.Middleware +} + +func newChainIntegration(t *testing.T) *chainIntegrationFixture { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("sqlite store not properly supported on Windows yet") + } + t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine)) + + st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + manager := agentnetwork.NewManager(st, nil, nil, nil) + + server := &mgmtgrpc.ProxyServiceServer{} + server.SetAgentNetworkLimitsService(manager) + + const bufSize = 1024 * 1024 + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + proto.RegisterProxyServiceServer(srv, server) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + mgmtClient := proto.NewProxyServiceClient(conn) + return &chainIntegrationFixture{ + store: st, + manager: manager, + gatecase: llm_limit_check.New(mgmtClient, nil), + recorder: llm_limit_record.New(mgmtClient, nil), + } +} + +// chainInput builds a middleware Input that mirrors what the proxy +// framework would synthesise for a tunnel-peer LLM request. The +// gate consumes the resolved provider id from upstream metadata +// (set by llm_router); the recorder consumes the attribution +// metadata stamped by the gate plus tokens / cost from +// llm_response_parser + cost_meter. +func chainInput(account, user, group, providerID string, requestMeta []middleware.KV) *middleware.Input { + _ = providerID // packed into requestMeta by the caller as KeyLLMResolvedProviderID + return &middleware.Input{ + AccountID: account, + UserID: user, + UserGroups: []string{group}, + Metadata: requestMeta, + } +} + +// chainCapPolicy builds a tight token-cap policy fixture for the +// chain integration tests. Inlined here (rather than imported) because +// the equivalent helper in the management gRPC package is unexported +// and this is a different package boundary. +func chainCapPolicy(id, account string, sourceGroups []string, providerID string, tokenCap, windowSec int64) *agentNetworkTypes.Policy { + return &agentNetworkTypes.Policy{ + ID: id, + AccountID: account, + Enabled: true, + Name: id, + SourceGroups: sourceGroups, + DestinationProviderIDs: []string{providerID}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{ + Enabled: true, + GroupCap: tokenCap, + WindowSeconds: windowSec, + }, + }, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } +} + +// TestChain_AllowPath_StampsAttributionAndRecordsCounter walks the +// full happy path: gate calls CheckLLMPolicyLimits → stamps +// attribution metadata → recorder reads metadata + tokens / cost → +// calls RecordLLMUsage → counters land in sqlite. Asserting on the +// store at the end proves every leg of the wire works together, +// not just each leg in isolation (which the unit tests already cover). +func TestChain_AllowPath_StampsAttributionAndRecordsCounter(t *testing.T) { + f := newChainIntegration(t) + + const account = "acc-1" + const user = "user-bob" + const group = "grp-engineers" + const provider = "prov-1" + + // Seed a policy with token + budget caps; both halves carry + // real ceilings so the request stays within headroom. + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), + chainCapPolicy("pol-1", account, []string{group}, provider, 10_000, 86_400))) + + // ── Stage 1 — gate: pre-flight check ────────────────────── + gateIn := chainInput(account, user, group, provider, []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }) + gateOut, err := f.gatecase.Invoke(context.Background(), gateIn) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, gateOut.Decision, "fresh policy must allow") + + // Verify attribution metadata was stamped — the recorder + // depends on these keys. + metaMap := map[string]string{} + for _, kv := range gateOut.Metadata { + metaMap[kv.Key] = kv.Value + } + assert.Equal(t, "pol-1", metaMap[middleware.KeyLLMSelectedPolicyID]) + assert.Equal(t, group, metaMap[middleware.KeyLLMAttributionGroupID]) + assert.Equal(t, "86400", metaMap[middleware.KeyLLMAttributionWindowS]) + + // ── Stage 2 — recorder: post-flight write ───────────────── + // Build the response-leg Input the framework would synthesise + // for the recorder: gate's emitted attribution metadata + the + // tokens / cost stamped by llm_response_parser + cost_meter. + const tokensIn = int64(123) + const tokensOut = int64(45) + const costUSD = 0.0042 + recordIn := chainInput(account, user, group, provider, append([]middleware.KV{}, + gateOut.Metadata...)) + recordIn.Metadata = append(recordIn.Metadata, + middleware.KV{Key: middleware.KeyLLMInputTokens, Value: strconv.FormatInt(tokensIn, 10)}, + middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: strconv.FormatInt(tokensOut, 10)}, + middleware.KV{Key: middleware.KeyCostUSDTotal, Value: strconv.FormatFloat(costUSD, 'f', 6, 64)}, + ) + recordOut, err := f.recorder.Invoke(context.Background(), recordIn) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, recordOut.Decision, "recorder always allows; its only side effect is the counter write") + + // ── Stage 3 — assert state in sqlite ────────────────────── + windowStart := agentNetworkTypes.WindowStart(time.Now(), 86_400) + userRow, err := f.store.GetAgentNetworkConsumption( + context.Background(), store.LockingStrengthNone, account, + agentNetworkTypes.DimensionUser, user, int64(86_400), windowStart, + ) + require.NoError(t, err) + assert.Equal(t, tokensIn, userRow.TokensInput, "user counter must hold the input tokens the recorder posted") + assert.Equal(t, tokensOut, userRow.TokensOutput) + assert.InDelta(t, costUSD, userRow.CostUSD, 1e-6) + + groupRow, err := f.store.GetAgentNetworkConsumption( + context.Background(), store.LockingStrengthNone, account, + agentNetworkTypes.DimensionGroup, group, int64(86_400), windowStart, + ) + require.NoError(t, err) + assert.Equal(t, tokensIn, groupRow.TokensInput, "group counter mirrors the user counter — single Record posts both dims") +} + +// TestChain_DenyPath_GateRejectsAndNoConsumptionWritten covers the +// negative side: when the gate denies, the recorder is never +// invoked (the proxy framework short-circuits on Decision=Deny). +// We assert no consumption row materialises after the gate-deny +// path, even though the test technically calls the recorder +// afterwards — the recorder must skip on missing attribution +// metadata so the framework's short-circuit isn't load-bearing for +// data integrity. +func TestChain_DenyPath_GateRejectsAndNoConsumptionWritten(t *testing.T) { + f := newChainIntegration(t) + + const account = "acc-1" + const user = "user-bob" + const group = "grp-tight" + const provider = "prov-1" + + policy := chainCapPolicy("pol-tight", account, []string{group}, provider, 100, 86_400) + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), policy)) + + // Pre-burn the counter to the cap so the gate denies. + require.NoError(t, f.store.IncrementAgentNetworkConsumption( + context.Background(), account, + agentNetworkTypes.DimensionGroup, group, int64(86_400), + agentNetworkTypes.WindowStart(time.Now(), 86_400), + 100, 0, 0, + )) + + gateOut, err := f.gatecase.Invoke(context.Background(), chainInput(account, user, group, provider, + []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, gateOut.Decision, "policy at-cap must deny on the gate") + require.NotNil(t, gateOut.DenyReason) + assert.Equal(t, "llm_policy.token_cap_exceeded", gateOut.DenyReason.Code) + + // On deny, the gate emits no attribution metadata. If the + // proxy framework still invokes the recorder (defense in + // depth), the recorder's "no attribution window = skip" guard + // prevents a phantom counter increment. + recordOut, err := f.recorder.Invoke(context.Background(), chainInput(account, user, group, provider, + gateOut.Metadata, // no llm.attribution_window_seconds stamped + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, recordOut.Decision) + + // The pre-burned 100 tokens are the only counter movement — + // the recorder must NOT have added a fresh row for the user + // dimension on this denied request. + windowStart := agentNetworkTypes.WindowStart(time.Now(), 86_400) + userRow, err := f.store.GetAgentNetworkConsumption( + context.Background(), store.LockingStrengthNone, account, + agentNetworkTypes.DimensionUser, user, int64(86_400), windowStart, + ) + require.NoError(t, err) + assert.Zero(t, userRow.TokensInput, "user dimension must not gain tokens from a denied request — recorder skip is the safety net") +} + +// TestChain_CapExhaustTransition exercises the allow→deny boundary +// the operator cares most about: a request just under cap allows +// AND records, the next request post-record at-cap denies. This is +// the same lifecycle 50-grpc-allow-record-deny.sh runs in bash, but +// against the actual middleware pair rather than the smoke binary +// driving the gRPC RPCs directly. +func TestChain_CapExhaustTransition(t *testing.T) { + f := newChainIntegration(t) + + const account = "acc-1" + const user = "user-alice" + const group = "grp-cap-edge" + const provider = "prov-1" + const tightCap = int64(100) + + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), + chainCapPolicy("pol-edge", account, []string{group}, provider, tightCap, 86_400))) + + // Pre-burn 99 tokens so we're at the very edge. + require.NoError(t, f.store.IncrementAgentNetworkConsumption( + context.Background(), account, + agentNetworkTypes.DimensionGroup, group, int64(86_400), + agentNetworkTypes.WindowStart(time.Now(), 86_400), + 99, 0, 0, + )) + + // Gate at 99/100 — must allow (one token of headroom). + gateOut, err := f.gatecase.Invoke(context.Background(), chainInput(account, user, group, provider, + []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + )) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, gateOut.Decision, "99/100 must allow — one token of headroom") + + // Record one more input token — pushes us to 100/100. + recordIn := chainInput(account, user, group, provider, append([]middleware.KV{}, + gateOut.Metadata...)) + recordIn.Metadata = append(recordIn.Metadata, + middleware.KV{Key: middleware.KeyLLMInputTokens, Value: "1"}, + middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: "0"}, + middleware.KV{Key: middleware.KeyCostUSDTotal, Value: "0.000001"}, + ) + _, err = f.recorder.Invoke(context.Background(), recordIn) + require.NoError(t, err) + + // Next gate call must deny — counter is exactly at cap. + gateOut2, err := f.gatecase.Invoke(context.Background(), chainInput(account, user, group, provider, + []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, gateOut2.Decision, + "once recorder pushed the group counter to 100/100, the next gate call must deny — allow→deny transition is the operator-visible product semantic") + require.NotNil(t, gateOut2.DenyReason) + assert.Equal(t, "llm_policy.token_cap_exceeded", gateOut2.DenyReason.Code) +} diff --git a/proxy/internal/middleware/builtin/all_test.go b/proxy/internal/middleware/builtin/all_test.go new file mode 100644 index 000000000..28576e248 --- /dev/null +++ b/proxy/internal/middleware/builtin/all_test.go @@ -0,0 +1,40 @@ +package builtin_test + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + + mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router" +) + +// TestDefaultRegistry_BuiltinIDs locks the set of middleware IDs that +// the default builtin registry exposes once every sub-package's init() +// has run. The list is the source of truth wired by the synthesiser +// in management; adding a new built-in middleware should consciously +// extend this list. +func TestDefaultRegistry_BuiltinIDs(t *testing.T) { + got := mwbuiltin.DefaultRegistry().IDs() + sort.Strings(got) + want := []string{ + "cost_meter", + "llm_guardrail", + "llm_identity_inject", + "llm_limit_check", + "llm_limit_record", + "llm_request_parser", + "llm_response_parser", + "llm_router", + } + assert.Equal(t, want, got, "default registry must expose every built-in middleware after anonymous imports") +} diff --git a/proxy/internal/middleware/builtin/builtin.go b/proxy/internal/middleware/builtin/builtin.go new file mode 100644 index 000000000..9ea4cf89d --- /dev/null +++ b/proxy/internal/middleware/builtin/builtin.go @@ -0,0 +1,93 @@ +// Package builtin holds the package-level middleware registry that +// concrete middleware packages register themselves into via init(). +// Server boot anonymous-imports each middleware sub-package; the +// resolver attached to the middleware Manager pulls factories out of +// this registry. +package builtin + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// MgmtClient is the narrow slice of proto.ProxyServiceClient that +// builtin middlewares may use during request / response handling. +// Only the agent-network limit pair (llm_limit_check + llm_limit_record) +// uses this today; declaring the surface here keeps the dependency +// explicit at boot time. +// +// proto.ProxyServiceClient already satisfies this interface so server +// boot just forwards its existing client. +type MgmtClient interface { + CheckLLMPolicyLimits(ctx context.Context, in *proto.CheckLLMPolicyLimitsRequest, opts ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) + RecordLLMUsage(ctx context.Context, in *proto.RecordLLMUsageRequest, opts ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) +} + +// defaultRegistry is the package-level registry that concrete builtin +// middlewares register themselves into via init(). +var defaultRegistry = middleware.NewRegistry() + +// FactoryContext is the per-process bag that concrete factories may +// consult during construction. It carries the proxy-lifetime context, +// the data directory used for static config files (pricing tables, +// allowlists), the OTel meter, and the proxy logger. +// +// Configure must be called once at boot before any chain build calls +// Resolve. Calling it twice overwrites the prior value; tests may rely +// on this to reset state. +type FactoryContext struct { + Context context.Context + DataDir string + Meter metric.Meter + Logger *log.Logger + MgmtClient MgmtClient +} + +var ( + ctxStore FactoryContext + ctxMu sync.RWMutex +) + +// Configure stores the per-process FactoryContext. Concrete factories +// reach for it via Context(). mgmt may be nil on tests / standalone +// builds with no management server; consumers must guard. +func Configure(ctx context.Context, dataDir string, meter metric.Meter, logger *log.Logger, mgmt MgmtClient) { + ctxMu.Lock() + defer ctxMu.Unlock() + ctxStore = FactoryContext{ + Context: ctx, + DataDir: dataDir, + Meter: meter, + Logger: logger, + MgmtClient: mgmt, + } +} + +// Context returns the stored FactoryContext. Returns a zero value when +// Configure was never called; consumers must guard against nil +// Context/Meter/Logger if they care. +func Context() FactoryContext { + ctxMu.RLock() + defer ctxMu.RUnlock() + return ctxStore +} + +// Register adds a factory to the default registry. Called from init() +// blocks of concrete middleware packages. Panics on collision so +// duplicate IDs surface at startup. +func Register(f middleware.Factory) { + defaultRegistry.MustRegister(f) +} + +// DefaultRegistry returns the shared registry. The proxy server +// constructs the Resolver from it at boot. +func DefaultRegistry() *middleware.Registry { + return defaultRegistry +} diff --git a/proxy/internal/middleware/builtin/cost_meter/factory.go b/proxy/internal/middleware/builtin/cost_meter/factory.go new file mode 100644 index 000000000..b8a58d10e --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_meter/factory.go @@ -0,0 +1,88 @@ +package cost_meter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/llm/pricing" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// defaultPricingFilename is the basename probed inside the proxy data +// directory when no override is configured. +const defaultPricingFilename = "pricing.yaml" + +// Config is the on-wire configuration for the middleware. +type Config struct { + // PricingPath optionally overrides the basename of the pricing + // file probed inside the proxy data directory. When empty the + // loader falls back to "pricing.yaml". + PricingPath string `json:"pricing_path"` +} + +// Factory builds cost_meter instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs +// are accepted; non-empty rawConfig that fails to unmarshal is +// rejected so misconfigurations surface at chain build time. The +// pricing loader is built once per instance and reused across +// invocations. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg, err := decodeConfig(rawConfig) + if err != nil { + return nil, err + } + + fctx := builtin.Context() + pricingPath := cfg.PricingPath + if pricingPath == "" { + pricingPath = defaultPricingFilename + } + + loader, err := pricing.NewLoader(fctx.DataDir, pricingPath, ID, nil) + if err != nil { + return nil, fmt.Errorf("init pricing loader: %w", err) + } + + cancel := startReloader(fctx.Context, loader) + + return newMiddleware(loader, cancel), nil +} + +// startReloader binds the loader's mtime-poll goroutine to a context +// derived from the proxy-lifetime context and returns its cancel func so +// the owning middleware can stop the goroutine on teardown. Returns nil +// when there's nothing to watch (nil context or defaults-only loader), in +// which case the middleware's Close is a no-op. +func startReloader(ctx context.Context, loader *pricing.Loader) context.CancelFunc { + if ctx == nil || !loader.WatchesFile() { + return nil + } + cctx, cancel := context.WithCancel(ctx) + go loader.Reload(cctx) + return cancel +} + +// decodeConfig accepts empty, null, and {} configs, returning a +// zero-value Config. Non-empty payloads must parse cleanly. +func decodeConfig(rawConfig []byte) (Config, error) { + var cfg Config + if len(bytes.TrimSpace(rawConfig)) == 0 { + return cfg, nil + } + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return cfg, fmt.Errorf("decode config: %w", err) + } + return cfg, nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go new file mode 100644 index 000000000..4da620310 --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -0,0 +1,193 @@ +// Package cost_meter implements the SlotOnResponse middleware that +// converts token-usage metadata emitted by llm_response_parser into a +// per-request USD cost estimate. The middleware uses the shared pricing +// loader so operator pricing overrides apply to the chain. +package cost_meter + +import ( + "context" + "fmt" + "strconv" + + "github.com/netbirdio/netbird/proxy/internal/llm/pricing" + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// ID is the registry identifier for this middleware. +const ID = "cost_meter" + +// Version is the implementation version emitted via the spec merge. +const Version = "1.0.0" + +// Skip reasons emitted under KeyCostSkipped. The set is closed; the +// dashboard surfaces these verbatim. +const ( + skipMissingProvider = "missing_provider" + skipMissingModel = "missing_model" + skipMissingTokens = "missing_tokens" + //nolint:gosec // skip-reason label, not a credential + skipUnparseableTokens = "unparseable_tokens" + skipZeroTokens = "zero_tokens" + skipUnknownModel = "unknown_model" +) + +var metadataKeys = []string{ + middleware.KeyCostUSDTotal, + middleware.KeyCostSkipped, +} + +// Middleware computes a per-response cost estimate from the token +// counts emitted upstream by llm_response_parser. +type Middleware struct { + loader *pricing.Loader + // cancel stops this instance's pricing-reload goroutine. Non-nil only + // when the loader watches an override file; Close calls it so a chain + // rebuild doesn't leak a poll goroutine per retired instance. + cancel context.CancelFunc +} + +// newMiddleware constructs a Middleware bound to the given pricing loader. +// cancel may be nil (defaults-only loader with no reloader to stop). +func newMiddleware(loader *pricing.Loader, cancel context.CancelFunc) *Middleware { + return &Middleware{loader: loader, cancel: cancel} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports that the middleware runs after the upstream call. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } + +// AcceptedContentTypes is empty: cost_meter never inspects bodies. +func (m *Middleware) AcceptedContentTypes() []string { return []string{} } + +// MetadataKeys returns the closed allowlist of keys this middleware +// may emit. +func (m *Middleware) MetadataKeys() []string { + return append([]string(nil), metadataKeys...) +} + +// MutationsSupported reports that this middleware never mutates the +// response. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close stops this instance's pricing-reload goroutine, if any. Called by +// the chain when a rebuild retires the instance, so the mtime-poll loop +// doesn't outlive the chain it belonged to. Safe to call on a nil receiver +// and on an instance with no reloader. +func (m *Middleware) Close() error { + if m != nil && m.cancel != nil { + m.cancel() + } + return nil +} + +// Invoke reads provider, model, and token metadata, looks up pricing, +// and emits either KeyCostUSDTotal or KeyCostSkipped. The decision is +// always DecisionAllow; cost metering never denies or mutates. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if in == nil { + return out, nil + } + + provider := lookupKV(in.Metadata, middleware.KeyLLMProvider) + if provider == "" { + out.Metadata = skip(skipMissingProvider) + return out, nil + } + + model := lookupKV(in.Metadata, middleware.KeyLLMModel) + if model == "" { + out.Metadata = skip(skipMissingModel) + return out, nil + } + + inRaw, hasIn := lookupKVOK(in.Metadata, middleware.KeyLLMInputTokens) + outRaw, hasOut := lookupKVOK(in.Metadata, middleware.KeyLLMOutputTokens) + if !hasIn || !hasOut { + out.Metadata = skip(skipMissingTokens) + return out, nil + } + + inTokens, err := strconv.ParseInt(inRaw, 10, 64) + if err != nil || inTokens < 0 { + // Unparseable or negative tokens are not a runtime error: the + // upstream llm_response_parser emitted a non-numeric / invalid + // value, so we surface that as cost.skipped and continue with + // Allow rather than pricing a negative count. + out.Metadata = skip(skipUnparseableTokens) + return out, nil //nolint:nilerr // structured skip; not a runtime error + } + outTokens, err := strconv.ParseInt(outRaw, 10, 64) + if err != nil || outTokens < 0 { + out.Metadata = skip(skipUnparseableTokens) + return out, nil //nolint:nilerr // structured skip; not a runtime error + } + + // Cache buckets are optional and silently zeroed on a missing / + // malformed value; they're a refinement on top of input cost, + // not a precondition. A buggy value falls back to 0, never aborts. + cachedTokens := parseOptionalInt64(in.Metadata, middleware.KeyLLMCachedInputTokens) + cacheCreationTokens := parseOptionalInt64(in.Metadata, middleware.KeyLLMCacheCreationTokens) + + if inTokens == 0 && outTokens == 0 && cachedTokens == 0 && cacheCreationTokens == 0 { + out.Metadata = skip(skipZeroTokens) + return out, nil + } + + table := m.loader.Get() + cost, ok := table.Cost(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) + if !ok { + out.Metadata = skip(skipUnknownModel) + return out, nil + } + + out.Metadata = []middleware.KV{ + {Key: middleware.KeyCostUSDTotal, Value: fmt.Sprintf("%.6f", cost)}, + } + return out, nil +} + +// skip returns a single-entry metadata slice carrying the given skip +// reason under KeyCostSkipped. +func skip(reason string) []middleware.KV { + return []middleware.KV{{Key: middleware.KeyCostSkipped, Value: reason}} +} + +// lookupKV returns the value associated with key, or the empty string +// when the key is absent. +func lookupKV(kvs []middleware.KV, key string) string { + v, _ := lookupKVOK(kvs, key) + return v +} + +// lookupKVOK returns the value associated with key plus a presence +// flag so callers can distinguish absent from empty. +func lookupKVOK(kvs []middleware.KV, key string) (string, bool) { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +// parseOptionalInt64 reads a metadata value and decodes it as int64. +// Absent or unparseable values yield 0 — the caller treats absence as +// "no cached tokens" rather than an error, since cache buckets are a +// refinement, not a precondition. +func parseOptionalInt64(kvs []middleware.KV, key string) int64 { + raw, ok := lookupKVOK(kvs, key) + if !ok { + return 0 + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v < 0 { + return 0 + } + return v +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go new file mode 100644 index 000000000..d1c161cab --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go @@ -0,0 +1,459 @@ +package cost_meter + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +const fixturePricing = `openai: + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + gpt-4o-mini: + input_per_1k: 0.00015 + output_per_1k: 0.0006 +anthropic: + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 +` + +// configureBuiltin points the package-level FactoryContext at a tmp +// directory containing the test pricing fixture. Returns the path so +// callers can override files later if needed. +func configureBuiltin(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricing), 0o600), "write pricing fixture") + builtin.Configure(context.Background(), dir, nil, nil, nil) + return dir +} + +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func buildMiddleware(t *testing.T, raw []byte) middleware.Middleware { + t.Helper() + mw, err := Factory{}.New(raw) + require.NoError(t, err, "factory must accept the supplied config") + return mw +} + +func TestMiddleware_StaticSurface(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + assert.Equal(t, ID, mw.ID(), "ID must match the registered constant") + assert.Equal(t, Version, mw.Version(), "Version must match the constant") + assert.Equal(t, middleware.SlotOnResponse, mw.Slot(), "must run in the response slot") + assert.Empty(t, mw.AcceptedContentTypes(), "cost_meter does not inspect bodies") + assert.False(t, mw.MutationsSupported(), "cost_meter never mutates") + assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op") + + keys := mw.MetadataKeys() + expected := []string{middleware.KeyCostUSDTotal, middleware.KeyCostSkipped} + assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") +} + +func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) { + configureBuiltin(t) + cases := [][]byte{nil, {}, []byte("null"), []byte("{}"), []byte(" ")} + for _, raw := range cases { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "empty/null/object config must be accepted") + require.NotNil(t, mw, "factory must return a middleware instance") + } +} + +func TestFactory_RejectsMalformedConfig(t *testing.T) { + configureBuiltin(t) + mw, err := Factory{}.New([]byte("{not json")) + require.Error(t, err, "malformed config must surface at construction") + assert.Nil(t, mw, "no instance is returned on error") +} + +func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o-mini"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision, "cost_meter always allows") + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cost.usd_total must be emitted for known model") + assert.Equal(t, "0.000750", value, "0.00015 + 0.0006 per 1k tokens, 6-decimal format") +} + +func TestFactory_PricingPathOverride(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "custom.yaml"), []byte(fixturePricing), 0o600), "write custom pricing") + builtin.Configure(context.Background(), dir, nil, nil, nil) + + raw, err := json.Marshal(Config{PricingPath: "custom.yaml"}) + require.NoError(t, err) + + mw := buildMiddleware(t, raw) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "2000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cost.usd_total must be emitted with custom pricing path") + assert.Equal(t, "0.015000", value, "2*0.0025 + 1*0.01 = 0.015 with 6-decimal format") +} + +func TestInvoke_ComputesCostForKnownModel(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cost.usd_total must be emitted") + assert.Equal(t, "0.018000", value, "0.003 + 0.015 = 0.018 with 6-decimal format") + _, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + assert.False(t, skipped, "cost.skipped must not be set when cost is computed") +} + +func TestInvoke_MissingProvider(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when provider is missing") + assert.Equal(t, skipMissingProvider, value, "skip reason matches missing_provider") +} + +func TestInvoke_MissingModel(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when model is missing") + assert.Equal(t, skipMissingModel, value, "skip reason matches missing_model") +} + +func TestInvoke_MissingTokens(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + cases := []struct { + name string + md []middleware.KV + }{ + { + name: "input only", + md: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + }, + }, + { + name: "output only", + md: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }, + { + name: "neither", + md: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{Metadata: tc.md}) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when token keys are missing") + assert.Equal(t, skipMissingTokens, value, "skip reason matches missing_tokens") + }) + } +} + +func TestInvoke_UnparseableTokens(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + cases := []struct { + name string + in string + out string + }{ + {name: "input non-numeric", in: "abc", out: "10"}, + {name: "output non-numeric", in: "10", out: "xyz"}, + {name: "both garbage", in: "??", out: "??"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: tc.in}, + {Key: middleware.KeyLLMOutputTokens, Value: tc.out}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set on unparseable tokens") + assert.Equal(t, skipUnparseableTokens, value, "skip reason matches unparseable_tokens") + }) + } +} + +func TestInvoke_ZeroTokens(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "0"}, + {Key: middleware.KeyLLMOutputTokens, Value: "0"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when both token counts are zero") + assert.Equal(t, skipZeroTokens, value, "skip reason matches zero_tokens") + _, hasCost := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + assert.False(t, hasCost, "cost.usd_total must not be emitted for zero tokens") +} + +func TestInvoke_UnknownModel(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "fantasy-model-9000"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when pricing entry is absent") + assert.Equal(t, skipUnknownModel, value, "skip reason matches unknown_model") +} + +func TestInvoke_NilInput(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, out, "output must be returned even on nil input") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be allow on nil input") + assert.Empty(t, out.Metadata, "no metadata must be emitted on nil input") +} + +const fixturePricingWithCache = `openai: + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + cached_input_per_1k: 0.00125 +anthropic: + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 +` + +// configureBuiltinWithCacheRates points the package-level +// FactoryContext at a tmp directory containing pricing entries that +// include the cache rate fields. +func configureBuiltinWithCacheRates(t *testing.T) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricingWithCache), 0o600), "write cache-aware pricing fixture") + builtin.Configure(context.Background(), dir, nil, nil, nil) +} + +// TestInvoke_OpenAICachedSubsetDiscount proves the OpenAI shape end +// to end through the middleware: cached_input_tokens is treated as a +// SUBSET of input_tokens and discounted at the configured rate, not +// added on top. +func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "500"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "750"}, + }, + }) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cached subset path must produce a cost — never a skip") + // 250 non-cached at 0.0025/1k + 750 cached at 0.00125/1k + 500 output at 0.01/1k. + assert.Equal(t, "0.006563", value, + "cached subset must be billed at the discount rate, non-cached at the full rate; never double-billed") +} + +// TestInvoke_AnthropicCacheBucketsAdditive proves the Anthropic +// shape: cache_read and cache_creation are additive to input_tokens +// and each carries its own rate. +func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + {Key: middleware.KeyLLMInputTokens, Value: "256"}, + {Key: middleware.KeyLLMOutputTokens, Value: "200"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "768"}, + {Key: middleware.KeyLLMCacheCreationTokens, Value: "512"}, + }, + }) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok) + // 256 input * 0.003 + 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 + 200 output * 0.015 + // = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184 → "0.005918" with 6-decimal format. + assert.Equal(t, "0.005918", value, + "each Anthropic input bucket must bill at its own rate — cache_read cheap, cache_creation expensive, regular input mid") +} + +// TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the +// "operator hasn't opted in" path: with no cached metadata keys +// emitted, the meter must produce exactly the same cost as before +// the feature landed. Critical so operators with the new binary but +// no YAML changes see no behavioural drift on OpenAI requests. +func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "500"}, + // No KeyLLMCachedInputTokens — the parser didn't see one. + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok) + // 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075 + assert.Equal(t, "0.007500", value, "no cached metadata = same cost as before the feature landed") +} + +// TestInvoke_UnparseableCachedTokensSkippedSilently proves the +// optional-bucket contract: a malformed cached_input_tokens metadata +// value falls back to 0 (= no cached count) and continues with the +// regular formula. Cache buckets are a refinement, never a reason to +// abort cost computation. +func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "500"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "not-a-number"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "garbage cache metadata must NOT switch the response from a cost to a skip — fall back to 0 cached") + assert.Equal(t, "0.007500", value, "same as the no-cached-metadata path") +} + +// TestMiddleware_CloseCancelsReloader proves Close stops the per-instance +// pricing-reload goroutine: a chain rebuild retires the old instance and +// calls Close, which must invoke the cancel func startReloader handed it so +// the mtime-poll loop doesn't outlive the chain. +func TestMiddleware_CloseCancelsReloader(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + m := newMiddleware(nil, cancel) + + require.NoError(t, m.Close(), "Close must not error") + require.Error(t, ctx.Err(), "Close must cancel the reloader context so the poll goroutine exits") +} + +// TestMiddleware_CloseNilSafe confirms Close is a no-op (no panic) for an +// instance with no reloader and for a nil receiver. +func TestMiddleware_CloseNilSafe(t *testing.T) { + require.NoError(t, newMiddleware(nil, nil).Close(), "no-reloader Close must be a no-op") + var m *Middleware + require.NoError(t, m.Close(), "nil-receiver Close must be safe") +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/factory.go b/proxy/internal/middleware/builtin/llm_guardrail/factory.go new file mode 100644 index 000000000..6dd2a8e8d --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/factory.go @@ -0,0 +1,82 @@ +package llm_guardrail + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// Config is the JSON-decoded shape accepted by the factory. The +// runtime path consumes the normalised allowlist; raw config is not +// retained beyond construction. +type Config struct { + ModelAllowlist []string `json:"model_allowlist"` + PromptCapture PromptCapture `json:"prompt_capture"` +} + +// PromptCapture toggles the optional prompt capture + redaction step +// that emits llm.request_prompt onto the metadata bag. +type PromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +// Factory builds a configured llm_guardrail middleware instance. +type Factory struct{} + +// ID returns the registry identifier matching the middleware ID. +func (Factory) ID() string { return ID } + +// New decodes the raw JSON config and returns a ready Middleware. An +// empty / null / empty-object payload yields a zero-value Config. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg := Config{} + if len(rawConfig) > 0 && !isEmptyJSON(rawConfig) { + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + return New(cfg), nil +} + +// isEmptyJSON reports whether the payload is whitespace, null, or an +// empty object/array. The caller skips Unmarshal in that case so the +// zero-value Config flows through unchanged. +func isEmptyJSON(raw []byte) bool { + trimmed := strings.TrimSpace(string(raw)) + switch trimmed { + case "", "null", "{}", "[]": + return true + } + return false +} + +// normaliseConfig lowercases and trims allowlist entries so the runtime +// match is case-insensitive. Empty entries are dropped. +func normaliseConfig(cfg Config) Config { + if len(cfg.ModelAllowlist) == 0 { + return cfg + } + cleaned := make([]string, 0, len(cfg.ModelAllowlist)) + for _, entry := range cfg.ModelAllowlist { + n := normaliseModel(entry) + if n == "" { + continue + } + cleaned = append(cleaned, n) + } + cfg.ModelAllowlist = cleaned + return cfg +} + +// normaliseModel lowercases and trims a single model identifier. +func normaliseModel(model string) string { + return strings.ToLower(strings.TrimSpace(model)) +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go new file mode 100644 index 000000000..e6259f06f --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -0,0 +1,183 @@ +// Package llm_guardrail implements the SlotOnRequest middleware that +// enforces the per-target LLM guardrail policy: a model allowlist +// check and an opt-in prompt-capture step that may run a PII redactor +// before emitting the prompt into the metadata bag. +// +// The middleware runs after llm_request_parser, which is responsible +// for extracting the model and raw prompt onto the metadata side +// channel. llm_guardrail consumes those keys, decides allow/deny, and +// emits its own decision metadata plus the optional redacted prompt. +package llm_guardrail + +import ( + "context" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// ID is the registry key for this middleware. +const ID = "llm_guardrail" + +const ( + version = "1.0.0" + maxPromptBytes = 3500 + denyCodeModel = "llm_policy.model_blocked" + denyReasonModel = "model_blocked" + denyMessageModel = "model is not in the policy allowlist" +) + +// Middleware enforces the model allowlist and optionally captures the +// request prompt with PII redaction. +type Middleware struct { + cfg Config +} + +// New constructs a Middleware with the supplied configuration. Model +// allowlist entries are normalised so the runtime check is +// case-insensitive and trim-tolerant. +func New(cfg Config) *Middleware { + return &Middleware{cfg: normaliseConfig(cfg)} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes lists the request body content types the +// middleware needs. Guardrail consumes metadata produced upstream and +// does not touch the body itself, but we keep application/json so the +// body policy retains the parsed payload upstream when required. +func (m *Middleware) AcceptedContentTypes() []string { + return []string{"application/json"} +} + +// MetadataKeys is the closed set of metadata keys this middleware may +// emit. The accumulator drops anything outside this allowlist. +func (m *Middleware) MetadataKeys() []string { + return []string{ + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + middleware.KeyLLMRequestPrompt, + } +} + +// MutationsSupported reports whether the middleware emits header / body +// mutations. Guardrail never mutates the request. +func (m *Middleware) MutationsSupported() bool { return false } + +// Invoke runs the policy. The model allowlist is the only deny path; +// prompt capture only affects the metadata emitted alongside an allow. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + + if denial := m.evaluateAllowlist(model, modelPresent); denial != nil { + return denial, nil + } + + out := &middleware.Output{ + Decision: middleware.DecisionAllow, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + {Key: middleware.KeyLLMPolicyReason, Value: ""}, + }, + } + + if prompt, ok := m.capturePrompt(in.Metadata); ok { + out.Metadata = append(out.Metadata, middleware.KV{ + Key: middleware.KeyLLMRequestPrompt, + Value: prompt, + }) + } + + return out, nil +} + +// Close releases resources owned by the middleware. Stateless, so this +// is a no-op. +func (m *Middleware) Close() error { return nil } + +// evaluateAllowlist returns a deny Output when the configured allowlist +// rejects the model. A nil return means the request should proceed. +func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middleware.Output { + if len(m.cfg.ModelAllowlist) == 0 { + return nil + } + if !modelPresent { + return nil + } + if m.modelInAllowlist(model) { + return nil + } + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeModel, + Message: denyMessageModel, + Details: map[string]string{"model": model}, + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel}, + }, + } +} + +// modelInAllowlist reports whether the model matches any allowlist +// entry under the case-insensitive, trim-tolerant comparison rule. +func (m *Middleware) modelInAllowlist(model string) bool { + normalised := normaliseModel(model) + if normalised == "" { + return false + } + for _, allowed := range m.cfg.ModelAllowlist { + if allowed == normalised { + return true + } + } + return false +} + +// capturePrompt returns the prompt to emit and whether it should be +// emitted at all. The truncation guarantee is upheld here regardless of +// whether redaction grew the string. +func (m *Middleware) capturePrompt(meta []middleware.KV) (string, bool) { + if !m.cfg.PromptCapture.Enabled { + return "", false + } + raw, ok := lookupMetadata(meta, middleware.KeyLLMRequestPromptRaw) + if !ok { + return "", false + } + prompt := raw + if m.cfg.PromptCapture.RedactPii { + prompt = redactPII(prompt) + } + if len(prompt) > maxPromptBytes { + // Back off to a UTF-8 rune boundary so we never emit a string + // split mid-rune. + cut := maxPromptBytes + for cut > 0 && !utf8.RuneStart(prompt[cut]) { + cut-- + } + prompt = prompt[:cut] + } + return prompt, true +} + +// lookupMetadata finds the first KV with the given key. Returns the +// value and true when present; the empty string and false otherwise. +func lookupMetadata(meta []middleware.KV, key string) (string, bool) { + for _, kv := range meta { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go new file mode 100644 index 000000000..865dc07af --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -0,0 +1,219 @@ +package llm_guardrail + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func newInput(meta ...middleware.KV) *middleware.Input { + return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: meta} +} + +func TestMiddlewareIdentity(t *testing.T) { + mw := New(Config{}) + assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_guardrail") + assert.Equal(t, "1.0.0", mw.Version(), "version must be 1.0.0") + assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "guardrail must run in SlotOnRequest") + assert.False(t, mw.MutationsSupported(), "guardrail must not mutate requests") + assert.Equal(t, []string{"application/json"}, mw.AcceptedContentTypes(), "guardrail accepts application/json bodies") + assert.Equal(t, + []string{ + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + middleware.KeyLLMRequestPrompt, + }, + mw.MetadataKeys(), + "metadata key allowlist must match the spec", + ) + require.NoError(t, mw.Close()) +} + +func TestAllowlistEmptyAllowsAnyModel(t *testing.T) { + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "empty allowlist must allow any model") + v, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + require.True(t, ok, "decision metadata must be emitted") + assert.Equal(t, "allow", v, "decision must be allow") + r, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + require.True(t, ok, "reason metadata must be emitted") + assert.Equal(t, "", r, "reason must be empty on allow") +} + +func TestAllowlistMatchAllows(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{"gpt-4o", "claude-opus-4"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model in allowlist must be allowed") +} + +func TestAllowlistMissDenies(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "non-allowlisted model must be denied") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must match spec") + assert.Equal(t, "model is not in the policy allowlist", out.DenyReason.Message, "deny message must match spec") + assert.Equal(t, "claude-opus-4", out.DenyReason.Details["model"], "deny details must include the offending model") + + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec, "decision metadata must be deny") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_blocked", reason, "reason metadata must be model_blocked") +} + +func TestAllowlistCaseInsensitive(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{" GPT-4o ", "Claude-OPUS-4"}}) + cases := []string{"gpt-4o", "GPT-4O", " claude-opus-4 "} + for _, model := range cases { + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: model}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "case/whitespace variants must match: %q", model) + } +} + +func TestAllowlistMissingModelKeyAllows(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "missing model key must allow even with non-empty allowlist") + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "allow", dec, "decision must be allow when model key is absent") +} + +func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) { + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: "hello world"}, + )) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + assert.False(t, ok, "prompt must not be emitted when capture is disabled") +} + +func TestPromptCaptureNoRedactionEmitsRaw(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true}}) + raw := "hello world from user@example.com" + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: raw}, + )) + require.NoError(t, err) + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + require.True(t, ok, "prompt must be emitted when capture is enabled") + assert.Equal(t, raw, prompt, "prompt must pass through unchanged when redaction is off") +} + +func TestPromptCaptureWithRedactionRedacts(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}}) + raw := "contact me at user@example.com or +14155551234" + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: raw}, + )) + require.NoError(t, err) + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + require.True(t, ok, "prompt must be emitted when capture is enabled") + assert.Contains(t, prompt, "[REDACTED:email]", "email must be redacted") + assert.Contains(t, prompt, "[REDACTED:phone]", "phone must be redacted") + assert.NotContains(t, prompt, "user@example.com", "raw email must not leak") +} + +func TestPromptCaptureRedactionTruncatesIfGrows(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}}) + body := strings.Repeat("a", maxPromptBytes-10) + " user@example.com" + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: body}, + )) + require.NoError(t, err) + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + require.True(t, ok, "prompt must be emitted when capture is enabled") + assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must be truncated to maxPromptBytes") +} + +func TestPromptCaptureMissingRawNoEmit(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + assert.False(t, ok, "prompt must not be emitted when raw key is missing") +} + +func TestFactoryAcceptsZeroConfigs(t *testing.T) { + cases := map[string][]byte{ + "nil": nil, + "empty": []byte(""), + "whitespace": []byte(" \n "), + "null": []byte("null"), + "emptyObject": []byte("{}"), + } + f := Factory{} + for name, raw := range cases { + mw, err := f.New(raw) + require.NoError(t, err, "case %s must yield a zero-value config", name) + require.NotNil(t, mw) + assert.Equal(t, ID, mw.ID(), "case %s must build a guardrail middleware", name) + } +} + +func TestFactoryDecodesValidConfig(t *testing.T) { + cfg := Config{ + ModelAllowlist: []string{"gpt-4o"}, + PromptCapture: PromptCapture{Enabled: true, RedactPii: true}, + } + raw, err := json.Marshal(cfg) + require.NoError(t, err, "marshalling test config must succeed") + mw, err := Factory{}.New(raw) + require.NoError(t, err) + require.NotNil(t, mw) +} + +func TestFactoryRejectsMalformedJSON(t *testing.T) { + mw, err := Factory{}.New([]byte("{not-json")) + assert.Error(t, err, "malformed JSON must surface as a factory error") + assert.Nil(t, mw, "no middleware must be returned on malformed config") +} + +func TestFactoryNormalisesAllowlist(t *testing.T) { + raw := []byte(`{"model_allowlist":[" GPT-4o ","",""," Claude-3 "]}`) + mw, err := Factory{}.New(raw) + require.NoError(t, err) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "factory must lowercase + trim allowlist entries") + out2, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-3"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out2.Decision, "trimmed entry must still match") +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/redact.go b/proxy/internal/middleware/builtin/llm_guardrail/redact.go new file mode 100644 index 000000000..c6cb270df --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/redact.go @@ -0,0 +1,75 @@ +package llm_guardrail + +import ( + "regexp" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// PII redactor scope: redact prompt content BEFORE it lands in the metadata +// bag. The bearer-with-keyword pass runs first so the keyword is preserved. +// We then chain the package-level middleware.Scan to pick up PEM, JWT, AWS +// access keys, generic bearer tokens (40+ chars), and Luhn-validated credit +// cards — keeping prompt redaction in sync with metadata-value scanning. Email, +// SSN (dashed form), phone (E.164 + NA), and IPv4 are prompt-shaped patterns +// the metadata scanner intentionally leaves alone. +var ( + emailRegex = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) + ssnRegex = regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`) + phoneE164 = regexp.MustCompile(`\+\d{8,15}\b`) + // phoneNARgx accepts the 3-3-4 North-American shape with any of the common + // separators (space, dot, dash, slash) or none at all between the area code + // and the body. The optional `\(?...\)?` wraps the area code; the separator + // classes use `*` (not `?`) so multi-char separators ("(202) " followed by + // space-and-something) and zero-separator runs ("2025550134") both match. + // False-positive tradeoff: 10 consecutive digits in a prompt will be + // treated as a phone number. For PII redaction that is the correct way to + // err — under-redaction leaks; over-redaction is annoying. + phoneNARgx = regexp.MustCompile(`\(?\b\d{3}\)?[\s.\-/]*\d{3}[\s.\-/]*\d{4}\b`) + bearerRegex = regexp.MustCompile(`(?i)\b(bearer|token|api[_-]?key|authorization)([\s:=]+)(\S{20,})`) + ipv4Regex = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b`) +) + +// redactPII is the package-private alias kept for internal callers; new code +// outside the guardrail middleware should call RedactPII. +func redactPII(value string) string { return RedactPII(value) } + +// RedactPII replaces high-signal PII patterns in value with +// `[REDACTED:]`. Non-matching input is returned unchanged. Exported so +// the request / response parsers can reuse the same coverage on raw prompts +// and completions when the account's redact_pii toggle is on. +func RedactPII(value string) string { + if value == "" { + return value + } + result := value + // Keyword-preserving bearer first so the "bearer "/"token=" prefix survives + // before the generic scanner gets at the same content. + result = bearerRegex.ReplaceAllStringFunc(result, redactBearer) + // Structured secrets shared with metadata-value scanning: PEM, JWT, AWS + // keys, generic bearer (40+), and Luhn-validated credit cards. + result = middleware.Scan(result) + // Prompt-shaped PII the metadata scanner doesn't cover. + result = emailRegex.ReplaceAllString(result, "[REDACTED:email]") + result = ssnRegex.ReplaceAllString(result, "[REDACTED:ssn]") + result = phoneE164.ReplaceAllString(result, "[REDACTED:phone]") + result = phoneNARgx.ReplaceAllString(result, "[REDACTED:phone]") + result = ipv4Regex.ReplaceAllString(result, "[REDACTED:ip]") + return result +} + +// redactBearer keeps the leading keyword and its separator, replacing +// only the secret payload so the surrounding context is preserved. +func redactBearer(match string) string { + sub := bearerRegex.FindStringSubmatch(match) + if len(sub) < 4 { + return "[REDACTED:bearer]" + } + var b strings.Builder + b.Grow(len(sub[1]) + len(sub[2]) + len("[REDACTED:bearer]")) + b.WriteString(sub[1]) + b.WriteString(sub[2]) + b.WriteString("[REDACTED:bearer]") + return b.String() +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/redact_test.go b/proxy/internal/middleware/builtin/llm_guardrail/redact_test.go new file mode 100644 index 000000000..ef17f1d0a --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/redact_test.go @@ -0,0 +1,217 @@ +package llm_guardrail + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactPIIEmptyInput(t *testing.T) { + assert.Equal(t, "", redactPII(""), "empty input must round-trip unchanged") +} + +func TestRedactPIIPlainTextUntouched(t *testing.T) { + in := "the quick brown fox jumps over the lazy dog" + assert.Equal(t, in, redactPII(in), "non-PII text must pass through unchanged") +} + +func TestRedactPIIEmail(t *testing.T) { + cases := []string{ + "contact user@example.com today", + "first.last+tag@sub.example.co", + "USER_42@EXAMPLE.COM", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:email]", "email must be redacted in %q", in) + assert.NotContains(t, strings.ToLower(out), "@example", "raw email host must not survive in %q", in) + } +} + +func TestRedactPIISSN(t *testing.T) { + in := "ssn 123-45-6789 should be hidden" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:ssn]", "SSN must be redacted") + assert.NotContains(t, out, "123-45-6789", "raw SSN must not survive") +} + +func TestRedactPIIPhoneE164(t *testing.T) { + in := "call me at +14155551234 anytime" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:phone]", "E.164 phone must be redacted") + assert.NotContains(t, out, "+14155551234", "raw E.164 phone must not survive") +} + +func TestRedactPIIPhoneNorthAmerican(t *testing.T) { + cases := []string{ + "call (415) 555-1234 now", + "call 415-555-1234 now", + "call 415.555.1234 now", + "call 415 555 1234 now", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:phone]", "NA phone must be redacted in %q", in) + assert.NotContains(t, out, "555-1234", "raw NA phone must not survive in %q", in) + } +} + +func TestRedactPIIBearerKeepsKeyword(t *testing.T) { + cases := []struct { + in string + keyword string + }{ + {"Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123", "Bearer"}, + {"token = abcdefghijklmnopqrstuvwxyz", "token"}, + {"api_key=abcdefghijklmnopqrstuvwxyz0123", "api_key"}, + {"API-KEY: abcdefghijklmnopqrstuvwxyz0123", "API-KEY"}, + {"authorization: abcdefghijklmnopqrstuvwxyz0123", "authorization"}, + } + for _, tc := range cases { + out := redactPII(tc.in) + assert.Contains(t, out, "[REDACTED:bearer]", "bearer-style secret must be redacted in %q", tc.in) + assert.Contains(t, out, tc.keyword, "leading keyword %q must be preserved in %q", tc.keyword, tc.in) + assert.NotContains(t, out, "abcdefghijklmnopqrstuvwxyz0123", "raw bearer payload must not survive in %q", tc.in) + } +} + +func TestRedactPIIBearerShortValueUntouched(t *testing.T) { + in := "token=short" + out := redactPII(in) + assert.Equal(t, in, out, "short bearer-style values must not be redacted") +} + +func TestRedactPIICombined(t *testing.T) { + in := "email user@example.com phone +14155551234 ssn 123-45-6789 token abcdefghijklmnopqrstuvwxyz0123" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:email]", "email must be redacted in combined input") + assert.Contains(t, out, "[REDACTED:phone]", "phone must be redacted in combined input") + assert.Contains(t, out, "[REDACTED:ssn]", "SSN must be redacted in combined input") + assert.Contains(t, out, "[REDACTED:bearer]", "bearer must be redacted in combined input") + assert.NotContains(t, out, "user@example.com", "raw email must not survive combined input") + assert.NotContains(t, out, "+14155551234", "raw phone must not survive combined input") + assert.NotContains(t, out, "123-45-6789", "raw SSN must not survive combined input") +} + +func TestRedactPIICreditCard(t *testing.T) { + // 4242424242424242 is a well-known Stripe test number (Visa, Luhn-valid). + cases := []string{ + "please charge 4242424242424242 now", + "card: 4242-4242-4242-4242", + "4242 4242 4242 4242 expires 12/30", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:cc]", "Luhn-valid credit card must be redacted in %q", in) + assert.NotContains(t, out, "4242424242424242", "raw card digits must not survive in %q", in) + assert.NotContains(t, out, "4242-4242-4242-4242", "raw dashed card must not survive in %q", in) + } +} + +func TestRedactPIIIPv4(t *testing.T) { + cases := []string{ + "connect to 10.0.42.7 over the tunnel", + "server 192.168.1.100 down", + "public address 203.0.113.42 was hit", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:ip]", "IPv4 must be redacted in %q", in) + } +} + +func TestRedactPIIJWT(t *testing.T) { + // No "token "/"bearer " prefix here, so the bearer-with-keyword pass leaves + // it alone and the JWT pattern from middleware.Scan must catch it. + in := "session eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIn0.signaturepart expires soon" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:jwt]", "JWT must be redacted when no bearer keyword precedes it") + assert.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9", "raw JWT header must not survive") +} + +func TestRedactPIIAWSAccessKey(t *testing.T) { + in := "the key AKIAIOSFODNN7EXAMPLE belongs to test user" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:aws_key]", "AWS access key must be redacted") + assert.NotContains(t, out, "AKIAIOSFODNN7EXAMPLE", "raw AWS key must not survive") +} + +func TestRedactPIIPlainNumbersUntouched(t *testing.T) { + // 1234567890123 is 13 digits but fails Luhn; must NOT trip the CC redactor. + // We use a 13-digit value (the CC-candidate range starts at 13) so the only + // risk is the CC pattern firing. Phone redaction is 10-digit by design and + // would catch 1234567890123 as a phone — that's expected and not what this + // test guards against. + in := "order number 1234567890123 is queued" + out := redactPII(in) + assert.NotContains(t, out, "[REDACTED:cc]", "non-Luhn digit sequences must not be redacted as credit cards") +} + +// piiFixture mirrors the user-supplied test fixture: each record carries one +// email, one SSN, and one phone in a representative format. The test asserts +// that EVERY raw token disappears after redaction and the right [REDACTED:*] +// markers show up. Names are kept in the input and must survive — names are +// not a pattern the redactor tries to catch. +type piiFixture struct { + name string // person name (must survive redaction) + email string + ssn string + phone string +} + +var fixtureRecords = []piiFixture{ + {"Alice Johnson", "alice.johnson@example.com", "123-45-6789", "(202) 555-0147"}, + {"Brian Smith", "brian.smith@example.org", "987-65-4321", "202-555-0163"}, + {"Carla Nguyen", "c.nguyen@test.local", "111-22-3333", "+1-202-555-0188"}, + {"David Martinez", "david.martinez@example.com", "222-33-4444", "202.555.0199"}, + {"Evelyn Parker", "evelyn.parker@example.org", "333-44-5555", "1-202-555-0112"}, + {"Frank O'Connor", "frank.oconnor@test.local", "444-55-6666", "2025550134"}, + {"Grace Lee", "grace.lee@example.com", "555-66-7777", "(202)555-0156"}, + {"Hassan Ali", "hassan.ali@example.org", "666-77-8888", "+1 (202) 555-0175"}, + {"Isabella Rossi", "i.rossi@test.local", "777-88-9999", "202 555 0121"}, + {"Jamal Thompson", "jamal.thompson@example.com", "888-99-0001", "202/555/0108"}, +} + +// TestRedactPII_FixtureRecord drives every record through redactPII and +// asserts the email, SSN, and phone are all redacted, the name survives, and +// the appropriate REDACTED markers are present. This is the spec the redactor +// must meet for the kind of prompts operators throw at it. +func TestRedactPII_FixtureRecord(t *testing.T) { + for _, rec := range fixtureRecords { + t.Run(rec.name, func(t *testing.T) { + in := "Name: " + rec.name + "\n Email: " + rec.email + "\n SSN: " + rec.ssn + "\n Phone: " + rec.phone + out := redactPII(in) + + assert.Contains(t, out, rec.name, "name must survive (not a PII pattern the redactor catches)") + assert.Contains(t, out, "[REDACTED:email]", "email marker must appear for %q", rec.email) + assert.Contains(t, out, "[REDACTED:ssn]", "ssn marker must appear for %q", rec.ssn) + assert.Contains(t, out, "[REDACTED:phone]", "phone marker must appear for %q", rec.phone) + + assert.NotContains(t, out, rec.email, "raw email must not survive: %q", rec.email) + assert.NotContains(t, out, rec.ssn, "raw SSN must not survive: %q", rec.ssn) + // Phone: assert the local digits (last 7) are gone. Country-code + // remnants like "+1 " or "1-" may remain in front of the redaction + // because the E.164 pattern needs digits-only after '+' — that's + // acceptable, the personally-identifying portion is removed. + localDigits := lastSevenDigits(rec.phone) + assert.NotContains(t, out, localDigits, "raw phone local digits %q must not survive in redacted output of %q", localDigits, rec.phone) + }) + } +} + +// lastSevenDigits returns the last 7 digits of a phone number, ignoring +// formatting. It's the unique "subscriber" portion that absolutely must be +// scrubbed regardless of which prefix the redactor leaves behind. +func lastSevenDigits(phone string) string { + digits := make([]byte, 0, len(phone)) + for i := 0; i < len(phone); i++ { + if phone[i] >= '0' && phone[i] <= '9' { + digits = append(digits, phone[i]) + } + } + if len(digits) <= 7 { + return string(digits) + } + return string(digits[len(digits)-7:]) +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go new file mode 100644 index 000000000..8594c392d --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go @@ -0,0 +1,108 @@ +package llm_identity_inject + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ProviderInjection describes one resolved provider's injection rule. +// Identity stamping uses one of HeaderPair / JSONMetadata; ExtraHeaders +// is independent — each entry is a static (operator-configured) header +// stamped on every matching request with anti-spoof. A rule with no +// shape AND no extras is dropped at New() time as a no-op. +type ProviderInjection struct { + // ProviderID is the resolved provider id — matches the value + // llm_router stamps under KeyLLMResolvedProviderID. + ProviderID string `json:"provider_id"` + // HeaderPair is the LiteLLM-style wire convention: separate + // headers for end-user id and tags CSV. + HeaderPair *HeaderPairRule `json:"header_pair,omitempty"` + // JSONMetadata is the Portkey-style wire convention: a single + // header carrying a JSON object keyed by reserved field names. + JSONMetadata *JSONMetadataRule `json:"json_metadata,omitempty"` + // ExtraHeaders is an operator-configured list of static headers + // (e.g. "x-portkey-config: pc-...") that the middleware stamps + // on every matching request. The synth pre-resolves the values + // from the provider record's ExtraValues map; the middleware + // just emits them. Each name is also added to HeadersRemove for + // anti-spoof so a client can't smuggle their own value. + ExtraHeaders []ExtraHeaderKV `json:"extra_headers,omitempty"` +} + +// ExtraHeaderKV is one static header entry the middleware stamps as-is. +type ExtraHeaderKV struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// HeaderPairRule emits identity through dedicated per-dimension +// headers. The two *InBody flags layer body-level identity on top: when +// TagsInBody is set the middleware also writes the tag list into the +// request body's metadata.tags array (required for LiteLLM tag-budget +// enforcement, which only inspects the body); when EndUserIDInBody is +// set the display identity is also written into the body's top-level +// "user" field (the OpenAI-standard end-user identifier — defense-in- +// depth and anti-spoof on top of the header path). +type HeaderPairRule struct { + EndUserIDHeader string `json:"end_user_id_header,omitempty"` + TagsHeader string `json:"tags_header,omitempty"` + TagsInBody bool `json:"tags_in_body,omitempty"` + EndUserIDInBody bool `json:"end_user_id_in_body,omitempty"` +} + +// JSONMetadataRule emits identity through a single JSON-object header. +// Empty UserKey/GroupsKey skip that dimension at emit time. When +// MaxValueLength > 0 each emitted JSON value is truncated to that many +// bytes — Portkey enforces 128 chars per value. +type JSONMetadataRule struct { + Header string `json:"header"` + UserKey string `json:"user_key,omitempty"` + GroupsKey string `json:"groups_key,omitempty"` + MaxValueLength int `json:"max_value_length,omitempty"` +} + +// Config is the on-wire configuration accepted by the factory. An +// empty Providers slice yields a no-op middleware (every resolved +// provider passes through unchanged). +type Config struct { + Providers []ProviderInjection `json:"providers"` +} + +// Factory builds llm_identity_inject instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs +// yield a no-op middleware. Non-empty payloads must parse cleanly so +// misconfigurations surface at chain build time. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg := Config{} + if !isEmptyJSON(rawConfig) { + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + return New(cfg), nil +} + +// isEmptyJSON reports whether the payload is whitespace, null, or an +// empty object/array. +func isEmptyJSON(raw []byte) bool { + trimmed := strings.TrimSpace(string(bytes.TrimSpace(raw))) + switch trimmed { + case "", "null", "{}", "[]": + return true + } + return false +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go new file mode 100644 index 000000000..ee3f1c20d --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go @@ -0,0 +1,439 @@ +// Package llm_identity_inject implements the SlotOnRequest middleware +// that stamps the caller's NetBird identity onto upstream LLM-gateway +// requests. It runs after llm_router (which resolves the provider) and +// looks up the resolved provider id against a per-account injection +// table built by the synthesiser from the catalog's IdentityInjection +// metadata. +// +// Two wire shapes are supported, dispatched per-rule: +// +// - HeaderPair (LiteLLM-style): separate end-user-id and tags +// headers; tags emitted as a CSV value. +// - JSONMetadata (Portkey-style): one header carrying a JSON +// object with reserved keys for user / groups; per-value byte +// length capped when the rule sets MaxValueLength. +// +// In both cases, identity comes from Input.UserEmail (peer-attached +// user's email or peer.Name fallback) and groups come from the +// authorising-groups intersection llm_router emitted (with +// id→display-name translation via Input.UserGroups / UserGroupNames +// positional pairing). HeadersRemove runs before HeadersAdd in the +// framework, so a client can never spoof identity by stamping these +// headers themselves. +package llm_identity_inject + +import ( + "context" + "encoding/json" + "sort" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_identity_inject" + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// Middleware stamps NetBird identity onto upstream requests for the +// configured set of resolved providers. +type Middleware struct { + cfg Config + byID map[string]ProviderInjection +} + +// New constructs a Middleware from the supplied configuration. A nil +// or empty Providers slice yields a no-op middleware. +func New(cfg Config) *Middleware { + byID := make(map[string]ProviderInjection, len(cfg.Providers)) + for _, p := range cfg.Providers { + if p.ProviderID == "" || !injectionEmitsAnything(p) { + continue + } + byID[p.ProviderID] = p + } + return &Middleware{cfg: cfg, byID: byID} +} + +// injectionEmitsAnything reports whether a provider injection rule would +// stamp anything at runtime. Rules that set both identity shapes are a +// configuration error (we refuse to guess which wins), and rules that +// resolve to no headers are dropped to keep the runtime check tight. +// Non-empty extras alone keep a rule alive even when neither identity +// shape is set. +func injectionEmitsAnything(p ProviderInjection) bool { + hasExtras := false + for _, e := range p.ExtraHeaders { + if e.Name != "" && e.Value != "" { + hasExtras = true + break + } + } + switch { + case p.HeaderPair != nil && p.JSONMetadata != nil: + return false + case p.HeaderPair != nil: + return p.HeaderPair.EndUserIDHeader != "" || p.HeaderPair.TagsHeader != "" || + p.HeaderPair.TagsInBody || p.HeaderPair.EndUserIDInBody || hasExtras + case p.JSONMetadata != nil: + if p.JSONMetadata.Header == "" { + return false + } + return p.JSONMetadata.UserKey != "" || p.JSONMetadata.GroupsKey != "" || hasExtras + default: + return hasExtras + } +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes returns nil — this middleware reads only +// metadata and identity fields on the Input envelope. +func (m *Middleware) AcceptedContentTypes() []string { return nil } + +// MetadataKeys is empty: the middleware emits no metadata. Identity +// stamping is a header-only operation. +func (m *Middleware) MetadataKeys() []string { return nil } + +// MutationsSupported reports that the middleware emits header +// mutations on the Output envelope. +func (m *Middleware) MutationsSupported() bool { return true } + +// Close releases resources owned by the middleware. Stateless, so +// this is a no-op. +func (m *Middleware) Close() error { return nil } + +// Invoke stamps identity headers when the resolved provider has an +// injection rule. Always Allow. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if len(m.byID) == 0 || in == nil { + return out, nil + } + resolved, ok := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID) + if !ok || resolved == "" { + return out, nil + } + rule, ok := m.byID[resolved] + if !ok { + return out, nil + } + + var mutations *middleware.Mutations + switch { + case rule.HeaderPair != nil: + mutations = applyHeaderPair(rule.HeaderPair, in) + case rule.JSONMetadata != nil: + mutations = applyJSONMetadata(rule.JSONMetadata, in) + } + + // ExtraHeaders are independent of the identity shape. Stamp each + // non-empty entry with anti-spoof: Remove first (frame strips it + // before our Add lands) so a client can't smuggle a value, then + // Add our trusted one. + if len(rule.ExtraHeaders) > 0 { + if mutations == nil { + mutations = &middleware.Mutations{} + } + for _, h := range rule.ExtraHeaders { + if h.Name == "" || h.Value == "" { + continue + } + mutations.HeadersRemove = append(mutations.HeadersRemove, h.Name) + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: h.Name, + Value: h.Value, + }) + } + } + + if mutations == nil || (len(mutations.HeadersAdd) == 0 && len(mutations.HeadersRemove) == 0 && len(mutations.BodyReplace) == 0) { + return out, nil + } + out.Mutations = mutations + return out, nil +} + +// applyHeaderPair builds the LiteLLM-style mutations: separate per- +// dimension headers, with anti-spoof Removes paired with trusted Adds. +func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mutations { + mutations := &middleware.Mutations{} + + if rule.EndUserIDHeader != "" { + mutations.HeadersRemove = append(mutations.HeadersRemove, rule.EndUserIDHeader) + // Prefer the email when the auth path carried it: gateways + // like LiteLLM key per-user budgets and dashboards on a + // human-readable identifier; the user_id is an opaque + // management-server primary key. Fall back to user_id when + // no email is available (non-OIDC schemes, legacy JWTs). + if identity := identityFor(in); identity != "" { + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: rule.EndUserIDHeader, + Value: identity, + }) + } + } + + if rule.TagsHeader != "" { + mutations.HeadersRemove = append(mutations.HeadersRemove, rule.TagsHeader) + if csv := authorisingTagsCSV(in); csv != "" { + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: rule.TagsHeader, + Value: csv, + }) + } + } + + if rule.TagsInBody || rule.EndUserIDInBody { + // Body-level identity unlocks gateway behaviour the header + // path can't reach (LiteLLM's _tag_max_budget_check only + // inspects the body; OpenAI direct only reads the body's + // "user" field for attribution). The header path stays + // intact, so we still get attribution + per-end-user budget + // gating when body inject can't run (truncated body, + // non-JSON, hostile metadata shape). + var bodyTags []string + if rule.TagsInBody { + bodyTags = authorisingTagsSlice(in) + } + var bodyUser string + if rule.EndUserIDInBody { + bodyUser = identityFor(in) + } + if newBody, ok := injectIntoBody(in, bodyTags, bodyUser); ok { + mutations.BodyReplace = newBody + } + } + + return mutations +} + +// injectIntoBody parses the request body and writes the supplied +// identity dimensions into it. Tags land at metadata.tags (creating +// the metadata object when absent); the user identity lands at the +// top-level "user" field (OpenAI-standard end-user identifier). +// Returns the re-marshaled body and ok=true when at least one field +// was written. Returns ok=false (no mutation) when: +// +// - both inputs are empty (nothing to write); +// - the body is empty or truncated (we don't have the full document +// to safely round-trip); +// - the body isn't a JSON object (skip silently — this middleware +// only knows how to inject into OpenAI-compatible JSON payloads). +// +// A non-object existing `metadata` field skips the tag write but +// still allows the user write to land — we don't clobber the client's +// non-object metadata, but the orthogonal user field is fair game. +// The header path emission still runs in skip cases, so spend tracking +// + header-resolved end-user budgets continue to work without body- +// level enforcement. +func injectIntoBody(in *middleware.Input, tags []string, userID string) ([]byte, bool) { + wantTags := len(tags) > 0 + wantUser := userID != "" + if !wantTags && !wantUser { + return nil, false + } + if in == nil || len(in.Body) == 0 || in.BodyTruncated { + return nil, false + } + var doc map[string]any + if err := json.Unmarshal(in.Body, &doc); err != nil { + return nil, false + } + injected := false + if wantTags { + var meta map[string]any + if existing, ok := doc["metadata"]; ok { + if typed, isObject := existing.(map[string]any); isObject { + meta = typed + } + // non-object metadata: leave it; tags go unwritten so we + // don't clobber the client's value. Header fallback covers + // spend tracking. + } else { + meta = map[string]any{} + } + if meta != nil { + meta["tags"] = tags + doc["metadata"] = meta + injected = true + } + } + if wantUser { + // Anti-spoof: overwrite any client-supplied "user" so the + // gateway only sees our trusted identity. + doc["user"] = userID + injected = true + } + if !injected { + return nil, false + } + out, err := json.Marshal(doc) + if err != nil { + return nil, false + } + return out, true +} + +// applyJSONMetadata builds the Portkey-style mutations: a single header +// carrying a JSON object keyed by the rule's reserved field names. Per- +// value byte length is capped at MaxValueLength when set (Portkey +// enforces 128 chars). +func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware.Mutations { + mutations := &middleware.Mutations{} + mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header) + + payload := map[string]string{} + if rule.UserKey != "" { + if identity := identityFor(in); identity != "" { + payload[rule.UserKey] = truncate(identity, rule.MaxValueLength) + } + } + if rule.GroupsKey != "" { + if csv := authorisingTagsCSV(in); csv != "" { + payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength) + } + } + if len(payload) == 0 { + return mutations + } + raw, err := json.Marshal(payload) + if err != nil { + return mutations + } + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: rule.Header, + Value: string(raw), + }) + return mutations +} + +// identityFor returns the caller's display identity. UserEmail wins +// (carries the user email when peer-attached, peer.Name otherwise); +// UserID falls in only as a defensive last resort. +func identityFor(in *middleware.Input) string { + if in.UserEmail != "" { + return in.UserEmail + } + return in.UserID +} + +// authorisingTagsSlice returns the sorted, deduplicated slice of group +// display names the request was authorised under. Prefers the per- +// request authorising groups emitted by llm_router (intersection of the +// caller's UserGroups with the resolved route's AllowedGroupIDs) so the +// tags carry only the groups that actually authorise THIS request, not +// every group the peer happens to be in. Falls back to the full +// UserGroups when the router metadata key is absent. +func authorisingTagsSlice(in *middleware.Input) []string { + ids := tagsIDsFromAuthorising(in.Metadata) + if len(ids) == 0 { + ids = in.UserGroups + } + return tagsNamedSlice(ids, in.UserGroups, in.UserGroupNames) +} + +// authorisingTagsCSV is a convenience wrapper that joins +// authorisingTagsSlice with commas for HeaderPair-style emission. +func authorisingTagsCSV(in *middleware.Input) string { + return strings.Join(authorisingTagsSlice(in), ",") +} + +// truncate caps s to maxBytes bytes when maxBytes > 0. No-op when +// maxBytes <= 0 or s already fits. Truncation is byte-wise — sufficient +// for Portkey's 128-char ASCII limit. UTF-8 sequences could in theory +// be split, but the gateway treats the value as opaque bytes. +func truncate(s string, maxBytes int) string { + if maxBytes <= 0 || len(s) <= maxBytes { + return s + } + return s[:maxBytes] +} + +// tagsIDsFromAuthorising reads llm_router's authorising-groups metadata +// (a CSV of group ids) and returns the parsed slice. Returns nil when +// the key is absent or empty so the caller can fall back to the full +// UserGroups. +func tagsIDsFromAuthorising(meta []middleware.KV) []string { + v, ok := lookupMetadata(meta, middleware.KeyLLMAuthorisingGroups) + if !ok { + return nil + } + v = strings.TrimSpace(v) + if v == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// tagsNamedSlice returns the sorted, deduplicated list of group display +// names. ids carries the canonical group identifiers to emit; +// userGroups + userGroupNames provide the positional id→name +// translation table from the Input envelope. When a name is missing +// for a given id (slice shorter than userGroups, or id absent from the +// table), the id is used verbatim so the tag still attributes +// correctly. Sorted so the same caller produces the same header value +// across requests (helps gateway-side cache hits and log correlation). +func tagsNamedSlice(ids, userGroups, userGroupNames []string) []string { + if len(ids) == 0 { + return nil + } + idToName := make(map[string]string, len(userGroups)) + for i, id := range userGroups { + if i < len(userGroupNames) { + idToName[id] = userGroupNames[i] + } + } + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + tag := idToName[id] + if tag == "" { + tag = id + } + if _, dup := seen[tag]; dup { + continue + } + seen[tag] = struct{}{} + out = append(out, tag) + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return out +} + +// lookupMetadata returns the value for key plus a presence flag. +func lookupMetadata(meta []middleware.KV, key string) (string, bool) { + for _, kv := range meta { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go new file mode 100644 index 000000000..aab1271d8 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go @@ -0,0 +1,666 @@ +package llm_identity_inject + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +const ( + litellmProvider = "ainp_litellm-test" + portkeyProvider = "ainp_portkey-test" +) + +func newInput(resolvedProvider, userID string, groups []string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + AccountID: "acct-test", + UserID: userID, + UserGroups: groups, + SourceIP: "100.64.0.5", + RequestID: "req-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: resolvedProvider}, + }, + } +} + +func liteLLMRule() ProviderInjection { + return ProviderInjection{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + }, + } +} + +func TestMiddlewareIdentity(t *testing.T) { + mw := New(Config{}) + assert.Equal(t, ID, mw.ID()) + assert.Equal(t, Version, mw.Version()) + assert.Equal(t, middleware.SlotOnRequest, mw.Slot()) + assert.True(t, mw.MutationsSupported()) + assert.Empty(t, mw.MetadataKeys(), "middleware emits no metadata") + assert.Nil(t, mw.AcceptedContentTypes()) + require.NoError(t, mw.Close()) +} + +func TestInject_MatchedProvider_StampsHeaders(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-it"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations) + + // Strips the same headers we're about to add (anti-spoof). + assert.ElementsMatch(t, + []string{"x-litellm-end-user-id", "x-litellm-tags"}, + out.Mutations.HeadersRemove, + "every injected header must also appear in HeadersRemove so client-supplied values are wiped before our trusted values land") + + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "alice", added["x-litellm-end-user-id"]) + assert.Equal(t, "grp-eng,grp-it", added["x-litellm-tags"], "tags CSV must be sorted") +} + +func TestInject_UnmatchedProvider_NoMutations(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput("ainp_some-other-provider", "alice", []string{"grp-eng"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + assert.Nil(t, out.Mutations, "non-LiteLLM resolved provider must produce no mutations") +} + +func TestInject_NoResolvedProvider_NoMutations(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := &middleware.Input{Slot: middleware.SlotOnRequest, UserID: "alice"} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Nil(t, out.Mutations, + "missing llm.resolved_provider_id metadata means the router didn't run; never stamp identity blindly") +} + +func TestInject_PartialRule_StampsOnlyConfiguredHeaders(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + // TagsHeader intentionally empty. + }, + }}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + assert.Equal(t, []string{"x-litellm-end-user-id"}, out.Mutations.HeadersRemove, + "only configured header should be stripped") + require.Len(t, out.Mutations.HeadersAdd, 1) + assert.Equal(t, "x-litellm-end-user-id", out.Mutations.HeadersAdd[0].Key) + assert.Equal(t, "alice", out.Mutations.HeadersAdd[0].Value) +} + +func TestInject_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { + // Caller has no UserID and no groups. We still strip the headers + // (so the client can't inject identity) but we don't add empty + // values that would mislead the gateway. + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "", nil) + in.AccountID = "" + in.SourceIP = "" + in.RequestID = "" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + assert.ElementsMatch(t, + []string{"x-litellm-end-user-id", "x-litellm-tags"}, + out.Mutations.HeadersRemove, + "identity headers must be stripped even when we don't have values to add — anti-spoof") + assert.Empty(t, out.Mutations.HeadersAdd, + "no NetBird identity available; do not stamp empty / misleading values") +} + +func TestInject_TagsCSV_DedupesAndSorts(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-zzz", "grp-aaa", "grp-zzz", "", " "}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-aaa,grp-zzz", kv.Value, + "tags CSV must dedupe, drop empty, and sort") + return + } + } + t.Fatalf("expected x-litellm-tags in HeadersAdd; got %v", out.Mutations.HeadersAdd) +} + +func TestFactory_RejectsBadJSON(t *testing.T) { + _, err := Factory{}.New([]byte("{not json")) + require.Error(t, err) +} + +func TestFactory_AcceptsEmptyShapes(t *testing.T) { + for _, raw := range [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")} { + mw, err := Factory{}.New(raw) + require.NoError(t, err) + require.NotNil(t, mw) + + out, ierr := mw.Invoke(context.Background(), + newInput(litellmProvider, "alice", []string{"grp-eng"})) + require.NoError(t, ierr) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + assert.Nil(t, out.Mutations, + "empty config means no providers to inject for; every resolved provider passes through") + } +} + +func TestFactory_DropsInjectionRuleWithEmptyHeaders(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"providers":[{"provider_id":"x"}]}`)) + require.NoError(t, err) + out, ierr := mw.Invoke(context.Background(), newInput("x", "alice", []string{"grp-eng"})) + require.NoError(t, ierr) + assert.Nil(t, out.Mutations, + "a rule with no header names is functionally a no-op and must be dropped at New() time") +} + +// TestInject_TagsFromAuthorisingMetadata pins that when llm_router has +// emitted llm.authorising_groups, the inject middleware uses THAT +// (the per-request authorising intersection) for the tags header — not +// the full UserGroups, which can include groups unrelated to this +// request's routing. +func TestInject_TagsFromAuthorisingMetadata(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-it", "grp-oncall"}) + in.Metadata = append(in.Metadata, middleware.KV{ + Key: middleware.KeyLLMAuthorisingGroups, + Value: "grp-eng", + }) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-eng", kv.Value, + "tags must come from llm.authorising_groups, not the full UserGroups; unrelated peer groups must not leak") + return + } + } + t.Fatalf("expected x-litellm-tags in HeadersAdd; got %v", out.Mutations.HeadersAdd) +} + +// TestInject_TagsFallsBackToUserGroups pins the defensive fallback: if +// llm_router didn't emit authorising-groups metadata (chain +// misconfiguration) the middleware uses UserGroups so identity is +// still stamped, just over-broad. +func TestInject_TagsFallsBackToUserGroups(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-it"}) + // No llm.authorising_groups metadata. + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-eng,grp-it", kv.Value, + "absent metadata must fall back to the full UserGroups CSV") + return + } + } + t.Fatalf("expected x-litellm-tags in HeadersAdd; got %v", out.Mutations.HeadersAdd) +} + +// portkeyRule is the JSONMetadata-shape analogue of liteLLMRule: a +// single x-portkey-metadata header carrying _user and groups, with +// Portkey's 128-byte per-value cap. +func portkeyRule() ProviderInjection { + return ProviderInjection{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + GroupsKey: "groups", + MaxValueLength: 128, + }, + } +} + +// TestInject_JSONMetadata_StampsHeader pins the Portkey-style emission: +// one header carrying a JSON envelope with reserved keys for user +// identity and groups CSV. +func TestInject_JSONMetadata_StampsHeader(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{portkeyRule()}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng", "grp-it"}) + in.UserEmail = "alice@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + assert.Equal(t, []string{"x-portkey-metadata"}, out.Mutations.HeadersRemove, + "the JSON header must be stripped before we add our trusted value") + require.Len(t, out.Mutations.HeadersAdd, 1) + added := out.Mutations.HeadersAdd[0] + assert.Equal(t, "x-portkey-metadata", added.Key) + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(added.Value), &payload)) + assert.Equal(t, "alice@example.com", payload["_user"], + "_user reserved key carries the display identity (UserEmail)") + assert.Equal(t, "grp-eng,grp-it", payload["groups"], + "groups key carries the sorted CSV of group display names") +} + +// TestInject_JSONMetadata_TruncatesValues pins the per-value byte cap. +// Portkey rejects metadata values longer than 128 chars; oversized +// values are truncated rather than failing the request. +func TestInject_JSONMetadata_TruncatesValues(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{portkeyRule()}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng"}) + in.UserEmail = strings.Repeat("a", 200) + "@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.Len(t, out.Mutations.HeadersAdd, 1) + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(out.Mutations.HeadersAdd[0].Value), &payload)) + assert.Len(t, payload["_user"], 128, + "per-value byte length must be capped at MaxValueLength") +} + +// TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the +// anti-spoof Remove still fires when there's nothing to stamp. +func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{portkeyRule()}}) + in := newInput(portkeyProvider, "", nil) + in.UserEmail = "" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + + assert.Equal(t, []string{"x-portkey-metadata"}, out.Mutations.HeadersRemove, + "strip even with no payload — client can't smuggle identity headers") + assert.Empty(t, out.Mutations.HeadersAdd, + "no NetBird identity available; do not stamp empty / misleading values") +} + +// TestFactory_RejectsRuleWithBothShapes pins the configuration-error +// guard: a rule that sets both HeaderPair and JSONMetadata is dropped +// at New() time rather than guessing which wins. +func TestFactory_RejectsRuleWithBothShapes(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + }, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + }, + }}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Nil(t, out.Mutations, + "a rule that sets both shapes is ambiguous and must be dropped at New() time") +} + +// liteLLMRuleWithBody is the LiteLLM-style rule with body tag injection +// enabled (matches the catalog default). +func liteLLMRuleWithBody() ProviderInjection { + return ProviderInjection{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + TagsInBody: true, + }, + } +} + +// TestInject_BodyTags_AddsMetadataTags pins the body-inject path that +// LiteLLM's _tag_max_budget_check requires. With TagsInBody set, the +// middleware writes the authorising-groups slice into +// request.metadata.tags (in addition to the header). +func TestInject_BodyTags_AddsMetadataTags(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-sre"}) + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotEmpty(t, out.Mutations.BodyReplace, "body must be rewritten when TagsInBody is set") + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + meta, ok := doc["metadata"].(map[string]any) + require.True(t, ok, "metadata must be an object") + tags, ok := meta["tags"].([]any) + require.True(t, ok, "metadata.tags must be a JSON array") + got := make([]string, 0, len(tags)) + for _, t := range tags { + s, _ := t.(string) + got = append(got, s) + } + assert.Equal(t, []string{"grp-eng", "grp-sre"}, got, + "metadata.tags must carry the sorted authorising-groups slice") + assert.Equal(t, "gpt-4o-mini", doc["model"], + "the rest of the body must be preserved verbatim") +} + +// TestInject_BodyTags_PreservesExistingMetadata pins that an existing +// metadata object on the request is merged with our tags rather than +// clobbered — clients sometimes set metadata fields the proxy +// shouldn't blow away (jobID, taskName, etc.). +func TestInject_BodyTags_PreservesExistingMetadata(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`{"model":"gpt-4o-mini","metadata":{"jobID":"j-42","tags":["should-be-replaced"]}}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + meta := doc["metadata"].(map[string]any) + assert.Equal(t, "j-42", meta["jobID"], + "client-supplied metadata fields outside `tags` must survive") + tags := meta["tags"].([]any) + require.Len(t, tags, 1) + assert.Equal(t, "grp-eng", tags[0], + "our tags overwrite any client-supplied metadata.tags so spoofing is impossible") +} + +// TestInject_BodyTags_SkipsHostileMetadataShape pins the defensive +// refusal: when the request body has a non-object metadata field +// (string/number/array), we don't inject — header path still emits. +func TestInject_BodyTags_SkipsHostileMetadataShape(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`{"model":"gpt-4o-mini","metadata":"not-an-object"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + assert.Empty(t, out.Mutations.BodyReplace, + "non-object metadata must skip body inject (don't clobber)") + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-eng", kv.Value, + "header path must still emit so spend tracking keeps working") + return + } + } + t.Fatalf("expected x-litellm-tags header even when body inject was skipped") +} + +// TestInject_BodyTags_SkipsTruncatedBody pins that we don't blindly +// rewrite a body we don't have in full. The header path still runs. +func TestInject_BodyTags_SkipsTruncatedBody(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + in.BodyTruncated = true + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Empty(t, out.Mutations.BodyReplace, + "truncated body must skip body inject — re-marshaling would corrupt the request") +} + +// TestInject_BodyTags_SkipsNonJSONBody pins graceful behavior when the +// body isn't JSON (e.g. a streaming binary or form upload sneaking +// through the LLM chain). Header path still runs. +func TestInject_BodyTags_SkipsNonJSONBody(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`not even close to json`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Empty(t, out.Mutations.BodyReplace, + "non-JSON body must skip body inject silently") +} + +// liteLLMRuleFull mirrors the catalog default: header path + body +// metadata.tags (groups) + body user (end-user id). +func liteLLMRuleFull() ProviderInjection { + return ProviderInjection{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + TagsInBody: true, + EndUserIDInBody: true, + }, + } +} + +// TestInject_BodyUser_WritesTopLevelUser pins the EndUserIDInBody path +// alone: body's top-level "user" field carries the display identity. +// Tags-in-body is OFF here so we isolate the user write. +func TestInject_BodyUser_WritesTopLevelUser(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + EndUserIDInBody: true, + }, + }}}) + in := newInput(litellmProvider, "alice", nil) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"], + "body's top-level user field must carry the display identity") + _, hasMeta := doc["metadata"] + assert.False(t, hasMeta, "TagsInBody is off; metadata must not be added") +} + +// TestInject_BodyUser_OverwritesClientSupplied pins anti-spoof: a +// client-supplied "user" in the body is overwritten so the gateway +// only sees our trusted identity. +func TestInject_BodyUser_OverwritesClientSupplied(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleFull()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini","user":"ceo@company.com"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"], + "client-supplied user must be overwritten with the trusted identity") +} + +// TestInject_BodyCombined_TagsAndUser pins that with both flags on, +// the body carries both metadata.tags AND top-level user, and the +// header path still emits. +func TestInject_BodyCombined_TagsAndUser(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleFull()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-sre"}) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"]) + meta := doc["metadata"].(map[string]any) + tags := meta["tags"].([]any) + require.Len(t, tags, 2) + assert.Equal(t, "grp-eng", tags[0]) + assert.Equal(t, "grp-sre", tags[1]) + + // Header path still emits — header end-user-id is the primary + // path for LiteLLM's resolver, body is defense-in-depth. + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "alice@example.com", added["x-litellm-end-user-id"]) + assert.Equal(t, "grp-eng,grp-sre", added["x-litellm-tags"]) +} + +// TestInject_BodyCombined_HostileMetadataKeepsUser pins the partial- +// success path: a hostile (non-object) metadata field skips the tag +// write but still allows the orthogonal user write to land. +func TestInject_BodyCombined_HostileMetadataKeepsUser(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleFull()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini","metadata":"not-an-object"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace, + "user write must still go through even when metadata is hostile") + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"]) + assert.Equal(t, "not-an-object", doc["metadata"], + "hostile metadata must be left untouched, not clobbered") +} + +// TestInject_ExtraHeaders_Stamped pins the extras path: with a +// per-provider ExtraHeader configured (e.g. Portkey config id), the +// middleware stamps it on every matching request and adds the same +// name to HeadersRemove for anti-spoof. +func TestInject_ExtraHeaders_Stamped(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + GroupsKey: "groups", + }, + ExtraHeaders: []ExtraHeaderKV{ + {Name: "x-portkey-config", Value: "pc-prod-3f2a"}, + }, + }}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + + assert.Contains(t, out.Mutations.HeadersRemove, "x-portkey-config", + "extras must be stripped before stamping for anti-spoof") + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "pc-prod-3f2a", added["x-portkey-config"], + "extras must carry the operator-configured value verbatim") + // Identity-stamping shape (JSONMetadata header) still emitted. + assert.Contains(t, added, "x-portkey-metadata", + "extras and identity stamping are independent — both must land") +} + +// TestInject_ExtraHeaders_OnlyRule pins that an extras-only rule +// (no HeaderPair, no JSONMetadata) survives New() and stamps the +// extras anyway. Useful for hypothetical gateways that need a static +// routing header but no NetBird identity stamping. +func TestInject_ExtraHeaders_OnlyRule(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: "ainp_extras-only", + ExtraHeaders: []ExtraHeaderKV{ + {Name: "x-routing-key", Value: "rk-1"}, + }, + }}}) + in := newInput("ainp_extras-only", "alice", nil) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations, + "extras alone keep the rule alive — middleware must emit them") + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "rk-1", added["x-routing-key"]) +} + +// TestInject_ExtraHeaders_EmptyValueSkipped pins that empty values are +// dropped silently (the synth would normally not send them, but the +// middleware is defensive). +func TestInject_ExtraHeaders_EmptyValueSkipped(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + }, + ExtraHeaders: []ExtraHeaderKV{ + {Name: "x-portkey-config", Value: ""}, + }, + }}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + assert.NotContains(t, out.Mutations.HeadersRemove, "x-portkey-config", + "empty extra value must not even strip the header") + for _, kv := range out.Mutations.HeadersAdd { + assert.NotEqual(t, "x-portkey-config", kv.Key, + "empty extra value must not be stamped") + } +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/factory.go b/proxy/internal/middleware/builtin/llm_limit_check/factory.go new file mode 100644 index 000000000..1068a6867 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_check/factory.go @@ -0,0 +1,38 @@ +// Package llm_limit_check is the SlotOnRequest middleware that asks +// management which agent-network policy "pays" for the current LLM +// request. On allow, it stamps the selected policy id, attribution +// group id, and effective window length onto the metadata bag so the +// post-flight llm_limit_record middleware can tick the right counters. +// On deny, it returns a 403 carrying the canonical llm_policy.* deny +// code surfaced by management. +package llm_limit_check + +import ( + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_limit_check" + +// Factory builds a configured llm_limit_check instance. The factory +// has no per-target config — it pulls the management gRPC client from +// the package-level FactoryContext at construction time. A nil +// MgmtClient on the context is allowed; the middleware then becomes +// a no-op pass-through (allow without attribution) so a partially +// wired environment doesn't break the chain. +type Factory struct{} + +// ID returns the registry identifier matching the middleware ID. +func (Factory) ID() string { return ID } + +// New ignores the rawConfig payload (no per-target config today) and +// returns a Middleware bound to the FactoryContext's MgmtClient. +func (Factory) New(_ []byte) (middleware.Middleware, error) { + ctx := builtin.Context() + return New(ctx.MgmtClient, ctx.Logger), nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go new file mode 100644 index 000000000..bebe4dca4 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go @@ -0,0 +1,196 @@ +package llm_limit_check + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// callTimeout caps the wall-clock budget for the pre-flight RPC. The +// middleware sits on the request leg, so a slow management call +// translates directly to user-visible latency. 2s is loose enough for +// a healthy management cluster but tight enough that a stalled call +// fails open via the same path nil-MgmtClient does — an enforcement +// gate that adds 30s of latency is worse than a stale gate. +const callTimeout = 2 * time.Second + +// Middleware is the per-target instance that runs the pre-flight check. +type Middleware struct { + mgmt builtin.MgmtClient + logger *log.Logger +} + +// New constructs a Middleware. mgmt may be nil — that's the +// no-management-wired case where the middleware is a pass-through +// (allow without attribution); useful for unit tests and for +// progressive rollout of the management RPC. +func New(mgmt builtin.MgmtClient, logger *log.Logger) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{mgmt: mgmt, logger: logger} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes returns nil because the gate consults metadata +// emitted upstream (KeyLLMResolvedProviderID) and never inspects bodies. +func (m *Middleware) AcceptedContentTypes() []string { return nil } + +// MetadataKeys is the closed allowlist of keys this middleware emits. +func (m *Middleware) MetadataKeys() []string { + return []string{ + middleware.KeyLLMSelectedPolicyID, + middleware.KeyLLMAttributionGroupID, + middleware.KeyLLMAttributionWindowS, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + } +} + +// MutationsSupported reports that the middleware never mutates the +// request body or headers; the only outcome is allow + metadata or +// deny. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close releases resources owned by the middleware. Stateless, so +// this is a no-op. +func (m *Middleware) Close() error { return nil } + +// Invoke runs the pre-flight policy check. +func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middleware.Output, error) { + if m.mgmt == nil { + // No management client wired — fall through to allow with + // no attribution. RecordLLMUsage on the response leg will + // also be a no-op so counters stay at zero. This matches + // the PR1 behaviour exactly so a partial wiring is + // indistinguishable from "no enforcement". + return allowNoAttribution(), nil + } + + providerID := lookupKV(in.Metadata, middleware.KeyLLMResolvedProviderID) + if providerID == "" { + // llm_router didn't emit a resolved provider id — usually + // because the request didn't carry an llm.model. The + // router itself denied; we won't reach here in production, + // but defensively pass through so we never deny on top of + // an upstream allow. + return allowNoAttribution(), nil + } + + rpcCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + + resp, err := m.mgmt.CheckLLMPolicyLimits(rpcCtx, &proto.CheckLLMPolicyLimitsRequest{ + AccountId: in.AccountID, + UserId: in.UserID, + GroupIds: append([]string(nil), in.UserGroups...), + ProviderId: providerID, + Model: lookupKV(in.Metadata, middleware.KeyLLMModel), + }) + if err != nil { + // Fail-open on transport / management errors. The + // alternative — denying every request when management is + // unreachable — is worse for v1 (operational outage = + // total LLM outage). Operators can audit via the + // access-log; PR3 can switch to fail-closed under a flag. + m.logger.WithError(err). + WithField("middleware", ID). + Debugf("management pre-flight failed; failing open") + return allowNoAttribution(), nil + } + + if resp.GetDecision() == "deny" { + return denyFromManagement(resp), nil + } + return allowFromManagement(resp), nil +} + +// allowNoAttribution returns the no-op allow envelope used when no +// management client is wired or no provider was resolved. Stamps +// decision=allow but no policy / attribution metadata so +// llm_limit_record skips its post-flight write. +func allowNoAttribution() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionAllow, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + }, + } +} + +// allowFromManagement converts a successful CheckLLMPolicyLimits +// response into the chain's allow envelope, stamping the attribution +// metadata the response leg consumes. +func allowFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output { + out := &middleware.Output{ + Decision: middleware.DecisionAllow, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + }, + } + if id := resp.GetSelectedPolicyId(); id != "" { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMSelectedPolicyID, Value: id}) + } + if g := resp.GetAttributionGroupId(); g != "" { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMAttributionGroupID, Value: g}) + } + if w := resp.GetWindowSeconds(); w > 0 { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMAttributionWindowS, Value: strconv.FormatInt(w, 10)}) + } + return out +} + +// denyFromManagement converts a deny response into the chain's deny +// envelope. The deny code surfaces verbatim through the framework's +// fixed JSON template; arbitrary middleware bytes can't reach the +// wire. +func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output { + code := resp.GetDenyCode() + if code == "" { + code = "llm_policy.cap_exceeded" + } + // The canonical code is safe to surface; the management-supplied + // reason can name internal quota details (used amounts, caps, rule + // ids), so keep the public message generic and leave the detail to + // server-side logs. + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: code, + Message: "LLM policy limit exceeded", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: code}, + }, + } +} + +// lookupKV returns the value associated with key, or the empty +// string when absent. +func lookupKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go new file mode 100644 index 000000000..2c26c2abe --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go @@ -0,0 +1,186 @@ +package llm_limit_check + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// fakeMgmt is a minimal builtin.MgmtClient stub that lets the test +// drive CheckLLMPolicyLimits responses without a real gRPC dial. +type fakeMgmt struct { + checkResp *proto.CheckLLMPolicyLimitsResponse + checkErr error + checkReq *proto.CheckLLMPolicyLimitsRequest +} + +func (f *fakeMgmt) CheckLLMPolicyLimits(_ context.Context, in *proto.CheckLLMPolicyLimitsRequest, _ ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) { + f.checkReq = in + return f.checkResp, f.checkErr +} + +func (f *fakeMgmt) RecordLLMUsage(_ context.Context, _ *proto.RecordLLMUsageRequest, _ ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) { + return &proto.RecordLLMUsageResponse{}, nil +} + +func runInvoke(t *testing.T, m *Middleware, in *middleware.Input) *middleware.Output { + t.Helper() + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not propagate transport errors") + require.NotNil(t, out, "Invoke must always return an Output") + return out +} + +// TestInvoke_AllowStampsAttributionMetadata covers the happy path: +// management returns an allow decision with selected_policy_id + +// attribution_group_id + window_seconds, the middleware emits all three +// onto the metadata bag so the post-flight llm_limit_record +// middleware has everything it needs to tick the right counter. +func TestInvoke_AllowStampsAttributionMetadata(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "allow", + SelectedPolicyId: "pol-X", + AttributionGroupId: "grp-engineers", + WindowSeconds: 86_400, + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision) + assert.Equal(t, "acc-1", mgmt.checkReq.GetAccountId(), "account_id must round-trip onto the RPC") + assert.Equal(t, "user-bob", mgmt.checkReq.GetUserId()) + assert.Equal(t, []string{"grp-engineers"}, mgmt.checkReq.GetGroupIds()) + assert.Equal(t, "prov-1", mgmt.checkReq.GetProviderId(), "resolved provider id must come from metadata") + assert.Equal(t, "gpt-4o", mgmt.checkReq.GetModel(), "model must come from metadata") + + want := map[string]string{ + middleware.KeyLLMPolicyDecision: "allow", + middleware.KeyLLMSelectedPolicyID: "pol-X", + middleware.KeyLLMAttributionGroupID: "grp-engineers", + middleware.KeyLLMAttributionWindowS: "86400", + } + got := map[string]string{} + for _, kv := range out.Metadata { + got[kv.Key] = kv.Value + } + assert.Equal(t, want, got, "attribution metadata must land on the bag for the response leg to consume") +} + +// TestInvoke_DenyConvertsToProxyDeny proves the deny envelope round- +// trips: management's deny code becomes the proxy framework's deny +// payload at status 403, and the deny reason text is preserved so +// operators can debug from the access log. +func TestInvoke_DenyConvertsToProxyDeny(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + DenyCode: "llm_policy.token_cap_exceeded", + DenyReason: "group token cap exhausted on policy pol-X (used 1000 of 1000)", + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}}, + }) + + assert.Equal(t, middleware.DecisionDeny, out.Decision) + assert.Equal(t, 403, out.DenyStatus, "policy denials are 403 — same as llm_router's") + require.NotNil(t, out.DenyReason, "deny envelope must carry a reason payload") + assert.Equal(t, "llm_policy.token_cap_exceeded", out.DenyReason.Code, "canonical deny code surfaces to the caller") + // The public message must stay generic: the management reason names + // internal quota detail (used/cap, rule id) that must not leak. + assert.Equal(t, "LLM policy limit exceeded", out.DenyReason.Message, "public deny message must be generic") + assert.NotContains(t, out.DenyReason.Message, "exhausted", "internal quota detail must not reach the caller") + assert.NotContains(t, out.DenyReason.Message, "1000", "internal cap numbers must not reach the caller") +} + +// TestInvoke_NoMgmtClientPassesThrough proves the partial-wiring +// safety: a middleware constructed without a management client +// allows every request without attribution. This makes a half-set-up +// environment indistinguishable from "no enforcement" rather than +// breaking the chain. +func TestInvoke_NoMgmtClientPassesThrough(t *testing.T) { + m := New(nil, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}}, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision) + for _, kv := range out.Metadata { + assert.NotEqual(t, middleware.KeyLLMSelectedPolicyID, kv.Key, + "no mgmt client = no attribution metadata; record middleware then skips its write") + } +} + +// TestInvoke_NoResolvedProviderPassesThrough covers the defensive +// path: when llm_router didn't set llm.resolved_provider_id (which +// only happens on the deny side of llm_router), the gate must NOT +// stack a second deny on top — pass through and let the upstream +// deny stand. +func TestInvoke_NoResolvedProviderPassesThrough(t *testing.T) { + m := New(&fakeMgmt{}, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{}, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "no resolved provider = the gate has nothing to check; never deny on top of an upstream allow") +} + +// TestInvoke_RPCErrorFailsOpen proves the fail-open contract: a +// transport error from management does NOT deny the request. v1 +// trades enforcement strictness for availability — an unreachable +// management server otherwise turns into a total LLM outage. +func TestInvoke_RPCErrorFailsOpen(t *testing.T) { + m := New(&fakeMgmt{checkErr: errors.New("connection refused")}, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}}, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "transport errors must not cascade into total LLM outages — operators audit via access log") +} + +// TestMetadataKeys_Allowlist locks the closed set this middleware can +// emit. The accumulator drops anything outside this list; adding a +// new emission means updating both the slice and this test. +func TestMetadataKeys_Allowlist(t *testing.T) { + keys := New(nil, nil).MetadataKeys() + want := []string{ + middleware.KeyLLMSelectedPolicyID, + middleware.KeyLLMAttributionGroupID, + middleware.KeyLLMAttributionWindowS, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + } + assert.ElementsMatch(t, want, keys) +} diff --git a/proxy/internal/middleware/builtin/llm_limit_record/factory.go b/proxy/internal/middleware/builtin/llm_limit_record/factory.go new file mode 100644 index 000000000..b42931c0c --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_record/factory.go @@ -0,0 +1,35 @@ +// Package llm_limit_record is the SlotOnResponse middleware that +// posts the served request's token + cost deltas back to management +// so the per-(user, group, window) consumption counters tick. Reads +// the attribution metadata stamped by llm_limit_check on the request +// leg + the token / cost metadata stamped by llm_response_parser and +// cost_meter; skips the write entirely when no attribution metadata +// is present (e.g. catch-all-allow policy with no caps configured). +package llm_limit_record + +import ( + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_limit_record" + +// Factory builds a configured llm_limit_record instance bound to the +// FactoryContext's MgmtClient. nil-MgmtClient disables the post-flight +// write entirely (no-op pass-through), matching the request-leg gate's +// behaviour so a partially wired environment is consistent. +type Factory struct{} + +// ID returns the registry identifier matching the middleware ID. +func (Factory) ID() string { return ID } + +// New ignores the rawConfig payload (no per-target config today). +func (Factory) New(_ []byte) (middleware.Middleware, error) { + ctx := builtin.Context() + return New(ctx.MgmtClient, ctx.Logger), nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_limit_record/middleware.go b/proxy/internal/middleware/builtin/llm_limit_record/middleware.go new file mode 100644 index 000000000..52dfc73f0 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_record/middleware.go @@ -0,0 +1,144 @@ +package llm_limit_record + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// callTimeout caps the wall-clock budget for the post-flight RPC. +// Longer than the pre-flight gate because this runs after the +// upstream returned and is not on the user-facing latency path — +// a slow record is just a delayed counter increment, not a delayed +// response to the caller. +const callTimeout = 5 * time.Second + +// Middleware posts token + cost deltas to management after a served +// request. Stateless; per-call values come entirely from metadata +// emitted upstream. +type Middleware struct { + mgmt builtin.MgmtClient + logger *log.Logger +} + +// New constructs a Middleware bound to the supplied management +// client. mgmt may be nil — that disables the write entirely so a +// partially wired environment doesn't attempt to dial nothing. +func New(mgmt builtin.MgmtClient, logger *log.Logger) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{mgmt: mgmt, logger: logger} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports that the middleware runs after the upstream call. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } + +// AcceptedContentTypes is empty: this middleware never inspects +// bodies. It only reads metadata emitted upstream. +func (m *Middleware) AcceptedContentTypes() []string { return []string{} } + +// MetadataKeys is empty — the record middleware never emits its own +// metadata. Its only side effect is the gRPC write to management. +func (m *Middleware) MetadataKeys() []string { return []string{} } + +// MutationsSupported reports that the middleware never mutates the +// response. Its outcome is always Allow. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close releases resources owned by the middleware. Stateless. +func (m *Middleware) Close() error { return nil } + +// Invoke reads the attribution + tokens + cost metadata, calls +// management's RecordLLMUsage, and always returns Allow. RPC errors +// are logged at debug level — the response has already been served +// to the client by the time we get here, so a record failure must +// not surface back through the proxy. +func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if m.mgmt == nil { + return out, nil + } + + tokensIn, _ := strconv.ParseInt(lookupKV(in.Metadata, middleware.KeyLLMInputTokens), 10, 64) + tokensOut, _ := strconv.ParseInt(lookupKV(in.Metadata, middleware.KeyLLMOutputTokens), 10, 64) + costUSD, _ := strconv.ParseFloat(lookupKV(in.Metadata, middleware.KeyCostUSDTotal), 64) + if tokensIn == 0 && tokensOut == 0 && costUSD == 0 { + // llm_response_parser couldn't read usage off the upstream + // response (streaming-not-yet-supported, malformed body, …). + // Skipping the write keeps phantom rows out of the + // consumption table. + return out, nil + } + + windowStr := lookupKV(in.Metadata, middleware.KeyLLMAttributionWindowS) + windowSeconds, _ := strconv.ParseInt(windowStr, 10, 64) + groupID := lookupKV(in.Metadata, middleware.KeyLLMAttributionGroupID) + + // A zero attribution window means no policy cap bound this request (deny at + // the gate, or a catch-all-allow policy). We still record so account-level + // budget rules — which live in their own windows and bind independently of + // policies — accumulate. The management side books the policy dimensions + // only when window_seconds > 0 and fans out to account rules regardless. + if in.UserID == "" && groupID == "" && len(in.UserGroups) == 0 { + m.logger.WithField("middleware", ID). + WithField("account_id", in.AccountID). + Debugf("post-flight skipped: no user/group/groups to attribute (tokens=%d/%d cost=%g window=%d)", tokensIn, tokensOut, costUSD, windowSeconds) + return out, nil + } + + rpcCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + + m.logger.WithField("middleware", ID). + WithField("account_id", in.AccountID). + WithField("user_id", in.UserID). + WithField("group_id", groupID). + WithField("group_ids_len", len(in.UserGroups)). + Debugf("post-flight sending RecordLLMUsage (tokens=%d/%d cost=%g window=%d)", tokensIn, tokensOut, costUSD, windowSeconds) + + if _, err := m.mgmt.RecordLLMUsage(rpcCtx, &proto.RecordLLMUsageRequest{ + AccountId: in.AccountID, + UserId: in.UserID, + GroupId: groupID, + WindowSeconds: windowSeconds, + TokensInput: tokensIn, + TokensOutput: tokensOut, + CostUsd: costUSD, + GroupIds: append([]string(nil), in.UserGroups...), + }); err != nil { + m.logger.WithError(err). + WithField("middleware", ID). + WithField("account_id", in.AccountID). + WithField("user_id", in.UserID). + WithField("group_id", groupID). + Debugf("post-flight record failed; counter will lag this request") + } + return out, nil +} + +// lookupKV returns the value associated with key, or the empty +// string when absent. +func lookupKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} diff --git a/proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go new file mode 100644 index 000000000..a98ce9b9f --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go @@ -0,0 +1,191 @@ +package llm_limit_record + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type fakeMgmt struct { + recordReq *proto.RecordLLMUsageRequest + recordCalled bool + recordErr error +} + +func (f *fakeMgmt) CheckLLMPolicyLimits(_ context.Context, _ *proto.CheckLLMPolicyLimitsRequest, _ ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) { + return &proto.CheckLLMPolicyLimitsResponse{Decision: "allow"}, nil +} + +func (f *fakeMgmt) RecordLLMUsage(_ context.Context, in *proto.RecordLLMUsageRequest, _ ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) { + f.recordCalled = true + f.recordReq = in + return &proto.RecordLLMUsageResponse{}, f.recordErr +} + +func runInvoke(t *testing.T, m *Middleware, in *middleware.Input) *middleware.Output { + t.Helper() + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + return out +} + +// TestInvoke_PostsAttributionWithTokensAndCost covers the happy path: +// when the request leg stamped attribution + the upstream parsers +// stamped tokens + cost, the post-flight call carries every field +// through to RecordLLMUsage. +func TestInvoke_PostsAttributionWithTokensAndCost(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "150"}, + {Key: middleware.KeyLLMOutputTokens, Value: "75"}, + {Key: middleware.KeyCostUSDTotal, Value: "0.0125"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision) + require.True(t, mgmt.recordCalled, "record must be invoked when attribution + usage are both present") + assert.Equal(t, "acc-1", mgmt.recordReq.GetAccountId()) + assert.Equal(t, "user-bob", mgmt.recordReq.GetUserId()) + assert.Equal(t, "grp-engineers", mgmt.recordReq.GetGroupId()) + assert.Equal(t, int64(86_400), mgmt.recordReq.GetWindowSeconds()) + assert.Equal(t, int64(150), mgmt.recordReq.GetTokensInput()) + assert.Equal(t, int64(75), mgmt.recordReq.GetTokensOutput()) + assert.InDelta(t, 0.0125, mgmt.recordReq.GetCostUsd(), 1e-9) +} + +// TestInvoke_NoAttributionWindowStillRecordsForAccountFanOut proves the +// catch-all-allow path now STILL records (window 0): account-level budget +// rules live in their own windows and bind independently of policies, so the +// management side needs the post-flight call even when no policy cap applied. +// The full group set is forwarded so the account fan-out can attribute. +func TestInvoke_NoAttributionWindowStillRecordsForAccountFanOut(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + UserGroups: []string{"grp-eng", "grp-oncall"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMInputTokens, Value: "150"}, + {Key: middleware.KeyLLMOutputTokens, Value: "75"}, + {Key: middleware.KeyCostUSDTotal, Value: "0.0125"}, + }, + }) + + require.True(t, mgmt.recordCalled, "must record even without a policy window so account budgets accumulate") + assert.Equal(t, int64(0), mgmt.recordReq.GetWindowSeconds(), "no policy window is forwarded as 0") + assert.Empty(t, mgmt.recordReq.GetGroupId(), "no attribution group without a policy") + assert.Equal(t, []string{"grp-eng", "grp-oncall"}, mgmt.recordReq.GetGroupIds(), "full group set must be forwarded for the account fan-out") +} + +// TestInvoke_NoPrincipalSkipsRecord proves that with neither a user nor any +// groups there is nothing to attribute, so the write is skipped. +func TestInvoke_NoPrincipalSkipsRecord(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMInputTokens, Value: "150"}, + {Key: middleware.KeyCostUSDTotal, Value: "0.0125"}, + }, + }) + + assert.False(t, mgmt.recordCalled, "no user and no groups = nothing to attribute") +} + +// TestInvoke_ZeroUsageSkipsRecord proves the no-usage-no-write path: +// when the upstream parser couldn't extract token counts (streaming, +// malformed body, …), skipping the write keeps phantom rows out of +// the consumption table. +func TestInvoke_ZeroUsageSkipsRecord(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + }, + }) + + assert.False(t, mgmt.recordCalled, "zero tokens AND zero cost = nothing to record; an upstream parse miss must not surface as a row") +} + +// TestInvoke_RPCErrorIsSwallowed proves the post-flight isolation +// contract: management errors must NOT cascade back to the proxy +// because the upstream response has already been served — failing +// the chain at this point would corrupt the response. Errors are +// logged at debug level and swallowed. +func TestInvoke_RPCErrorIsSwallowed(t *testing.T) { + mgmt := &fakeMgmt{recordErr: errors.New("management down")} + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "100"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a record failure must not surface — the upstream response is already on the wire") +} + +// TestInvoke_NoMgmtClientPassesThrough mirrors the gate's safety +// contract: a partial wiring is consistent. No mgmt client = silent +// skip rather than an unhandled nil-deref. +func TestInvoke_NoMgmtClientPassesThrough(t *testing.T) { + m := New(nil, nil) + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "100"}, + }, + }) + assert.Equal(t, middleware.DecisionAllow, out.Decision) +} + +// TestInvoke_NoIdentitySkipsRecord covers a defensive guard: stamped +// attribution but no user_id AND no group_id (shouldn't happen, but +// possible if the gate ever changes shape) must not write a row keyed +// on empty dimension ids. +func TestInvoke_NoIdentitySkipsRecord(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "100"}, + }, + }) + + assert.False(t, mgmt.recordCalled, + "empty user + group identity must skip the write — never key on empty dimension ids") +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go new file mode 100644 index 000000000..827b81d07 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go @@ -0,0 +1,55 @@ +package llm_request_parser + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeBedrockModel(t *testing.T) { + cases := map[string]string{ + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", + "apac.anthropic.claude-haiku-4-5-v1:0": "anthropic.claude-haiku-4-5", + "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "amazon.nova-pro-v1:0": "amazon.nova-pro", + "amazon.nova-2-lite-v1:0": "amazon.nova-2-lite", + // Inference-profile ARN — model id lives in the last path segment. + "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + } + for in, want := range cases { + require.Equal(t, want, normalizeBedrockModel(in), "normalize %q", in) + } +} + +func TestParseBedrockPath(t *testing.T) { + tests := []struct { + path string + model string + stream bool + ok bool + }{ + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", "anthropic.claude-sonnet-4-5", false, true}, + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", "anthropic.claude-sonnet-4-5", true, true}, + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/converse", "anthropic.claude-sonnet-4-5", false, true}, + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/converse-stream", "anthropic.claude-sonnet-4-5", true, true}, + // URL-encoded colon in the version suffix. + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke", "anthropic.claude-sonnet-4-5", false, true}, + // Optional "/bedrock" gateway-namespace prefix. + {"/bedrock/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", "anthropic.claude-sonnet-4-5", true, true}, + {"/bedrock/model/anthropic.claude-sonnet-4-5-20250929-v1:0/converse", "anthropic.claude-sonnet-4-5", false, true}, + {"/v1/chat/completions", "", false, false}, + {"/model/foo", "", false, false}, + {"/model//invoke", "", false, false}, + {"/model/x/unknown-action", "", false, false}, + } + for _, tt := range tests { + br, ok := parseBedrockPath(tt.path) + require.Equal(t, tt.ok, ok, "ok for %q", tt.path) + if tt.ok { + require.Equal(t, tt.model, br.model, "model for %q", tt.path) + require.Equal(t, tt.stream, br.stream, "stream for %q", tt.path) + } + } +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/factory.go b/proxy/internal/middleware/builtin/llm_request_parser/factory.go new file mode 100644 index 000000000..8b3776877 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/factory.go @@ -0,0 +1,71 @@ +package llm_request_parser + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// config is the on-wire config envelope for the middleware. +// +// ProviderID, when set, names the parser to use directly (matched +// against llm.ParserByName, e.g. "openai", "anthropic"). The +// agent-network synthesiser stamps this so requests routed through a +// synthesised provider service don't depend on URL-shape sniffing, +// which is the only signal the middleware otherwise has. +type config struct { + ProviderID string `json:"provider_id,omitempty"` + // RedactPii, when true, runs PII redaction over the captured raw prompt + // before it is emitted as llm.request_prompt_raw — so the + // agent-network access-log row does NOT carry raw emails / SSNs / + // phone numbers even though the framework's per-key redactor (Scan) + // doesn't cover those prompt-shaped patterns. Sourced by the + // synthesiser from the account's redact_pii toggle. + RedactPii bool `json:"redact_pii,omitempty"` + // CapturePrompt gates emission of llm.request_prompt_raw. A nil pointer + // preserves the legacy default (emit), so callers that don't know about + // the toggle (or pre-existing tests with empty config) keep working. + // The synthesiser sets this explicitly to the account's + // enable_prompt_collection toggle: false here suppresses the key + // entirely so the access-log row carries no prompt content at all, + // independent of redact_pii (which only controls the form of the + // content when it IS emitted). + CapturePrompt *bool `json:"capture_prompt,omitempty"` +} + +// Factory builds llm_request_parser instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs are +// accepted; non-empty rawConfig that fails to unmarshal is rejected so +// misconfigurations surface at chain build time. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + var cfg config + if len(bytes.TrimSpace(rawConfig)) > 0 { + // Strict decode: a typo'd field (e.g. "capture_prompts") must fail + // chain build rather than silently fall back to the emit-everything + // default and leak prompts. + dec := json.NewDecoder(bytes.NewReader(rawConfig)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + // Default capturePrompt to true (legacy emission) when the field is + // absent so non-agent-network callers and pre-toggle tests keep working. + capturePrompt := true + if cfg.CapturePrompt != nil { + capturePrompt = *cfg.CapturePrompt + } + return middlewareImpl{providerID: cfg.ProviderID, redactPii: cfg.RedactPii, capturePrompt: capturePrompt}, nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go new file mode 100644 index 000000000..64ca04e6a --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go @@ -0,0 +1,453 @@ +// Package llm_request_parser implements the SlotOnRequest middleware +// that detects the LLM provider from the request URL, parses the JSON +// request body for model and streaming flags, and extracts the user +// prompt text. Emitted metadata feeds downstream middlewares (guardrail, +// cost meter) and the access-log terminal sink. +package llm_request_parser + +import ( + "context" + "net/url" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/llm" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// ID is the registry key for this middleware. +const ID = "llm_request_parser" + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// maxPromptBytes caps llm.request_prompt_raw at a size that fits within +// MaxMetadataValueBytes with headroom. Truncation is rune-safe. +const maxPromptBytes = 3500 + +// middlewareImpl is the concrete implementation. providerID, when set, +// names the parser to use directly (bypasses URL sniffing). It is empty +// for non-agent-network targets, which fall back to DetectParser on the +// request path. +type middlewareImpl struct { + providerID string + redactPii bool + capturePrompt bool +} + +// ID returns the registry identifier. +func (middlewareImpl) ID() string { return ID } + +// Version returns the implementation version. +func (middlewareImpl) Version() string { return Version } + +// Slot reports the request slot. +func (middlewareImpl) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes restricts body inspection to JSON. +func (middlewareImpl) AcceptedContentTypes() []string { + return []string{"application/json"} +} + +// MetadataKeys lists the closed allowlist of keys this middleware emits. +func (middlewareImpl) MetadataKeys() []string { + return []string{ + middleware.KeyLLMProvider, + middleware.KeyLLMModel, + middleware.KeyLLMStream, + middleware.KeyLLMRequestPromptRaw, + middleware.KeyLLMCaptureTruncated, + middleware.KeyLLMSessionID, + } +} + +// MutationsSupported reports that this middleware never mutates. +func (middlewareImpl) MutationsSupported() bool { return false } + +// Close is a no-op; the middleware is stateless. +func (middlewareImpl) Close() error { return nil } + +// Invoke detects the LLM provider, parses request facts, and emits +// metadata. Always returns DecisionAllow; never errors. Provider +// selection prefers the configured providerID (synthesiser-stamped on +// agent-network targets) so requests routed to a custom upstream URL +// still resolve. Falls back to URL sniffing when no providerID is set. +func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if in == nil { + return out, nil + } + + // Google Vertex AI carries the model + publisher (vendor) in the URL path, + // not the body, so it needs a dedicated extraction path. + if vx, okv := parseVertexPath(extractPath(in.URL)); okv { + return m.invokeVertex(in, vx), nil + } + + // AWS Bedrock likewise carries the model in the URL path (/model/{id}/{action}). + if br, okb := parseBedrockPath(extractPath(in.URL)); okb { + return m.invokeBedrock(in, br), nil + } + + parser, ok := llm.ParserByName(m.providerID) + if !ok { + parser, ok = llm.DetectParser(extractPath(in.URL)) + } + if !ok { + return out, nil + } + + md := []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: parser.ProviderName()}, + } + + // Session id is an opaque grouping identifier, not prompt content, so + // it's emitted regardless of the prompt-collection toggle — session + // grouping must work even when prompt capture is off. Prefer a header + // (Codex sends the session as an HTTP header, and headers survive an + // oversized request whose body capture was bypassed) and resolve it + // before ParseRequest so a malformed body still keeps the header id. + sessionID := sessionIDFromHeaders(in.Headers) + if sessionID == "" { + sessionID = parser.ExtractSessionID(in.Body) + } + appendSessionID := func(md []middleware.KV) []middleware.KV { + if sessionID != "" { + return append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + } + return md + } + + facts, err := parser.ParseRequest(in.Body) + if err != nil { + if logger := builtin.Context().Logger; logger != nil { + logger.Debugf("llm_request_parser: parse request body: %v", err) + } + md = appendSessionID(md) + md = appendCaptureTruncated(md, false, in.BodyTruncated) + out.Metadata = md + return out, nil + } + + if facts.Model != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: facts.Model}) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(facts.Stream)}) + md = appendSessionID(md) + + prompt, promptTruncated := truncatePrompt(parser.ExtractPrompt(in.Body)) + if prompt != "" && m.capturePrompt { + if m.redactPii { + // Apply redaction BEFORE the value lands in the metadata bag, so + // the access-log row never carries raw emails / SSNs / phones. + // The downstream llm_guardrail middleware reads this key to + // produce llm.request_prompt; RedactPII is idempotent so its + // second pass is a no-op. Redaction can grow the text, so + // re-truncate to keep the value within the metadata cap. + prompt = llm_guardrail.RedactPII(prompt) + var redactedTruncated bool + prompt, redactedTruncated = truncatePrompt(prompt) + promptTruncated = promptTruncated || redactedTruncated + } + md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt}) + } + + md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated) + out.Metadata = md + return out, nil +} + +// sessionIDHeaders are request header names that may carry a client +// session identifier, checked in order, case-insensitively. Matching is +// against Go's canonical header form, so use the hyphenated names the +// clients actually send: "x-claude-code-session-id" (Claude Code), +// "session-id" (OpenAI Codex — confirmed on the wire as "Session-Id"), +// and "x-session-id" as a generic convention. +var sessionIDHeaders = []string{"x-claude-code-session-id", "session-id", "x-session-id"} + +// sessionIDFromHeaders returns the first non-empty value among the known +// session header names, or "" when none is present. Headers arrive in +// canonical form, so the match is case-insensitive. +func sessionIDFromHeaders(headers []middleware.KV) string { + for _, want := range sessionIDHeaders { + for _, kv := range headers { + if strings.EqualFold(kv.Key, want) && kv.Value != "" { + return kv.Value + } + } + } + return "" +} + +// appendCaptureTruncated stamps the capture_truncated marker reflecting +// either prompt-side truncation or upstream body truncation. +func appendCaptureTruncated(md []middleware.KV, promptTruncated, bodyTruncated bool) []middleware.KV { + value := "false" + if promptTruncated || bodyTruncated { + value = "true" + } + return append(md, middleware.KV{Key: middleware.KeyLLMCaptureTruncated, Value: value}) +} + +// truncatePrompt clamps a prompt string to maxPromptBytes on a UTF-8 +// rune boundary. Returns the clamped string and whether truncation +// occurred. +func truncatePrompt(s string) (string, bool) { + if len(s) <= maxPromptBytes { + return s, false + } + cut := maxPromptBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut], true +} + +// extractPath returns the path component of a URL that may be absolute +// or already a path. Parse errors fall back to the raw input. +func extractPath(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil || u.Path == "" { + return raw + } + return u.Path +} + +// vertexRequest is the model + vendor extracted from a Vertex AI publisher +// path (the model is in the URL, not the body). +type vertexRequest struct { + publisher string + model string + stream bool +} + +// parseVertexPath extracts the publisher, model, and streaming flag from a +// Vertex publisher endpoint: +// +// /v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{action} +// +// The model's "@version" suffix is stripped so it matches catalog/pricing. +func parseVertexPath(reqPath string) (vertexRequest, bool) { + const pubSep, modSep = "/publishers/", "/models/" + if !strings.HasPrefix(reqPath, "/v1/projects/") { + return vertexRequest{}, false + } + pubIdx := strings.Index(reqPath, pubSep) + modIdx := strings.Index(reqPath, modSep) + if pubIdx < 0 || modIdx <= pubIdx { + return vertexRequest{}, false + } + publisher := reqPath[pubIdx+len(pubSep) : modIdx] + rest := reqPath[modIdx+len(modSep):] // {model}:{action} + if publisher == "" || rest == "" { + return vertexRequest{}, false + } + model, action := rest, "" + if c := strings.LastIndex(rest, ":"); c >= 0 { + model, action = rest[:c], rest[c+1:] + } + if at := strings.Index(model, "@"); at >= 0 { + model = model[:at] + } + if model == "" { + return vertexRequest{}, false + } + return vertexRequest{publisher: publisher, model: model, stream: strings.HasPrefix(action, "stream")}, true +} + +// vertexPublisherVendor maps a Vertex publisher to the parser surface its +// requests/responses speak. Empty for publishers without a parser yet +// (e.g. google/gemini) — the request still routes, but isn't metered. +func vertexPublisherVendor(publisher string) string { + switch strings.ToLower(publisher) { + case "anthropic": + return "anthropic" + case "openai": + return "openai" + default: + return "" + } +} + +// invokeVertex emits the model/vendor/session/prompt for a Vertex publisher +// request, using the publisher's parser to read the (vendor-native) body. +func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *middleware.Output { + out := &middleware.Output{Decision: middleware.DecisionAllow} + vendor := vertexPublisherVendor(vx.publisher) + + md := []middleware.KV{} + if vendor != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor}) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: vx.model}) + md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)}) + + var parser llm.Parser + if vendor != "" { + parser, _ = llm.ParserByName(vendor) + } + + sessionID := sessionIDFromHeaders(in.Headers) + if sessionID == "" && parser != nil { + sessionID = parser.ExtractSessionID(in.Body) + } + if sessionID != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + } + + promptTruncated := false + if parser != nil && m.capturePrompt { + var prompt string + prompt, promptTruncated = truncatePrompt(parser.ExtractPrompt(in.Body)) + if prompt != "" { + if m.redactPii { + prompt = llm_guardrail.RedactPII(prompt) + var rt bool + prompt, rt = truncatePrompt(prompt) + promptTruncated = promptTruncated || rt + } + md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt}) + } + } + md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated) + out.Metadata = md + return out +} + +// bedrockRequest is the model + streaming flag extracted from an AWS Bedrock +// model path. The InvokeModel vs Converse distinction is recovered downstream +// from the response body shape, so only the streaming flag is carried here. +type bedrockRequest struct { + model string + stream bool +} + +// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients +// put before the native Bedrock path to disambiguate it from other providers +// that also use "/model/...". +const bedrockNamespacePrefix = "/bedrock" + +// trimBedrockNamespace removes an optional "/bedrock" namespace prefix, leaving +// the native Bedrock path ("/model/..."). +func trimBedrockNamespace(reqPath string) string { + if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") { + return strings.TrimPrefix(reqPath, bedrockNamespacePrefix) + } + return reqPath +} + +// bedrockRegionPrefixes are the cross-region inference-profile prefixes that +// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). +var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + +// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" +// version/throughput suffix of a Bedrock model id. +var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) + +// parseBedrockPath extracts the model and streaming/converse flags from an AWS +// Bedrock runtime model endpoint: +// +// /model/{modelId}/{action} +// +// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream}. +// The modelId may be URL-encoded and may carry a cross-region inference-profile +// prefix and a version suffix; normalizeBedrockModel strips both so the model +// matches catalog pricing. +func parseBedrockPath(reqPath string) (bedrockRequest, bool) { + reqPath = trimBedrockNamespace(reqPath) + const prefix = "/model/" + if !strings.HasPrefix(reqPath, prefix) { + return bedrockRequest{}, false + } + rest := reqPath[len(prefix):] + slash := strings.LastIndex(rest, "/") + if slash <= 0 || slash == len(rest)-1 { + return bedrockRequest{}, false + } + rawModel, action := rest[:slash], rest[slash+1:] + if decoded, err := url.PathUnescape(rawModel); err == nil { + rawModel = decoded + } + model := normalizeBedrockModel(rawModel) + if model == "" { + return bedrockRequest{}, false + } + switch action { + case "invoke", "converse": + return bedrockRequest{model: model}, true + case "invoke-with-response-stream", "converse-stream": + return bedrockRequest{model: model, stream: true}, true + default: + return bedrockRequest{}, false + } +} + +// normalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key, e.g. +// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" +// and "arn:aws:bedrock:eu-central-1:123:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0" +// -> "anthropic.claude-sonnet-4-5". +func normalizeBedrockModel(modelID string) string { + m := modelID + // A full ARN (inference-profile / provisioned-throughput / foundation-model) + // carries the model id in its last path segment. + if strings.HasPrefix(m, "arn:") { + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + } + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") +} + +// invokeBedrock emits the model/provider/session/prompt for an AWS Bedrock +// request. Bedrock is metered under the dedicated "bedrock" parser, which reads +// both the InvokeModel and Converse response shapes. +func (m middlewareImpl) invokeBedrock(in *middleware.Input, br bedrockRequest) *middleware.Output { + out := &middleware.Output{Decision: middleware.DecisionAllow} + md := []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: llm.ProviderNameBedrock}, + {Key: middleware.KeyLLMModel, Value: br.model}, + {Key: middleware.KeyLLMStream, Value: strconv.FormatBool(br.stream)}, + } + + parser, _ := llm.ParserByName(llm.ProviderNameBedrock) + sessionID := sessionIDFromHeaders(in.Headers) + if sessionID == "" && parser != nil { + sessionID = parser.ExtractSessionID(in.Body) + } + if sessionID != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + } + + promptTruncated := false + if parser != nil && m.capturePrompt { + var prompt string + prompt, promptTruncated = truncatePrompt(parser.ExtractPrompt(in.Body)) + if prompt != "" { + if m.redactPii { + prompt = llm_guardrail.RedactPII(prompt) + var rt bool + prompt, rt = truncatePrompt(prompt) + promptTruncated = promptTruncated || rt + } + md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt}) + } + } + md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated) + out.Metadata = md + return out +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go new file mode 100644 index 000000000..bc185b295 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go @@ -0,0 +1,418 @@ +package llm_request_parser + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func newMiddleware(t *testing.T) middleware.Middleware { + t.Helper() + mw, err := Factory{}.New(nil) + require.NoError(t, err, "factory must accept nil config") + return mw +} + +func TestMiddleware_StaticSurface(t *testing.T) { + mw := newMiddleware(t) + assert.Equal(t, ID, mw.ID(), "ID must match the registered constant") + assert.Equal(t, Version, mw.Version(), "Version must match the constant") + assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "must run in the request slot") + assert.Equal(t, []string{"application/json"}, mw.AcceptedContentTypes(), "only JSON bodies are needed") + assert.False(t, mw.MutationsSupported(), "request parser never mutates") + assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op") + + keys := mw.MetadataKeys() + expected := []string{ + middleware.KeyLLMProvider, + middleware.KeyLLMModel, + middleware.KeyLLMStream, + middleware.KeyLLMRequestPromptRaw, + middleware.KeyLLMCaptureTruncated, + middleware.KeyLLMSessionID, + } + assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") +} + +func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) { + cases := [][]byte{nil, {}, []byte("null"), []byte("{}"), []byte(" ")} + for _, raw := range cases { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "empty/null/object config must be accepted") + require.NotNil(t, mw, "factory must return a middleware instance") + } +} + +func TestFactory_RejectsMalformedConfig(t *testing.T) { + mw, err := Factory{}.New([]byte("{not json")) + require.Error(t, err, "malformed config must surface at construction") + assert.Nil(t, mw, "no instance is returned on error") +} + +func TestInvoke_OpenAIBufferedChatCompletion(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"gpt-4o-mini","stream":false,"messages":[{"role":"user","content":"Hello, world!"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + require.NotNil(t, out, "output must be returned") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "request parser always allows") + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider metadata must be set") + assert.Equal(t, "openai", provider, "OpenAI provider detected from path") + + model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel) + require.True(t, ok, "model metadata must be set") + assert.Equal(t, "gpt-4o-mini", model, "model echoed from request body") + + stream, ok := metaValue(t, out.Metadata, middleware.KeyLLMStream) + require.True(t, ok, "stream metadata must be set") + assert.Equal(t, "false", stream, "buffered request reports stream=false") + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set when extractable") + assert.Contains(t, prompt, "Hello, world!", "extracted prompt carries the user message") + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated must always be emitted on success") + assert.Equal(t, "false", truncated, "no truncation on a small body") +} + +func TestInvoke_EmitsSessionID(t *testing.T) { + mw := newMiddleware(t) + + t.Run("codex session from client_metadata", func(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","client_metadata":{"session_id":"sess-codex-1"},"input":[]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/responses", Body: body}) + require.NoError(t, err) + sid, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + require.True(t, ok, "session id must be emitted for Codex requests") + assert.Equal(t, "sess-codex-1", sid, "session id must come from client_metadata.session_id") + }) + + t.Run("no session id key when absent", func(t *testing.T) { + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + assert.False(t, ok, "no session id key emitted when the request carries none") + }) + + t.Run("claude code session header", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","messages":[{"role":"user","content":"hi"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: body, + Headers: []middleware.KV{{Key: "X-Claude-Code-Session-Id", Value: "cc-sess-1"}}, + }) + require.NoError(t, err) + sid, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + require.True(t, ok, "Claude Code session id must be read from X-Claude-Code-Session-Id") + assert.Equal(t, "cc-sess-1", sid, "session id must come from the Claude Code session header") + }) + + t.Run("codex Session-Id header", func(t *testing.T) { + // Codex sends the session as the canonical header "Session-Id". + body := []byte(`{"model":"gpt-5.5","input":[]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/responses", + Body: body, + Headers: []middleware.KV{{Key: "Session-Id", Value: "sess-hdr-1"}}, + }) + require.NoError(t, err) + sid, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + require.True(t, ok, "session id must be read from the Session-Id header") + assert.Equal(t, "sess-hdr-1", sid, "session id must come from the Codex Session-Id header") + }) + + t.Run("header wins over body and survives bypassed body", func(t *testing.T) { + // Oversized request: body was bypassed to a routing stub with no + // client_metadata, but the header still carries the session. + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/responses", + Body: []byte(`{"model":"gpt-5.5","stream":true}`), + Headers: []middleware.KV{{Key: "X-Session-Id", Value: "sess-hdr-2"}}, + }) + require.NoError(t, err) + sid, _ := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + assert.Equal(t, "sess-hdr-2", sid, "x-session-id header must be honoured when the body carries no marker") + }) +} + +func TestInvoke_OpenAIStreamingChatCompletion(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + + stream, ok := metaValue(t, out.Metadata, middleware.KeyLLMStream) + require.True(t, ok, "stream metadata must be set") + assert.Equal(t, "true", stream, "stream flag echoed for SSE-bound request") +} + +func TestInvoke_AnthropicMessages(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"messages":[{"role":"user","content":"What is the weather?"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: body, + }) + require.NoError(t, err) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider metadata must be set") + assert.Equal(t, "anthropic", provider, "Anthropic provider detected from path") + + model, _ := metaValue(t, out.Metadata, middleware.KeyLLMModel) + assert.Equal(t, "claude-sonnet-4-5", model, "anthropic model echoed") + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set") + assert.Contains(t, prompt, "What is the weather?", "anthropic message text extracted") +} + +func TestInvoke_UnknownURLNoMetadata(t *testing.T) { + mw := newMiddleware(t) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/healthz", + Body: []byte(`{"model":"x"}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "unknown paths still allow") + assert.Empty(t, out.Metadata, "no metadata is emitted when no parser matches") +} + +func TestInvoke_ProviderIDConfigBypassesURLSniff(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"provider_id":"openai"}`)) + require.NoError(t, err, "factory must accept provider_id config") + + // URL doesn't match any of the OpenAI path hints — the provider_id + // config is the only signal the middleware has. + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/custom/gateway/foo/bar", + Body: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hi"}]}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider must be emitted when provider_id is configured even on unknown URLs") + assert.Equal(t, "openai", provider, "provider_id config selects the OpenAI parser") + + model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel) + require.True(t, ok, "model still extracted from the body") + assert.Equal(t, "gpt-4o-mini", model) +} + +func TestInvoke_UnknownProviderIDFallsBackToURL(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"provider_id":"not-a-real-parser"}`)) + require.NoError(t, err, "factory must accept any provider_id string") + + // URL hits the OpenAI surface, so URL sniffing should still resolve + // even though the configured provider_id doesn't match a parser. + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: []byte(`{"model":"gpt-4o-mini"}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "fallback URL sniffing must populate the provider") + assert.Equal(t, "openai", provider) +} + +func TestInvoke_MalformedBodyAllowsWithProvider(t *testing.T) { + mw := newMiddleware(t) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: []byte(`{not json`), + }) + require.NoError(t, err, "malformed body must not error") + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "decision is always allow") + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider metadata is emitted before body parse") + assert.Equal(t, "openai", provider, "provider stays even when body parse fails") + + _, hasModel := metaValue(t, out.Metadata, middleware.KeyLLMModel) + assert.False(t, hasModel, "no model metadata when parse fails") + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated is emitted on parse error path") + assert.Equal(t, "false", truncated, "no truncation marker without truncated body or prompt") +} + +func TestInvoke_TruncatesLongPrompt(t *testing.T) { + mw := newMiddleware(t) + long := strings.Repeat("x", maxPromptBytes*2) + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"` + long + `"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set") + assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must respect the byte budget") + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated must be set") + assert.Equal(t, "true", truncated, "truncation marker raised when prompt is clipped") +} + +func TestInvoke_TruncatesOnRuneBoundary(t *testing.T) { + mw := newMiddleware(t) + // Each ☃ is 3 bytes in UTF-8; build a string whose byte length exceeds + // maxPromptBytes with snowmen straddling the cut point. + rune3 := "☃" + repeats := (maxPromptBytes / len(rune3)) + 5 + long := strings.Repeat(rune3, repeats) + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"` + long + `"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set") + assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must respect the byte budget") + assert.True(t, strings.HasSuffix(prompt, rune3) || !strings.ContainsRune(prompt[len(prompt)-1:], 0xFFFD), + "truncation must not split a multi-byte rune") +} + +func TestInvoke_BodyTruncatedRaisesCaptureTruncated(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + BodyTruncated: true, + }) + require.NoError(t, err) + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated must be set") + assert.Equal(t, "true", truncated, "BodyTruncated input flips the marker even when prompt fits") +} + +// TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt covers the GC contract: +// when the synthesiser sets redact_pii=true on the parser config, the value +// emitted as llm.request_prompt_raw must already be redacted, so the +// access-log row never carries raw emails / SSNs / phones — even though the +// downstream llm_guardrail middleware also runs. +func TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"redact_pii":true}`)) + require.NoError(t, err) + + body := []byte(`{"model":"gpt-4o-mini","stream":false,"messages":[{"role":"user","content":"contact alice.johnson@example.com SSN 123-45-6789 phone (202) 555-0147 and bob 202/555/0108"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + require.NotNil(t, out) + + raw, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "raw prompt key must still be emitted") + assert.Contains(t, raw, "[REDACTED:email]", "email must be redacted before emit") + assert.Contains(t, raw, "[REDACTED:ssn]", "ssn must be redacted before emit") + assert.Contains(t, raw, "[REDACTED:phone]", "phone must be redacted before emit") + assert.NotContains(t, raw, "alice.johnson@example.com", "raw email must not survive") + assert.NotContains(t, raw, "123-45-6789", "raw SSN must not survive") + assert.NotContains(t, raw, "(202) 555-0147", "parenthesised phone must not survive") + assert.NotContains(t, raw, "202/555/0108", "slash-separated phone must not survive") +} + +// TestInvoke_CapturePromptOff_DoesNotEmitRawPrompt covers the contract for +// the account-level enable_prompt_collection toggle: when the synthesiser sets +// capture_prompt=false (operator hasn't opted in to prompt content), the +// parser MUST NOT emit llm.request_prompt_raw at all — otherwise the access +// log carries the user's input even though log collection is meant to be +// metadata-only (provider, model, tokens, cost). The other facts the parser +// emits (provider, model, stream, capture_truncated) stay. +func TestInvoke_CapturePromptOff_DoesNotEmitRawPrompt(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"capture_prompt":false}`)) + require.NoError(t, err) + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"contact alice@example.com SSN 123-45-6789"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + require.NotNil(t, out) + + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + assert.False(t, ok, "llm.request_prompt_raw must NOT be emitted when capture_prompt is false") + // Non-content facts must still flow. + _, ok = metaValue(t, out.Metadata, middleware.KeyLLMModel) + assert.True(t, ok, "model fact must still be emitted") + _, ok = metaValue(t, out.Metadata, middleware.KeyLLMProvider) + assert.True(t, ok, "provider fact must still be emitted") +} + +// TestInvoke_CapturePromptUnset_PreservesLegacyEmission documents the default +// behavior: an empty / legacy config (no capture_prompt field) keeps the +// existing emission, so non-agent-network callers and pre-toggle tests don't +// suddenly lose data. +func TestInvoke_CapturePromptUnset_PreservesLegacyEmission(t *testing.T) { + mw, err := Factory{}.New([]byte(`{}`)) + require.NoError(t, err) + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + assert.True(t, ok, "absent capture_prompt must preserve emission (backwards-compatible default)") +} + +// TestInvoke_RedactPii_OffShipsRawPrompt is the inverse: when redact_pii is +// false (default) the operator opted out and the raw prompt is shipped +// verbatim, so audit / debugging consumers still get the full body. +func TestInvoke_RedactPii_OffShipsRawPrompt(t *testing.T) { + mw, err := Factory{}.New([]byte(`{}`)) + require.NoError(t, err) + + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"alice.johnson@example.com"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + + raw, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok) + assert.Contains(t, raw, "alice.johnson@example.com", "redact off → raw email passes through") + assert.NotContains(t, raw, "[REDACTED:", "redact off → no markers") +} + +func TestInvoke_NilInputAllows(t *testing.T) { + mw := newMiddleware(t) + out, err := mw.Invoke(context.Background(), nil) + require.NoError(t, err, "nil input must not panic or error") + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "nil input still allows") + assert.Empty(t, out.Metadata, "nil input emits no metadata") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/factory.go b/proxy/internal/middleware/builtin/llm_response_parser/factory.go new file mode 100644 index 000000000..e7d634109 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/factory.go @@ -0,0 +1,43 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// Factory constructs configured Middleware instances for the registry. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New decodes RawConfig (empty / null / "{}" all accepted) and returns +// a configured Middleware. Construction never fails on a well-formed +// empty config; only structurally invalid JSON is rejected. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg, err := decodeConfig(rawConfig) + if err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + return New(cfg), nil +} + +func decodeConfig(raw []byte) (config, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return config{}, nil + } + var cfg config + if err := json.Unmarshal(trimmed, &cfg); err != nil { + return config{}, err + } + return cfg, nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go b/proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go new file mode 100644 index 000000000..017153e4c --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go @@ -0,0 +1,133 @@ +package llm_response_parser + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// gzipBytes returns data gzip-compressed — the wire shape Anthropic +// returns when the client (Claude Code) negotiated Accept-Encoding: gzip. +func gzipBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, err := w.Write(data) + require.NoError(t, err, "gzip write must succeed") + require.NoError(t, w.Close(), "gzip close must succeed") + return buf.Bytes() +} + +// TestInvoke_AnthropicStreaming_Gzip is the regression guard for the live +// bug: Claude Code negotiates gzip, Anthropic gzips the SSE stream, the +// proxy captures the compressed bytes, and the parser must decompress +// before accumulating — otherwise token usage is silently dropped and +// cost_meter skips with missing_tokens. +func TestInvoke_AnthropicStreaming_Gzip(t *testing.T) { + m := newTestMiddleware(t) + body := gzipBytes(t, loadFixture(t, "anthropic_stream.txt")) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{ + {Key: "Content-Type", Value: "text/event-stream; charset=utf-8"}, + {Key: "Content-Encoding", Value: "gzip"}, + }, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-opus-4-8"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a gzip-encoded streaming body") + + in123, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted from a gzip SSE stream") + assert.Equal(t, "123", in123, "input tokens must survive gzip decompression") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must survive gzip decompression") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must survive gzip decompression") +} + +// TestInvoke_AnthropicBuffered_Gzip covers the non-streaming JSON path +// under gzip — the same decode must happen before ParseResponse. +func TestInvoke_AnthropicBuffered_Gzip(t *testing.T) { + m := newTestMiddleware(t) + body := gzipBytes(t, loadFixture(t, "anthropic_messages.json")) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{ + {Key: "Content-Type", Value: "application/json"}, + {Key: "Content-Encoding", Value: "gzip"}, + }, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-opus-4-8"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a gzip-encoded buffered body") + + _, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted from a gzip JSON body") +} + +// TestDecodeResponseBody covers the encoding matrix directly. +func TestDecodeResponseBody(t *testing.T) { + plain := []byte(`{"hello":"world"}`) + + t.Run("identity passthrough", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(plain, "")) + assert.Equal(t, plain, decodeResponseBody(plain, "identity")) + }) + + t.Run("gzip", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(gzipBytes(t, plain), "gzip")) + }) + + t.Run("gzip with multi-coding header takes outermost", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(gzipBytes(t, plain), "identity, gzip")) + }) + + t.Run("deflate zlib-wrapped", func(t *testing.T) { + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + _, _ = zw.Write(plain) + _ = zw.Close() + assert.Equal(t, plain, decodeResponseBody(buf.Bytes(), "deflate")) + }) + + t.Run("deflate raw flate fallback", func(t *testing.T) { + var buf bytes.Buffer + fw, _ := flate.NewWriter(&buf, flate.DefaultCompression) + _, _ = fw.Write(plain) + _ = fw.Close() + assert.Equal(t, plain, decodeResponseBody(buf.Bytes(), "deflate")) + }) + + t.Run("gzip header but not actually gzip falls back to raw", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(plain, "gzip")) + }) + + t.Run("unknown encoding (br) returns raw", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(plain, "br")) + }) +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/middleware.go b/proxy/internal/middleware/builtin/llm_response_parser/middleware.go new file mode 100644 index 000000000..a204460cb --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/middleware.go @@ -0,0 +1,339 @@ +// Package llm_response_parser implements the SlotOnResponse middleware +// that decodes OpenAI- and Anthropic-shaped LLM responses (buffered or +// streaming) and emits token usage and completion metadata. Provider +// and model are read from the request-side metadata bag emitted by +// llm_request_parser; without that context the middleware is a no-op. +package llm_response_parser + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "io" + "strconv" + "strings" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/llm" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_response_parser" + +const version = "1.0.0" + +// maxCompletionBytes is the rune-safe cap applied to the extracted +// completion text before emitting it as metadata. +const maxCompletionBytes = 3500 + +// maxDecodedBytes bounds the inflated size of a compressed response body +// so a small gzip/deflate payload can't expand into a memory blow-up. The +// captured input is already capped (per-direction body cap), so this only +// bounds the decompression ratio; the parser is best-effort and tolerates a +// truncated decode. +const maxDecodedBytes = 16 << 20 + +var ( + acceptedContentTypes = []string{"application/json", "text/event-stream"} + metadataKeys = []string{ + middleware.KeyLLMInputTokens, + middleware.KeyLLMOutputTokens, + middleware.KeyLLMTotalTokens, + middleware.KeyLLMCachedInputTokens, + middleware.KeyLLMCacheCreationTokens, + middleware.KeyLLMResponseCompletion, + } +) + +// config is the wire-side configuration for this middleware. RedactPii, when +// true, runs PII redaction on the extracted completion text BEFORE it is +// emitted as llm.response_completion — keeping the access-log row free of +// emails / SSNs / phone numbers the model itself generated. CaptureCompletion +// gates emission of the completion key entirely: a nil pointer preserves +// legacy emission (so callers without the toggle aren't broken), an explicit +// false suppresses the key so the access-log row carries token / cost facts +// only. Both are sourced by the synthesiser from the account's redact_pii +// and enable_prompt_collection toggles respectively. +type config struct { + RedactPii bool `json:"redact_pii,omitempty"` + CaptureCompletion *bool `json:"capture_completion,omitempty"` +} + +// Middleware implements middleware.Middleware. +type Middleware struct { + parsers []llm.Parser + redactPii bool + captureCompletion bool +} + +// New constructs a configured Middleware instance. +func New(cfg config) *Middleware { + capture := true + if cfg.CaptureCompletion != nil { + capture = *cfg.CaptureCompletion + } + return &Middleware{parsers: llm.Parsers(), redactPii: cfg.RedactPii, captureCompletion: capture} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return version } + +// Slot reports that the middleware runs after the upstream call. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } + +// AcceptedContentTypes lists the response content types the middleware +// inspects. +func (m *Middleware) AcceptedContentTypes() []string { + return append([]string(nil), acceptedContentTypes...) +} + +// MetadataKeys returns the closed allowlist of keys this middleware +// may emit. +func (m *Middleware) MetadataKeys() []string { + return append([]string(nil), metadataKeys...) +} + +// MutationsSupported reports that this middleware never mutates the +// response. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close releases any resources held by the middleware. The parser-set +// is stateless so this is a no-op. +func (m *Middleware) Close() error { return nil } + +// Invoke decodes the response body and emits token-usage and completion +// metadata. The decision is always DecisionAllow; parse errors degrade +// silently to omitted metadata rather than chain failures. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if in == nil { + return out, nil + } + + provider := lookupKV(in.Metadata, middleware.KeyLLMProvider) + if provider == "" { + return out, nil + } + + parser := m.parserByName(provider) + if parser == nil { + return out, nil + } + + // Upstreams compress the response when the client negotiated it + // (Claude Code sends Accept-Encoding: gzip). The transport leaves it + // compressed because the request carried an explicit Accept-Encoding, + // so the captured copy is gzip/deflate bytes — decompress it before + // parsing or token usage is silently lost. The forwarded client + // stream is untouched; this only affects our parse copy. + body := decodeResponseBody(in.RespBody, headerLookup(in.RespHeaders, "Content-Encoding")) + + contentType := headerLookup(in.RespHeaders, "Content-Type") + switch { + case isEventStream(contentType), isAWSEventStream(contentType): + out.Metadata = m.invokeStreaming(parser, body) + case isJSON(contentType): + out.Metadata = m.invokeBuffered(parser, in, contentType, body) + } + + return out, nil +} + +// invokeBuffered decodes a non-streaming JSON response body. Status +// codes >= 400 short-circuit because providers don't include usage on +// error responses. +func (m *Middleware) invokeBuffered(parser llm.Parser, in *middleware.Input, contentType string, body []byte) []middleware.KV { + if in.Status >= 400 { + return nil + } + + var md []middleware.KV + + usage, err := parser.ParseResponse(in.Status, contentType, body) + if err == nil { + md = appendUsage(md, usage) + } + + if completion := truncateCompletion(parser.ExtractCompletion(in.Status, contentType, body)); completion != "" && m.captureCompletion { + if m.redactPii { + completion = llm_guardrail.RedactPII(completion) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMResponseCompletion, Value: completion}) + } + + return md +} + +// invokeStreaming walks the buffered SSE prefix and accumulates token +// deltas plus completion text. Truncated bodies are processed +// best-effort; partial usage is preferred over no metadata. +func (m *Middleware) invokeStreaming(parser llm.Parser, body []byte) []middleware.KV { + if len(body) == 0 { + return nil + } + + usage, completion := accumulateStream(parser.ProviderName(), body) + + var md []middleware.KV + if usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.TotalTokens > 0 { + md = appendUsage(md, usage) + } + if c := truncateCompletion(completion); c != "" && m.captureCompletion { + if m.redactPii { + c = llm_guardrail.RedactPII(c) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMResponseCompletion, Value: c}) + } + return md +} + +// parserByName returns the parser matching the provider label emitted +// by llm_request_parser, or nil when none claims it. +func (m *Middleware) parserByName(name string) llm.Parser { + for _, p := range m.parsers { + if p.ProviderName() == name { + return p + } + } + return nil +} + +// appendUsage emits the three baseline token-count metadata keys plus +// optional cached / cache-creation bucket counts when nonzero. Total +// is computed when the provider omitted one but reported per-direction +// counts; cache buckets are excluded from the legacy total because +// llm.input_tokens already absorbs the OpenAI cached subset and the +// sum-of-everything is a separate downstream concern. +func appendUsage(md []middleware.KV, usage llm.Usage) []middleware.KV { + total := usage.TotalTokens + if total == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { + total = usage.InputTokens + usage.OutputTokens + } + md = append(md, + middleware.KV{Key: middleware.KeyLLMInputTokens, Value: strconv.FormatInt(usage.InputTokens, 10)}, + middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: strconv.FormatInt(usage.OutputTokens, 10)}, + middleware.KV{Key: middleware.KeyLLMTotalTokens, Value: strconv.FormatInt(total, 10)}, + ) + if usage.CachedInputTokens > 0 { + md = append(md, middleware.KV{ + Key: middleware.KeyLLMCachedInputTokens, + Value: strconv.FormatInt(usage.CachedInputTokens, 10), + }) + } + if usage.CacheCreationTokens > 0 { + md = append(md, middleware.KV{ + Key: middleware.KeyLLMCacheCreationTokens, + Value: strconv.FormatInt(usage.CacheCreationTokens, 10), + }) + } + return md +} + +// truncateCompletion clamps an extracted completion to maxCompletionBytes. +// The cut is rune-safe so we never split a multi-byte UTF-8 sequence. +func truncateCompletion(s string) string { + if len(s) <= maxCompletionBytes { + return s + } + cut := maxCompletionBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} + +func lookupKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} + +func headerLookup(h []middleware.KV, name string) string { + lower := strings.ToLower(name) + for _, kv := range h { + if strings.ToLower(kv.Key) == lower { + return kv.Value + } + } + return "" +} + +func isEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/event-stream") +} + +// isAWSEventStream reports whether contentType is the AWS binary event-stream +// framing used by Bedrock's streaming endpoints. +func isAWSEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "application/vnd.amazon.eventstream") +} + +func isJSON(contentType string) bool { + lower := strings.ToLower(contentType) + return strings.Contains(lower, "application/json") || strings.Contains(lower, "+json") +} + +// decodeResponseBody returns body decompressed per its Content-Encoding, +// or the original bytes when the encoding is identity, unrecognised +// (e.g. br — no stdlib decoder), or the body isn't actually compressed. +// Decoding is best-effort: a truncated stream (capture hit the byte cap) +// yields the decompressed prefix rather than an error, which is enough to +// recover the leading message_start usage on Anthropic SSE. +func decodeResponseBody(body []byte, contentEncoding string) []byte { + enc := strings.ToLower(strings.TrimSpace(contentEncoding)) + // Content-Encoding may list multiple codings; the last applied is + // the outermost on the wire. + if idx := strings.LastIndex(enc, ","); idx >= 0 { + enc = strings.TrimSpace(enc[idx+1:]) + } + switch enc { + case "", "identity": + return body + case "gzip", "x-gzip": + zr, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return body + } + defer zr.Close() + if out := readCapped(zr); len(out) > 0 { + return out + } + return body + case "deflate": + // "deflate" on the wire is usually zlib-wrapped; fall back to raw + // flate when there's no zlib header. + if zr, err := zlib.NewReader(bytes.NewReader(body)); err == nil { + defer zr.Close() + if out := readCapped(zr); len(out) > 0 { + return out + } + return body + } + fr := flate.NewReader(bytes.NewReader(body)) + defer fr.Close() + if out := readCapped(fr); len(out) > 0 { + return out + } + return body + default: + return body + } +} + +// readCapped reads at most maxDecodedBytes from r, discarding any excess. +// Best-effort: a read error returns whatever was decoded so far, which is +// enough for the parser to recover leading usage events. +func readCapped(r io.Reader) []byte { + out, _ := io.ReadAll(io.LimitReader(r, maxDecodedBytes)) + return out +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go b/proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go new file mode 100644 index 000000000..084118802 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go @@ -0,0 +1,433 @@ +package llm_response_parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + root, err := os.Getwd() + require.NoError(t, err, "must resolve cwd to locate fixture") + + dir := root + for i := 0; i < 8; i++ { + candidate := filepath.Join(dir, "proxy", "internal", "llm", "fixtures", name) + if data, err := os.ReadFile(candidate); err == nil { + return data + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatalf("fixture %q not found relative to %q", name, root) + return nil +} + +func metaValue(kvs []middleware.KV, key string) (string, bool) { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func newTestMiddleware(t *testing.T) *Middleware { + t.Helper() + mw, err := Factory{}.New(nil) + require.NoError(t, err, "factory must accept empty config") + concrete, ok := mw.(*Middleware) + require.True(t, ok, "factory must return *Middleware") + return concrete +} + +func TestMiddleware_StaticSurface(t *testing.T) { + m := newTestMiddleware(t) + assert.Equal(t, ID, m.ID(), "ID must match registry constant") + assert.Equal(t, "1.0.0", m.Version(), "Version must be 1.0.0") + assert.Equal(t, middleware.SlotOnResponse, m.Slot(), "Slot must be SlotOnResponse") + assert.False(t, m.MutationsSupported(), "response parser does not mutate") + assert.ElementsMatch(t, + []string{"application/json", "text/event-stream"}, + m.AcceptedContentTypes(), + "AcceptedContentTypes must list JSON and SSE", + ) + assert.ElementsMatch(t, + []string{ + middleware.KeyLLMInputTokens, + middleware.KeyLLMOutputTokens, + middleware.KeyLLMTotalTokens, + middleware.KeyLLMCachedInputTokens, + middleware.KeyLLMCacheCreationTokens, + middleware.KeyLLMResponseCompletion, + }, + m.MetadataKeys(), + "MetadataKeys must be the documented response-side keys, including the optional cache buckets emitted only when nonzero", + ) + require.NoError(t, m.Close(), "Close must be a no-op") +} + +func TestFactory_AcceptsEmptyAndNullConfig(t *testing.T) { + for name, raw := range map[string][]byte{ + "nil": nil, + "empty": {}, + "null": []byte("null"), + "obj": []byte("{}"), + "ws": []byte(" "), + } { + t.Run(name, func(t *testing.T) { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "factory must accept %s config", name) + require.NotNil(t, mw, "factory must return middleware for %s", name) + }) + } +} + +func TestFactory_RejectsMalformedJSON(t *testing.T) { + _, err := Factory{}.New([]byte("not-json")) + require.Error(t, err, "malformed config must surface a decode error") +} + +func TestInvoke_OpenAIBuffered(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "openai_chat_completion.json") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o-mini"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a valid buffered response") + require.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow") + + in123, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted") + assert.Equal(t, "123", in123, "input tokens must match fixture prompt_tokens") + + outTok, ok := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + require.True(t, ok, "output tokens must be emitted") + assert.Equal(t, "45", outTok, "output tokens must match fixture completion_tokens") + + totTok, ok := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + require.True(t, ok, "total tokens must be emitted") + assert.Equal(t, "168", totTok, "total tokens must match fixture") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted") + assert.Equal(t, "Hello, world!", completion, "completion text must match fixture") +} + +func TestInvoke_AnthropicBuffered(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "anthropic_messages.json") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a valid buffered response") + require.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow") + + in123, _ := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.Equal(t, "123", in123, "input tokens must match anthropic fixture") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must match anthropic fixture") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must be input+output for anthropic") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for anthropic") + assert.Equal(t, "Hello, world!", completion, "completion text must match fixture") +} + +// TestInvoke_OpenAICachedTokensSurfaceOnMetadata covers the +// end-to-end path from the JSON usage block to the +// llm.cached_input_tokens metadata key the cost meter consumes. +// llm.cache_creation_tokens is NOT emitted for OpenAI because +// OpenAI has no cache_creation analogue. +func TestInvoke_OpenAICachedTokensSurfaceOnMetadata(t *testing.T) { + m := newTestMiddleware(t) + body := []byte(`{"usage":{"prompt_tokens":1024,"completion_tokens":200,"total_tokens":1224,"prompt_tokens_details":{"cached_tokens":768}}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err) + cached, ok := metaValue(out.Metadata, middleware.KeyLLMCachedInputTokens) + require.True(t, ok, "cached_input_tokens must land on the bag when the OpenAI response carries cached_tokens") + assert.Equal(t, "768", cached) + + _, hasCreation := metaValue(out.Metadata, middleware.KeyLLMCacheCreationTokens) + assert.False(t, hasCreation, "cache_creation_tokens must NOT be emitted for OpenAI — no analogue in the OpenAI shape") +} + +// TestInvoke_AnthropicCacheBucketsSurfaceOnMetadata covers the +// Anthropic shape: both cache_read and cache_creation values flow +// onto the metadata bag so the cost meter can apply per-bucket +// rates. +func TestInvoke_AnthropicCacheBucketsSurfaceOnMetadata(t *testing.T) { + m := newTestMiddleware(t) + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err) + + cached, ok := metaValue(out.Metadata, middleware.KeyLLMCachedInputTokens) + require.True(t, ok, "cache_read_input_tokens lands under cached_input_tokens — same key carries OpenAI cached subset and Anthropic cache reads, meter switches formula on provider") + assert.Equal(t, "768", cached) + + creation, ok := metaValue(out.Metadata, middleware.KeyLLMCacheCreationTokens) + require.True(t, ok, "cache_creation_input_tokens lands under cache_creation_tokens for Anthropic") + assert.Equal(t, "512", creation) +} + +func TestInvoke_NoProviderMetadata_NoOp(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: loadFixture(t, "openai_chat_completion.json"), + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "missing provider metadata is not an error") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow") + assert.Empty(t, out.Metadata, "no metadata when provider context is missing") +} + +func TestInvoke_UnknownProvider_NoOp(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: loadFixture(t, "openai_chat_completion.json"), + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "cohere"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "unknown provider must not surface an error") + assert.Empty(t, out.Metadata, "unknown providers emit no metadata") +} + +func TestInvoke_ErrorStatus_NoUsageEmitted(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 500, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: []byte(`{"error":{"message":"upstream blew up"}}`), + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "error responses must not surface as middleware error") + _, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.False(t, ok, "no usage metadata on >=400 responses") +} + +func TestInvoke_NonInspectedContentType_NoOp(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/plain"}}, + RespBody: []byte("not json"), + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must tolerate non-inspected content types") + assert.Empty(t, out.Metadata, "no metadata for non-JSON, non-SSE bodies") +} + +func TestInvoke_NilInput(t *testing.T) { + m := newTestMiddleware(t) + out, err := m.Invoke(context.Background(), nil) + require.NoError(t, err, "nil input must not error") + require.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow even on nil input") + assert.Empty(t, out.Metadata, "no metadata for nil input") +} + +func TestInvoke_CompletionTruncatedAt3500Bytes(t *testing.T) { + m := newTestMiddleware(t) + long := strings.Repeat("x", 5000) + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"` + long + `"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "long-completion body must parse cleanly") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for long body") + assert.LessOrEqual(t, len(completion), maxCompletionBytes, "completion must be truncated to <=3500 bytes") + assert.Equal(t, maxCompletionBytes, len(completion), "completion must be truncated exactly at the cap when input is ASCII and longer") +} + +// TestInvoke_RedactPii_RedactsCompletionBeforeEmit covers the GC contract on +// the response leg: when the synthesiser sets redact_pii=true, the value +// emitted as llm.response_completion must already be redacted, so the +// access-log row never carries raw emails / SSNs / phones the model generated. +// Without this, the response side leaked dozens of raw PII tokens per request. +func TestInvoke_RedactPii_RedactsCompletionBeforeEmit(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"redact_pii":true}`)) + require.NoError(t, err) + + piiCompletion := "Sample record: Alice Johnson, alice.johnson@example.com, SSN 123-45-6789, phone (202) 555-0147. Bob: 202/555/0108." + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"` + piiCompletion + `"}}],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion key must be emitted") + assert.Contains(t, completion, "[REDACTED:email]", "email must be redacted before emit") + assert.Contains(t, completion, "[REDACTED:ssn]", "ssn must be redacted before emit") + assert.Contains(t, completion, "[REDACTED:phone]", "phone must be redacted before emit") + assert.NotContains(t, completion, "alice.johnson@example.com", "raw email must not survive") + assert.NotContains(t, completion, "123-45-6789", "raw SSN must not survive") + assert.NotContains(t, completion, "(202) 555-0147", "parens-phone must not survive") + assert.NotContains(t, completion, "202/555/0108", "slash-phone must not survive") +} + +// TestInvoke_CaptureCompletionOff_DoesNotEmitCompletion mirrors the request +// parser test: when capture_completion=false (operator has enable_prompt_ +// collection off), llm.response_completion MUST NOT appear in the access log. +// The token / cost / usage facts the response parser also emits stay so +// operators still get billing data on log-only mode. +func TestInvoke_CaptureCompletionOff_DoesNotEmitCompletion(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"capture_completion":false}`)) + require.NoError(t, err) + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"alice@example.com 123-45-6789"}}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + + _, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + assert.False(t, ok, "llm.response_completion must NOT be emitted when capture_completion is false") + + // Token facts must still flow. + _, ok = metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.True(t, ok, "input tokens fact must still be emitted") + _, ok = metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.True(t, ok, "output tokens fact must still be emitted") +} + +// TestInvoke_CaptureCompletionUnset_PreservesLegacyEmission documents the +// default behavior: empty config keeps emitting completion, so callers +// without the toggle aren't broken. +func TestInvoke_CaptureCompletionUnset_PreservesLegacyEmission(t *testing.T) { + mw, err := Factory{}.New([]byte(`{}`)) + require.NoError(t, err) + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"hello"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + _, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + assert.True(t, ok, "absent capture_completion must preserve emission (backwards-compatible default)") +} + +// TestInvoke_RedactPii_OffShipsRawCompletion covers the inverse: with +// redact_pii=false (default) the model output is shipped verbatim. +func TestInvoke_RedactPii_OffShipsRawCompletion(t *testing.T) { + mw, err := Factory{}.New(nil) + require.NoError(t, err) + + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"alice@example.com 123-45-6789"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok) + assert.Contains(t, completion, "alice@example.com", "redact off → raw email passes through") + assert.Contains(t, completion, "123-45-6789", "redact off → raw SSN passes through") + assert.NotContains(t, completion, "[REDACTED:", "redact off → no markers") +} + +func TestInvoke_CompletionTruncationRuneSafe(t *testing.T) { + rune4 := "\xf0\x9f\x98\x80" // 4-byte emoji + body := strings.Repeat("a", maxCompletionBytes-1) + rune4 + require.Greater(t, len(body), maxCompletionBytes, "test setup must exceed the cap") + + got := truncateCompletion(body) + assert.True(t, len(got) < maxCompletionBytes, "truncated bytes must drop the partial rune entirely") + assert.NotContains(t, got, "\x80", "truncated text must not end on a continuation byte") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go b/proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go new file mode 100644 index 000000000..0475b8cca --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go @@ -0,0 +1,69 @@ +package llm_response_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// TestInvoke_OpenAIResponsesStreaming is the regression guard for the live +// bug where Codex hits /v1/responses (the OpenAI Responses API), whose SSE +// shape differs from chat.completions: completion text rides +// response.output_text.delta and usage rides response.completed under +// response.usage. The old parser only knew the chat.completions shape, so +// resp_meta came back empty (no tokens, no cost). +func TestInvoke_OpenAIResponsesStreaming(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "openai_responses_stream.txt") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream; charset=utf-8"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-5.5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a Responses-API streaming body") + + inTok, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted from a Responses-API stream") + assert.Equal(t, "123", inTok, "input_tokens must come from response.completed usage") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output_tokens must come from response.completed usage") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total_tokens must come from response.completed usage") + + cached, ok := metaValue(out.Metadata, middleware.KeyLLMCachedInputTokens) + require.True(t, ok, "cached input tokens must surface from input_tokens_details") + assert.Equal(t, "40", cached, "cached_tokens subset must surface for cost discounting") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for Responses-API streams") + assert.Equal(t, "Hello, world!", completion, "output_text.delta events must concatenate") +} + +// TestAccumulateOpenAIStream_ResponsesNoUsage confirms that a Responses-API +// stream with text but no terminal usage frame still yields the completion +// and leaves tokens at zero rather than erroring. +func TestAccumulateOpenAIStream_ResponsesNoUsage(t *testing.T) { + body := []byte(`event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"partial"} + +`) + + usage, completion := accumulateOpenAIStream(body) + assert.Equal(t, int64(0), usage.InputTokens, "no usage frame leaves input tokens at zero") + assert.Equal(t, int64(0), usage.OutputTokens, "no usage frame leaves output tokens at zero") + assert.Equal(t, "partial", completion, "output_text deltas accumulate even without a usage frame") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming.go new file mode 100644 index 000000000..6462ab4ae --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming.go @@ -0,0 +1,270 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/llm" +) + +// openAIDoneSentinel is the OpenAI end-of-stream marker. The scanner +// stops once this data frame is observed. +const openAIDoneSentinel = "[DONE]" + +// accumulateStream walks the SSE byte slice, dispatches per provider, +// and returns the running token-usage and concatenated completion text. +// Errors from the scanner short-circuit accumulation but never panic +// — partial results are returned for truncated bodies. +func accumulateStream(provider string, body []byte) (llm.Usage, string) { + switch provider { + case "openai": + return accumulateOpenAIStream(body) + case "anthropic": + return accumulateAnthropicStream(body) + case llm.ProviderNameBedrock: + return accumulateBedrockStream(body) + default: + return llm.Usage{}, "" + } +} + +// openAIStreamUsage is the usage block shared by both OpenAI streaming +// envelopes. Pointer fields tell "absent" from zero; the chat.completions +// (prompt_/completion_) and Responses-API (input_/output_) names are both +// accepted so a single decode covers either endpoint. +type openAIStreamUsage struct { + PromptTokens *int64 `json:"prompt_tokens"` + CompletionTokens *int64 `json:"completion_tokens"` + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + TotalTokens *int64 `json:"total_tokens"` + PromptTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + InputTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"input_tokens_details"` +} + +// openAIStreamChunk matches both OpenAI streaming envelopes. The +// chat.completions chunk carries text in choices[].delta.content and a +// trailing top-level usage block. The Responses API (/v1/responses) emits +// typed events instead: completion text rides response.output_text.delta +// (top-level "delta" string) and the final usage rides response.completed +// under response.usage. Only fields used for accumulation are declared. +type openAIStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + Usage *openAIStreamUsage `json:"usage"` + + Type string `json:"type"` + Delta json.RawMessage `json:"delta"` + Response *struct { + Usage *openAIStreamUsage `json:"usage"` + } `json:"response"` +} + +// accumulateOpenAIStream sums per-chunk content deltas and lifts the usage +// block off the final frame, handling both the chat.completions and the +// Responses-API event shapes. Clients without stream_options.include_usage +// (chat.completions) and any provider that omits the final usage simply +// leave tokens at zero; the caller chooses what to emit. +func accumulateOpenAIStream(body []byte) (llm.Usage, string) { + var ( + usage llm.Usage + completion strings.Builder + ) + scanner := llm.NewScanner(bytes.NewReader(body)) + for { + ev, err := scanner.Next() + if err != nil { + break + } + if ev.Data == openAIDoneSentinel { + break + } + if ev.Data == "" { + continue + } + + var chunk openAIStreamChunk + if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil { + continue + } + for _, c := range chunk.Choices { + completion.WriteString(c.Delta.Content) + } + if chunk.Type == "response.output_text.delta" { + if s, ok := decodeJSONString(chunk.Delta); ok { + completion.WriteString(s) + } + } + + u := chunk.Usage + if u == nil && chunk.Response != nil { + u = chunk.Response.Usage + } + applyOpenAIStreamUsage(u, &usage) + } + return usage, completion.String() +} + +// applyOpenAIStreamUsage lifts the token counts off a final-frame usage +// block into the running usage, normalising the chat.completions +// (prompt_/completion_) and Responses-API (input_/output_) names and +// backfilling total tokens when the provider omits them. +func applyOpenAIStreamUsage(u *openAIStreamUsage, usage *llm.Usage) { + if u == nil { + return + } + usage.InputTokens = pickInt64(u.InputTokens, u.PromptTokens) + usage.OutputTokens = pickInt64(u.OutputTokens, u.CompletionTokens) + usage.TotalTokens = derefInt64(u.TotalTokens) + if u.InputTokensDetails != nil { + if v := derefInt64(u.InputTokensDetails.CachedTokens); v > 0 { + usage.CachedInputTokens = v + } + } + if usage.CachedInputTokens == 0 && u.PromptTokensDetails != nil { + usage.CachedInputTokens = derefInt64(u.PromptTokensDetails.CachedTokens) + } + if usage.TotalTokens == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } +} + +// decodeJSONString unmarshals a JSON-encoded string value, returning +// ok=false when the raw message is empty or not a string. +func decodeJSONString(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + return s, true +} + +// anthropicStreamEvent captures the union of Messages-API stream event +// payloads we care about. Each named event on the wire fills only its +// shape's fields; unknown keys are ignored. +type anthropicStreamUsage struct { + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + CacheReadInputTokens *int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens *int64 `json:"cache_creation_input_tokens"` +} + +type anthropicStreamEvent struct { + Type string `json:"type"` + Message *struct { + Usage *anthropicStreamUsage `json:"usage"` + } `json:"message"` + Delta *struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + Usage *anthropicStreamUsage `json:"usage"` +} + +// accumulateAnthropicStream tracks input_tokens from message_start, +// output_tokens from message_delta, and concatenates text_delta payloads +// from content_block_delta events. Final usage prefers message_delta +// values which carry the post-completion totals. +func accumulateAnthropicStream(body []byte) (llm.Usage, string) { + var ( + usage llm.Usage + completion strings.Builder + ) + scanner := llm.NewScanner(bytes.NewReader(body)) + for { + ev, err := scanner.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + break + } + if ev.Data == "" { + continue + } + + var payload anthropicStreamEvent + if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil { + continue + } + + eventType := ev.Type + if eventType == "" { + eventType = payload.Type + } + applyAnthropicStreamEvent(eventType, payload, &usage, &completion) + } + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + usage.CachedInputTokens + usage.CacheCreationTokens + } + return usage, completion.String() +} + +// applyAnthropicStreamEvent folds one parsed Anthropic Messages stream event +// into the running usage/completion. Shared by the SSE accumulator and the +// Bedrock InvokeModel event-stream, whose chunks wrap the same event JSON. +func applyAnthropicStreamEvent(eventType string, payload anthropicStreamEvent, usage *llm.Usage, completion *strings.Builder) { + switch eventType { + case "message_start": + if payload.Message != nil { + applyAnthropicStreamUsage(payload.Message.Usage, usage) + } + case "content_block_delta": + if payload.Delta != nil && payload.Delta.Type == "text_delta" { + completion.WriteString(payload.Delta.Text) + } + case "message_delta": + applyAnthropicStreamUsage(payload.Usage, usage) + case "message_stop": + // No-op; Anthropic does not emit usage here. + } +} + +// applyAnthropicStreamUsage folds a non-nil Anthropic usage block into the +// running totals. Each field overwrites only when present and positive, so +// message_delta's post-completion counts supersede the message_start seed +// without zeroing dimensions a later event omits. +func applyAnthropicStreamUsage(u *anthropicStreamUsage, usage *llm.Usage) { + if u == nil { + return + } + if v := derefInt64(u.InputTokens); v > 0 { + usage.InputTokens = v + } + if v := derefInt64(u.OutputTokens); v > 0 { + usage.OutputTokens = v + } + if v := derefInt64(u.CacheReadInputTokens); v > 0 { + usage.CachedInputTokens = v + } + if v := derefInt64(u.CacheCreationInputTokens); v > 0 { + usage.CacheCreationTokens = v + } +} + +func pickInt64(preferred, fallback *int64) int64 { + if preferred != nil { + return *preferred + } + return derefInt64(fallback) +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go new file mode 100644 index 000000000..a82a9cdbc --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go @@ -0,0 +1,110 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + + "github.com/netbirdio/netbird/proxy/internal/llm" +) + +// bedrockEventTypeHeader names each AWS event-stream frame's event type. +const bedrockEventTypeHeader = ":event-type" + +// accumulateBedrockStream decodes the AWS binary event-stream returned by +// Bedrock's streaming endpoints and folds it into running usage/completion. +// Two framings are handled: +// - InvokeModel (invoke-with-response-stream): each "chunk" frame's payload is +// {"bytes":""} wrapping a vendor-native (Anthropic) stream event. +// - Converse (converse-stream): native frames (contentBlockDelta, metadata, …) +// whose payload JSON carries text deltas and a final usage block. +// +// A truncated stream (cut at the capture cap) decodes best-effort: frames up to +// the cut are applied and the partial usage is returned. +func accumulateBedrockStream(body []byte) (llm.Usage, string) { + var ( + usage llm.Usage + completion strings.Builder + ) + dec := eventstream.NewDecoder() + r := bytes.NewReader(body) + for { + msg, err := dec.Decode(r, nil) + if err != nil { + break // EOF or a partial trailing frame — return what we have. + } + eventType := "" + if v := msg.Headers.Get(bedrockEventTypeHeader); v != nil { + eventType = v.String() + } + if eventType == "chunk" { + applyBedrockInvokeChunk(msg.Payload, &usage, &completion) + continue + } + applyConverseStreamEvent(eventType, msg.Payload, &usage, &completion) + } + if usage.TotalTokens == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + usage.CachedInputTokens + usage.CacheCreationTokens + } + return usage, completion.String() +} + +// applyBedrockInvokeChunk decodes an InvokeModel stream "chunk" frame +// ({"bytes":""}) and folds the wrapped Anthropic event +// into usage/completion via the shared accumulator. +func applyBedrockInvokeChunk(payload []byte, usage *llm.Usage, completion *strings.Builder) { + var wrap struct { + Bytes []byte `json:"bytes"` // base64 string — encoding/json decodes it + } + if err := json.Unmarshal(payload, &wrap); err != nil || len(wrap.Bytes) == 0 { + return + } + var ev anthropicStreamEvent + if err := json.Unmarshal(wrap.Bytes, &ev); err != nil { + return + } + applyAnthropicStreamEvent(ev.Type, ev, usage, completion) +} + +// converseStreamEvent captures the Converse stream frames carrying completion +// text (contentBlockDelta) and the final token usage (metadata). +type converseStreamEvent struct { + Delta *struct { + Text string `json:"text"` + } `json:"delta"` + Usage *struct { + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalTokens int64 `json:"totalTokens"` + } `json:"usage"` +} + +// applyConverseStreamEvent folds one native Converse stream frame into the +// running usage/completion: contentBlockDelta carries assistant text, and the +// trailing metadata frame carries the final usage block. +func applyConverseStreamEvent(eventType string, payload []byte, usage *llm.Usage, completion *strings.Builder) { + var ev converseStreamEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return + } + switch eventType { + case "contentBlockDelta": + if ev.Delta != nil { + completion.WriteString(ev.Delta.Text) + } + case "metadata": + if ev.Usage != nil { + if ev.Usage.InputTokens > 0 { + usage.InputTokens = ev.Usage.InputTokens + } + if ev.Usage.OutputTokens > 0 { + usage.OutputTokens = ev.Usage.OutputTokens + } + if ev.Usage.TotalTokens > 0 { + usage.TotalTokens = ev.Usage.TotalTokens + } + } + } +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go new file mode 100644 index 000000000..f93505882 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go @@ -0,0 +1,74 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + "github.com/stretchr/testify/require" +) + +// bedrockFrame encodes a single AWS event-stream frame with the given +// :event-type header and JSON payload, mirroring what Bedrock sends. +func bedrockFrame(t *testing.T, eventType string, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + enc := eventstream.NewEncoder() + err := enc.Encode(&buf, eventstream.Message{ + Headers: eventstream.Headers{{Name: ":event-type", Value: eventstream.StringValue(eventType)}}, + Payload: payload, + }) + require.NoError(t, err, "encode event-stream frame") + return buf.Bytes() +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +func TestAccumulateBedrockStream_Invoke(t *testing.T) { + // invoke-with-response-stream: each "chunk" frame wraps a base64-encoded + // Anthropic stream event under {"bytes": ...}. + events := [][]byte{ + mustJSON(t, map[string]any{"type": "message_start", "message": map[string]any{"usage": map[string]any{"input_tokens": 13}}}), + mustJSON(t, map[string]any{"type": "content_block_delta", "delta": map[string]any{"type": "text_delta", "text": "po"}}), + mustJSON(t, map[string]any{"type": "content_block_delta", "delta": map[string]any{"type": "text_delta", "text": "ng"}}), + mustJSON(t, map[string]any{"type": "message_delta", "usage": map[string]any{"output_tokens": 5}}), + } + var body bytes.Buffer + for _, ev := range events { + wrap := mustJSON(t, map[string]any{"bytes": base64.StdEncoding.EncodeToString(ev)}) + body.Write(bedrockFrame(t, "chunk", wrap)) + } + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(13), usage.InputTokens, "input tokens from message_start") + require.Equal(t, int64(5), usage.OutputTokens, "output tokens from message_delta") + require.Equal(t, int64(18), usage.TotalTokens, "total is additive") + require.Equal(t, "pong", completion, "text deltas concatenated") +} + +func TestAccumulateBedrockStream_Converse(t *testing.T) { + var body bytes.Buffer + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "po"}}))) + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "ng"}}))) + body.Write(bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3, "totalTokens": 14}}))) + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(11), usage.InputTokens, "input tokens from metadata frame") + require.Equal(t, int64(3), usage.OutputTokens, "output tokens from metadata frame") + require.Equal(t, int64(14), usage.TotalTokens, "total from metadata frame") + require.Equal(t, "pong", completion, "converse text deltas concatenated") +} + +func TestAccumulateBedrockStream_Truncated(t *testing.T) { + // A body cut mid-frame must not panic; partial usage is returned. + full := bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3}})) + usage, _ := accumulateBedrockStream(full[:len(full)-4]) + require.Zero(t, usage.OutputTokens, "truncated trailing frame is dropped, not panicked on") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go new file mode 100644 index 000000000..400aac0bd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go @@ -0,0 +1,169 @@ +package llm_response_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func TestInvoke_OpenAIStreamingWithUsage(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "openai_stream.txt") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o-mini"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on streaming OpenAI body") + + in123, _ := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.Equal(t, "123", in123, "input tokens must come from final-chunk usage block") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must come from final-chunk usage block") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must come from final-chunk usage block") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for streaming responses") + assert.Equal(t, "Hello, world!", completion, "deltas must concatenate into the buffered fixture's text") +} + +func TestInvoke_OpenAIStreamingWithoutUsage(t *testing.T) { + body := []byte(`data: {"choices":[{"delta":{"content":"Hi"}}]} + +data: {"choices":[{"delta":{"content":" there"}}]} + +data: [DONE] + +`) + + usage, completion := accumulateOpenAIStream(body) + assert.Equal(t, int64(0), usage.InputTokens, "input tokens must stay zero without a usage frame") + assert.Equal(t, int64(0), usage.OutputTokens, "output tokens must stay zero without a usage frame") + assert.Equal(t, int64(0), usage.TotalTokens, "total tokens must stay zero without a usage frame") + assert.Equal(t, "Hi there", completion, "deltas must still accumulate when usage is absent") +} + +func TestInvoke_OpenAIStreamingNoUsage_OmitsUsageMetadata(t *testing.T) { + m := newTestMiddleware(t) + body := []byte(`data: {"choices":[{"delta":{"content":"Hello"}}]} + +data: [DONE] + +`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on usage-less streams") + + _, hasIn := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + _, hasOut := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + _, hasTot := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.False(t, hasIn, "input tokens omitted when no usage frame") + assert.False(t, hasOut, "output tokens omitted when no usage frame") + assert.False(t, hasTot, "total tokens omitted when no usage frame") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must still be emitted from deltas") + assert.Equal(t, "Hello", completion, "completion must come from delta accumulation") +} + +func TestInvoke_AnthropicStreaming(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "anthropic_stream.txt") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on streaming Anthropic body") + + in123, _ := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.Equal(t, "123", in123, "input tokens must come from message_start usage") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must come from message_delta usage") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must be input+output for anthropic streaming") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted from text_delta accumulation") + assert.Equal(t, "Hello, world!", completion, "anthropic streaming text must accumulate across content_block_delta events") +} + +func TestInvoke_StreamingTruncatedBody_BestEffort(t *testing.T) { + m := newTestMiddleware(t) + full := loadFixture(t, "anthropic_stream.txt") + cut := len(full) / 2 + truncated := full[:cut] + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: truncated, + RespBodyTruncated: true, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "anthropic"}}, + } + + require.NotPanics(t, func() { + _, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "truncated streaming body must not surface as error") + }, "Invoke must never panic on a truncated SSE body") +} + +func TestInvoke_StreamingEmptyBody(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: nil, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "empty SSE body must not surface as error") + assert.Empty(t, out.Metadata, "no metadata for empty SSE body") +} + +func TestAccumulateAnthropicStream_PartialUsage(t *testing.T) { + body := []byte(`event: message_start +data: {"type":"message_start","message":{"usage":{"input_tokens":10}}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}} + +`) + usage, completion := accumulateAnthropicStream(body) + assert.Equal(t, int64(10), usage.InputTokens, "partial input_tokens must survive truncated stream") + assert.Equal(t, int64(0), usage.OutputTokens, "output_tokens stays zero without message_delta") + assert.Equal(t, "hi", completion, "completion must come from observed text_delta events") +} diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go new file mode 100644 index 000000000..3c3b607ac --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/factory.go @@ -0,0 +1,106 @@ +package llm_router + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ProviderRoute describes one upstream LLM provider the router can +// hand a request to. Models lists the model identifiers the provider +// claims; UpstreamScheme + UpstreamHost replace the synth target's +// placeholder URL on a match. UpstreamPath is the path component of +// the configured upstream URL — the router uses it to disambiguate +// providers that claim the same model: when more than one provider +// matches the model, the route whose UpstreamPath is a prefix of the +// incoming request path is preferred (longest match wins, empty path +// is the catchall). AuthHeaderName + AuthHeaderValue are the +// per-provider credential the router injects after stripping the +// vendor auth headers from the inbound request. +// +// AllowedGroupIDs is the union of source-group IDs across every +// enabled policy that authorises this provider. The router treats it +// as a hard filter: a route whose AllowedGroupIDs has no intersection +// with the caller's UserGroups is removed from the candidate list +// before the path-prefix tiebreak. A route with empty AllowedGroupIDs +// is unreachable; the synthesiser only emits policy-bound routes. +type ProviderRoute struct { + ID string `json:"id"` + // Vendor is the parser surface this provider speaks ("openai", + // "anthropic", …), matching the llm.provider value llm_request_parser + // emits from the request. When set, the router keeps a vendor-tagged + // request on a same-vendor route so catch-all gateways of a different + // vendor can't swallow it. Empty disables vendor filtering for this + // route. + Vendor string `json:"vendor,omitempty"` + Models []string `json:"models"` + UpstreamScheme string `json:"upstream_scheme"` + UpstreamHost string `json:"upstream_host"` + UpstreamPath string `json:"upstream_path,omitempty"` + AuthHeaderName string `json:"auth_header_name"` + AuthHeaderValue string `json:"auth_header_value"` + AllowedGroupIDs []string `json:"allowed_group_ids"` + // Vertex marks a Google Vertex AI provider. Vertex requests carry the + // model in the URL path, so the router selects this route by path + // (isVertexPath) and bypasses the model/vendor table entirely. + Vertex bool `json:"vertex,omitempty"` + // Bedrock marks an AWS Bedrock provider. Bedrock requests carry the model + // in the URL path (/model/{id}/{action}), so the router selects this route + // by path (isBedrockPath) and bypasses the model/vendor table; auth is the + // static AuthHeaderValue bearer token (no token minting). + Bedrock bool `json:"bedrock,omitempty"` + // GCPServiceAccountKeyB64 is a base64-encoded GCP service-account JSON + // key. When set, the router mints + refreshes a short-lived OAuth2 access + // token from it at request time and injects it as the auth header value + // (instead of the static AuthHeaderValue) — so the gateway holds a durable + // Vertex credential rather than a 1-hour token. + GCPServiceAccountKeyB64 string `json:"gcp_sa_key_b64,omitempty"` +} + +// Config is the on-wire configuration accepted by the factory. An +// empty Providers slice yields a router that denies every request as +// not-routable; the synthesiser is responsible for stamping the +// account's enabled providers into this slice. +type Config struct { + Providers []ProviderRoute `json:"providers"` +} + +// Factory builds llm_router instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs +// yield a router with an empty Providers slice — every request denies +// with model_not_routable. Non-empty payloads must parse cleanly so +// misconfigurations surface at chain build time. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg := Config{} + if !isEmptyJSON(rawConfig) { + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + return New(cfg), nil +} + +// isEmptyJSON reports whether the payload is whitespace, null, or an +// empty object/array. The caller skips Unmarshal in that case so the +// zero-value Config flows through unchanged. +func isEmptyJSON(raw []byte) bool { + trimmed := strings.TrimSpace(string(bytes.TrimSpace(raw))) + switch trimmed { + case "", "null", "{}", "[]": + return true + } + return false +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go new file mode 100644 index 000000000..73cc59c95 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -0,0 +1,793 @@ +// Package llm_router implements the SlotOnRequest middleware that +// routes a request to an upstream LLM provider based on the model name +// emitted upstream by llm_request_parser. The router rewrites the +// request's outbound target (scheme + host), strips known LLM-vendor +// auth headers, and injects the per-provider auth header from the +// matched route. Unknown or unconfigured models deny with a 403 and +// the canonical llm_policy.model_not_routable code. +package llm_router + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// gcpScope is the OAuth2 scope minted for Vertex AI service-account auth. +const gcpScope = "https://www.googleapis.com/auth/cloud-platform" + +// gcpTokenTimeout bounds each GCP token mint/refresh HTTP call so a slow or +// unreachable token endpoint can't block the request indefinitely. +const gcpTokenTimeout = 10 * time.Second + +// ID is the registry key for this middleware. +const ID = "llm_router" + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +const ( + denyCodeNotRoutable = "llm_policy.model_not_routable" + denyReasonNotRoutable = "model_not_routable" + denyCodeNoAuthorisedRoute = "llm_policy.no_authorised_provider" + denyReasonNoAuthorisedRoute = "no_authorised_provider" + //nolint:gosec // deny code label, not a credential + denyCodeUpstreamAuth = "llm_policy.upstream_auth_failed" + denyCodeUnmeterable = "llm_policy.unmeterable_publisher" + denyReasonUnmeterable = "unmeterable_publisher" +) + +// strippedAuthHeaders is the closed list of vendor authentication +// credentials the router clears before injecting the provider-specific +// credential. Strictly auth headers — vendor-specific metadata +// (anthropic-version, openai-organization, openai-project, etc.) is +// NOT stripped because the client SDK sets those and the upstream +// requires them (e.g. Anthropic returns 400 without +// anthropic-version). Each entry is canonicalised by Go's +// http.Header.Del/Set, so listing the canonical shapes here is +// sufficient. +var strippedAuthHeaders = []string{ + "Authorization", // OpenAI, OpenAI-compatible, most vendors, Bedrock bearer + "Proxy-Authorization", // upstream proxy auth (defense-in-depth) + "x-api-key", // Anthropic + "api-key", // Azure OpenAI + "X-Amz-Date", // AWS SigV4 — strip client-supplied AWS signing material + "X-Amz-Security-Token", + "X-Amz-Content-Sha256", +} + +// Middleware routes requests to upstream LLM providers based on the +// llm.model metadata emitted by llm_request_parser. +type Middleware struct { + cfg Config + // tokenSrc caches one auto-refreshing OAuth2 TokenSource per GCP + // service-account key (keyed by a hash of the key material), so Vertex + // token minting happens once and refreshes are amortised across requests. + tokenMu sync.Mutex + tokenSrc map[string]oauth2.TokenSource +} + +// New constructs a Middleware with the supplied configuration. Empty +// or nil Providers slice yields a router that denies every request as +// not-routable. +func New(cfg Config) *Middleware { + return &Middleware{cfg: cfg, tokenSrc: map[string]oauth2.TokenSource{}} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes returns nil because the router only consults +// the metadata emitted by llm_request_parser. +func (m *Middleware) AcceptedContentTypes() []string { return nil } + +// MetadataKeys is the closed set of metadata keys this middleware may +// emit. The accumulator drops anything outside this allowlist. +func (m *Middleware) MetadataKeys() []string { + return []string{ + middleware.KeyLLMResolvedProviderID, + middleware.KeyLLMAuthorisingGroups, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + } +} + +// MutationsSupported reports that the middleware emits header and +// upstream-rewrite mutations. +func (m *Middleware) MutationsSupported() bool { return true } + +// Close releases resources owned by the middleware. The router is +// stateless, so this is a no-op. +func (m *Middleware) Close() error { return nil } + +// matchOutcome captures why matchRoute returned what it did so the +// caller can distinguish "no provider knows this model" from "providers +// know it but none authorise this peer's groups". +type matchOutcome int + +const ( + matchOutcomeFound matchOutcome = iota + matchOutcomeUnknownModel + matchOutcomeUnauthorised +) + +// Invoke resolves the model to a provider authorised for the caller's +// groups, strips known vendor auth headers, and injects the route's +// auth header. Unknown models deny with model_not_routable; models +// known to a provider that no policy authorises for the caller deny +// with no_authorised_provider. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + // Vertex AI carries the model in the URL path, not the body, and is + // selected by path rather than by the model/vendor table. Route it before + // the model lookup so a model the parser extracted from the path can't be + // claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com). + reqPath := requestPath(in.URL) + if isVertexPath(reqPath) { + model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + // The request parser emits no llm.provider for a Vertex publisher it + // can't parse (e.g. google/gemini). Forwarding such a request would + // bypass token/budget metering, so deny it rather than serve it + // unmetered. + if vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider); vendor == "" { + return denyUnmeterable(), nil + } + route, outcome := m.matchVertex(reqPath, model, in.UserGroups) + switch outcome { + case matchOutcomeFound: + return m.allowWithRoute(route, in.UserGroups), nil + case matchOutcomeUnauthorised: + return denyNoAuthorisedRoute(model), nil + default: + return denyUnknownModel(model), nil + } + } + + // Bedrock likewise carries the model in the URL path (/model/{id}/{action}), + // optionally behind a "/bedrock" gateway-namespace prefix. Route it by path + // before the model lookup; when the prefix is present, strip it from the + // forwarded path so the real Bedrock endpoint receives its native path. + if isBedrockPath(reqPath) { + model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + native, hadPrefix := splitBedrockNamespace(reqPath) + route, outcome := m.matchBedrock(native, model, in.UserGroups) + switch outcome { + case matchOutcomeFound: + out := m.allowWithRoute(route, in.UserGroups) + if hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix + } + return out, nil + case matchOutcomeUnauthorised: + return denyNoAuthorisedRoute(model), nil + default: + return denyUnknownModel(model), nil + } + } + + model, ok := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + if !ok || model == "" { + // Non-inference endpoints (model listing) carry no model but still + // need rewriting from the synth placeholder to a real upstream; + // clients such as Codex call GET /v1/models at startup to enumerate + // availability and read a 403 as "model unavailable". + route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups) + switch outcome { + case matchOutcomeFound: + return m.allowWithRoute(route, in.UserGroups), nil + case matchOutcomeUnauthorised: + // A recognised model-less endpoint exists but no provider + // authorises the caller — deny as an authorisation failure + // rather than masking it as a missing model. + return denyNoAuthorisedRoute(model), nil + default: + return denyMissingModel(), nil + } + } + + vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) + route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups) + switch outcome { + case matchOutcomeFound: + return m.allowWithRoute(route, in.UserGroups), nil + case matchOutcomeUnauthorised: + return denyNoAuthorisedRoute(model), nil + default: + return denyUnknownModel(model), nil + } +} + +// matchRoute returns the ProviderRoute that should serve the given +// model + request path for a caller in the given user-groups. Selection +// is: +// +// 1. Filter the configured providers to those whose Models list +// contains the model. +// 2. Filter the model-matched candidates to those whose +// AllowedGroupIDs intersect the caller's UserGroups. A route with +// no AllowedGroupIDs is the catch-all: it stays in the list. If +// the model was known but no candidate is authorised for this +// peer, return matchOutcomeUnauthorised so the caller can emit +// the dedicated no_authorised_provider deny code. +// 3. Vendor precedence: when the request carries a detected vendor +// (llm.provider) and at least one candidate is the same vendor, +// drop the rest — a vendor-tagged request must never cross to +// another vendor's route (e.g. an Anthropic call landing on an +// OpenAI-compatible gateway that also claims the model). +// 4. Model precedence over path: a route that explicitly lists the +// model beats a catch-all (empty Models) gateway. +// 5. Disambiguate the survivors by URL path prefix: longest +// UpstreamPath that prefix-matches the request path wins; an empty +// UpstreamPath is the catchall. If none prefix-matches, fall back +// to declaration order so the model stays routable. +func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []string) (ProviderRoute, matchOutcome) { + var modelMatched []ProviderRoute + for _, route := range m.cfg.Providers { + if routeClaimsModel(route, model) { + modelMatched = append(modelMatched, route) + } + } + if len(modelMatched) == 0 { + return ProviderRoute{}, matchOutcomeUnknownModel + } + + // Vendor pinning runs BEFORE the group filter so a request the parser + // tagged with a vendor can never cross to another vendor's route — not + // even an authorised one. Narrow to same-vendor routes when any + // model-matched route declares that vendor; setups with no vendor tag on + // any route fall through unchanged. After narrowing, if no same-vendor + // route authorises the caller, that's matchOutcomeUnauthorised (no + // cross-vendor fallback). + if vendor != "" { + if vendorMatched := matchingVendor(modelMatched, vendor); len(vendorMatched) > 0 { + modelMatched = vendorMatched + } + } + + var candidates []ProviderRoute + for _, route := range modelMatched { + if routeAuthorisesGroups(route, userGroups) { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return ProviderRoute{}, matchOutcomeUnauthorised + } + + // Model routing takes precedence over path. A route that explicitly + // lists the model must beat a catch-all (empty Models) gateway that + // claims every model — otherwise an Anthropic request can fall through + // to an OpenAI-compatible gateway declared earlier. Only when no + // candidate explicitly claims the model do the catch-alls compete, and + // the path-prefix tiebreak applies within whichever tier wins. + if explicit := explicitlyClaiming(candidates, model); len(explicit) > 0 { + candidates = explicit + } + if len(candidates) == 1 { + return candidates[0], matchOutcomeFound + } + + best := candidates[0] + bestLen := -1 + for _, c := range candidates { + if !pathPrefixMatches(c.UpstreamPath, reqPath) { + continue + } + if len(c.UpstreamPath) > bestLen { + best = c + bestLen = len(c.UpstreamPath) + } + } + return best, matchOutcomeFound +} + +// isModelLessPath reports whether reqPath is a known OpenAI-shaped +// non-inference endpoint that legitimately carries no model in its +// request (the model-listing endpoints). These must route to an upstream +// rather than deny, so model enumeration works end to end. +func isModelLessPath(reqPath string) bool { + return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/") +} + +// isVertexPath reports whether reqPath is a Google Vertex AI publisher +// endpoint: /v1/projects/{project}/locations/{region}/publishers/{publisher}/ +// models/{model}:{action}. The model + vendor live in the path, so these +// requests are routed by path to the Vertex provider rather than by model. +func isVertexPath(reqPath string) bool { + return strings.HasPrefix(reqPath, "/v1/projects/") && + strings.Contains(reqPath, "/publishers/") && + strings.Contains(reqPath, "/models/") +} + +// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients +// place before the native Bedrock path to disambiguate it from other providers +// that also use "/model/...". It is stripped before forwarding upstream. +const bedrockNamespacePrefix = "/bedrock" + +// splitBedrockNamespace removes an optional "/bedrock" namespace prefix, +// returning the native Bedrock path and whether the prefix was present. +func splitBedrockNamespace(reqPath string) (string, bool) { + if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") { + return strings.TrimPrefix(reqPath, bedrockNamespacePrefix), true + } + return reqPath, false +} + +// isBedrockPath reports whether reqPath is an AWS Bedrock runtime model +// endpoint: /model/{modelId}/{action} where action is invoke, +// invoke-with-response-stream, converse, or converse-stream — optionally behind +// a "/bedrock" gateway-namespace prefix. The model lives in the path, so these +// requests are routed by path to the Bedrock provider. +func isBedrockPath(reqPath string) bool { + native, _ := splitBedrockNamespace(reqPath) + if !strings.HasPrefix(native, "/model/") { + return false + } + return strings.HasSuffix(native, "/invoke") || + strings.HasSuffix(native, "/invoke-with-response-stream") || + strings.HasSuffix(native, "/converse") || + strings.HasSuffix(native, "/converse-stream") +} + +// matchVertex selects the Vertex provider authorised for the caller's groups +// and claiming the requested model. +func (m *Middleware) matchVertex(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) { + return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Vertex }) +} + +// matchBedrock selects the Bedrock provider authorised for the caller's groups +// and claiming the requested model. +func (m *Middleware) matchBedrock(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) { + return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Bedrock }) +} + +// matchPathRoute selects a path-routed provider (Vertex/Bedrock). These carry +// the model in the URL, so the model/vendor table is bypassed — but the route's +// configured Models allowlist is still enforced (empty Models = catch-all) so a +// provider credential can't be used for models the operator didn't authorise. +// Returns matchOutcomeUnauthorised when no style route authorises the caller's +// groups, matchOutcomeUnknownModel when an authorised route exists but none +// claims the model (or no style route exists at all), else the chosen route +// (longest UpstreamPath prefix-match wins among multiple). +func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string, isStyle func(ProviderRoute) bool) (ProviderRoute, matchOutcome) { + var styled []ProviderRoute + for _, route := range m.cfg.Providers { + if isStyle(route) { + styled = append(styled, route) + } + } + if len(styled) == 0 { + return ProviderRoute{}, matchOutcomeUnknownModel + } + + var authorised []ProviderRoute + for _, route := range styled { + if routeAuthorisesGroups(route, userGroups) { + authorised = append(authorised, route) + } + } + if len(authorised) == 0 { + return ProviderRoute{}, matchOutcomeUnauthorised + } + + var candidates []ProviderRoute + for _, route := range authorised { + if routeClaimsModel(route, model) { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return ProviderRoute{}, matchOutcomeUnknownModel + } + if len(candidates) == 1 { + return candidates[0], matchOutcomeFound + } + + best := candidates[0] + bestLen := -1 + for _, c := range candidates { + if !pathPrefixMatches(c.UpstreamPath, reqPath) { + continue + } + if len(c.UpstreamPath) > bestLen { + best = c + bestLen = len(c.UpstreamPath) + } + } + return best, matchOutcomeFound +} + +// matchModelless selects a route for a non-inference, model-less request. +// It mirrors matchRoute's group-authorisation filter and path-prefix +// tiebreak but skips the per-model filter, since any provider the caller's +// groups authorise can serve a model-listing request. Returns +// matchOutcomeFound with the chosen route (single authorised provider wins +// outright; multiple fall to the longest UpstreamPath prefix-match, then +// declaration order), matchOutcomeUnauthorised when no provider authorises +// the caller, or matchOutcomeUnknownModel when the path isn't a recognised +// model-less endpoint. +func (m *Middleware) matchModelless(reqPath string, userGroups []string) (ProviderRoute, matchOutcome) { + if !isModelLessPath(reqPath) { + return ProviderRoute{}, matchOutcomeUnknownModel + } + var candidates []ProviderRoute + for _, route := range m.cfg.Providers { + // Vertex/Bedrock are path-routed and don't serve OpenAI-style + // model-listing endpoints; including them here could rewrite a + // GET /v1/models to an upstream that 404s it. + if route.Vertex || route.Bedrock { + continue + } + if routeAuthorisesGroups(route, userGroups) { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return ProviderRoute{}, matchOutcomeUnauthorised + } + if len(candidates) == 1 { + return candidates[0], matchOutcomeFound + } + + best := candidates[0] + bestLen := -1 + for _, c := range candidates { + if !pathPrefixMatches(c.UpstreamPath, reqPath) { + continue + } + if len(c.UpstreamPath) > bestLen { + best = c + bestLen = len(c.UpstreamPath) + } + } + return best, matchOutcomeFound +} + +// routeAuthorisesGroups reports whether the route's AllowedGroupIDs +// intersect the caller's userGroups. A route with empty AllowedGroupIDs +// is unreachable: the synthesiser only emits routes bound to at least +// one enabled policy, so an empty list signals a misconfiguration that +// must not be allowed to fall through. +func routeAuthorisesGroups(r ProviderRoute, userGroups []string) bool { + for _, ug := range userGroups { + for _, ag := range r.AllowedGroupIDs { + if ug == ag { + return true + } + } + } + return false +} + +// authorisingGroupsCSV returns the sorted, deduplicated comma-separated +// intersection of routeGroups and userGroups — i.e. the groups that +// actually authorise the resolved route for this caller. Returns the +// empty string when the intersection is empty (shouldn't happen on the +// allow path, but defensive). +func authorisingGroupsCSV(routeGroups, userGroups []string) string { + if len(routeGroups) == 0 || len(userGroups) == 0 { + return "" + } + allowed := make(map[string]struct{}, len(routeGroups)) + for _, g := range routeGroups { + allowed[g] = struct{}{} + } + seen := make(map[string]struct{}, len(userGroups)) + out := make([]string, 0, len(userGroups)) + for _, ug := range userGroups { + if _, ok := allowed[ug]; !ok { + continue + } + if _, dup := seen[ug]; dup { + continue + } + seen[ug] = struct{}{} + out = append(out, ug) + } + if len(out) == 0 { + return "" + } + sort.Strings(out) + return strings.Join(out, ",") +} + +// matchingVendor returns the subset of routes whose Vendor equals the +// request's detected vendor. Routes with an empty Vendor never match — an +// untagged route can't be asserted to speak the request's surface, so it +// stays out of the vendor-filtered set (but remains eligible via the +// fall-through when no route matches the vendor at all). +func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute { + var out []ProviderRoute + for _, r := range routes { + if r.Vendor == vendor { + out = append(out, r) + } + } + return out +} + +// explicitlyClaiming returns the subset of routes whose Models list +// names the model exactly. Catch-all routes (empty Models) are excluded, +// so callers can prefer a provider that genuinely declares the model over +// a gateway that claims everything. +func explicitlyClaiming(routes []ProviderRoute, model string) []ProviderRoute { + var out []ProviderRoute + for _, r := range routes { + for _, candidate := range r.Models { + if candidate == model { + out = append(out, r) + break + } + } + } + return out +} + +// routeClaimsModel reports whether the route's Models list contains +// the given model identifier. An empty Models list is treated as +// "claim every model" — used by gateway-style providers (LiteLLM, +// custom OpenAI-compatible endpoints) that proxy an open-ended set of +// upstream models the operator can't enumerate in NetBird's provider +// config. +func routeClaimsModel(route ProviderRoute, model string) bool { + if len(route.Models) == 0 { + return true + } + for _, candidate := range route.Models { + if candidate == model { + return true + } + } + return false +} + +// pathPrefixMatches reports whether upstreamPath matches reqPath on a path- +// segment boundary: an exact match, or reqPath continuing after +// upstreamPath at a "/" separator. This avoids a sibling base like +// "/openai" spuriously matching "/openai-test". An empty (or "/") +// upstreamPath always matches (catchall). +func pathPrefixMatches(upstreamPath, reqPath string) bool { + if upstreamPath == "" || upstreamPath == "/" { + return true + } + upstreamPath = strings.TrimRight(upstreamPath, "/") + return reqPath == upstreamPath || strings.HasPrefix(reqPath, upstreamPath+"/") +} + +// requestPath extracts the path component from an Input.URL string +// (which is r.URL.String() — typically "/path?query"). Returns the +// raw input on parse failure so the prefix check can still operate on +// the unparsed value. +func requestPath(raw string) string { + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + return parsed.Path +} + +// allowWithRoute builds the Output for a successful route match. The +// returned Mutations carry the upstream rewrite plus — riding on it — +// the StripHeaders list and the AuthHeader to inject. +// +// The strip + inject MUST go through UpstreamRewrite (not HeadersAdd / +// HeadersRemove) because the framework's mutation gate runs every +// header change through a denylist that blocks Authorization, +// Cookie, etc. — exactly the headers the router is replacing. The +// proxy's upstream-build path applies AuthHeader / StripHeaders +// directly, bypassing the denylist by virtue of being a trusted +// proxy operation rather than an arbitrary middleware mutation. +// +// Emits the authorising-groups intersection alongside the resolved +// provider id so identity-stamping middlewares (llm_identity_inject) +// tag the request with ONLY the groups that authorised this specific +// route — not every group the peer happens to be in. +func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *middleware.Output { + rewrite := &middleware.UpstreamRewrite{ + Scheme: route.UpstreamScheme, + Host: route.UpstreamHost, + // UpstreamPath is the path component the operator pasted on + // the provider record (e.g. "/v1/{account}/{gateway}/compat" + // for Cloudflare AI Gateway). Carrying it on the rewrite so + // the proxy's URL composer joins it with the agent's request + // path — without this, the operator's configured upstream + // path is silently dropped and the gateway returns a 4xx for + // the malformed URL. Empty value leaves the original + // target's path untouched. + Path: route.UpstreamPath, + StripHeaders: append([]string(nil), strippedAuthHeaders...), + } + authValue := route.AuthHeaderValue + if route.GCPServiceAccountKeyB64 != "" { + // Mint a short-lived OAuth2 token from the service-account key at + // request time (cached + auto-refreshed) instead of a static value. + bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64) + if err != nil { + return denyUpstreamAuth() + } + authValue = bearer + } + if route.AuthHeaderName != "" && authValue != "" { + rewrite.AuthHeader = &middleware.AuthHeader{ + Name: route.AuthHeaderName, + Value: authValue, + } + } + return &middleware.Output{ + Decision: middleware.DecisionAllow, + Mutations: &middleware.Mutations{RewriteUpstream: rewrite}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: route.ID}, + {Key: middleware.KeyLLMAuthorisingGroups, Value: authorisingGroupsCSV(route.AllowedGroupIDs, userGroups)}, + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + }, + } +} + +// gcpBearer returns a "Bearer " value minted from a base64-encoded GCP +// service-account key, using a cached, auto-refreshing token source. +func (m *Middleware) gcpBearer(saKeyB64 string) (string, error) { + ts, err := m.gcpTokenSource(saKeyB64) + if err != nil { + return "", err + } + tok, err := ts.Token() + if err != nil { + return "", fmt.Errorf("mint gcp token: %w", err) + } + return "Bearer " + tok.AccessToken, nil +} + +// gcpTokenSource returns the cached TokenSource for the given service-account +// key, building it (decode base64 → parse JSON → cloud-platform scope) on first +// use. The returned source caches the token and refreshes it before expiry. +func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error) { + sum := sha256.Sum256([]byte(saKeyB64)) + key := hex.EncodeToString(sum[:]) + + m.tokenMu.Lock() + defer m.tokenMu.Unlock() + if m.tokenSrc == nil { + m.tokenSrc = map[string]oauth2.TokenSource{} + } + if ts, ok := m.tokenSrc[key]; ok { + return ts, nil + } + jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64)) + if err != nil { + return nil, fmt.Errorf("decode gcp service-account key: %w", err) + } + conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope) + if err != nil { + return nil, fmt.Errorf("parse gcp service-account key: %w", err) + } + // Bound mint/refresh with a timeout HTTP client so a slow token endpoint + // can't hang the request. The oauth2 library uses this client for the + // lifetime of the (auto-refreshing) source. + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: gcpTokenTimeout}) + ts := conf.TokenSource(ctx) + m.tokenSrc[key] = ts + return ts, nil +} + +// denyUpstreamAuth is returned when the router cannot obtain the upstream +// credential (e.g. a malformed service-account key or an unreachable token +// endpoint). It surfaces as a 502 — an upstream problem, not a policy denial. +func denyUpstreamAuth() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 502, + DenyReason: &middleware.DenyReason{ + Code: denyCodeUpstreamAuth, + Message: "could not obtain upstream credential", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: "upstream_auth_failed"}, + }, + } +} + +// denyUnmeterable returns the deny envelope for a path-routed request whose +// publisher has no parser surface, so its usage can't be metered. Serving it +// would bypass token/budget caps, so it is rejected with a 403. +func denyUnmeterable() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeUnmeterable, + Message: "request publisher is not supported for metering", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonUnmeterable}, + }, + } +} + +// denyMissingModel returns the deny envelope for a request whose +// envelope has no llm.model metadata. +func denyMissingModel() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeNotRoutable, + Message: "missing llm.model on request envelope", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable}, + }, + } +} + +// denyUnknownModel returns the deny envelope for a model that no +// configured provider claims. +func denyUnknownModel(model string) *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeNotRoutable, + Message: fmt.Sprintf("no provider configured for model %s", model), + Details: map[string]string{"model": model}, + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable}, + }, + } +} + +// denyNoAuthorisedRoute returns the deny envelope for a model that one +// or more providers claim, but where no policy authorises the caller's +// groups for any of those providers. +func denyNoAuthorisedRoute(model string) *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeNoAuthorisedRoute, + Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model), + Details: map[string]string{"model": model}, + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonNoAuthorisedRoute}, + }, + } +} + +// lookupMetadata returns the value for key plus a presence flag so +// callers can distinguish absent from empty. +func lookupMetadata(meta []middleware.KV, key string) (string, bool) { + for _, kv := range meta { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go new file mode 100644 index 000000000..8ae03c5ba --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -0,0 +1,840 @@ +package llm_router + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// metaValue returns the value for the first KV with the given key. +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +// defaultTestGroup is the group id used by routes and inputs in tests +// that don't specifically exercise the group-filter logic. Pairing it +// with the same id on every test route keeps the legacy assertions +// focused on routing/path behaviour without each one having to bake in +// its own ACL. +const defaultTestGroup = "grp-test" + +// newInputWithModel returns an Input carrying llm.model in its metadata +// bag, mimicking the post-llm_request_parser state the router observes +// in production. UserGroups is populated with defaultTestGroup so the +// router's group-filter pass authorises any test route whose +// AllowedGroupIDs contains the same id. +func newInputWithModel(model string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + Metadata: []middleware.KV{{Key: middleware.KeyLLMModel, Value: model}}, + UserGroups: []string{defaultTestGroup}, + } +} + +// newInputWithModelAndURL returns an Input carrying both llm.model and +// a request URL so router tests can exercise path-based disambiguation. +func newInputWithModelAndURL(model, reqURL string) *middleware.Input { + in := newInputWithModel(model) + in.URL = reqURL + return in +} + +func TestMiddlewareIdentity(t *testing.T) { + mw := New(Config{}) + assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_router") + assert.Equal(t, Version, mw.Version(), "version must match the constant") + assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "router must run in SlotOnRequest") + assert.True(t, mw.MutationsSupported(), "router must declare mutations support") + assert.Nil(t, mw.AcceptedContentTypes(), "router does not inspect bodies") + assert.ElementsMatch(t, + []string{ + middleware.KeyLLMResolvedProviderID, + middleware.KeyLLMAuthorisingGroups, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + }, + mw.MetadataKeys(), + "metadata key allowlist must match the spec", + ) + require.NoError(t, mw.Close()) +} + +func TestRouter_HappyPath(t *testing.T) { + route := ProviderRoute{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer sk-test-123", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "matched model must allow") + + require.NotNil(t, out.Mutations, "matched route must emit mutations") + rewrite := out.Mutations.RewriteUpstream + require.NotNil(t, rewrite, "matched route must emit upstream rewrite") + assert.Equal(t, "https", rewrite.Scheme, "rewrite scheme must come from the matched route") + assert.Equal(t, "api.openai.com", rewrite.Host, "rewrite host must come from the matched route") + + assert.ElementsMatch(t, strippedAuthHeaders, rewrite.StripHeaders, + "strip list rides on UpstreamRewrite (bypasses framework denylist) and must cover every known vendor auth header") + require.NotNil(t, rewrite.AuthHeader, "router must inject the auth header via the rewrite (not HeadersAdd) so the proxy bypasses the denylist") + assert.Equal(t, "Authorization", rewrite.AuthHeader.Name, "injected header name must come from the route") + assert.Equal(t, "Bearer sk-test-123", rewrite.AuthHeader.Value, "injected header value must come from the route") + assert.Empty(t, out.Mutations.HeadersAdd, "router must not use HeadersAdd; auth flows through UpstreamRewrite.AuthHeader") + assert.Empty(t, out.Mutations.HeadersRemove, "router must not use HeadersRemove; strip flows through UpstreamRewrite.StripHeaders") + + resolved, ok := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + require.True(t, ok, "router must emit llm.resolved_provider_id on a match") + assert.Equal(t, "openai-prod", resolved, "resolved provider id must be the matched route's ID") + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "allow", dec, "decision metadata must be allow on a match") +} + +func TestRouter_MissingModel(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + out, err := mw.Invoke(context.Background(), &middleware.Input{Slot: middleware.SlotOnRequest}) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "missing llm.model must deny") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code must be model_not_routable") + assert.Equal(t, "missing llm.model on request envelope", out.DenyReason.Message, "deny message must match spec") + + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec, "decision metadata must be deny") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_not_routable", reason, "reason metadata must be model_not_routable") +} + +// newModellessInput returns an Input with no llm.model and the given +// request path, mimicking a GET /v1/models call (which carries no body +// from which a model could be parsed). UserGroups matches defaultTestGroup. +func newModellessInput(reqURL string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: reqURL, + UserGroups: []string{defaultTestGroup}, + } +} + +func TestRouter_ModelLessPath_RoutesToAuthorisedProvider(t *testing.T) { + route := ProviderRoute{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models?client_version=1")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "GET /v1/models must pass through, not deny") + require.NotNil(t, out.Mutations, "a pass-through must rewrite the upstream") + require.NotNil(t, out.Mutations.RewriteUpstream, "model-less route must still rewrite to the real upstream") + assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "must target the authorised provider's host") + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai-prod", provider, "resolved provider must be the authorised route") +} + +func TestRouter_ModelLessPath_MultiProviderDeclarationOrder(t *testing.T) { + first := ProviderRoute{ + ID: "openai-a", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "a.example.com", + } + second := ProviderRoute{ + ID: "openai-b", + Models: []string{"gpt-4o-mini"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "b.example.com", + } + mw := New(Config{Providers: []ProviderRoute{first, second}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model-less path must pass through with multiple providers") + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai-a", provider, "no path-prefix match falls back to declaration order") +} + +func TestRouter_ModelLessPath_UnauthorisedDenies(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{"some-other-group"}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "no provider authorising the caller must still deny") +} + +func TestRouter_NonModelLessBodilessStillDenies(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + // A bodiless POST to an inference path has no model and is NOT a + // model-less endpoint, so it must keep denying. + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/responses")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "bodiless inference request must still deny") + assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code stays model_not_routable") +} + +// TestRouter_ExplicitModelBeatsCatchallGateway is the regression guard +// for multi-provider misrouting: a catch-all (empty Models) OpenAI-compat +// gateway declared first must NOT swallow a model an explicit provider +// claims. Anthropic's claude request must reach the Anthropic route even +// though the gateway claims every model and wins declaration order. +func TestRouter_ExplicitModelBeatsCatchallGateway(t *testing.T) { + gateway := ProviderRoute{ + ID: "openai-gateway", + Models: nil, // catch-all: claims every model + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + anthropic := ProviderRoute{ + ID: "anthropic-prod", + Models: []string{"claude-opus-4"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + // Gateway declared first to prove explicit claim beats declaration order. + mw := New(Config{Providers: []ProviderRoute{gateway, anthropic}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("claude-opus-4", "/v1/messages")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "explicit-model request must route, not deny") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host, "claude must reach the explicit Anthropic route, not the catch-all gateway") + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "anthropic-prod", provider, "resolved provider must be the explicit Anthropic route") +} + +// TestRouter_CatchallStillServesUnlistedModel confirms the catch-all +// gateway still wins models no explicit provider claims (its whole point). +func TestRouter_CatchallStillServesUnlistedModel(t *testing.T) { + gateway := ProviderRoute{ + ID: "openai-gateway", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "gateway.example.com", + } + anthropic := ProviderRoute{ + ID: "anthropic-prod", + Models: []string{"claude-opus-4"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + mw := New(Config{Providers: []ProviderRoute{gateway, anthropic}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("some-exotic-model", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "unlisted model must still route via the catch-all") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "gateway.example.com", out.Mutations.RewriteUpstream.Host, "unlisted model falls to the catch-all gateway") +} + +// newInputVendorModelURL returns an Input carrying both the detected +// vendor (llm.provider) and the model, plus a request URL — mimicking the +// post-llm_request_parser state for a real inference call. +func newInputVendorModelURL(vendor, model, reqURL string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: reqURL, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: vendor}, + {Key: middleware.KeyLLMModel, Value: model}, + }, + UserGroups: []string{defaultTestGroup}, + } +} + +// TestRouter_VendorKeepsAnthropicOffOpenAIGateway is the regression guard +// for the reported multi-provider break: two catch-all providers (neither +// enumerates models), the OpenAI one declared first. Without vendor +// awareness, a claude request matches both, no path prefixes, and +// declaration order sends it to OpenAI → 502. The detected vendor must +// pin it to the Anthropic route. +func TestRouter_VendorKeepsAnthropicOffOpenAIGateway(t *testing.T) { + openai := ProviderRoute{ + ID: "openai-gw", + Vendor: "openai", + Models: nil, // catch-all + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + anthropic := ProviderRoute{ + ID: "anthropic-gw", + Vendor: "anthropic", + Models: nil, // catch-all + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + mw := New(Config{Providers: []ProviderRoute{openai, anthropic}}) // openai first + + out, err := mw.Invoke(context.Background(), newInputVendorModelURL("anthropic", "claude-opus-4-8", "/v1/messages")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "claude request must route, not deny") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host, "anthropic vendor must pin to the anthropic route despite openai being declared first") + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "anthropic-gw", provider) +} + +// TestRouter_VendorKeepsOpenAIOffAnthropic is the reciprocal: an OpenAI +// request must stay on the OpenAI route even when the Anthropic catch-all +// is declared first. +func TestRouter_VendorKeepsOpenAIOffAnthropic(t *testing.T) { + anthropic := ProviderRoute{ + ID: "anthropic-gw", + Vendor: "anthropic", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + openai := ProviderRoute{ + ID: "openai-gw", + Vendor: "openai", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{anthropic, openai}}) // anthropic first + + out, err := mw.Invoke(context.Background(), newInputVendorModelURL("openai", "gpt-5.5", "/v1/responses")) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "openai vendor must pin to the openai route despite anthropic being declared first") +} + +// TestRouter_VendorAbsentFallsBackToModelPath confirms vendor filtering is +// inert when the request carries no detected vendor: routing then relies on +// model/path as before. +func TestRouter_VendorAbsentFallsBackToModelPath(t *testing.T) { + openai := ProviderRoute{ + ID: "openai-gw", + Vendor: "openai", + Models: []string{"gpt-5.5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{openai}}) + + // No llm.provider in metadata — only the model. + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-5.5")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "explicit-model match must still route with no vendor present") +} + +func TestRouter_UnknownModel(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("claude-opus-4")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "unrouted model must deny") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code must be model_not_routable") + assert.Equal(t, "no provider configured for model claude-opus-4", out.DenyReason.Message, "deny message must reference the offending model") + assert.Equal(t, "claude-opus-4", out.DenyReason.Details["model"], "deny details must include the offending model") +} + +func TestRouter_HeaderStripList(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer sk-test-123", + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations, "matched route must emit mutations") + + expected := []string{ + "Authorization", + "Proxy-Authorization", + "x-api-key", + "api-key", + } + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + for _, header := range expected { + assert.Contains(t, out.Mutations.RewriteUpstream.StripHeaders, header, + "strip list (on UpstreamRewrite) must include the well-known vendor auth header %s", header) + } + + // Vendor metadata headers MUST NOT be stripped: the client SDK sets them + // and the upstream requires them. Anthropic returns 400 "anthropic-version: + // header is required" if we drop it. Lock the regression. + preserved := []string{"anthropic-version", "openai-organization", "openai-project"} + for _, header := range preserved { + assert.NotContains(t, out.Mutations.RewriteUpstream.StripHeaders, header, + "vendor metadata header %s must NOT be stripped — upstreams require it", header) + } +} + +func TestRouter_FirstMatchWins(t *testing.T) { + first := ProviderRoute{ + ID: "first", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "first.test", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer first", + } + second := ProviderRoute{ + ID: "second", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "second.test", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer second", + } + mw := New(Config{Providers: []ProviderRoute{first, second}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "duplicate-model match must still allow") + require.NotNil(t, out.Mutations, "matched route must emit mutations") + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + assert.Equal(t, "first.test", out.Mutations.RewriteUpstream.Host, "first-match-wins must pick the earlier route") + + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "first", resolved, "resolved provider id must be the earlier route's ID") +} + +// TestRouter_PathDisambiguation_PrefixWinsOverCatchall locks in the +// rule the user nailed down: two providers claim the same model, one +// has an UpstreamPath that prefixes the incoming URL, the other has +// no path. The path-prefixed provider wins because the path is a +// strictly more specific match than the empty catchall. +func TestRouter_PathDisambiguation_PrefixWinsOverCatchall(t *testing.T) { + corp := ProviderRoute{ + ID: "corp-openai-compat", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "corp.example.com", + UpstreamPath: "/openai", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer corp", + } + openai := ProviderRoute{ + ID: "openai", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer openai", + } + mw := New(Config{Providers: []ProviderRoute{openai, corp}}) // openai listed first to prove path beats declaration order + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/openai/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, middleware.DecisionAllow, out.Decision, "path-prefix match must allow") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "corp.example.com", out.Mutations.RewriteUpstream.Host, + "path-prefixed provider must beat the catchall when its UpstreamPath is a prefix of the request path") + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "corp-openai-compat", resolved, "resolved provider id must reflect the path-prefix winner, not the first declared") +} + +// TestRouter_PathDisambiguation_CatchallWhenNoPrefixMatches is the +// inverse: the path-prefixed provider does NOT match the incoming +// path, so the empty-path catchall takes the request. +func TestRouter_PathDisambiguation_CatchallWhenNoPrefixMatches(t *testing.T) { + corp := ProviderRoute{ + ID: "corp-openai-compat", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "corp.example.com", + UpstreamPath: "/openai", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer corp", + } + openai := ProviderRoute{ + ID: "openai", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer openai", + } + mw := New(Config{Providers: []ProviderRoute{corp, openai}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, middleware.DecisionAllow, out.Decision, "catchall must allow when no path prefix matches") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, + "empty-path catchall must win when the path-prefixed provider's UpstreamPath does not match the request") + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai", resolved, "resolved provider id must be the catchall") +} + +// TestRouter_PathDisambiguation_LongestPrefixWins covers the case +// where multiple providers have non-empty UpstreamPath values that +// both prefix the request — the longer (more specific) one wins. +func TestRouter_PathDisambiguation_LongestPrefixWins(t *testing.T) { + short := ProviderRoute{ + ID: "short-prefix", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "short.example.com", + UpstreamPath: "/openai", + } + long := ProviderRoute{ + ID: "long-prefix", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "long.example.com", + UpstreamPath: "/openai/v1", + } + mw := New(Config{Providers: []ProviderRoute{short, long}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/openai/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "long.example.com", out.Mutations.RewriteUpstream.Host, + "longest matching UpstreamPath must win — most specific match") +} + +// TestRouter_SingleMatchIgnoresPath proves the path-prefix rule is a +// disambiguation pass, not a gate: when only one provider claims the +// model, it wins regardless of UpstreamPath. Otherwise a path-scoped +// provider would 403 every request whose URL doesn't include the +// path, which would break SDKs configured to hit the gateway root. +func TestRouter_SingleMatchIgnoresPath(t *testing.T) { + only := ProviderRoute{ + ID: "only", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "only.example.com", + UpstreamPath: "/openai", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer only", + } + mw := New(Config{Providers: []ProviderRoute{only}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "single model-matching provider must serve the request even when UpstreamPath doesn't prefix the URL — path is a tiebreaker, not a gate") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "only.example.com", out.Mutations.RewriteUpstream.Host, "the only model-matching provider should be selected") +} + +// TestRouter_PathDisambiguation_FallbackWhenNoPrefixMatches covers +// the multi-candidate edge case where every candidate has a +// non-matching non-empty UpstreamPath. The router falls back to +// declaration order so the model is still routable rather than 403'd. +func TestRouter_PathDisambiguation_FallbackWhenNoPrefixMatches(t *testing.T) { + first := ProviderRoute{ + ID: "first", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "first.example.com", + UpstreamPath: "/openai", + } + second := ProviderRoute{ + ID: "second", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "second.example.com", + UpstreamPath: "/anthropic", + } + mw := New(Config{Providers: []ProviderRoute{first, second}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no path match among multi-candidates must still allow") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "first.example.com", out.Mutations.RewriteUpstream.Host, + "when no candidate's UpstreamPath prefix-matches the request, fall back to declaration order") +} + +func TestRouter_FactoryRejectsBadJSON(t *testing.T) { + _, err := Factory{}.New([]byte("{not json")) + require.Error(t, err, "malformed JSON config must be rejected at chain build time") +} + +func TestRouter_FactoryAcceptsEmptyShapes(t *testing.T) { + cases := [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")} + for _, raw := range cases { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "empty-shaped config must yield a router with an empty Providers slice") + require.NotNil(t, mw, "factory must return a non-nil middleware on empty config") + + out, invErr := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, invErr) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "router with no providers must deny every model as not-routable") + } +} + +// newInputWithModelAndGroups returns an Input carrying llm.model + the +// caller's UserGroups, mimicking the post-auth, post-llm_request_parser +// state the router observes. +func newInputWithModelAndGroups(model string, groups []string) *middleware.Input { + in := newInputWithModel(model) + in.UserGroups = append([]string(nil), groups...) + return in +} + +// TestRouter_GroupFilter_PicksAuthorisedAmongDuplicates pins the Fix A +// behaviour: when two providers claim the same model but each +// authorises a different group, the router must pick the route the +// caller's groups intersect, regardless of declaration order. +func TestRouter_GroupFilter_PicksAuthorisedAmongDuplicates(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{ + { + ID: "openai-marketing", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "mkt-openai.example.com", + AllowedGroupIDs: []string{"grp-mkt"}, + }, + { + ID: "openai-engineering", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "eng-openai.example.com", + AllowedGroupIDs: []string{"grp-eng"}, + }, + }}) + + out, err := mw.Invoke(context.Background(), + newInputWithModelAndGroups("gpt-4o-mini", []string{"grp-eng"})) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "authorised candidate exists; must allow") + + resolved, ok := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + require.True(t, ok) + assert.Equal(t, "openai-engineering", resolved, + "router must pick the route whose AllowedGroupIDs intersects the caller's groups, ignoring declaration order") +} + +// TestRouter_GroupFilter_NoIntersection_DeniesNoAuthorisedRoute pins +// the dedicated deny code that fires when the model is known to a +// provider but no candidate is authorised for the caller's groups. +func TestRouter_GroupFilter_NoIntersection_DeniesNoAuthorisedRoute(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-marketing", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "mkt-openai.example.com", + AllowedGroupIDs: []string{"grp-mkt"}, + }}}) + + out, err := mw.Invoke(context.Background(), + newInputWithModelAndGroups("gpt-4o-mini", []string{"grp-eng"})) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "model exists but no route authorises grp-eng; must deny") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.no_authorised_provider", out.DenyReason.Code, + "deny code must be no_authorised_provider, not model_not_routable") + assert.Equal(t, "gpt-4o-mini", out.DenyReason.Details["model"], + "deny details must reference the offending model") + + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec) + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "no_authorised_provider", reason) +} + +// TestRouter_GroupFilter_EmptyAllowedGroupsIsUnreachable pins the +// strict semantics: a route with no AllowedGroupIDs is unreachable. +// The synthesiser only emits policy-bound routes, so an empty ACL +// signals a misconfiguration that must not silently fall through. +func TestRouter_GroupFilter_EmptyAllowedGroupsIsUnreachable(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-shared", + Models: []string{"gpt-4o"}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + // AllowedGroupIDs intentionally left empty. + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "empty AllowedGroupIDs must deny — there is no catch-all for routes without an authorising policy") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.no_authorised_provider", out.DenyReason.Code, + "empty ACL fails the group-filter pass; deny code must reflect that") +} + +// TestRouter_GroupFilter_OverlapTiebreakUnchanged pins that when more +// than one route is authorised for the caller's groups, the existing +// path-prefix tiebreak still decides. Group filtering is a hard gate +// before the tiebreak; it does not change the tiebreak semantics. +func TestRouter_GroupFilter_OverlapTiebreakUnchanged(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{ + { + ID: "openai-a", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "a.example.com", + UpstreamPath: "", + AllowedGroupIDs: []string{"grp-eng"}, + }, + { + ID: "openai-b", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "b.example.com", + UpstreamPath: "/v1/chat", + AllowedGroupIDs: []string{"grp-eng"}, + }, + }}) + + in := newInputWithModelAndURL("gpt-4o-mini", "/v1/chat/completions") + in.UserGroups = []string{"grp-eng"} + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai-b", resolved, + "longest-prefix path tiebreak still wins among group-authorised candidates") +} + +// TestRouter_AuthorisingGroups_EmitsIntersection pins that the router +// emits llm.authorising_groups containing only the intersection of the +// caller's UserGroups with the resolved route's AllowedGroupIDs — not +// every group the peer happens to be in. +func TestRouter_AuthorisingGroups_EmitsIntersection(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-eng", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "eng-openai.example.com", + AllowedGroupIDs: []string{"grp-eng", "grp-shared"}, + }}}) + + in := newInputWithModelAndGroups("gpt-4o-mini", + []string{"grp-eng", "grp-it", "grp-shared", "grp-oncall"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + + csv, ok := metaValue(t, out.Metadata, middleware.KeyLLMAuthorisingGroups) + require.True(t, ok, "router must emit llm.authorising_groups on a match") + assert.Equal(t, "grp-eng,grp-shared", csv, + "only groups in BOTH UserGroups AND AllowedGroupIDs may appear; result must be sorted and unique") +} + +// TestRouter_EmptyModelsClaimsAnyModel pins that a route with no +// configured Models matches every model — used by gateway-style +// providers (LiteLLM, custom OpenAI-compatible endpoints) where the +// operator can't enumerate the upstream's model catalog in NetBird. +func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "litellm", + Models: nil, // catch-all + UpstreamScheme: "https", + UpstreamHost: "litellm.example.com", + AllowedGroupIDs: []string{defaultTestGroup}, + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-5.5")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a route with empty Models must claim any model so gateway-style providers can route open-ended sets") + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "litellm", resolved) +} diff --git a/proxy/internal/middleware/builtin/llm_router/path_routed_test.go b/proxy/internal/middleware/builtin/llm_router/path_routed_test.go new file mode 100644 index 000000000..7dbd6b936 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/path_routed_test.go @@ -0,0 +1,159 @@ +package llm_router + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// pathRoutedInput builds an Input mimicking the post-llm_request_parser state +// for a path-routed (Vertex/Bedrock) request: a request URL plus the model and +// (optionally) provider/vendor metadata the parser emits. +func pathRoutedInput(url, provider, model string) *middleware.Input { + md := []middleware.KV{{Key: middleware.KeyLLMModel, Value: model}} + if provider != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: provider}) + } + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: url, + Metadata: md, + UserGroups: []string{defaultTestGroup}, + } +} + +func vertexRoute() ProviderRoute { + return ProviderRoute{ + ID: "vertex-prod", Vertex: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "europe-west1-aiplatform.googleapis.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } +} + +// A Vertex publisher with no parser surface (google/gemini emits no +// llm.provider) must be denied, not forwarded unmetered. +func TestRouter_VertexUnmeterablePublisherDenied(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{vertexRoute()}}) + in := pathRoutedInput( + "/v1/projects/p/locations/global/publishers/google/models/gemini-2.5-pro:generateContent", + "", // google -> request parser emits NO llm.provider + "gemini-2.5-pro", + ) + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "unmeterable Vertex publisher must deny") + assert.Equal(t, 403, out.DenyStatus, "unmeterable deny is a 403") + require.NotNil(t, out.DenyReason) + assert.Equal(t, denyCodeUnmeterable, out.DenyReason.Code, "deny code must flag the unmeterable publisher") +} + +// A Vertex publisher with a parser surface (anthropic) is allowed. +func TestRouter_VertexMeterablePublisherAllowed(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{vertexRoute()}}) + in := pathRoutedInput( + "/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-4-5:rawPredict", + "anthropic", + "claude-sonnet-4-5", + ) + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "meterable Vertex publisher must allow") +} + +// A path-routed provider with an explicit Models list must reject models not in +// the list (the provider credential can't be used for unauthorised models). +func TestRouter_PathRoutedModelAllowlistEnforced(t *testing.T) { + route := ProviderRoute{ + ID: "bedrock-prod", Bedrock: true, + Models: []string{"anthropic.claude-sonnet-4-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + allowed := pathRoutedInput( + "/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", + "bedrock", "anthropic.claude-sonnet-4-5", + ) + out, err := mw.Invoke(context.Background(), allowed) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model in the allowlist must be served") + + denied := pathRoutedInput( + "/model/amazon.nova-pro-v1:0/invoke", + "bedrock", "amazon.nova-pro", + ) + out, err = mw.Invoke(context.Background(), denied) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "model outside the allowlist must deny") + require.NotNil(t, out.DenyReason) + assert.Equal(t, denyCodeNotRoutable, out.DenyReason.Code, "unlisted model denies as not-routable") +} + +// A "/bedrock" gateway-namespace prefix routes the same as the native path and +// records the prefix on the rewrite so the proxy strips it before forwarding. +func TestRouter_BedrockNamespacePrefixStripped(t *testing.T) { + route := ProviderRoute{ + ID: "bedrock-prod", Bedrock: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + prefixed := pathRoutedInput( + "/bedrock/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", + "bedrock", "anthropic.claude-sonnet-4-5", + ) + out, err := mw.Invoke(context.Background(), prefixed) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision, "prefixed Bedrock path must route") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "/bedrock", out.Mutations.RewriteUpstream.StripPathPrefix, + "namespace prefix must be recorded so the proxy strips it before forwarding") + + native := pathRoutedInput( + "/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", + "bedrock", "anthropic.claude-sonnet-4-5", + ) + out, err = mw.Invoke(context.Background(), native) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision, "native Bedrock path must route") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Empty(t, out.Mutations.RewriteUpstream.StripPathPrefix, + "native path carries no namespace prefix to strip") +} + +// A path-routed provider with no configured Models is catch-all: any model the +// credential can reach is served (preserves the zero-config behaviour). +func TestRouter_PathRoutedCatchAllServesAnyModel(t *testing.T) { + route := ProviderRoute{ + ID: "bedrock-catchall", Bedrock: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + in := pathRoutedInput( + "/model/amazon.nova-pro-v1:0/invoke", + "bedrock", "amazon.nova-pro", + ) + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "catch-all path-routed provider serves any model") +} diff --git a/proxy/internal/middleware/chain.go b/proxy/internal/middleware/chain.go new file mode 100644 index 000000000..45d32cdb0 --- /dev/null +++ b/proxy/internal/middleware/chain.go @@ -0,0 +1,320 @@ +package middleware + +import ( + "context" + "net/http" + "sync" +) + +// boundMiddleware pairs a validated spec with the resolved middleware +// instance the chain will invoke. +type boundMiddleware struct { + spec Spec + mw Middleware +} + +// Chain is the ordered set of middlewares that run for a specific +// target. Chains are immutable once built; Manager produces a new +// Chain on every Rebuild. +// +// Ordering: middlewares are kept in registration order. RunRequest +// iterates the SlotOnRequest middlewares in order; RunResponse +// iterates the SlotOnResponse middlewares in reverse order +// (middleware-style LIFO so the last to see the request is the first +// to see the response); RunTerminal iterates the SlotTerminal +// middlewares in registration order, after every on_response slot has +// emitted, so the metadata bag they observe is complete. +// +// Close drains in-flight invocations and tears down each middleware. +// Callers swapping a chain via Manager invoke Close on the old chain +// after the swap so live requests finish on the previous instance. +type Chain struct { + targetID string + all []boundMiddleware + onRequest []int + onResponse []int + terminal []int + dispatcher *Dispatcher + inflight sync.WaitGroup +} + +// NewChain assembles a Chain from the bound middlewares. The slice +// order is the registration order; the chain captures index slices +// per slot so iteration does not re-scan the slot field per call. +func NewChain(targetID string, bound []boundMiddleware, d *Dispatcher) *Chain { + c := &Chain{ + targetID: targetID, + all: bound, + dispatcher: d, + } + for i, bm := range bound { + switch bm.spec.Slot { + case SlotOnRequest: + c.onRequest = append(c.onRequest, i) + case SlotOnResponse: + c.onResponse = append(c.onResponse, i) + case SlotTerminal: + c.terminal = append(c.terminal, i) + } + } + return c +} + +// Close waits for outstanding invocations against this chain to +// finish (bounded by ctx) and releases the middleware instances bound +// to it. Safe to call once the chain has been removed from the +// routing snapshot. Subsequent Run* calls are still safe (return +// without invoking) but Close itself is one-shot. +func (c *Chain) Close(ctx context.Context) error { + if c == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + done := make(chan struct{}) + go func() { + c.inflight.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + // Drain timed out: requests may still be running against these + // middleware instances, so tearing them down now risks a + // use-after-close. Leave them (a bounded leak) and surface the + // timeout; the runaway backstop in the Manager already alerts. + return ctx.Err() + } + for _, bm := range c.all { + if bm.mw == nil { + continue + } + if err := bm.mw.Close(); err != nil { + c.dispatcher.logger.Debugf("middleware %s close: %v", bm.spec.ID, err) + } + } + return nil +} + +// Empty reports whether the chain has no middlewares. +func (c *Chain) Empty() bool { + return c == nil || len(c.all) == 0 +} + +// TargetID returns the key used to find this chain. +func (c *Chain) TargetID() string { + if c == nil { + return "" + } + return c.targetID +} + +// IDs returns the ordered list of middleware IDs bound to this chain. +func (c *Chain) IDs() []string { + if c == nil { + return nil + } + out := make([]string, len(c.all)) + for i, bm := range c.all { + out[i] = bm.spec.ID + } + return out +} + +// RunRequest iterates the on_request slot in registration order. Deny +// short-circuits the remaining middlewares and returns the deny +// output. The caller owns applying mutations to the real request and +// merging the metadata returned in `merged` into the captured-data +// bag passed to subsequent slots. +// +// Each middleware sees the metadata emitted by earlier middlewares in +// the same slot — this is how llm_guardrail reads +// llm.request_prompt_raw from llm_request_parser without a side +// channel, and how cost_meter reads tokens emitted by +// llm_response_parser on the response leg. +// +// If any middleware emits a non-nil Mutations.RewriteUpstream while +// satisfying the mutation gates (CanMutate && MutationsSupported), the +// latest such value is returned to the caller. Last-write-wins so the +// last middleware in the slot can override an earlier rewrite. +func (c *Chain) RunRequest(ctx context.Context, r *http.Request, in *Input, acc *Accumulator) (denied *Output, merged []KV, rewrite *UpstreamRewrite, err error) { + if c.Empty() || len(c.onRequest) == 0 { + return nil, nil, nil, nil + } + c.inflight.Add(1) + defer c.inflight.Done() + running := append([]KV(nil), in.Metadata...) + for _, idx := range c.onRequest { + bm := c.all[idx] + call := cloneInputFor(in, SlotOnRequest) + call.Metadata = append([]KV(nil), running...) + out, invErr := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call) + if invErr != nil && out == nil { + continue + } + if out == nil { + continue + } + + accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata) + for _, rej := range rejected { + c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason) + } + merged = append(merged, accepted...) + running = append(running, accepted...) + + if out.Decision == DecisionDeny { + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "deny") + return out, merged, rewrite, nil + } + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "allow") + + if rw := mutationRewrite(bm.spec, out.Mutations); rw != nil { + rewrite = rw + } + if r != nil && bm.spec.CanMutate && out.Mutations != nil { + applyMutations(ctx, c.dispatcher, bm.spec, r, out.Mutations) + } + } + return nil, merged, rewrite, nil +} + +// RunResponse iterates the on_response slot in reverse registration +// order, matching the middleware "last in, first out" convention so +// the last middleware to see the request is the first to see the +// response. Middlewares cannot deny; they emit metadata. +// +// As with RunRequest, each middleware sees the metadata emitted by +// earlier middlewares in this slot — accumulated in the order the +// middlewares run (LIFO of registration). cost_meter relies on this +// to read llm.input_tokens / llm.output_tokens that +// llm_response_parser emitted just before it. +func (c *Chain) RunResponse(ctx context.Context, in *Input, acc *Accumulator) (merged []KV) { + if c.Empty() || len(c.onResponse) == 0 { + return nil + } + c.inflight.Add(1) + defer c.inflight.Done() + running := append([]KV(nil), in.Metadata...) + for i := len(c.onResponse) - 1; i >= 0; i-- { + bm := c.all[c.onResponse[i]] + call := cloneInputFor(in, SlotOnResponse) + call.Metadata = append([]KV(nil), running...) + out, _ := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call) + if out == nil { + continue + } + accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata) + for _, rej := range rejected { + c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason) + } + merged = append(merged, accepted...) + running = append(running, accepted...) + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "passthrough") + } + return merged +} + +// RunTerminal iterates the terminal slot in registration order, after +// every on_response middleware has emitted. Terminal middlewares +// observe the full metadata bag carried in `in.Metadata` plus any +// emissions from terminal middlewares that ran before them; they +// cannot deny and cannot mutate. +func (c *Chain) RunTerminal(ctx context.Context, in *Input, acc *Accumulator) (merged []KV) { + if c.Empty() || len(c.terminal) == 0 { + return nil + } + c.inflight.Add(1) + defer c.inflight.Done() + running := append([]KV(nil), in.Metadata...) + for _, idx := range c.terminal { + bm := c.all[idx] + call := cloneInputFor(in, SlotTerminal) + call.Metadata = append([]KV(nil), running...) + out, _ := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call) + if out == nil { + continue + } + accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata) + for _, rej := range rejected { + c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason) + } + merged = append(merged, accepted...) + running = append(running, accepted...) + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "terminal") + } + return merged +} + +// mutationRewrite returns the upstream rewrite carried in m when the +// spec's mutation gates allow it. The rewrite itself is not applied +// here; the caller (reverse proxy) decides whether to honour it. +func mutationRewrite(spec Spec, m *Mutations) *UpstreamRewrite { + if m == nil || m.RewriteUpstream == nil { + return nil + } + if !spec.CanMutate || !spec.MutationsSupported { + return nil + } + return m.RewriteUpstream +} + +func applyMutations(ctx context.Context, d *Dispatcher, spec Spec, r *http.Request, m *Mutations) { + if m == nil { + return + } + add, remove, blocked := FilterHeaderMutations(m) + for _, h := range blocked { + d.metrics.IncHeaderMutationBlocked(ctx, spec.ID, h) + } + for _, name := range remove { + r.Header.Del(name) + } + for _, kv := range add { + r.Header.Add(kv.Key, kv.Value) + } + if len(m.BodyReplace) == 0 { + return + } + if err := ValidateBodyReplace(r, m.BodyReplace, true); err != nil { + d.logger.Warnf("middleware %s body replace rejected: %v", spec.ID, err) + return + } + ApplyBodyReplace(r, m.BodyReplace) +} + +// cloneInputFor deep-copies the mutation-prone fields of Input so +// each middleware receives an isolated view. +func cloneInputFor(in *Input, slot Slot) *Input { + if in == nil { + return nil + } + out := *in + out.Slot = slot + out.Headers = cloneKVs(in.Headers) + out.RespHeaders = cloneKVs(in.RespHeaders) + out.Metadata = cloneKVs(in.Metadata) + if len(in.UserGroups) > 0 { + out.UserGroups = append([]string(nil), in.UserGroups...) + } + if len(in.UserGroupNames) > 0 { + out.UserGroupNames = append([]string(nil), in.UserGroupNames...) + } + if len(in.Body) > 0 { + out.Body = append([]byte(nil), in.Body...) + } + if len(in.RespBody) > 0 { + out.RespBody = append([]byte(nil), in.RespBody...) + } + return &out +} + +func cloneKVs(in []KV) []KV { + if len(in) == 0 { + return nil + } + out := make([]KV, len(in)) + copy(out, in) + return out +} diff --git a/proxy/internal/middleware/chain_test.go b/proxy/internal/middleware/chain_test.go new file mode 100644 index 000000000..929ccee08 --- /dev/null +++ b/proxy/internal/middleware/chain_test.go @@ -0,0 +1,370 @@ +package middleware + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeMiddleware is a minimal Middleware for chain composition tests. +// It records the metadata the dispatcher hands to it and emits a +// caller-supplied Output. Tests use the recorded snapshot to assert +// that earlier-in-slot emissions are visible to later middlewares. +type fakeMiddleware struct { + id string + slot Slot + keys []string + emit []KV + decision Decision + mutationsSupported bool + canMutate bool + mutations *Mutations + + // seen captures the in.Metadata snapshot the dispatcher passed to + // Invoke, so tests can assert ordering and visibility. + seen []KV +} + +func (f *fakeMiddleware) ID() string { return f.id } +func (f *fakeMiddleware) Version() string { return "test" } +func (f *fakeMiddleware) Slot() Slot { return f.slot } +func (f *fakeMiddleware) AcceptedContentTypes() []string { return nil } +func (f *fakeMiddleware) MetadataKeys() []string { return f.keys } +func (f *fakeMiddleware) MutationsSupported() bool { return f.mutationsSupported } +func (f *fakeMiddleware) Close() error { return nil } + +func (f *fakeMiddleware) Invoke(_ context.Context, in *Input) (*Output, error) { + f.seen = append([]KV(nil), in.Metadata...) + out := &Output{Decision: f.decision, Metadata: append([]KV(nil), f.emit...)} + if f.mutations != nil { + m := *f.mutations + out.Mutations = &m + } + return out, nil +} + +// chainFor builds a Chain over the given middlewares with a noop +// dispatcher. +func chainFor(t *testing.T, mws ...*fakeMiddleware) *Chain { + t.Helper() + bound := make([]boundMiddleware, len(mws)) + for i, mw := range mws { + bound[i] = boundMiddleware{ + spec: Spec{ + ID: mw.id, + Slot: mw.slot, + Enabled: true, + MetadataKeys: mw.keys, + CanMutate: mw.canMutate, + MutationsSupported: mw.mutationsSupported, + }, + mw: mw, + } + } + disp := NewDispatcher(nil, nil) + return NewChain("t-1", bound, disp) +} + +// TestChain_RunRequest_ThreadsMetadataAcrossMiddlewares locks that +// each on_request middleware sees metadata emitted by earlier +// middlewares in the same slot. Regression cover for the original +// chain.go where every iteration cloned from the same source `in` and +// later middlewares (e.g. llm_guardrail) couldn't read what the first +// (e.g. llm_request_parser) had just emitted. +func TestChain_RunRequest_ThreadsMetadataAcrossMiddlewares(t *testing.T) { + first := &fakeMiddleware{ + id: "first", + slot: SlotOnRequest, + keys: []string{"foo.k"}, + emit: []KV{{Key: "foo.k", Value: "v"}}, + } + second := &fakeMiddleware{ + id: "second", + slot: SlotOnRequest, + keys: []string{"bar.k"}, + emit: []KV{{Key: "bar.k", Value: "z"}}, + } + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + denied, merged, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "no deny without DecisionDeny") + assert.Nil(t, rewrite, "no rewrite without Mutations.RewriteUpstream") + + require.Len(t, second.seen, 1, "the second middleware must observe one prior emission") + assert.Equal(t, "foo.k", second.seen[0].Key, "second middleware must see the first middleware's key") + assert.Equal(t, "v", second.seen[0].Value, "second middleware must see the first middleware's value") + + require.Len(t, merged, 2, "merged slice contains both middleware emissions") +} + +// TestChain_RunResponse_ThreadsMetadataAcrossMiddlewares does the +// same for the response slot. The response slot iterates in reverse +// registration order, so the middleware registered LAST runs first. +// This test asserts that a middleware running later (in reverse +// order) sees the metadata emitted by the one that ran before it. +func TestChain_RunResponse_ThreadsMetadataAcrossMiddlewares(t *testing.T) { + // Registration order: [outer, inner]. + // Reverse iteration runs inner first, outer second. + // outer must see inner's emission. + outer := &fakeMiddleware{ + id: "outer", + slot: SlotOnResponse, + keys: []string{"outer.k"}, + emit: []KV{{Key: "outer.k", Value: "o"}}, + } + inner := &fakeMiddleware{ + id: "inner", + slot: SlotOnResponse, + keys: []string{"inner.k"}, + emit: []KV{{Key: "inner.k", Value: "i"}}, + } + c := chainFor(t, outer, inner) + acc := NewAccumulator(0) + + merged := c.RunResponse(context.Background(), &Input{}, acc) + + require.Len(t, outer.seen, 1, "outer must observe inner's emission") + assert.Equal(t, "inner.k", outer.seen[0].Key) + require.Len(t, merged, 2, "merged slice contains both response emissions") +} + +// TestChain_RunResponse_CostMeterScenario simulates the synth-service +// chain shape (response_parser registered AFTER cost_meter so reverse +// iter runs response_parser first). The cost_meter analogue must see +// the tokens response_parser just emitted — this is the exact +// regression that produced cost.skipped=missing_tokens in the live +// access logs. +func TestChain_RunResponse_CostMeterScenario(t *testing.T) { + // Synthesizer registers cost_meter first, response_parser second. + costMeter := &fakeMiddleware{ + id: "cost_meter", + slot: SlotOnResponse, + keys: []string{"cost.usd_total", "cost.skipped"}, + } + respParser := &fakeMiddleware{ + id: "llm_response_parser", + slot: SlotOnResponse, + keys: []string{"llm.input_tokens", "llm.output_tokens"}, + emit: []KV{ + {Key: "llm.input_tokens", Value: "13"}, + {Key: "llm.output_tokens", Value: "259"}, + }, + } + c := chainFor(t, costMeter, respParser) + acc := NewAccumulator(0) + + _ = c.RunResponse(context.Background(), &Input{}, acc) + + require.Len(t, costMeter.seen, 2, "cost_meter must observe both token keys emitted by response_parser") + keys := []string{costMeter.seen[0].Key, costMeter.seen[1].Key} + assert.ElementsMatch(t, []string{"llm.input_tokens", "llm.output_tokens"}, keys, + "cost_meter must see the exact keys response_parser emitted") + values := []string{costMeter.seen[0].Value, costMeter.seen[1].Value} + assert.ElementsMatch(t, []string{"13", "259"}, values, "cost_meter must see the exact token counts") + for _, kv := range costMeter.seen { + _, err := strconv.Atoi(kv.Value) + assert.NoError(t, err, "values handed to cost_meter must be numeric (regression for missing_tokens)") + } +} + +// TestChain_RunResponse_DetachedContextStillRecords guards the metering +// fix in reverseproxy.go. The response/terminal phase runs after the body +// is forwarded, so a streaming client has usually disconnected by then, +// cancelling its request context. The dispatcher derives each middleware's +// context from the one passed here and short-circuits to fail-mode the +// instant it's Done, which silently drops token/cost metering. The reverse +// proxy now detaches that phase with context.WithoutCancel; this proves a +// context detached from an already-cancelled parent still lets a response +// middleware emit. (The cancelled-parent direction is intentionally not +// asserted: the dispatcher's select over ctx.Done vs the result channel is +// racy when both are ready, which is exactly why the bug was intermittent.) +func TestChain_RunResponse_DetachedContextStillRecords(t *testing.T) { + resp := &fakeMiddleware{ + id: "recorder", + slot: SlotOnResponse, + keys: []string{"llm.input_tokens"}, + emit: []KV{{Key: "llm.input_tokens", Value: "42"}}, + decision: DecisionPassthrough, + } + c := chainFor(t, resp) + + clientCtx, cancel := context.WithCancel(context.Background()) + cancel() // client disconnected after the stream completed + require.Error(t, clientCtx.Err(), "client context must be cancelled for the test to be meaningful") + + detached := context.WithoutCancel(clientCtx) + require.NoError(t, detached.Err(), "detached context must not inherit the client's cancellation") + + acc := NewAccumulator(MaxRequestMetadataBytes) + merged := c.RunResponse(detached, &Input{Slot: SlotOnResponse}, acc) + + var got string + for _, kv := range merged { + if kv.Key == "llm.input_tokens" { + got = kv.Value + } + } + assert.Equal(t, "42", got, "response middleware must still emit token metadata under the detached context") +} + +// TestChain_RunRequest_LatestRewriteWins asserts that when two +// on_request middlewares both emit an UpstreamRewrite, the chain +// returns the value from the later middleware. +func TestChain_RunRequest_LatestRewriteWins(t *testing.T) { + first := &fakeMiddleware{ + id: "first", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: true, + mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "first.test"}}, + } + second := &fakeMiddleware{ + id: "second", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: true, + mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "second.test"}}, + } + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "neither middleware denies") + require.NotNil(t, rewrite, "chain must surface the rewrite emitted by the on_request slot") + assert.Equal(t, "https", rewrite.Scheme, "rewrite scheme must come from the later middleware") + assert.Equal(t, "second.test", rewrite.Host, "rewrite host must come from the later middleware (last-write-wins)") +} + +// TestChain_RunRequest_NoRewrite_NilReturn asserts the chain returns a +// nil rewrite when no middleware emits one. +func TestChain_RunRequest_NoRewrite_NilReturn(t *testing.T) { + first := &fakeMiddleware{id: "first", slot: SlotOnRequest} + second := &fakeMiddleware{id: "second", slot: SlotOnRequest} + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "neither middleware denies") + assert.Nil(t, rewrite, "chain must return nil rewrite when no middleware emits one") +} + +// TestChain_ApplyMutations_RewriteGatedOnCanMutate asserts that a +// middleware emitting an UpstreamRewrite with CanMutate=false has its +// rewrite filtered out by the chain. The dispatcher's filterOutput +// already clears Mutations when the gates fail; the chain's defensive +// gate inside mutationRewrite mirrors that contract so a stale +// Mutations field cannot leak through. +func TestChain_ApplyMutations_RewriteGatedOnCanMutate(t *testing.T) { + mw := &fakeMiddleware{ + id: "first", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: false, + mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "denied.test"}}, + } + c := chainFor(t, mw) + acc := NewAccumulator(0) + + denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "middleware does not deny") + assert.Nil(t, rewrite, "rewrite must be filtered when CanMutate=false") +} + +// TestChain_RunRequest_PropagatesUserGroups asserts the chain forwards +// Input.UserGroups verbatim through cloneInputFor so policy-aware +// middlewares (e.g. llm_policy_check) can authorise without an extra +// management round-trip. +func TestChain_RunRequest_PropagatesUserGroups(t *testing.T) { + groupCapture := &userGroupCaptureMiddleware{ + id: "group-capture", + slot: SlotOnRequest, + } + c := chainFor(t, groupCapture.fake()) + groupCapture.bind(c) + acc := NewAccumulator(0) + + in := &Input{UserGroups: []string{"g1"}} + denied, _, _, err := c.RunRequest(context.Background(), nil, in, acc) + require.NoError(t, err) + assert.Nil(t, denied, "no deny without DecisionDeny") + + require.Len(t, groupCapture.seenGroups, 1, "middleware must observe the caller's UserGroups") + assert.Equal(t, "g1", groupCapture.seenGroups[0], "UserGroups must reach the middleware verbatim") +} + +// userGroupCaptureMiddleware is a fakeMiddleware variant that records +// Input.UserGroups during Invoke. It exists so the cloneInputFor +// behaviour for the new field can be asserted without leaking into +// every other chain test. +type userGroupCaptureMiddleware struct { + id string + slot Slot + seenGroups []string + fakeMW *fakeMiddleware +} + +func (u *userGroupCaptureMiddleware) fake() *fakeMiddleware { + u.fakeMW = &fakeMiddleware{id: u.id, slot: u.slot} + return u.fakeMW +} + +func (u *userGroupCaptureMiddleware) bind(c *Chain) { + for i, bm := range c.all { + if bm.spec.ID != u.id { + continue + } + c.all[i].mw = userGroupRecorder{ + fakeMiddleware: u.fakeMW, + parent: u, + } + } +} + +type userGroupRecorder struct { + *fakeMiddleware + parent *userGroupCaptureMiddleware +} + +func (r userGroupRecorder) Invoke(ctx context.Context, in *Input) (*Output, error) { + r.parent.seenGroups = append([]string(nil), in.UserGroups...) + return r.fakeMiddleware.Invoke(ctx, in) +} + +// TestChain_RunTerminal_SeesAccumulatedMetadata locks that terminal +// middlewares observe the full bag (the caller-supplied in.Metadata +// plus any prior terminal emissions). +func TestChain_RunTerminal_SeesAccumulatedMetadata(t *testing.T) { + first := &fakeMiddleware{ + id: "term-1", + slot: SlotTerminal, + keys: []string{"term.first"}, + emit: []KV{{Key: "term.first", Value: "1"}}, + } + second := &fakeMiddleware{ + id: "term-2", + slot: SlotTerminal, + keys: []string{"term.second"}, + } + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + in := &Input{Metadata: []KV{{Key: "ext.k", Value: "ext"}}} + merged := c.RunTerminal(context.Background(), in, acc) + + require.Len(t, second.seen, 2, "second terminal must see ext bag + first terminal's emission") + got := map[string]string{} + for _, kv := range second.seen { + got[kv.Key] = kv.Value + } + assert.Equal(t, "ext", got["ext.k"], "external bag carries through") + assert.Equal(t, "1", got["term.first"], "first terminal's emission visible to second terminal") + assert.Len(t, merged, 1, "only first terminal emitted; second emitted nothing") +} diff --git a/proxy/internal/middleware/decision.go b/proxy/internal/middleware/decision.go new file mode 100644 index 000000000..0970bdea4 --- /dev/null +++ b/proxy/internal/middleware/decision.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "regexp" +) + +var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) + +// denyResponse is the on-wire shape rendered by RenderDenyResponse. +// Keeping this as a typed struct ensures we never leak +// middleware-supplied bytes outside known fields. +type denyResponse struct { + Code string `json:"code"` + Message string `json:"message,omitempty"` + Details map[string]string `json:"details,omitempty"` + Middleware string `json:"middleware,omitempty"` +} + +// RenderDenyResponse writes a structured JSON deny body. Status is +// clamped to [400, 499] excluding 401 (to avoid conflicts with the +// proxy's auth flow). All middleware-supplied strings are redacted and +// truncated. On any validation failure the function writes a generic +// 403. +func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *DenyReason, defaultStatus int) { + status := clampDenyStatus(defaultStatus) + + if reason == nil || !codeRegex.MatchString(reason.Code) { + writeGenericDeny(w, middlewareID, status) + return + } + + resp := denyResponse{ + Code: reason.Code, + Message: truncate(Scan(reason.Message), 256), + Middleware: truncate(Scan(middlewareID), 64), + } + if n := len(reason.Details); n > 0 { + resp.Details = make(map[string]string, min(n, 8)) + for k, v := range reason.Details { + if len(resp.Details) >= 8 { + break + } + safeKey := truncate(Scan(k), 64) + if safeKey == "" { + continue + } + resp.Details[safeKey] = truncate(Scan(v), 256) + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } +} + +func writeGenericDeny(w http.ResponseWriter, middlewareID string, status int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(denyResponse{Code: "middleware.error", Middleware: truncate(Scan(middlewareID), 64)}) +} + +func clampDenyStatus(s int) int { + if s < 400 || s >= 500 { + return http.StatusForbidden + } + if s == http.StatusUnauthorized { + return http.StatusForbidden + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/proxy/internal/middleware/dispatcher.go b/proxy/internal/middleware/dispatcher.go new file mode 100644 index 000000000..316604651 --- /dev/null +++ b/proxy/internal/middleware/dispatcher.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "context" + "errors" + "fmt" + "reflect" + "runtime" + "time" + + log "github.com/sirupsen/logrus" +) + +// Dispatcher reliability kinds reported via +// proxy.middleware.errors_total{kind=...}. +const ( + ErrorKindPanic = "panic" + ErrorKindTimeout = "timeout" + ErrorKindInvokeError = "invoke_error" +) + +// Dispatcher drives a single middleware invocation with panic +// recovery, deadline, and output filtering. Safe for concurrent use. +type Dispatcher struct { + metrics *Metrics + logger *log.Logger +} + +// NewDispatcher returns a dispatcher that emits on the provided +// metrics bundle and logger. A nil metrics bundle falls back to a noop +// instrument set; a nil logger falls back to the standard logger. +func NewDispatcher(metrics *Metrics, logger *log.Logger) *Dispatcher { + if metrics == nil { + metrics, _ = NewMetrics(nil) + } + if logger == nil { + logger = log.StandardLogger() + } + return &Dispatcher{metrics: metrics, logger: logger} +} + +// Invoke runs a single middleware under the reliability wrappers: +// deadline, panic recovery (type + truncated stack only), fail-mode, +// metric emission, and output filtering. The returned output is always +// safe to apply. +func (d *Dispatcher) Invoke(ctx context.Context, spec Spec, mw Middleware, in *Input) (*Output, error) { + if mw == nil { + return nil, fmt.Errorf("middleware %s: instance unavailable", spec.ID) + } + + timeout := clampTimeout(spec.Timeout) + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + d.metrics.IncInvocation(ctx, spec.ID) + start := time.Now() + + type result struct { + out *Output + err error + } + ch := make(chan result, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + stack := make([]byte, 4<<10) + n := runtime.Stack(stack, false) + requestID := "" + if in != nil { + requestID = in.RequestID + } + d.logger.Warnf("middleware %s panic: request_id=%s type=%s stack=%s", + spec.ID, requestID, reflect.TypeOf(r).String(), stack[:n]) + ch <- result{err: panicError{msg: fmt.Sprintf("middleware %s panic: %s", spec.ID, reflect.TypeOf(r).String())}} + } + }() + out, err := mw.Invoke(callCtx, in) + ch <- result{out: out, err: err} + }() + + var ( + out *Output + invErr error + kind string + ) + + select { + case <-callCtx.Done(): + invErr = callCtx.Err() + kind = ErrorKindTimeout + case res := <-ch: + out = res.out + invErr = res.err + if invErr != nil { + kind = d.classifyError(invErr) + } + } + + d.metrics.ObserveDuration(ctx, spec.ID, time.Since(start).Milliseconds()) + + if invErr != nil { + d.metrics.IncError(ctx, spec.ID, kind) + return d.failMode(spec, kind), invErr + } + + return d.filterOutput(spec, out), nil +} + +func (d *Dispatcher) classifyError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.DeadlineExceeded) { + return ErrorKindTimeout + } + var pe panicError + if errors.As(err, &pe) { + return ErrorKindPanic + } + return ErrorKindInvokeError +} + +// panicError marks an error as coming from the recover branch so the +// classifier can tag it without string inspection. +type panicError struct{ msg string } + +func (p panicError) Error() string { return p.msg } + +// failMode converts an error into a synthesised output per the +// middleware's fail-mode. An mw..error_kind metadata entry is +// attached so operators can alert on error rate even when the +// decision is fail-open. Slot constraints still apply: response and +// terminal slots clamp deny back to passthrough in filterOutput. +func (d *Dispatcher) failMode(spec Spec, kind string) *Output { + meta := []KV{{Key: fmt.Sprintf(KeyFrameworkErrorKindFmt, spec.ID), Value: kind}} + if spec.FailMode == FailClosed && spec.Slot == SlotOnRequest { + return &Output{ + Decision: DecisionDeny, + DenyStatus: 500, + DenyReason: &DenyReason{Code: "middleware.error"}, + Metadata: meta, + } + } + return &Output{Decision: DecisionAllow, Metadata: meta} +} + +// filterOutput applies the output-filter pipeline (slot-aware decision +// clamp, mutations gate) so downstream consumers never see +// middleware-supplied values that violate the contract. Metadata is +// passed through; the Accumulator is the single owner of allowlist + +// caps + redaction (called by Chain). +func (d *Dispatcher) filterOutput(spec Spec, out *Output) *Output { + if out == nil { + return &Output{Decision: DecisionAllow} + } + if spec.Slot != SlotOnRequest && out.Decision == DecisionDeny { + out.Decision = DecisionPassthrough + out.DenyStatus = 0 + out.DenyReason = nil + } + if out.Decision == DecisionDeny { + if out.DenyStatus == 0 { + out.DenyStatus = 403 + } else { + out.DenyStatus = clampDenyStatus(out.DenyStatus) + } + } + if !spec.CanMutate || !spec.MutationsSupported { + out.Mutations = nil + } + if spec.Slot == SlotTerminal { + out.Mutations = nil + } + return out +} + +func clampTimeout(d time.Duration) time.Duration { + if d <= 0 { + return DefaultTimeout + } + if d < MinTimeout { + return MinTimeout + } + if d > MaxTimeout { + return MaxTimeout + } + return d +} diff --git a/proxy/internal/middleware/headerpolicy.go b/proxy/internal/middleware/headerpolicy.go new file mode 100644 index 000000000..d041ad1e1 --- /dev/null +++ b/proxy/internal/middleware/headerpolicy.go @@ -0,0 +1,99 @@ +package middleware + +import "strings" + +var denyHeaders = []string{ + "Authorization", + "Connection", + "Cookie", + "Set-Cookie", + "Forwarded", + "Keep-Alive", + "Proxy-Authorization", + "Proxy-Authenticate", + "Proxy-Connection", + "TE", + "Upgrade", + "Via", + "X-Real-IP", + "X-Request-ID", + "Host", + "Content-Length", + "Transfer-Encoding", + "Trailer", +} + +var denyHeaderPrefixes = []string{ + "X-Authenticated-", + "X-Forwarded-", + "X-Remote-", + "X-NetBird-", +} + +// IsHeaderMutable reports whether a middleware is allowed to mutate +// the named header. The check is case-insensitive and honours both +// exact matches and the compiled-in prefix denylist. +func IsHeaderMutable(name string) bool { + if name == "" { + return false + } + if !isHeaderFieldName(name) { + return false + } + for _, d := range denyHeaders { + if strings.EqualFold(d, name) { + return false + } + } + for _, p := range denyHeaderPrefixes { + if len(name) >= len(p) && strings.EqualFold(name[:len(p)], p) { + return false + } + } + return true +} + +// isHeaderFieldName reports whether name is a valid RFC 7230 header +// field-name (a non-empty token of tchar octets). Rejects names with +// spaces, control characters, or separators that could enable header +// injection or smuggling when applied to the outbound request. +func isHeaderFieldName(name string) bool { + for i := 0; i < len(name); i++ { + c := name[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + continue + } + switch c { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~': + continue + default: + return false + } + } + return true +} + +// FilterHeaderMutations returns the subsets of HeadersAdd and +// HeadersRemove that are safe to apply, plus the list of blocked +// header names so the dispatcher can increment the blocked-header +// metric. +func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []string, blocked []string) { + if m == nil { + return nil, nil, nil + } + for _, kv := range m.HeadersAdd { + if IsHeaderMutable(kv.Key) { + filteredAdd = append(filteredAdd, kv) + continue + } + blocked = append(blocked, kv.Key) + } + for _, name := range m.HeadersRemove { + if IsHeaderMutable(name) { + filteredRemove = append(filteredRemove, name) + continue + } + blocked = append(blocked, name) + } + return filteredAdd, filteredRemove, blocked +} diff --git a/proxy/internal/middleware/keys.go b/proxy/internal/middleware/keys.go new file mode 100644 index 000000000..9c584ad82 --- /dev/null +++ b/proxy/internal/middleware/keys.go @@ -0,0 +1,86 @@ +package middleware + +// Metadata key namespace constants shared across the built-in +// middlewares. Each domain owns a prefix; middlewares declare their +// per-key allowlist drawn from these constants. Agents implementing +// the G2 middlewares import this file so the dashboard's expanded-row +// viewer and the access-log writer see a stable key surface. +// +// Key shape rules (enforced by the metadata accumulator): +// - Lowercase ASCII letters, digits, dot, underscore, hyphen. +// - At least one dot separating namespace from leaf. +// - Max length: MaxMetadataKeyBytes. +const ( + // LLM request-side metadata (emitted by llm_request_parser). + KeyLLMProvider = "llm.provider" + KeyLLMModel = "llm.model" + KeyLLMStream = "llm.stream" + KeyLLMRequestPromptRaw = "llm.request_prompt_raw" + KeyLLMCaptureTruncated = "llm.capture_truncated" + // KeyLLMSessionID groups requests of the same conversation / coding + // session, read from the per-provider session marker in the request + // body. Empty for clients that don't send one. + KeyLLMSessionID = "llm.session_id" + + // LLM response-side metadata (emitted by llm_response_parser). + //nolint:gosec // metadata key name, not a credential + KeyLLMInputTokens = "llm.input_tokens" + //nolint:gosec // metadata key name, not a credential + KeyLLMOutputTokens = "llm.output_tokens" + //nolint:gosec // metadata key name, not a credential + KeyLLMTotalTokens = "llm.total_tokens" + // LLM cached-input bucket. For OpenAI it's the SUBSET of input + // tokens that hit the prompt cache (prompt_tokens_details. + // cached_tokens) — billed at the cached_input_per_1k rate when + // configured. For Anthropic it's cache_read_input_tokens, which + // is ADDITIVE to llm.input_tokens — billed at cache_read_per_1k. + // cost_meter switches formula on llm.provider. + //nolint:gosec // metadata key name, not a credential + KeyLLMCachedInputTokens = "llm.cached_input_tokens" + // LLM cache-creation bucket (Anthropic only). ADDITIVE to + // llm.input_tokens; billed at cache_creation_per_1k. + //nolint:gosec // metadata key name, not a credential + KeyLLMCacheCreationTokens = "llm.cache_creation_tokens" + KeyLLMResponseCompletion = "llm.response_completion" + + // Guardrail outcomes (emitted by llm_guardrail). The guardrail + // also re-emits llm.request_prompt as a redacted variant of the + // raw prompt and drops llm.request_prompt_raw from the bag. + KeyLLMRequestPrompt = "llm.request_prompt" + KeyLLMPolicyDecision = "llm_policy.decision" + KeyLLMPolicyReason = "llm_policy.reason" + + // LLM router routing decision (emitted by llm_router). The router + // stamps the resolved provider id so downstream middlewares and + // the access-log emitter can attribute the request without + // re-parsing the body. + KeyLLMResolvedProviderID = "llm.resolved_provider_id" + + // LLM authorising groups for this request (emitted by llm_router + // on the allow path). Carries the comma-separated intersection of + // the caller's UserGroups with the resolved route's + // AllowedGroupIDs — i.e. the groups that actually authorise this + // specific request, NOT every group the peer happens to be in. + // Identity-stamping middlewares use this for per-request tag + // attribution so unrelated group memberships don't leak into + // downstream gateways' spend logs. + KeyLLMAuthorisingGroups = "llm.authorising_groups" + + // LLM policy attribution (emitted by llm_limit_check on the allow + // path). Names the policy that paid for this request and the + // dimension counters the post-flight llm_limit_record middleware + // must tick. Empty when no applicable policy has any caps + // configured (catch-all-allow attribution). + KeyLLMSelectedPolicyID = "llm.selected_policy_id" + KeyLLMAttributionGroupID = "llm.attribution_group_id" + KeyLLMAttributionWindowS = "llm.attribution_window_seconds" + + // Cost metering (emitted by cost_meter). + KeyCostUSDTotal = "cost.usd_total" + KeyCostSkipped = "cost.skipped" + + // Framework-emitted error markers. Use the mw..* prefix to + // distinguish framework-injected entries from middleware-emitted + // metadata. + KeyFrameworkErrorKindFmt = "mw.%s.error_kind" +) diff --git a/proxy/internal/middleware/manager.go b/proxy/internal/middleware/manager.go new file mode 100644 index 000000000..9b22edeff --- /dev/null +++ b/proxy/internal/middleware/manager.go @@ -0,0 +1,412 @@ +package middleware + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" +) + +// chainCloseTimeout bounds how long closeChainsAsync waits for an +// individual chain to drain before forcing teardown. Set to 2x +// MaxTimeout so a middleware blocked on the dispatcher's per-Invoke +// deadline always wins; anything running longer is a runaway and gets +// force-closed. +const chainCloseTimeout = 2 * MaxTimeout + +// PathTargetBinding is the minimal per-path binding the server passes +// to Rebuild. It carries the stable keys Manager uses for snapshot +// lookups plus the validated middleware spec list for that path. +type PathTargetBinding struct { + ServiceID string + PathID string + Specs []Spec +} + +// LiveServiceCheck reports whether the given service ID is still +// present in the proxy's live mapping cache. The Manager calls it +// during InvalidateMiddleware so a chain whose service has been +// removed since the last Rebuild is not resurrected from the binding +// cache, closing the auth-revocation race. +type LiveServiceCheck func(serviceID string) bool + +// chainTable holds the immutable per-target chain snapshot. It is +// cloned into a new instance on every Rebuild and swapped in via +// atomic.Pointer. The reverse index byMiddleware lets +// InvalidateMiddleware find the chain keys that reference a given +// middleware without scanning the whole table. +type chainTable struct { + byTarget map[string]*Chain + byMiddleware map[string]map[string]struct{} +} + +func newChainTable() *chainTable { + return &chainTable{ + byTarget: make(map[string]*Chain), + byMiddleware: make(map[string]map[string]struct{}), + } +} + +func (c *chainTable) clone() *chainTable { + out := newChainTable() + for k, v := range c.byTarget { + out.byTarget[k] = v + } + for id, keys := range c.byMiddleware { + set := make(map[string]struct{}, len(keys)) + for k := range keys { + set[k] = struct{}{} + } + out.byMiddleware[id] = set + } + return out +} + +func (c *chainTable) addChain(key string, ch *Chain) { + c.byTarget[key] = ch + if ch == nil { + return + } + for _, bm := range ch.all { + set, ok := c.byMiddleware[bm.spec.ID] + if !ok { + set = make(map[string]struct{}) + c.byMiddleware[bm.spec.ID] = set + } + set[key] = struct{}{} + } +} + +func (c *chainTable) removeChain(key string) (*Chain, []string) { + ch, ok := c.byTarget[key] + if !ok { + return nil, nil + } + delete(c.byTarget, key) + if ch == nil { + return nil, nil + } + ids := make([]string, 0, len(ch.all)) + for _, bm := range ch.all { + ids = append(ids, bm.spec.ID) + set, ok := c.byMiddleware[bm.spec.ID] + if !ok { + continue + } + delete(set, key) + if len(set) == 0 { + delete(c.byMiddleware, bm.spec.ID) + } + } + return ch, ids +} + +// Manager owns the per-target middleware chains, the global capture +// budget, and the shared dispatcher. Readers (ChainFor) are lock-free; +// writers (Rebuild, Invalidate*) serialise on writeMu so two +// concurrent mapping updates do not lose writes. +type Manager struct { + writeMu sync.Mutex + chains atomic.Pointer[chainTable] + budget bodytap.Budget + metrics *Metrics + logger *log.Logger + dispatcher *Dispatcher + resolver *Resolver + lastBindings map[string]PathTargetBinding + liveServiceCheck atomic.Pointer[LiveServiceCheck] +} + +// NewManager constructs a Manager with the given capture budget size. +// A zero or negative budget falls back to bodytap.DefaultCaptureBudgetBytes. +func NewManager(budgetBytes int64, metrics *Metrics, logger *log.Logger) *Manager { + if metrics == nil { + metrics, _ = NewMetrics(nil) + } + if logger == nil { + logger = log.StandardLogger() + } + if budgetBytes <= 0 { + budgetBytes = bodytap.DefaultCaptureBudgetBytes + } + m := &Manager{ + budget: bodytap.NewBudget(budgetBytes), + metrics: metrics, + logger: logger, + dispatcher: NewDispatcher(metrics, logger), + lastBindings: make(map[string]PathTargetBinding), + } + m.chains.Store(newChainTable()) + return m +} + +// SetResolver installs the resolver used by Rebuild. Safe to call +// once at boot before any Rebuild; not safe to swap concurrently. +func (m *Manager) SetResolver(r *Resolver) { + m.resolver = r +} + +// SetLiveServiceCheck installs a callback the Manager uses to confirm +// a service ID still maps to a live mapping before resurrecting its +// chain from the binding cache during InvalidateMiddleware. A nil fn +// disables the check. +func (m *Manager) SetLiveServiceCheck(fn LiveServiceCheck) { + if fn == nil { + m.liveServiceCheck.Store(nil) + return + } + m.liveServiceCheck.Store(&fn) +} + +// Budget returns the shared capture budget. +func (m *Manager) Budget() bodytap.Budget { + return m.budget +} + +// Metrics returns the shared metrics bundle. +func (m *Manager) Metrics() *Metrics { + return m.metrics +} + +// Dispatcher returns the shared dispatcher (primarily for testing). +func (m *Manager) Dispatcher() *Dispatcher { + return m.dispatcher +} + +// Rebuild replaces every chain keyed by serviceID with the provided +// bindings. Entries for other services are preserved. Replaced chains +// are closed asynchronously after the atomic swap so in-flight +// requests against the previous chain finish before middleware +// resources are released. +func (m *Manager) Rebuild(serviceID string, bindings []PathTargetBinding) error { + m.writeMu.Lock() + defer m.writeMu.Unlock() + + cur := m.chains.Load() + next := cur.clone() + + prefix := serviceID + "|" + var retired []*Chain + for k := range cur.byTarget { + if !strings.HasPrefix(k, prefix) { + continue + } + ch, _ := next.removeChain(k) + if ch != nil { + retired = append(retired, ch) + } + delete(m.lastBindings, k) + } + + for _, b := range bindings { + if b.ServiceID != serviceID { + return fmt.Errorf("binding service %q does not match rebuild service %q", b.ServiceID, serviceID) + } + key := chainKey(b.ServiceID, b.PathID) + m.lastBindings[key] = cloneBinding(b) + chain := m.buildChain(b) + if chain == nil || chain.Empty() { + delete(m.lastBindings, key) + continue + } + next.addChain(key, chain) + } + + m.chains.Store(next) + m.closeChainsAsync(retired) + return nil +} + +// Invalidate drops every chain for the given service ID. +func (m *Manager) Invalidate(serviceID string) { + m.writeMu.Lock() + defer m.writeMu.Unlock() + cur := m.chains.Load() + next := cur.clone() + prefix := serviceID + "|" + var retired []*Chain + for k := range cur.byTarget { + if !strings.HasPrefix(k, prefix) { + continue + } + ch, _ := next.removeChain(k) + if ch != nil { + retired = append(retired, ch) + } + delete(m.lastBindings, k) + } + for k := range m.lastBindings { + if strings.HasPrefix(k, prefix) { + delete(m.lastBindings, k) + } + } + m.chains.Store(next) + m.closeChainsAsync(retired) +} + +// InvalidateMiddleware rebuilds only the chains that reference id. +func (m *Manager) InvalidateMiddleware(id string) { + if id == "" { + return + } + m.writeMu.Lock() + defer m.writeMu.Unlock() + + cur := m.chains.Load() + keys, ok := cur.byMiddleware[id] + if !ok || len(keys) == 0 { + return + } + + affected := make([]string, 0, len(keys)) + for k := range keys { + affected = append(affected, k) + } + + next := cur.clone() + var retired []*Chain + check := m.loadLiveServiceCheck() + for _, k := range affected { + ch, _ := next.removeChain(k) + if ch != nil { + retired = append(retired, ch) + } + b, ok := m.lastBindings[k] + if !ok { + delete(m.lastBindings, k) + continue + } + if check != nil && !check(b.ServiceID) { + m.logger.Debugf("middleware %s: skipping rebuild for %s; service no longer live", id, k) + delete(m.lastBindings, k) + continue + } + chain := m.buildChain(b) + if chain == nil || chain.Empty() { + delete(m.lastBindings, k) + continue + } + next.addChain(k, chain) + } + + m.chains.Store(next) + m.closeChainsAsync(retired) +} + +func (m *Manager) loadLiveServiceCheck() LiveServiceCheck { + p := m.liveServiceCheck.Load() + if p == nil { + return nil + } + return *p +} + +// InvalidateAll drops every chain. +func (m *Manager) InvalidateAll() { + m.writeMu.Lock() + defer m.writeMu.Unlock() + cur := m.chains.Load() + retired := make([]*Chain, 0, len(cur.byTarget)) + for _, c := range cur.byTarget { + retired = append(retired, c) + } + m.chains.Store(newChainTable()) + for k := range m.lastBindings { + delete(m.lastBindings, k) + } + m.closeChainsAsync(retired) +} + +func (m *Manager) closeChainsAsync(retired []*Chain) { + if len(retired) == 0 { + return + } + chains := make([]*Chain, len(retired)) + copy(chains, retired) + go func() { + for _, c := range chains { + ctx, cancel := context.WithTimeout(context.Background(), chainCloseTimeout) + start := time.Now() + if err := c.Close(ctx); err != nil { + if m.metrics != nil { + m.metrics.IncError(context.Background(), c.TargetID(), "chain_close_timeout") + } + m.logger.Warnf("middleware chain %s close exceeded %s after %s: %v", + c.TargetID(), chainCloseTimeout, time.Since(start), err) + } + cancel() + } + }() +} + +// ChainFor returns the chain for serviceID/pathID or nil if none is +// registered. Lock-free. +func (m *Manager) ChainFor(serviceID, pathID string) *Chain { + tbl := m.chains.Load() + if tbl == nil { + return nil + } + c, ok := tbl.byTarget[chainKey(serviceID, pathID)] + if !ok { + return nil + } + return c +} + +// buildChain resolves each enabled spec and returns the assembled +// chain. Returns a nil chain when no middlewares are bound; resolver +// errors per middleware are logged and counted but do not abort the +// chain. +func (m *Manager) buildChain(b PathTargetBinding) *Chain { + if len(b.Specs) == 0 || m.resolver == nil { + return nil + } + + bound := make([]boundMiddleware, 0, len(b.Specs)) + for _, spec := range b.Specs { + if !spec.Enabled { + continue + } + mw, merged, err := m.resolver.Resolve(spec) + if err != nil { + m.logger.Warnf("middleware %s resolve on target %s/%s: %v", spec.ID, b.ServiceID, b.PathID, err) + m.metrics.IncError(context.Background(), spec.ID, "resolve_error") + continue + } + if mw == nil { + continue + } + bound = append(bound, boundMiddleware{spec: merged, mw: mw}) + } + if len(bound) == 0 { + return nil + } + return NewChain(chainKey(b.ServiceID, b.PathID), bound, m.dispatcher) +} + +// cloneBinding returns a deep copy of b suitable for caching across +// mapping updates. +func cloneBinding(b PathTargetBinding) PathTargetBinding { + out := PathTargetBinding{ + ServiceID: b.ServiceID, + PathID: b.PathID, + } + if len(b.Specs) == 0 { + return out + } + out.Specs = make([]Spec, len(b.Specs)) + for i, s := range b.Specs { + out.Specs[i] = s.Clone() + } + return out +} + +func chainKey(serviceID, pathID string) string { + return serviceID + "|" + pathID +} diff --git a/proxy/internal/middleware/metadata.go b/proxy/internal/middleware/metadata.go new file mode 100644 index 000000000..576c379ec --- /dev/null +++ b/proxy/internal/middleware/metadata.go @@ -0,0 +1,99 @@ +package middleware + +import "regexp" + +// keyRegex constrains metadata keys to the cross-domain shape +// described in keys.go. At least one dot, lowercase ASCII / digits / +// dot / underscore / hyphen only, length within MaxMetadataKeyBytes. +var keyRegex = regexp.MustCompile(`^[a-z][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`) + +// MetadataRejection describes a single rejected key/value so the +// dispatcher can emit per-reason counter increments. +type MetadataRejection struct { + Key string + Reason string +} + +// Rejection reasons reported by Accumulator.Emit. +const ( + MetadataReasonBadKey = "bad_key" + MetadataReasonNotAllowlisted = "not_allowlisted" + MetadataReasonKeyTooLong = "key_too_long" + MetadataReasonValueTooLong = "value_too_long" + MetadataReasonMiddlewareCap = "middleware_cap" + MetadataReasonRequestCap = "request_cap" +) + +// Accumulator enforces per-middleware and per-request metadata caps. +// Not safe for concurrent use; callers hold one inside a single chain +// execution. +type Accumulator struct { + perMiddlewareUsed map[string]int + totalUsed int + maxPerRequest int +} + +// NewAccumulator returns an accumulator configured for the per-request +// total cap. A maxPerRequest of zero means use MaxRequestMetadataBytes. +func NewAccumulator(maxPerRequest int) *Accumulator { + if maxPerRequest <= 0 { + maxPerRequest = MaxRequestMetadataBytes + } + return &Accumulator{ + perMiddlewareUsed: make(map[string]int), + maxPerRequest: maxPerRequest, + } +} + +// Emit validates the candidate metadata against the middleware's +// allowlist and the global caps, redacts each accepted value, and +// returns the accepted entries plus any rejections for metric emission. +func (a *Accumulator) Emit(middlewareID string, allow []string, out []KV) ([]KV, []MetadataRejection) { + if len(out) == 0 { + return nil, nil + } + allowSet := make(map[string]struct{}, len(allow)) + for _, k := range allow { + allowSet[k] = struct{}{} + } + + accepted := make([]KV, 0, len(out)) + var rejected []MetadataRejection + + for _, kv := range out { + if len(kv.Key) == 0 || len(kv.Key) > MaxMetadataKeyBytes { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonKeyTooLong}) + continue + } + if !keyRegex.MatchString(kv.Key) { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonBadKey}) + continue + } + if _, ok := allowSet[kv.Key]; !ok { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonNotAllowlisted}) + continue + } + if len(kv.Value) > MaxMetadataValueBytes { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonValueTooLong}) + continue + } + + redacted := Scan(kv.Value) + cost := len(kv.Key) + len(redacted) + + if a.perMiddlewareUsed[middlewareID]+cost > MaxMiddlewareMetadataBytes { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonMiddlewareCap}) + continue + } + if a.totalUsed+cost > a.maxPerRequest { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonRequestCap}) + continue + } + + a.perMiddlewareUsed[middlewareID] += cost + a.totalUsed += cost + accepted = append(accepted, KV{Key: kv.Key, Value: redacted}) + } + + return accepted, rejected +} diff --git a/proxy/internal/middleware/metrics.go b/proxy/internal/middleware/metrics.go new file mode 100644 index 000000000..73745a86b --- /dev/null +++ b/proxy/internal/middleware/metrics.go @@ -0,0 +1,171 @@ +package middleware + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +// Metrics is the bundle of OTel instruments emitted by the middleware +// dispatcher. The constructor falls back to a noop meter when given +// nil so tests can skip metrics wiring entirely. +type Metrics struct { + requestsTotal metric.Int64Counter + durationMs metric.Int64Histogram + invocationsTotal metric.Int64Counter + errorsTotal metric.Int64Counter + metadataRejectedTotal metric.Int64Counter + headerMutationBlocked metric.Int64Counter + captureBypassTotal metric.Int64Counter +} + +// NewMetrics registers the proxy.middleware.* instruments on the +// given meter. A nil meter is treated as the global no-op provider. +func NewMetrics(meter metric.Meter) (*Metrics, error) { + if meter == nil { + meter = noop.NewMeterProvider().Meter("proxy.middleware.noop") + } + + m := &Metrics{} + var err error + + m.requestsTotal, err = meter.Int64Counter( + "proxy.middleware.requests_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware invocations grouped by outcome"), + ) + if err != nil { + return nil, err + } + + m.durationMs, err = meter.Int64Histogram( + "proxy.middleware.duration_ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Middleware Invoke latency"), + ) + if err != nil { + return nil, err + } + + m.invocationsTotal, err = meter.Int64Counter( + "proxy.middleware.invocations_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware Invoke heartbeat counter"), + ) + if err != nil { + return nil, err + } + + m.errorsTotal, err = meter.Int64Counter( + "proxy.middleware.errors_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware errors grouped by kind"), + ) + if err != nil { + return nil, err + } + + m.metadataRejectedTotal, err = meter.Int64Counter( + "proxy.middleware.metadata_rejected_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware metadata entries rejected by the allowlist/caps"), + ) + if err != nil { + return nil, err + } + + m.headerMutationBlocked, err = meter.Int64Counter( + "proxy.middleware.header_mutation_blocked_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware header mutations dropped by the denylist"), + ) + if err != nil { + return nil, err + } + + m.captureBypassTotal, err = meter.Int64Counter( + "proxy.middleware.capture_bypass_total", + metric.WithUnit("1"), + metric.WithDescription("Capture bypasses grouped by reason"), + ) + if err != nil { + return nil, err + } + + return m, nil +} + +// IncRequest increments proxy.middleware.requests_total with the +// middleware, target, and outcome labels. +func (m *Metrics) IncRequest(ctx context.Context, middlewareID, targetID, outcome string) { + if m == nil { + return + } + m.requestsTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("target_id", targetID), + attribute.String("outcome", outcome), + )) +} + +// ObserveDuration records the middleware Invoke latency in milliseconds. +func (m *Metrics) ObserveDuration(ctx context.Context, middlewareID string, ms int64) { + if m == nil { + return + } + m.durationMs.Record(ctx, ms, metric.WithAttributes(attribute.String("middleware", middlewareID))) +} + +// IncInvocation increments the heartbeat counter regardless of outcome. +func (m *Metrics) IncInvocation(ctx context.Context, middlewareID string) { + if m == nil { + return + } + m.invocationsTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("middleware", middlewareID))) +} + +// IncError increments the error counter with the given failure kind label. +func (m *Metrics) IncError(ctx context.Context, middlewareID, kind string) { + if m == nil { + return + } + m.errorsTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("kind", kind), + )) +} + +// IncMetadataRejected increments the rejected-metadata counter for a reason. +func (m *Metrics) IncMetadataRejected(ctx context.Context, middlewareID, reason string) { + if m == nil { + return + } + m.metadataRejectedTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("reason", reason), + )) +} + +// IncHeaderMutationBlocked increments the blocked-header counter. +func (m *Metrics) IncHeaderMutationBlocked(ctx context.Context, middlewareID, header string) { + if m == nil { + return + } + m.headerMutationBlocked.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("header", header), + )) +} + +// IncCaptureBypass increments the capture-bypass counter for a reason. +func (m *Metrics) IncCaptureBypass(ctx context.Context, targetID, reason string) { + if m == nil { + return + } + m.captureBypassTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("target_id", targetID), + attribute.String("reason", reason), + )) +} diff --git a/proxy/internal/middleware/middleware.go b/proxy/internal/middleware/middleware.go new file mode 100644 index 000000000..16d398d2c --- /dev/null +++ b/proxy/internal/middleware/middleware.go @@ -0,0 +1,47 @@ +package middleware + +import "context" + +// Middleware is the surface exposed by each concrete implementation. +// The Manager invokes it through the Dispatcher, passing a cloned +// Input. Each middleware lives in exactly one Slot. +// +// Close releases any resources owned by the middleware instance +// (background goroutines, file handles). It is invoked when the chain +// holding the middleware is replaced or torn down. Implementations +// must be idempotent and safe to call after construction even when +// Invoke was never called. +type Middleware interface { + ID() string + Version() string + Slot() Slot + + // AcceptedContentTypes lists the request/response content types + // the middleware needs the body for. Empty slice means the + // middleware does not inspect the body. + AcceptedContentTypes() []string + + // MetadataKeys is the closed set of metadata keys this middleware + // may emit. The accumulator drops anything outside this allowlist. + MetadataKeys() []string + + // MutationsSupported reports whether the middleware may emit + // header / body mutations. A spec with CanMutate=true is honoured + // only when the implementation also supports mutations. + MutationsSupported() bool + + Invoke(ctx context.Context, in *Input) (*Output, error) + + Close() error +} + +// Factory builds a configured Middleware instance from raw config +// bytes shipped on the wire. Each registered middleware ID has a +// single factory in the registry. Factory.New returns an error when +// the config is malformed or violates a per-middleware invariant; the +// chain build path logs the error, increments the resolve_error metric, +// and skips the middleware. +type Factory interface { + ID() string + New(rawConfig []byte) (Middleware, error) +} diff --git a/proxy/internal/middleware/redaction.go b/proxy/internal/middleware/redaction.go new file mode 100644 index 000000000..ebbe90c61 --- /dev/null +++ b/proxy/internal/middleware/redaction.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "regexp" + "strings" +) + +// Redaction scope: Scan handles the narrow, high-signal set of +// secrets we are comfortable masking with a regex. The intent is +// "make accidental leaks impossible to miss at a glance", not "be a +// DLP product". Contributors adding more patterns should weigh false +// positives carefully — a metadata value that over-redacts benign +// strings is strictly worse than one that misses a rare format. +var ( + jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}`) + pemRegex = regexp.MustCompile(`-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----`) + awsKeyRegex = regexp.MustCompile(`AKIA[0-9A-Z]{16}`) + bearerRegex = regexp.MustCompile(`(?i)\b(?:bearer|token|api[_-]?key|authorization)[\s:=]+([A-Za-z0-9_\-\.]{40,})`) + ccCandidateRgx = regexp.MustCompile(`\b(?:\d[ -]?){13,19}\b`) +) + +// Scan redacts high-signal secret patterns from value. Matches are +// replaced with `[REDACTED:]`. Non-matching input is returned +// unchanged. +func Scan(value string) string { + if value == "" { + return value + } + result := value + result = pemRegex.ReplaceAllString(result, "[REDACTED:pem]") + result = jwtRegex.ReplaceAllString(result, "[REDACTED:jwt]") + result = awsKeyRegex.ReplaceAllString(result, "[REDACTED:aws_key]") + result = bearerRegex.ReplaceAllStringFunc(result, func(match string) string { + sub := bearerRegex.FindStringSubmatch(match) + if len(sub) < 2 { + return "[REDACTED:bearer]" + } + return strings.Replace(match, sub[1], "[REDACTED:bearer]", 1) + }) + result = ccCandidateRgx.ReplaceAllStringFunc(result, func(match string) string { + digits := stripNonDigits(match) + if len(digits) < 13 || len(digits) > 19 { + return match + } + if !luhn(digits) { + return match + } + return "[REDACTED:cc]" + }) + return result +} + +func stripNonDigits(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +func luhn(digits string) bool { + sum := 0 + alt := false + for i := len(digits) - 1; i >= 0; i-- { + n := int(digits[i] - '0') + if alt { + n *= 2 + if n > 9 { + n -= 9 + } + } + sum += n + alt = !alt + } + return sum%10 == 0 +} diff --git a/proxy/internal/middleware/registry.go b/proxy/internal/middleware/registry.go new file mode 100644 index 000000000..c46552162 --- /dev/null +++ b/proxy/internal/middleware/registry.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "fmt" + "sync" +) + +// Registry maps middleware IDs to their factories. The proxy installs +// a single Registry at boot; concrete middlewares register themselves +// from init() functions inside their own packages so the boot wiring +// only needs an anonymous import. +// +// Registry is safe for concurrent reads after boot. Register / Unregister +// take the write lock; Get and IDs take the read lock. +type Registry struct { + mu sync.RWMutex + factories map[string]Factory +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{factories: make(map[string]Factory)} +} + +// Register installs the factory under its ID. Returns an error when an +// ID is already registered — collisions are programmer errors and must +// be visible at boot rather than silently last-write-wins. +func (r *Registry) Register(f Factory) error { + if f == nil { + return fmt.Errorf("middleware registry: nil factory") + } + id := f.ID() + if id == "" { + return fmt.Errorf("middleware registry: factory has empty id") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.factories[id]; exists { + return fmt.Errorf("middleware registry: %q already registered", id) + } + r.factories[id] = f + return nil +} + +// MustRegister panics on error. Intended for init() registration so +// duplicate IDs surface at startup. +func (r *Registry) MustRegister(f Factory) { + if err := r.Register(f); err != nil { + panic(err) + } +} + +// Get returns the factory for id, or nil when no factory is +// registered. +func (r *Registry) Get(id string) Factory { + r.mu.RLock() + defer r.mu.RUnlock() + return r.factories[id] +} + +// IDs returns the registered IDs in unspecified order. Used by the +// management translator to reject specs that reference unknown IDs at +// apply time. +func (r *Registry) IDs() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.factories)) + for id := range r.factories { + out = append(out, id) + } + return out +} + +// IsKnown reports whether id has a registered factory. +func (r *Registry) IsKnown(id string) bool { + return r.Get(id) != nil +} + +// Resolver wraps a Registry and produces a configured Middleware +// instance from a Spec. The Manager uses this during chain build. +type Resolver struct { + registry *Registry +} + +// NewResolver returns a resolver backed by the registry. +func NewResolver(registry *Registry) *Resolver { + if registry == nil { + registry = NewRegistry() + } + return &Resolver{registry: registry} +} + +// Resolve builds a Middleware instance and merges runtime-only fields +// (version, accepted content types, metadata key allowlist, mutation +// support) onto the spec. +// +// Return semantics: +// - (mw, mergedSpec, nil): instance built, include in chain. +// - (nil, spec, nil): id not registered; silently skip. +// - (nil, spec, err): factory rejected the config (logged + counted +// by Manager, other middlewares still bind). +func (r *Resolver) Resolve(spec Spec) (Middleware, Spec, error) { + f := r.registry.Get(spec.ID) + if f == nil { + return nil, spec, nil + } + mw, err := f.New(spec.RawConfig) + if err != nil { + return nil, spec, fmt.Errorf("middleware %s factory: %w", spec.ID, err) + } + if mw.Slot() != spec.Slot { + _ = mw.Close() + return nil, spec, fmt.Errorf("middleware %s slot mismatch: spec=%d impl=%d", spec.ID, spec.Slot, mw.Slot()) + } + merged := spec + merged.Version = mw.Version() + merged.MetadataKeys = append([]string(nil), mw.MetadataKeys()...) + merged.AcceptedContentTypes = append([]string(nil), mw.AcceptedContentTypes()...) + merged.MutationsSupported = mw.MutationsSupported() + return mw, merged, nil +} diff --git a/proxy/internal/middleware/spec.go b/proxy/internal/middleware/spec.go new file mode 100644 index 000000000..a154ceca2 --- /dev/null +++ b/proxy/internal/middleware/spec.go @@ -0,0 +1,44 @@ +package middleware + +import "time" + +// Spec is the apply-time, validated representation of a per-target +// middleware configuration merged with the runtime-only fields +// compiled into the middleware implementation. +// +// The wire shape is RawConfig (JSON bytes) instead of the older +// params map[string]string. Each middleware unmarshals RawConfig into +// its own typed config struct, surfacing structural validation errors +// at construction rather than per-invocation lookups. +type Spec struct { + ID string + Slot Slot + Version string + Enabled bool + FailMode FailMode + Timeout time.Duration + RawConfig []byte + CanMutate bool + + // Runtime-only fields populated from the registered middleware at + // chain build time; not sourced from proto. + MetadataKeys []string + AcceptedContentTypes []string + MutationsSupported bool +} + +// Clone returns a deep copy of the spec safe to cache across mapping +// updates. +func (s Spec) Clone() Spec { + out := s + if len(s.RawConfig) > 0 { + out.RawConfig = append([]byte(nil), s.RawConfig...) + } + if len(s.MetadataKeys) > 0 { + out.MetadataKeys = append([]string(nil), s.MetadataKeys...) + } + if len(s.AcceptedContentTypes) > 0 { + out.AcceptedContentTypes = append([]string(nil), s.AcceptedContentTypes...) + } + return out +} diff --git a/proxy/internal/middleware/types.go b/proxy/internal/middleware/types.go new file mode 100644 index 000000000..1b49e6159 --- /dev/null +++ b/proxy/internal/middleware/types.go @@ -0,0 +1,253 @@ +// Package middleware defines the per-target middleware chain that runs +// inside the reverse proxy hot path. It is the only chain wired into +// the request path. +// +// Concepts: +// - Slot: the position a middleware occupies in the chain. A +// middleware lives in exactly one slot — separate concerns become +// separate middlewares. +// - Decision: the on_request slot can DENY; on_response and terminal +// slots can only PASSTHROUGH. The dispatcher clamps decisions that +// violate this contract. +// - Metadata: the only side-channel between middlewares. Each +// middleware declares an allowlist of keys it may emit; the merger +// enforces caps and namespace rules. +package middleware + +import "time" + +// Slot identifies where in the request lifecycle a middleware runs. +// A middleware declares a single slot. Splitting per-purpose work +// (request parsing vs response parsing vs cost metering) into separate +// slot-keyed middlewares is the explicit architectural choice for the +// agent-network use case; no middleware participates in more than one +// slot. +type Slot int + +const ( + // SlotOnRequest runs before the upstream call. Middlewares in this + // slot may DENY the request, mutate headers/body (when permitted), + // and emit metadata derived from the request envelope. + SlotOnRequest Slot = 1 + // SlotOnResponse runs after the upstream returns. Middlewares in + // this slot observe the response, emit metadata, and may mutate + // response headers when permitted. They cannot DENY. + SlotOnResponse Slot = 2 + // SlotTerminal runs after every SlotOnResponse middleware has + // emitted. Terminal middlewares observe the full metadata bag and + // ship it to external sinks (access log, metrics export). They + // cannot DENY and cannot mutate the response. + SlotTerminal Slot = 3 +) + +// FailMode controls how the dispatcher reacts when a middleware +// returns an error, times out, or panics. Observer middlewares default +// to FailOpen; policy middlewares should default to FailClosed. +type FailMode int + +const ( + // FailOpen allows the request to proceed when a middleware fails. + FailOpen FailMode = 0 + // FailClosed denies the request when a middleware fails. Only + // meaningful for SlotOnRequest middlewares. + FailClosed FailMode = 1 +) + +// Decision captures the outcome of a middleware invocation as observed +// by the dispatcher. Response-phase middlewares always return +// DecisionPassthrough; the dispatcher clamps any other value. +type Decision int + +const ( + // DecisionAllow lets the request proceed. + DecisionAllow Decision = 0 + // DecisionDeny stops the chain and returns a rendered deny + // response. Only honoured in SlotOnRequest. + DecisionDeny Decision = 1 + // DecisionPassthrough is the response-phase neutral outcome. + DecisionPassthrough Decision = 2 +) + +// Resource limits enforced by the proxy at config apply time and by +// the dispatcher at runtime. Per-target values supplied by management +// are clamped to these bounds. +const ( + // MaxBodyCapBytes is the proxy-wide upper bound for per-direction + // body capture. Sized to hold a full LLM streaming response (token + // usage rides the trailing SSE event, so the captured prefix must + // reach the end of the stream); a single response is bounded by the + // model's max output tokens, so this is a real ceiling, not a + // treadmill. Request capture stays well under this — oversized + // requests use the tolerant routing scan instead of buffering. + MaxBodyCapBytes int64 = 8 << 20 + // MinTimeout is the proxy-wide lower bound for per-middleware + // Invoke timeouts. + MinTimeout = 10 * time.Millisecond + // MaxTimeout is the proxy-wide upper bound for per-middleware + // Invoke timeouts. + MaxTimeout = 5 * time.Second + // DefaultTimeout is used when the per-target timeout is zero or + // unset. + DefaultTimeout = 500 * time.Millisecond + + // MaxMiddlewareMetadataBytes is the per-middleware metadata total + // cap. + MaxMiddlewareMetadataBytes = 16 << 10 + // MaxRequestMetadataBytes is the per-request metadata total cap + // across all middlewares in the chain. Earlier middlewares win + // when the budget is exhausted. + MaxRequestMetadataBytes = 32 << 10 + // MaxMetadataKeyBytes is the maximum length of a metadata key. + MaxMetadataKeyBytes = 96 + // MaxMetadataValueBytes is the maximum length of a metadata value. + MaxMetadataValueBytes = 4 << 10 + // MaxMiddlewaresPerChain caps the number of middleware entries + // accepted per chain at the proxy translator and the management + // REST API. Mirrors the chain invocation cap so a misconfigured + // mapping cannot push the chain clone cost beyond a known bound. + MaxMiddlewaresPerChain = 16 +) + +// KV is the canonical header/metadata representation used across the +// middleware boundary. We use a slice of KV instead of http.Header +// because it preserves key order, is cheap to deep-copy per +// invocation, and is directly representable in a future protobuf +// envelope. +type KV struct { + Key string + Value string +} + +// Input is the immutable envelope handed to each middleware. The +// dispatcher deep-copies Headers, Body, Metadata, RespHeaders, and +// RespBody before each invocation so middlewares cannot mutate the +// shared in-flight copies; mutations must flow through Output.Mutations. +type Input struct { + Slot Slot + RequestID string + TargetID string + Method string + URL string + Headers []KV + Body []byte + BodyTruncated bool + OriginalBodySize int64 + + Status int + RespHeaders []KV + RespBody []byte + RespBodyTruncated bool + OriginalRespSize int64 + + ServiceID string + AccountID string + UserID string + // UserEmail is the calling user's email address when the auth path + // resolves a user record. Empty for non-OIDC schemes (PIN/Password/ + // Header) and for legacy session JWTs minted before the email claim + // was introduced. Identity-stamping middlewares (e.g. + // llm_identity_inject) prefer this over UserID for upstream gateways + // that key budgets / attribution on a human-readable identifier. + UserEmail string + AuthMethod string + SourceIP string + // UserGroups captures the calling peer's group memberships at + // request time, surfaced from the proxy's auth flow so policy-aware + // middlewares can authorise without an extra management round-trip. + UserGroups []string + // UserGroupNames carries the human-readable display names paired + // positionally with UserGroups (UserGroupNames[i] is the name of + // UserGroups[i]). Identity-stamping middlewares prefer names for + // upstream tags so attribution dashboards stay readable. Slice may + // be shorter than UserGroups for tokens minted before names were + // resolvable; consumers should fall back to ids for missing + // positions. + UserGroupNames []string + Metadata []KV + + // AgentNetwork is true when the target is a synthesised + // agent-network service. Carried on the input so the access-log + // terminal middleware can stamp the proto field without re-deriving + // from the service ID. + AgentNetwork bool +} + +// DenyReason is the structured payload a middleware returns alongside +// a DecisionDeny. The proxy renders it through a fixed JSON template +// so middlewares cannot emit arbitrary bytes to the wire. +type DenyReason struct { + Code string + Message string + Details map[string]string +} + +// Output is the value each middleware returns to the dispatcher. The +// dispatcher applies the output filter (clamp, mutations gate) before +// any side effect reaches the shared request. +type Output struct { + Decision Decision + DenyStatus int + DenyReason *DenyReason + Metadata []KV + Mutations *Mutations +} + +// Mutations describes the deltas a middleware wants applied to the +// in-flight request. The dispatcher filters HeadersAdd/HeadersRemove +// through the compiled-in denylist and runs BodyReplace through the +// body policy before anything is applied. RewriteUpstream redirects +// the outbound target (scheme + host) for the request; the chain +// returns the latest non-nil rewrite to the reverse proxy. +type Mutations struct { + HeadersAdd []KV + HeadersRemove []string + BodyReplace []byte + RewriteUpstream *UpstreamRewrite +} + +// UpstreamRewrite redirects the request's outbound target. Only +// scheme+host are honoured; path, query, and body are untouched. The +// reverse proxy reads the rewrite (when non-nil) instead of the +// PathTarget URL configured by the synth, so a single shared synth +// service can fan out to many upstreams selected per request. +// +// AuthHeader and StripHeaders carry the upstream auth substitution +// the router needs. They bypass the framework's HeadersAdd / +// HeadersRemove denylist (which blocks Authorization, Cookie, etc. +// from middleware mutation) on the grounds that the proxy itself is +// the entity rewriting auth here, not an arbitrary middleware. The +// reverse proxy applies them directly to the upstream request after +// the chain's regular mutation phase, so a malicious or misconfigured +// middleware can still emit RewriteUpstream but only the proxy's +// trusted upstream-build path actually unpacks AuthHeader. +type UpstreamRewrite struct { + Scheme string + Host string + // Path, when non-empty, replaces the path component of the + // proxy's effective upstream URL. The rewrite path is then joined + // with the agent's request path by httputil.ProxyRequest.SetURL — + // e.g. rewrite Path="/v1/{account}/{gateway}/compat" + agent + // request "/chat/completions" → outbound + // "/v1/{account}/{gateway}/compat/chat/completions". Used by + // llm_router to honor the operator-configured upstream path on + // gateways like Cloudflare AI Gateway whose URL contains + // account / gateway segments that the agent's app doesn't know + // about. Empty Path leaves the original target's path + // untouched (the historical behavior). + Path string + // StripPathPrefix, when non-empty, is removed from the front of the agent's + // request path before it is joined onto the upstream URL. Used for + // gateway-namespace prefixes (e.g. a client addressing Bedrock as + // "/bedrock/model/{id}/invoke") that must not reach the real upstream, whose + // native path is "/model/{id}/invoke". Empty leaves the request path intact. + StripPathPrefix string + AuthHeader *AuthHeader + StripHeaders []string +} + +// AuthHeader is a single name/value pair the proxy injects on the +// upstream request after stripping the client's auth headers. +type AuthHeader struct { + Name string + Value string +} diff --git a/proxy/internal/proxy/agent_network_chain_realstack_test.go b/proxy/internal/proxy/agent_network_chain_realstack_test.go new file mode 100644 index 000000000..bc611fc98 --- /dev/null +++ b/proxy/internal/proxy/agent_network_chain_realstack_test.go @@ -0,0 +1,321 @@ +package proxy_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" + mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + // Side-effect imports register every builtin middleware factory. + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router" + "github.com/netbirdio/netbird/proxy/internal/proxy" + nbproxytypes "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" + + log "github.com/sirupsen/logrus" +) + +// TestReverseProxy_AgentNetworkRequest_FullChain is the self-contained Go +// replacement for the bash 50 + 51 legs. It drives a real agent-network +// request through proxy.ReverseProxy.ServeHTTP with the actual middleware +// chain the synthesizer produces, against an in-process management gRPC and a +// httptest fake upstream — no tilt, no docker, no real LLM provider, no +// WireGuard tunnel. The test guarantees: +// +// 1. The reverse proxy's response-leg input construction copies UserGroups +// onto respInput so llm_limit_record sends a non-empty group_ids field +// on RecordLLMUsage. This is the exact bug class that motivated the +// reverseproxy.go fix — its regression would land the request OK but +// leave consumption at zero, defeating any group-targeted budget rule. +// 2. With settings.RedactPii=true the parsers ship redacted text on both +// llm.request_prompt_raw and llm.response_completion — proving the +// end-to-end wiring (synth → proto → spec → parser config) carries the +// toggle through to runtime emission. +// 3. The full chain (request + response + recorder) runs against a real +// management stack and the consumption row for the bound group dim +// increments. +// +// If any of those three guarantees regresses, this single test fails. +func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sqlite store not supported on Windows") + } + + const ( + testAccountID = "acct-fullchain-1" + testAdminUser = "user-admin-1" + adminGroupID = "grp-admins" + providerID = "prov-openai-test" + cluster = "test.proxy.local" + subdomain = "fullchain" + ) + testLogger := log.New() + testLogger.SetLevel(log.PanicLevel) // keep test output clean + + ctx := context.Background() + + // ---- 1. Fake upstream that returns OpenAI-shaped JSON with PII in the + // completion. The reverse proxy's chain will redact this when the synth + // stamps redact_pii=true on the response parser config. + completion := "Sample record: Alice Johnson alice.johnson@example.com SSN 123-45-6789 phone (202) 555-0147 also Bob 202/555/0108" + upstreamBody := []byte(`{"id":"x","model":"gpt-5.4","choices":[{"message":{"role":"assistant","content":"` + completion + `"}}],"usage":{"prompt_tokens":12,"completion_tokens":40,"total_tokens":52}}`) + var upstreamHits atomic.Int64 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHits.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(upstreamBody) + })) + t.Cleanup(upstream.Close) + upstreamHost := strings.TrimPrefix(upstream.URL, "http://") + + // ---- 2. In-process management gRPC server (bufconn) backed by a real + // sqlite store + real agentnetwork.Manager. The proxy's middlewares talk + // to this client. + st, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + t.Cleanup(cleanup) + + anMgr := agentnetwork.NewManager(st, nil, nil, nil) + server := &mgmtgrpc.ProxyServiceServer{} + server.SetAgentNetworkLimitsService(anMgr) + + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + proto.RegisterProxyServiceServer(srv, server) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + mgmtClient := proto.NewProxyServiceClient(conn) + + // ---- 3. Seed account state: settings (redact + capture on), provider + // whose upstream URL points at our fake server, policy (catch-all-allow + // over the Admins group → window=0 path), and a generous budget rule + // targeting Admins so the curl succeeds and we can prove the counter + // increments on the response leg. + require.NoError(t, st.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{ + AccountID: testAccountID, + Cluster: cluster, + Subdomain: subdomain, + EnablePromptCollection: true, + EnableLogCollection: true, + RedactPii: true, + })) + require.NoError(t, st.SaveAgentNetworkProvider(ctx, &agentNetworkTypes.Provider{ + ID: providerID, + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "openai-fullchain-test", + UpstreamURL: upstream.URL, // router rewrites to this + APIKey: "sk-test", + Enabled: true, + Models: []agentNetworkTypes.ProviderModel{{ID: "gpt-5.4"}}, + SessionPrivateKey: "priv", + SessionPublicKey: "pub", + })) + require.NoError(t, st.SaveAgentNetworkPolicy(ctx, &agentNetworkTypes.Policy{ + ID: "ainpol-fullchain", + AccountID: testAccountID, + Name: "admins-openai", + Enabled: true, + SourceGroups: []string{adminGroupID}, + DestinationProviderIDs: []string{providerID}, + // No token / budget caps → effectiveWindowSeconds=0 → exercises the + // catch-all-allow path that the GC-2 record-on-window=0 fix targets. + })) + require.NoError(t, st.SaveAgentNetworkBudgetRule(ctx, &agentNetworkTypes.AccountBudgetRule{ + ID: "ainbud-admins-fullchain", + AccountID: testAccountID, + Name: "admins-monthly", + Enabled: true, + TargetGroups: []string{adminGroupID}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 1_000_000, UserCap: 1_000_000, WindowSeconds: 60}, + }, + })) + + // ---- 4. Synth the service. This produces the exact middleware chain + // configuration the production reconcile path ships to the proxy. + services, err := agentnetwork.SynthesizeServices(ctx, st, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + synthSvc := services[0] + require.NotEmpty(t, synthSvc.Targets, "synth target must exist") + + // ---- 5. Wire the middleware framework — same registry the proxy uses + // in production, configured with our bufconn-backed management client. + mwbuiltin.Configure(ctx, t.TempDir(), nil, testLogger, mgmtClient) + registry := mwbuiltin.DefaultRegistry() + mwMetrics, err := middleware.NewMetrics(nil) + require.NoError(t, err) + mwMgr := middleware.NewManager(0, mwMetrics, testLogger) + mwMgr.SetResolver(middleware.NewResolver(registry)) + + // Convert the synth's rpservice.MiddlewareConfig list into proxy + // middleware.Spec values. Mirrors the proto→Spec translation server.go + // does at runtime; kept inline here so the test isn't coupled to the + // proxy server's private translateMiddlewareConfig helper. + specs := make([]middleware.Spec, 0, len(synthSvc.Targets[0].Options.Middlewares)) + for _, mw := range synthSvc.Targets[0].Options.Middlewares { + var slot middleware.Slot + switch mw.Slot { + case rpservice.MiddlewareSlotOnRequest: + slot = middleware.SlotOnRequest + case rpservice.MiddlewareSlotOnResponse: + slot = middleware.SlotOnResponse + case rpservice.MiddlewareSlotTerminal: + slot = middleware.SlotTerminal + default: + t.Fatalf("unknown middleware slot %q on %s", mw.Slot, mw.ID) + } + specs = append(specs, middleware.Spec{ + ID: mw.ID, + Slot: slot, + Enabled: mw.Enabled, + FailMode: middleware.FailOpen, + Timeout: middleware.DefaultTimeout, + RawConfig: append([]byte(nil), mw.ConfigJSON...), + CanMutate: mw.CanMutate, + }) + } + + serviceIDStr := synthSvc.ID + require.NoError(t, mwMgr.Rebuild(serviceIDStr, []middleware.PathTargetBinding{{ + ServiceID: serviceIDStr, + PathID: "/", + Specs: specs, + }})) + + // ---- 6. Build the reverse proxy, with a mapping whose target URL goes + // straight to the fake upstream (the router middleware rewriting upstream + // from the synth's noop placeholder isn't needed when we own the mapping + // in-process — point the target at the fake URL directly so the body + // arrives at the upstream the synth would have routed to). + upstreamURL, err := url.Parse(upstream.URL) + require.NoError(t, err) + + rp := proxy.NewReverseProxy(http.DefaultTransport, "auto", nil, testLogger, proxy.WithMiddlewareManager(mwMgr)) + rp.AddMapping(proxy.Mapping{ + ID: nbproxytypes.ServiceID(serviceIDStr), + AccountID: nbproxytypes.AccountID(testAccountID), + Host: synthSvc.Domain, + Paths: map[string]*proxy.PathTarget{ + "/": { + URL: upstreamURL, + DirectUpstream: true, + AgentNetwork: true, + Middlewares: specs, + CaptureConfig: &bodytap.Config{ + MaxRequestBytes: 1 << 20, + MaxResponseBytes: 1 << 20, + ContentTypes: []string{"application/json", "text/event-stream"}, + }, + }, + }, + }) + + // ---- 7. Send a request with the auth-stamped CapturedData (mimicking + // what the tunnel-peer auth middleware does at the edge of the proxy). + reqBody := `{"model":"gpt-5.4","client_metadata":{"session_id":"sess-fullchain-1"},"messages":[{"role":"user","content":"contact alice.johnson@example.com SSN 987-65-4321 phone (202)555-0156"}]}` + req := httptest.NewRequest("POST", "https://"+synthSvc.Domain+"/v1/chat/completions", strings.NewReader(reqBody)) + req.Host = synthSvc.Domain + req.Header.Set("Content-Type", "application/json") + + cd := proxy.NewCapturedData("test-request-1") + cd.SetServiceID(nbproxytypes.ServiceID(serviceIDStr)) + cd.SetAccountID(nbproxytypes.AccountID(testAccountID)) + cd.SetUserID(testAdminUser) + cd.SetUserGroups([]string{adminGroupID}) + cd.SetAuthMethod("tunnel_peer") + req = req.WithContext(proxy.WithCapturedData(req.Context(), cd)) + + w := httptest.NewRecorder() + rp.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "upstream call must succeed end-to-end; body=%s", w.Body.String()) + assert.GreaterOrEqual(t, upstreamHits.Load(), int64(1), "fake upstream must have been hit") + + // ---- 8. Assertions — the three guarantees this test exists for. + + // 8a. The reverseproxy.go respInput construction carried UserGroups + // into the response-leg middleware chain, so llm_limit_record sent a + // non-empty group_ids on RecordLLMUsage. Verifying via the management + // store directly bypasses the manager's permission gate (which is nil + // in this test) — we want to confirm the row landed, not who saw it. + require.Eventually(t, func() bool { + rows, lerr := st.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, testAccountID) + if lerr != nil { + return false + } + for _, r := range rows { + if r.DimensionKind == agentNetworkTypes.DimensionGroup && + r.DimensionID == adminGroupID && + r.WindowSeconds == 60 && + r.TokensInput+r.TokensOutput > 0 { + return true + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "Admins group consumption row must increment via the response leg — if this fails the proxy's respInput dropped UserGroups again or the parser/recorder wiring is broken") + + // 8b. Both the captured prompt and the captured completion are + // redacted — proves the synth threads redact_pii=true into BOTH parser + // configs and the parsers honour it at emission time. + md := cd.GetMetadata() + promptRaw := md["llm.request_prompt_raw"] + completionMeta := md["llm.response_completion"] + + // 8a-bis. The session id from client_metadata.session_id flows through + // the request parser into the captured metadata, so the access-log / + // usage rows can group this request with the rest of its conversation. + assert.Equal(t, "sess-fullchain-1", md["llm.session_id"], + "session id must be extracted from client_metadata.session_id and carried through the chain") + + assert.NotEmpty(t, promptRaw, "llm.request_prompt_raw must be present in captured metadata") + assert.Contains(t, promptRaw, "[REDACTED:", "captured raw prompt must carry redaction markers") + assert.NotContains(t, promptRaw, "alice.johnson@example.com", "raw email must NOT survive in prompt_raw") + assert.NotContains(t, promptRaw, "987-65-4321", "raw SSN must NOT survive in prompt_raw") + assert.NotContains(t, promptRaw, "(202)555-0156", "raw paren-no-space phone must NOT survive in prompt_raw") + + assert.NotEmpty(t, completionMeta, "llm.response_completion must be present in captured metadata") + assert.Contains(t, completionMeta, "[REDACTED:", "captured completion must carry redaction markers") + assert.NotContains(t, completionMeta, "alice.johnson@example.com", "raw email must NOT survive in completion") + assert.NotContains(t, completionMeta, "123-45-6789", "raw SSN must NOT survive in completion") + assert.NotContains(t, completionMeta, "(202) 555-0147", "raw paren+space phone must NOT survive in completion") + assert.NotContains(t, completionMeta, "202/555/0108", "raw slash phone must NOT survive in completion") + + _ = upstreamHost // kept for future header-inspection assertions if needed +} diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go index e05ec78aa..09bb9a8d1 100644 --- a/proxy/internal/proxy/context.go +++ b/proxy/internal/proxy/context.go @@ -58,9 +58,11 @@ type CapturedData struct { // the JWT's group_names claim or from ValidateSession/Tunnel // responses. Slice may be shorter than userGroups for tokens minted // before names were resolvable. - userGroupNames []string - authMethod string - metadata map[string]string + userGroupNames []string + authMethod string + metadata map[string]string + agentNetwork bool + suppressAccessLog bool } // NewCapturedData creates a CapturedData with the given request ID. @@ -178,6 +180,41 @@ func (c *CapturedData) SetUserGroups(groups []string) { c.userGroups = append(c.userGroups[:0], groups...) } +// SetAgentNetwork records whether the request hit a synthesised +// agent-network target. The terminal access-log middleware stamps the +// flag onto the proto so management can distinguish synthetic traffic. +func (c *CapturedData) SetAgentNetwork(b bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.agentNetwork = b +} + +// GetAgentNetwork reports whether the request matched a synthesised +// agent-network target. +func (c *CapturedData) GetAgentNetwork() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.agentNetwork +} + +// SetSuppressAccessLog records whether the per-request access-log emission +// must be skipped for this request. Stamped from the matched target's +// DisableAccessLog flag so the access-log middleware can short-circuit +// log delivery for opted-out agent-network targets. +func (c *CapturedData) SetSuppressAccessLog(b bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.suppressAccessLog = b +} + +// GetSuppressAccessLog reports whether access-log emission has been +// suppressed for this request. +func (c *CapturedData) GetSuppressAccessLog() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.suppressAccessLog +} + // GetUserGroups returns a copy of the authenticated user's group // memberships. func (c *CapturedData) GetUserGroups() []string { diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index da0bf6552..2c0304ecd 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "encoding/json" "errors" "fmt" "net" @@ -11,10 +12,13 @@ import ( "net/url" "strings" "sync" + "time" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" @@ -32,6 +36,25 @@ type ReverseProxy struct { mappingsMux sync.RWMutex mappings map[string]Mapping logger *log.Logger + // middlewareManager, when non-nil, drives per-target middleware + // dispatch. A nil manager (or an empty chain for the resolved + // target) keeps the reverse-proxy hot path on the no-capture fast + // path with no middleware overhead. + middlewareManager *middleware.Manager +} + +// Option configures optional ReverseProxy behavior. Options exist so the core +// constructor signature stays stable across additive features. +type Option func(*ReverseProxy) + +// WithMiddlewareManager attaches a middleware manager to the reverse +// proxy. When the manager is nil or returns an empty chain for the +// target, the request follows the fast path with no middleware +// overhead. +func WithMiddlewareManager(m *middleware.Manager) Option { + return func(p *ReverseProxy) { + p.middlewareManager = m + } } // NewReverseProxy configures a new NetBird ReverseProxy. @@ -40,29 +63,28 @@ type ReverseProxy struct { // between requested URLs and targets. // The internal mappings can be modified using the AddMapping // and RemoveMapping functions. -func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger) *ReverseProxy { +func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy { if logger == nil { logger = log.StandardLogger() } - return &ReverseProxy{ + p := &ReverseProxy{ transport: transport, forwardedProto: forwardedProto, trustedProxies: trustedProxies, mappings: make(map[string]Mapping), logger: logger, } + for _, opt := range opts { + opt(p) + } + return p } func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { result, exists := p.findTargetForRequest(r) if !exists { - if cd := CapturedDataFromContext(r.Context()); cd != nil { - cd.SetOrigin(OriginNoRoute) - } - requestID := getRequestID(r) - web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found", - "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.", - requestID, web.ErrorStatus{Proxy: true, Destination: false}) + p.serveRouteError(w, r, http.StatusNotFound, "Service Not Found", + "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.") return } @@ -72,38 +94,23 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // with 421 (Misdirected Request) so the caller sees an explicit // error instead of silently doubling tunnel traffic. if p.isSelfTargetLoop(r, result.target.URL) { - if cd := CapturedDataFromContext(r.Context()); cd != nil { - cd.SetOrigin(OriginNoRoute) - } - requestID := getRequestID(r) - web.ServeErrorPage(w, r, http.StatusMisdirectedRequest, "Loop Detected", - "This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.", - requestID, web.ErrorStatus{Proxy: true, Destination: false}) + p.serveRouteError(w, r, http.StatusMisdirectedRequest, "Loop Detected", + "This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.") return } - ctx := r.Context() - // Set the account ID in the context for the roundtripper to use. - ctx = roundtrip.WithAccountID(ctx, result.accountID) + pt := result.target + ctx := p.buildTargetContext(r.Context(), result) // Populate captured data if it exists (allows middleware to read after handler completes). // This solves the problem of passing data UP the middleware chain: we put a mutable struct // pointer in the context, and mutate the struct here so outer middleware can read it. - if capturedData := CapturedDataFromContext(ctx); capturedData != nil { + capturedData := CapturedDataFromContext(ctx) + if capturedData != nil { capturedData.SetServiceID(result.serviceID) capturedData.SetAccountID(result.accountID) - } - - pt := result.target - - if pt.SkipTLSVerify { - ctx = roundtrip.WithSkipTLSVerify(ctx) - } - if pt.RequestTimeout > 0 { - ctx = types.WithDialTimeout(ctx, pt.RequestTimeout) - } - if pt.DirectUpstream { - ctx = roundtrip.WithDirectUpstream(ctx) + capturedData.SetAgentNetwork(result.target != nil && result.target.AgentNetwork) + capturedData.SetSuppressAccessLog(result.target != nil && result.target.DisableAccessLog) } rewriteMatchedPath := result.matchedPath @@ -111,6 +118,45 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { rewriteMatchedPath = "" } + chain := p.resolveChain(result) + if chain == nil || chain.Empty() { + p.serveDirect(w, r, ctx, result, rewriteMatchedPath) + return + } + p.serveWithChain(w, r, ctx, result, chain, rewriteMatchedPath, capturedData) +} + +// serveRouteError marks the request as un-routed on any captured-data +// context and renders the proxy error page. +func (p *ReverseProxy) serveRouteError(w http.ResponseWriter, r *http.Request, status int, title, message string) { + if cd := CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(OriginNoRoute) + } + web.ServeErrorPage(w, r, status, title, message, getRequestID(r), + web.ErrorStatus{Proxy: true, Destination: false}) +} + +// buildTargetContext layers the per-target roundtrip flags (account id, +// TLS-verify skip, direct upstream, dial timeout) onto the request context. +func (p *ReverseProxy) buildTargetContext(ctx context.Context, result targetResult) context.Context { + pt := result.target + ctx = roundtrip.WithAccountID(ctx, result.accountID) + if pt.SkipTLSVerify { + ctx = roundtrip.WithSkipTLSVerify(ctx) + } + if pt.DirectUpstream { + ctx = roundtrip.WithDirectUpstream(ctx) + } + if pt.RequestTimeout > 0 { + ctx = types.WithDialTimeout(ctx, pt.RequestTimeout) + } + return ctx +} + +// serveDirect forwards the request without a middleware chain — the common +// path for plain reverse-proxy targets. +func (p *ReverseProxy) serveDirect(w http.ResponseWriter, r *http.Request, ctx context.Context, result targetResult, rewriteMatchedPath string) { + pt := result.target rp := &httputil.ReverseProxy{ Rewrite: p.rewriteFunc(pt.URL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders), Transport: p.transport, @@ -123,6 +169,344 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { rp.ServeHTTP(w, r.WithContext(ctx)) } +// serveWithChain runs the per-target middleware chain around the upstream +// request: request-leg capture and authorisation, then (on allow) the +// upstream forward with response/terminal observation deferred so it reads +// the captured response before the writer is released. +func (p *ReverseProxy) serveWithChain(w http.ResponseWriter, r *http.Request, ctx context.Context, result targetResult, chain *middleware.Chain, rewriteMatchedPath string, capturedData *CapturedData) { + middlewareIDs := chain.IDs() + p.logger.Debugf("middleware chain matched: service=%s path=%s middlewares=%v", result.serviceID, result.matchedPath, middlewareIDs) + + capturedBody, truncated, originalSize, releaseBudget := p.captureRequestForChain(ctx, r, result, capturedData) + defer releaseBudget() + + acc := middleware.NewAccumulator(middleware.MaxRequestMetadataBytes) + reqInput := buildRequestInput(r, result, capturedData, capturedBody, truncated, originalSize) + + denyOutput, requestMeta, upstreamRewrite, _ := chain.RunRequest(ctx, r, reqInput, acc) + if capturedData != nil { + for _, kv := range requestMeta { + capturedData.SetMetadata(kv.Key, kv.Value) + } + } + if denyOutput != nil { + p.serveDeny(w, denyOutput, result, middlewareIDs) + return + } + + respWriter, capturingWriter := p.newResponseWriter(ctx, w, result, capturedData) + if capturingWriter != nil { + defer capturingWriter.Release() + defer p.observeResponse(ctx, chain, acc, reqInput, requestMeta, capturingWriter, w, capturedData, result, middlewareIDs) + } + + p.forwardUpstream(respWriter, r, ctx, result, rewriteMatchedPath, upstreamRewrite) +} + +// captureRequestForChain copies the request body for inspection by the +// chain, records any capture bypass, and applies agent-network routing +// recovery for oversized bodies. The returned release frees the capture +// budget and must be deferred by the caller. +func (p *ReverseProxy) captureRequestForChain(ctx context.Context, r *http.Request, result targetResult, capturedData *CapturedData) ([]byte, bool, int64, func()) { + pt := result.target + capturedBody, truncated, originalSize, bypass, releaseBudget, captureErr := bodytap.CaptureRequest(r, pt.CaptureConfig, p.middlewareManager.Budget()) + if captureErr != nil { + p.logger.Debugf("middleware request body capture error: %v", captureErr) + } + if bypass != "" { + if capturedData != nil { + capturedData.SetMetadata("mw.capture.bypass_reason", bypass) + } + p.middlewareManager.Metrics().IncCaptureBypass(ctx, string(result.serviceID), bypass) + } + + // Routing recovery for oversized agent-network requests: when the body + // exceeded the capture cap (bypassed or truncated), the captured copy + // can't be parsed for the model, so llm_router would deny with + // model_not_routable. Scan the full stream for just the routing fields + // and hand the request parser a minimal stub so routing succeeds; the + // prompt stays uncaptured and the upstream still gets the full body. + if pt.AgentNetwork && (truncated || capturedBody == nil) { + if model, stream, ok := bodytap.ScanRoutingFields(r, bodytap.MaxRoutingScanBytes); ok { + capturedBody = buildRoutingStub(model, stream) + truncated = false + p.logger.Debugf("agent-network routing recovery: extracted model=%s stream=%t from oversized request body (service=%s)", model, stream, result.serviceID) + } + } + return capturedBody, truncated, originalSize, releaseBudget +} + +// serveDeny renders the chain's deny response. Policy/budget/routing/guardrail +// denials are expected runtime outcomes and can be high-volume under +// misconfigured or hostile clients; per-request detail stays at Debug and +// metrics/access logs carry the signal at scale. +func (p *ReverseProxy) serveDeny(w http.ResponseWriter, denyOutput *middleware.Output, result targetResult, middlewareIDs []string) { + middlewareID := "middleware" + if denyOutput.DenyReason != nil && denyOutput.DenyReason.Code != "" { + middlewareID = denyOutput.DenyReason.Code + } + p.logger.Debugf("middleware chain denied request: service=%s path=%s middlewares=%v reason=%s status=%d", + result.serviceID, result.matchedPath, middlewareIDs, middlewareID, denyOutput.DenyStatus) + middleware.RenderDenyResponse(w, middlewareID, denyOutput.DenyReason, denyOutput.DenyStatus) +} + +// newResponseWriter returns the writer the upstream forward should use. When +// response capture is enabled and not bypassed it wraps w in a capturing +// writer (also returned so the caller can release it and feed the response +// leg); otherwise the capturing writer is nil and w is used directly. +func (p *ReverseProxy) newResponseWriter(ctx context.Context, w http.ResponseWriter, result targetResult, capturedData *CapturedData) (http.ResponseWriter, *bodytap.CapturingResponseWriter) { + pt := result.target + if pt.CaptureConfig == nil || pt.CaptureConfig.MaxResponseBytes <= 0 { + return w, nil + } + capturingWriter := bodytap.NewCapturingResponseWriter(w, pt.CaptureConfig.MaxResponseBytes, p.middlewareManager.Budget()) + if capturingWriter.Bypassed() { + if capturedData != nil { + capturedData.SetMetadata("mw.capture.bypass_reason", capturingWriter.BypassReason()) + } + p.middlewareManager.Metrics().IncCaptureBypass(ctx, string(result.serviceID), capturingWriter.BypassReason()) + capturingWriter.Release() + return w, nil + } + return capturingWriter, capturingWriter +} + +// observeResponse runs the response and terminal middleware slots after the +// body has been forwarded. It is deferred by serveWithChain so it reads the +// captured response before the writer is released. +func (p *ReverseProxy) observeResponse(ctx context.Context, chain *middleware.Chain, acc *middleware.Accumulator, reqInput *middleware.Input, requestMeta []middleware.KV, capturingWriter *bodytap.CapturingResponseWriter, w http.ResponseWriter, capturedData *CapturedData, result targetResult, middlewareIDs []string) { + respInput := &middleware.Input{ + Slot: middleware.SlotOnResponse, + RequestID: reqInput.RequestID, + TargetID: reqInput.TargetID, + Method: reqInput.Method, + URL: reqInput.URL, + Headers: reqInput.Headers, + Status: capturingWriter.Status(), + RespHeaders: headerToKV(w.Header()), + RespBody: capturingWriter.Body(), + RespBodyTruncated: capturingWriter.Truncated(), + OriginalRespSize: capturingWriter.BytesWritten(), + ServiceID: reqInput.ServiceID, + AccountID: reqInput.AccountID, + UserID: reqInput.UserID, + // UserEmail / UserGroups / UserGroupNames must flow into the + // response leg too — llm_limit_record needs UserGroups to send + // group_ids on RecordLLMUsage so management's account-budget + // fan-out can match group-targeted rules; identity-stamping and + // any future response-side authorisation also depend on these. + UserEmail: reqInput.UserEmail, + UserGroups: reqInput.UserGroups, + UserGroupNames: reqInput.UserGroupNames, + AuthMethod: reqInput.AuthMethod, + SourceIP: reqInput.SourceIP, + Metadata: requestMeta, + AgentNetwork: reqInput.AgentNetwork, + } + // The response/terminal phase runs after the body is forwarded, so + // a streaming client (e.g. Codex) has usually disconnected by now, + // cancelling r.Context(). These middlewares only observe and record + // (token/cost metering, usage recording) and must still complete — + // otherwise the dispatcher short-circuits each to fail-mode and the + // usage is silently lost. Detach from client cancellation, keep ctx + // values, and bound the work. + obsCtx, obsCancel := context.WithTimeout(context.WithoutCancel(ctx), observabilityPhaseTimeout) + defer obsCancel() + + respMeta := chain.RunResponse(obsCtx, respInput, acc) + if capturedData != nil { + for _, kv := range respMeta { + capturedData.SetMetadata(kv.Key, kv.Value) + } + } + + // Terminal slot sees the merged metadata bag from request and + // response phases. + mergedMeta := append(append([]middleware.KV(nil), requestMeta...), respMeta...) + termInput := *respInput + termInput.Slot = middleware.SlotTerminal + termInput.Metadata = mergedMeta + termMeta := chain.RunTerminal(obsCtx, &termInput, acc) + if capturedData != nil { + for _, kv := range termMeta { + capturedData.SetMetadata(kv.Key, kv.Value) + } + } + + p.logger.Debugf("middleware chain ran: service=%s path=%s middlewares=%v status=%d req_meta=%d resp_meta=%d term_meta=%d", + result.serviceID, result.matchedPath, middlewareIDs, capturingWriter.Status(), len(requestMeta), len(respMeta), len(termMeta)) +} + +// forwardUpstream applies any middleware-emitted upstream rewrite and proxies +// the request to the effective upstream URL. +func (p *ReverseProxy) forwardUpstream(respWriter http.ResponseWriter, r *http.Request, ctx context.Context, result targetResult, rewriteMatchedPath string, upstreamRewrite *middleware.UpstreamRewrite) { + pt := result.target + effectiveURL := applyUpstreamRewrite(pt.URL, upstreamRewrite) + if upstreamRewrite != nil { + r.Host = effectiveURL.Host + applyUpstreamHeaders(r, upstreamRewrite) + stripUpstreamPathPrefix(r, upstreamRewrite.StripPathPrefix) + } + + rp := &httputil.ReverseProxy{ + Rewrite: p.rewriteFunc(effectiveURL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders), + Transport: p.transport, + FlushInterval: -1, + ErrorHandler: p.proxyErrorHandler, + } + if result.rewriteRedirects { + rp.ModifyResponse = p.rewriteLocationFunc(effectiveURL, rewriteMatchedPath, r) //nolint:bodyclose + } + rp.ServeHTTP(respWriter, r.WithContext(ctx)) +} + +// buildRoutingStub returns a minimal JSON request body carrying only the +// model and stream fields. It feeds the LLM request parser when the real +// body was too large to capture: the parser emits llm.model / llm.stream +// so llm_router can route, while ExtractPrompt on the stub yields nothing +// — no prompt is captured for oversized requests. +func buildRoutingStub(model string, stream bool) []byte { + b, err := json.Marshal(map[string]any{"model": model, "stream": stream}) + if err != nil { + return nil + } + return b +} + +// applyUpstreamRewrite returns the effective upstream URL after +// applying a middleware-emitted rewrite. When rewrite is nil or +// incomplete, the original target is returned unchanged. The original +// URL is never mutated; a clone is returned when a rewrite applies. +// +// Rewrite Path semantics: when non-empty, replaces the cloned URL's +// path entirely. httputil.ProxyRequest.SetURL then joins target.Path +// with the agent's request path, so an operator-configured upstream +// path like "/v1/{account}/{gateway}/compat" gets prepended to +// "/chat/completions" yielding the full Cloudflare-shaped path. +// Empty rewrite.Path preserves the original target's path (the +// historical, non-agent-network behavior). +func applyUpstreamRewrite(orig *url.URL, rewrite *middleware.UpstreamRewrite) *url.URL { + if rewrite == nil || orig == nil { + return orig + } + if rewrite.Scheme == "" || rewrite.Host == "" { + return orig + } + cloned := *orig + cloned.Scheme = rewrite.Scheme + cloned.Host = rewrite.Host + if rewrite.Path != "" { + cloned.Path = rewrite.Path + cloned.RawPath = "" + } + return &cloned +} + +// stripUpstreamPathPrefix removes a gateway-namespace prefix (e.g. "/bedrock") +// from the request path before it is forwarded, so the upstream receives its +// native path. The chain has already run by this point, so metering/logging +// keep the original client path; only the outbound path is rewritten. RawPath +// is cleared so the escaped form is recomputed from the trimmed Path. +func stripUpstreamPathPrefix(r *http.Request, prefix string) { + if r == nil || r.URL == nil || prefix == "" { + return + } + if !strings.HasPrefix(r.URL.Path, prefix+"/") && r.URL.Path != prefix { + return + } + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + r.URL.RawPath = "" +} + +// applyUpstreamHeaders strips the headers the rewrite asks for and +// injects the resolved auth header on the in-flight request. It is +// the proxy-trusted counterpart to chain.applyMutations: regular +// middleware HeadersAdd/HeadersRemove pass through the framework +// denylist (which blocks Authorization, Cookie, etc.), but the +// router middleware needs to replace Authorization on the upstream +// request as a first-class operation. AuthHeader/StripHeaders ride +// on UpstreamRewrite so only the proxy's upstream-build path +// unpacks them — middlewares can't smuggle these in via the +// regular mutation surface. +func applyUpstreamHeaders(r *http.Request, rewrite *middleware.UpstreamRewrite) { + if r == nil || rewrite == nil { + return + } + for _, name := range rewrite.StripHeaders { + if name == "" { + continue + } + r.Header.Del(name) + } + if rewrite.AuthHeader != nil && rewrite.AuthHeader.Name != "" { + r.Header.Set(rewrite.AuthHeader.Name, rewrite.AuthHeader.Value) + } +} + +// resolveChain returns the middleware chain registered for the +// resolved target, or nil when middleware is disabled for the proxy +// or the target. +func (p *ReverseProxy) resolveChain(result targetResult) *middleware.Chain { + if p.middlewareManager == nil { + return nil + } + return p.middlewareManager.ChainFor(string(result.serviceID), result.matchedPath) +} + +// buildRequestInput gathers the per-request fields the middleware +// chain needs. Body and captured metadata are passed in; the rest are +// copied from the request and CapturedData. +func buildRequestInput(r *http.Request, result targetResult, cd *CapturedData, body []byte, truncated bool, originalSize int64) *middleware.Input { + in := &middleware.Input{ + Slot: middleware.SlotOnRequest, + TargetID: result.matchedPath, + Method: r.Method, + URL: r.URL.String(), + Headers: headerToKV(r.Header), + Body: body, + BodyTruncated: truncated, + OriginalBodySize: originalSize, + ServiceID: string(result.serviceID), + AccountID: string(result.accountID), + AgentNetwork: result.target != nil && result.target.AgentNetwork, + } + if cd != nil { + in.RequestID = cd.GetRequestID() + in.UserID = cd.GetUserID() + in.UserEmail = cd.GetUserEmail() + in.UserGroups = cd.GetUserGroups() + in.UserGroupNames = cd.GetUserGroupNames() + in.AuthMethod = cd.GetAuthMethod() + if ip := cd.GetClientIP(); ip.IsValid() { + in.SourceIP = ip.String() + } + } + return in +} + +// headerToKV flattens an http.Header into the KV slice shape expected +// by the middleware envelope, preserving value order under the same +// key. +func headerToKV(h http.Header) []middleware.KV { + if len(h) == 0 { + return nil + } + total := 0 + for _, v := range h { + total += len(v) + } + out := make([]middleware.KV, 0, total) + for k, vs := range h { + for _, v := range vs { + out = append(out, middleware.KV{Key: k, Value: v}) + } + } + return out +} + // isSelfTargetLoop reports whether an overlay-origin request is about to // be forwarded back to the very peer that initiated it. The detection // is intentionally narrow: it only fires when the request arrived on @@ -486,6 +870,14 @@ const ( // comma or any non-printable byte are dropped at stamp time so the // list is unambiguously splittable by consumers. headerNetBirdGroups = "X-NetBird-Groups" + + // observabilityPhaseTimeout bounds the detached response/terminal + // metering phase. It runs after the client connection (and its context) + // may be gone, so it can't borrow the request deadline; this ceiling + // keeps a slow management round-trip (RecordLLMUsage) from pinning the + // handler goroutine indefinitely while still allowing each middleware + // its own per-invoke timeout. + observabilityPhaseTimeout = 30 * time.Second ) // isHeaderValueSafe reports whether v is a valid RFC 7230 field-value: diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index a8244fa56..9bd427056 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/middleware" "github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/web" @@ -1407,3 +1408,45 @@ func TestStampNetBirdIdentity_CapturedDataPresentButEmpty(t *testing.T) { assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups), "X-NetBird-Groups must be stripped when CapturedData has no groups") } + +// TestBuildRequestInput_PropagatesIdentityAndGroups locks the final wiring link +// between auth and the middleware chain: CapturedData identity (user, groups, +// auth method, client IP) and the target's AgentNetwork flag must land on the +// middleware Input the chain runs against. If UserGroups stops flowing here, +// llm_router denies every request with no_authorised_provider. +func TestBuildRequestInput_PropagatesIdentityAndGroups(t *testing.T) { + cd := NewCapturedData("req-123") + cd.SetUserID("user-1") + cd.SetUserEmail("user@example.com") + cd.SetUserGroups([]string{"grp-admins", "grp-users"}) + cd.SetUserGroupNames([]string{"Admins", "Users"}) + cd.SetAuthMethod("oidc") + cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) + + r := httptest.NewRequest(http.MethodPost, "http://agent.example.com/v1/chat/completions", nil) + r.Header.Set("Content-Type", "application/json") + + result := targetResult{ + target: &PathTarget{AgentNetwork: true}, + matchedPath: "/", + serviceID: types.ServiceID("svc-1"), + accountID: types.AccountID("acct-1"), + } + + body := []byte(`{"model":"gpt-5.4"}`) + in := buildRequestInput(r, result, cd, body, false, int64(len(body))) + + require.NotNil(t, in, "buildRequestInput must return an envelope") + assert.Equal(t, middleware.SlotOnRequest, in.Slot, "request input runs in the on-request slot") + assert.Equal(t, "svc-1", in.ServiceID, "service id must propagate") + assert.Equal(t, "acct-1", in.AccountID, "account id must propagate") + assert.Equal(t, "user-1", in.UserID, "user id must propagate") + assert.Equal(t, "user@example.com", in.UserEmail, "user email must propagate") + assert.Equal(t, []string{"grp-admins", "grp-users"}, in.UserGroups, + "CapturedData groups MUST reach the middleware Input — llm_router authorises against this") + assert.Equal(t, []string{"Admins", "Users"}, in.UserGroupNames, "group names must propagate") + assert.Equal(t, "oidc", in.AuthMethod, "auth method must propagate") + assert.Equal(t, "100.90.1.14", in.SourceIP, "client IP must propagate") + assert.True(t, in.AgentNetwork, "agent-network target flag must reach the Input") + assert.Equal(t, body, in.Body, "captured body must reach the Input") +} diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go index 46b4d2e8d..64fccc42a 100644 --- a/proxy/internal/proxy/servicemapping.go +++ b/proxy/internal/proxy/servicemapping.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" "github.com/netbirdio/netbird/proxy/internal/types" ) @@ -32,6 +34,20 @@ type PathTarget struct { // over the embedded NetBird WireGuard client when forwarding requests // to this target. Default false → embedded client (existing behaviour). DirectUpstream bool + // Middlewares is the validated per-target middleware chain. Nil or empty + // for non-agent-network targets, keeping them on the no-middleware fast path. + Middlewares []middleware.Spec + // CaptureConfig holds the per-target body-capture limits used by the + // middleware chain. Nil for targets without body-inspecting middlewares. + CaptureConfig *bodytap.Config + // AgentNetwork marks this target as a synthesised agent-network target so + // the proxy can tag access-log entries and gate agent-network behaviour. + AgentNetwork bool + // DisableAccessLog suppresses the per-request access-log emission for this + // target. Defaults false so non-agent-network targets continue to log + // unchanged. The agent-network synthesizer sets this true only when the + // account's EnableLogCollection toggle is off. + DisableAccessLog bool } // Mapping describes how a domain is routed by the HTTP reverse proxy. diff --git a/proxy/internal/proxy/strip_prefix_test.go b/proxy/internal/proxy/strip_prefix_test.go new file mode 100644 index 000000000..4ff364f6a --- /dev/null +++ b/proxy/internal/proxy/strip_prefix_test.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripUpstreamPathPrefix(t *testing.T) { + cases := []struct { + name string + path string + prefix string + want string + }{ + {"strips matching namespace prefix", "/bedrock/model/x/invoke", "/bedrock", "/model/x/invoke"}, + {"no-op when prefix absent", "/model/x/invoke", "/bedrock", "/model/x/invoke"}, + {"no-op on empty prefix", "/bedrock/model/x/invoke", "", "/bedrock/model/x/invoke"}, + {"no-op on non-segment match", "/bedrockfoo/model/x", "/bedrock", "/bedrockfoo/model/x"}, + {"bare prefix collapses to root", "/bedrock", "/bedrock", "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", tc.path, nil) + stripUpstreamPathPrefix(r, tc.prefix) + assert.Equal(t, tc.want, r.URL.Path, "stripped path for %q", tc.path) + }) + } +} diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index 13d386da2..cb2e7f930 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -8,6 +8,8 @@ import ( "net" "net/http" "net/netip" + "os" + "strings" "sync" "time" @@ -347,8 +349,20 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account "public_key": publicKey.String(), }).Info("proxy peer authenticated successfully with management") + // Embedded client log level: warn by default (quiet in production); set + // NB_PROXY_CLIENT_LOG_LEVEL (e.g. "trace") to surface the embedded NetBird + // client's relay / signal / handshake detail for local debugging. + clientLogLevel := log.WarnLevel.String() + if v := strings.TrimSpace(os.Getenv("NB_PROXY_CLIENT_LOG_LEVEL")); v != "" { + if lvl, err := log.ParseLevel(v); err == nil { + clientLogLevel = lvl.String() + } else { + n.logger.Warnf("invalid NB_PROXY_CLIENT_LOG_LEVEL %q, using %q: %v", v, clientLogLevel, err) + } + } + n.initLogOnce.Do(func() { - if err := util.InitLog(log.WarnLevel.String(), util.LogConsole); err != nil { + if err := util.InitLog(clientLogLevel, util.LogConsole); err != nil { n.logger.WithField("account_id", accountID).Warnf("failed to initialize embedded client logging: %v", err) } }) @@ -356,11 +370,11 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account // Create embedded NetBird client with the generated private key. // The peer has already been created via CreateProxyPeer RPC with the public key. wgPort := int(n.clientCfg.WGPort) - client, err := embed.New(embed.Options{ + embedOpts := embed.Options{ DeviceName: deviceNamePrefix + n.proxyID, ManagementURL: n.clientCfg.MgmtAddr, PrivateKey: privateKey.String(), - LogLevel: log.WarnLevel.String(), + LogLevel: clientLogLevel, BlockInbound: n.clientCfg.BlockInbound, // The embedded proxy peer must never be a stepping stone into // the proxy host's LAN: it only exists to reach NetBird mesh @@ -371,7 +385,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account WireguardPort: &wgPort, PreSharedKey: n.clientCfg.PreSharedKey, Performance: n.clientCfg.Performance, - }) + } + logEmbedOptions(n.logger, accountID, serviceID, publicKey.String(), embedOpts) + client, err := embed.New(embedOpts) if err != nil { return nil, fmt.Errorf("create netbird client: %w", err) } @@ -847,3 +863,53 @@ func DirectUpstreamFromContext(ctx context.Context) bool { v, _ := ctx.Value(directUpstreamContextKey{}).(bool) return v } + +// logEmbedOptions emits a single structured INFO line summarising every +// operationally meaningful flag handed to embed.New for this per-account +// client. Secrets (PrivateKey, PreSharedKey) are reduced to a "present" +// boolean — never logged verbatim. Use this when an embedded peer +// silently misbehaves: most failure modes (inbound drops, wrong +// management URL, v6 unexpectedly on, userspace flipped, port clash) +// are obvious from these flags before any traffic flows. +func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID types.ServiceID, publicKey string, opts embed.Options) { + wgPort := 0 + if opts.WireguardPort != nil { + wgPort = *opts.WireguardPort + } + mtu := uint16(0) + if opts.MTU != nil { + mtu = *opts.MTU + } + perfBuffers := uint32(0) + if opts.Performance.PreallocatedBuffersPerPool != nil { + perfBuffers = *opts.Performance.PreallocatedBuffersPerPool + } + perfBatch := uint32(0) + if opts.Performance.MaxBatchSize != nil { + perfBatch = *opts.Performance.MaxBatchSize + } + logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + "public_key": publicKey, + "device_name": opts.DeviceName, + "management_url": opts.ManagementURL, + "log_level": opts.LogLevel, + "wg_port": wgPort, + "mtu": mtu, + "block_inbound": opts.BlockInbound, + "block_lan_access": opts.BlockLANAccess, + "disable_ipv6": opts.DisableIPv6, + "disable_client_routes": opts.DisableClientRoutes, + "no_userspace": opts.NoUserspace, + "config_path_set": opts.ConfigPath != "", + "state_path_set": opts.StatePath != "", + "private_key_present": opts.PrivateKey != "", + "presharedkey_present": opts.PreSharedKey != "", + "setup_key_present": opts.SetupKey != "", + "jwt_token_present": opts.JWTToken != "", + "dns_labels": opts.DNSLabels, + "perf_buffers_per_pool": perfBuffers, + "perf_max_batch_size": perfBatch, + }).Info("starting embedded netbird client for account") +} diff --git a/proxy/internal/tcp/accept.go b/proxy/internal/tcp/accept.go new file mode 100644 index 000000000..a63560a9e --- /dev/null +++ b/proxy/internal/tcp/accept.go @@ -0,0 +1,85 @@ +package tcp + +import ( + "context" + "errors" + "net" + "strings" + "time" +) + +// gvisorInvalidEndpointMsg is the canonical text gVisor netstack returns +// when Accept() is called on a listener whose underlying endpoint has +// been destroyed (peer rekey, embedded-client reset, account churn). +// There is no exported sentinel from gvisor.dev/gvisor/pkg/tcpip that +// survives gonet's *net.OpError wrapping in a way errors.Is can match, +// so we fall back to a string check. Stable across the gVisor versions +// netbird pins. +const gvisorInvalidEndpointMsg = "endpoint is in invalid state" + +// IsClosedListenerErr reports whether err signals that an accept loop +// should exit because the underlying listener can no longer serve +// connections. It recognises: +// +// - net.ErrClosed for stdlib listeners (Listener.Close was called). +// - gVisor's "endpoint is in invalid state" for netstack-backed +// listeners whose endpoint was destroyed out from under them +// (typically when a per-account WireGuard netstack is reset without +// also tearing the listener entry down). +// +// Without the gVisor branch an accept loop on a netstack listener spins +// CPU-hot forever after the endpoint dies, because Accept never blocks +// again and the error neither matches net.ErrClosed nor cancels ctx. +func IsClosedListenerErr(err error) bool { + if err == nil { + return false + } + if errors.Is(err, net.ErrClosed) { + return true + } + return strings.Contains(err.Error(), gvisorInvalidEndpointMsg) +} + +// AcceptBackoff implements the exponential backoff used by +// net/http.Server.Serve for transient Accept errors. Without it a loop +// hitting a sticky unknown error burns a full CPU core. The zero value +// is ready to use; call Reset after a successful Accept. +type AcceptBackoff struct { + delay time.Duration +} + +// minAcceptDelay / maxAcceptDelay mirror the stdlib defaults +// (net/http.Server.Serve) and keep us well below 1 log line per second +// per orphaned listener. +const ( + minAcceptDelay = 5 * time.Millisecond + maxAcceptDelay = time.Second +) + +// Backoff waits the next exponential delay (5ms doubling up to 1s) and +// returns true when the wait completed. Returns false if ctx fired +// during the wait — callers should treat that as "exit the loop". +func (b *AcceptBackoff) Backoff(ctx context.Context) bool { + b.advance() + select { + case <-ctx.Done(): + return false + case <-time.After(b.delay): + return true + } +} + +// Reset clears the accumulated delay so the next failure starts at the +// minimum delay again. Call after a successful Accept. +func (b *AcceptBackoff) Reset() { b.delay = 0 } + +func (b *AcceptBackoff) advance() { + if b.delay == 0 { + b.delay = minAcceptDelay + } else { + b.delay *= 2 + } + if b.delay > maxAcceptDelay { + b.delay = maxAcceptDelay + } +} diff --git a/proxy/internal/tcp/accept_test.go b/proxy/internal/tcp/accept_test.go new file mode 100644 index 000000000..b2824d38a --- /dev/null +++ b/proxy/internal/tcp/accept_test.go @@ -0,0 +1,142 @@ +package tcp + +import ( + "context" + "errors" + "fmt" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIsClosedListenerErr_NetErrClosed verifies the stdlib path: a +// closed *net.Listener returns net.ErrClosed wrapped in *net.OpError, +// and IsClosedListenerErr must unwrap it. +func TestIsClosedListenerErr_NetErrClosed(t *testing.T) { + wrapped := &net.OpError{Op: "accept", Net: "tcp", Err: net.ErrClosed} + assert.True(t, IsClosedListenerErr(wrapped), + "net.OpError wrapping net.ErrClosed must be recognised as closed") +} + +// TestIsClosedListenerErr_GVisorInvalidEndpoint is the load-bearing +// regression guard. A gVisor netstack listener whose endpoint has been +// destroyed returns this exact text. Without recognising it the accept +// loop spins forever and burns a CPU core. +func TestIsClosedListenerErr_GVisorInvalidEndpoint(t *testing.T) { + err := fmt.Errorf("accept tcp 10.10.1.254:80: endpoint is in invalid state") + assert.True(t, IsClosedListenerErr(err), + "gVisor 'endpoint is in invalid state' must be recognised as closed") +} + +// TestIsClosedListenerErr_OtherError confirms we don't over-match — +// transient errors must keep returning false so the backoff path runs. +func TestIsClosedListenerErr_OtherError(t *testing.T) { + cases := []error{ + errors.New("temporary failure"), + errors.New("accept tcp 10.10.1.254:80: too many open files"), + nil, + } + for _, c := range cases { + assert.False(t, IsClosedListenerErr(c), + "unexpected match on %v — must not be treated as closed", c) + } +} + +// TestAcceptBackoff_ProgressionAndCap asserts the doubling schedule: +// 5ms, 10ms, 20ms, 40ms, ... capped at 1s. The test runs against a +// real timer but uses tight bounds so a slow CI machine still passes. +func TestAcceptBackoff_ProgressionAndCap(t *testing.T) { + var b AcceptBackoff + expected := []time.Duration{ + 5 * time.Millisecond, + 10 * time.Millisecond, + 20 * time.Millisecond, + 40 * time.Millisecond, + } + for i, want := range expected { + start := time.Now() + ok := b.Backoff(context.Background()) + elapsed := time.Since(start) + require.True(t, ok, "Backoff %d must complete; ctx is alive", i) + assert.GreaterOrEqual(t, elapsed, want, + "backoff %d (%v) must wait at least the configured delay", i, want) + assert.Less(t, elapsed, want*4, + "backoff %d (%v) must not overshoot by more than 4x — caps misbehaving", i, want) + } + + // Burn enough rounds to reach the cap, then assert subsequent + // rounds stay at exactly maxAcceptDelay (1s) — the timer should + // never exceed it. + for range 6 { + b.Backoff(context.Background()) + } + assert.Equal(t, maxAcceptDelay, b.delay, + "after enough doublings the delay must clamp to maxAcceptDelay") +} + +// TestAcceptBackoff_Reset confirms that a successful Accept resets the +// schedule — a busy-then-quiet listener mustn't stay on a 1s timer +// after recovery. +func TestAcceptBackoff_Reset(t *testing.T) { + var b AcceptBackoff + for range 5 { + b.Backoff(context.Background()) + } + require.NotEqual(t, time.Duration(0), b.delay, "precondition: delay must have accumulated") + + b.Reset() + assert.Equal(t, time.Duration(0), b.delay, "Reset must zero the delay") + + start := time.Now() + ok := b.Backoff(context.Background()) + elapsed := time.Since(start) + require.True(t, ok, "Backoff after Reset must complete") + assert.GreaterOrEqual(t, elapsed, minAcceptDelay, + "after Reset the next backoff must restart at minAcceptDelay") + assert.Less(t, elapsed, 50*time.Millisecond, + "after Reset the next backoff must NOT carry over the prior delay") +} + +// TestAcceptBackoff_CancelDuringWait proves the loop exits promptly +// when ctx fires mid-wait. Without this, a tear-down would still take +// up to 1 second per orphaned listener. +func TestAcceptBackoff_CancelDuringWait(t *testing.T) { + var b AcceptBackoff + // Drive the backoff up so the next call will wait ~1s — long + // enough that we can detect early cancellation. + for range 10 { + b.Backoff(context.Background()) + } + require.Equal(t, maxAcceptDelay, b.delay) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + start := time.Now() + ok := b.Backoff(ctx) + elapsed := time.Since(start) + assert.False(t, ok, "Backoff must return false when ctx is cancelled mid-wait") + assert.Less(t, elapsed, 200*time.Millisecond, + "cancellation must short-circuit the timer; took %v", elapsed) +} + +// TestAcceptBackoff_CancelBeforeCall — when ctx is already done the +// loop exits without sleeping at all. +func TestAcceptBackoff_CancelBeforeCall(t *testing.T) { + var b AcceptBackoff + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + ok := b.Backoff(ctx) + elapsed := time.Since(start) + assert.False(t, ok, "Backoff must return false when ctx is already cancelled") + assert.Less(t, elapsed, 50*time.Millisecond, + "already-cancelled ctx must return immediately; took %v", elapsed) +} diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go index 15c5022b0..307f2b4f3 100644 --- a/proxy/internal/tcp/router.go +++ b/proxy/internal/tcp/router.go @@ -297,18 +297,29 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error { } }() + var backoff AcceptBackoff for { conn, err := ln.Accept() if err != nil { - if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + if ctx.Err() != nil || IsClosedListenerErr(err) { + if ok := r.Drain(DefaultDrainTimeout); !ok { + r.logger.Warn("timed out waiting for connections to drain") + } + return nil + } + r.logger.Debugf("SNI router accept: %v; backing off", err) + if !backoff.Backoff(ctx) { + // Cancelled during backoff: still drain in-flight + // connections/relays before returning, matching the + // shutdown path above. if ok := r.Drain(DefaultDrainTimeout); !ok { r.logger.Warn("timed out waiting for connections to drain") } return nil } - r.logger.Debugf("SNI router accept: %v", err) continue } + backoff.Reset() r.logger.Debugf("SNI router accepted conn from %s on %s", conn.RemoteAddr(), conn.LocalAddr()) r.activeConns.Add(1) go func() { diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go index ea1b418f5..8be617dff 100644 --- a/proxy/internal/tcp/router_test.go +++ b/proxy/internal/tcp/router_test.go @@ -1836,3 +1836,132 @@ func TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled(t *testing.T) { t.Fatal("TLS conn never reached the TLS channel") } } + +// scriptedAcceptListener is a net.Listener whose Accept() returns +// pre-scripted errors. Used by the accept-loop exit tests to simulate +// the failure mode that triggers the tight-loop bug: a netstack +// listener whose endpoint has been destroyed and now returns the gVisor +// "endpoint is in invalid state" error from every Accept call. +type scriptedAcceptListener struct { + errs chan error + closed chan struct{} +} + +func newScriptedAcceptListener(errs ...error) *scriptedAcceptListener { + s := &scriptedAcceptListener{ + errs: make(chan error, len(errs)+1), + closed: make(chan struct{}), + } + for _, e := range errs { + s.errs <- e + } + return s +} + +func (s *scriptedAcceptListener) Accept() (net.Conn, error) { + select { + case <-s.closed: + return nil, net.ErrClosed + case err := <-s.errs: + return nil, err + } +} + +func (s *scriptedAcceptListener) Close() error { + select { + case <-s.closed: + default: + close(s.closed) + } + return nil +} + +func (s *scriptedAcceptListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +// TestRouter_Serve_ExitsOnGVisorInvalidEndpoint is the regression guard +// for the tight-loop bug: when the underlying netstack endpoint is +// destroyed, Accept returns "endpoint is in invalid state" forever. The +// loop must recognise that signal and return, otherwise it pegs a CPU +// core and floods logs. +func TestRouter_Serve_ExitsOnGVisorInvalidEndpoint(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + gvisorErr := &net.OpError{ + Op: "accept", + Net: "tcp", + Addr: addr, + Err: errSentinel("endpoint is in invalid state"), + } + ln := newScriptedAcceptListener(gvisorErr) + defer ln.Close() + + done := make(chan error, 1) + go func() { + done <- router.Serve(context.Background(), ln) + }() + + select { + case err := <-done: + assert.NoError(t, err, "Serve must return cleanly on a recognised closed-listener error") + case <-time.After(2 * time.Second): + t.Fatal("Serve did not exit on gVisor 'endpoint is in invalid state' — accept loop is spinning") + } +} + +// TestRouter_Serve_BacksOffOnTransientError verifies the defence-in- +// depth path: when Accept returns an unknown transient error, the loop +// MUST not spin. It backs off, then exits cleanly once ctx is cancelled. +// "Bounded call count" stands in for "no CPU spin" — without backoff +// the goroutine would issue thousands of Accept calls in this window. +func TestRouter_Serve_BacksOffOnTransientError(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + const transientErrCount = 5 + errs := make([]error, transientErrCount) + for i := range errs { + errs[i] = errSentinel("transient: too many open files") + } + ln := newScriptedAcceptListener(errs...) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + start := time.Now() + go func() { + done <- router.Serve(ctx, ln) + }() + + // Cancel after enough time for the backoff to climb (5ms + 10ms + + // 20ms + 40ms = 75ms minimum), but short enough that a spinning + // loop would have made thousands of calls by now. + time.AfterFunc(150*time.Millisecond, cancel) + + select { + case err := <-done: + assert.NoError(t, err, "Serve must return cleanly on ctx cancellation") + case <-time.After(2 * time.Second): + t.Fatal("Serve did not exit on ctx cancellation — backoff or exit path broken") + } + + // Without backoff the loop would burn through all 5 scripted errors + // in microseconds and then block on the channel. With backoff the + // total wall time should be at least 5ms (the first backoff). + elapsed := time.Since(start) + assert.GreaterOrEqual(t, elapsed, minAcceptDelay, + "loop ran without backing off — would burn CPU in production") +} + +// errSentinel mirrors gVisor's tcpip error message exactly. We can't +// import the gVisor package without dragging in the whole netstack, so +// the test uses the canonical string the production error formatter +// emits — same shape IsClosedListenerErr matches in production. +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } + diff --git a/proxy/middleware_register.go b/proxy/middleware_register.go new file mode 100644 index 000000000..736ee04c1 --- /dev/null +++ b/proxy/middleware_register.go @@ -0,0 +1,16 @@ +package proxy + +// Anonymous imports trigger init() in each built-in middleware +// sub-package so they self-register into mwbuiltin.DefaultRegistry() +// before initMiddlewareManager builds the resolver. Add a new line +// here when introducing another built-in middleware. +import ( + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router" +) diff --git a/proxy/middleware_translate.go b/proxy/middleware_translate.go new file mode 100644 index 000000000..c5d9fe016 --- /dev/null +++ b/proxy/middleware_translate.go @@ -0,0 +1,165 @@ +package proxy + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// translateMiddlewareCaptureConfig builds the per-target capture +// limits used by the middleware chain. Returns nil when the options +// are nil or no capture field is set. Negative caps are normalised to +// zero; oversized caps are clamped to middleware.MaxBodyCapBytes. +func translateMiddlewareCaptureConfig(targetID string, opts *proto.PathTargetOptions) *bodytap.Config { + if opts == nil { + return nil + } + reqCap := clampMiddlewareCaptureBytes(targetID, "request", opts.GetCaptureMaxRequestBytes()) + respCap := clampMiddlewareCaptureBytes(targetID, "response", opts.GetCaptureMaxResponseBytes()) + types := opts.GetCaptureContentTypes() + if reqCap == 0 && respCap == 0 && len(types) == 0 { + return nil + } + return &bodytap.Config{ + MaxRequestBytes: reqCap, + MaxResponseBytes: respCap, + ContentTypes: types, + } +} + +func clampMiddlewareCaptureBytes(targetID, direction string, v int64) int64 { + if v < 0 { + log.Debugf("target %s %s capture cap %d clamped to 0", targetID, direction, v) + return 0 + } + if v > middleware.MaxBodyCapBytes { + log.Debugf("target %s %s capture cap %d clamped to %d", targetID, direction, v, middleware.MaxBodyCapBytes) + return middleware.MaxBodyCapBytes + } + return v +} + +// translateMiddlewareConfigs converts the proto MiddlewareConfig list +// into validated middleware.Spec values. The list is truncated to +// middleware.MaxMiddlewaresPerChain when the caller exceeds the cap. +// Entries with empty IDs, unknown IDs (when registry is non-nil), or +// unspecified slots are skipped with a warn log. Timeouts are clamped +// to [MinTimeout, MaxTimeout] and zero substitutes for DefaultTimeout. +// Returns nil when the resulting slice is empty so callers can leave +// PathTarget.Middlewares unset. +func translateMiddlewareConfigs( + ctx context.Context, + targetID string, + in []*proto.MiddlewareConfig, + registry *middleware.Registry, +) []middleware.Spec { + _ = ctx + if len(in) == 0 { + return nil + } + if len(in) > middleware.MaxMiddlewaresPerChain { + log.Warnf("middleware list for target %q truncated: %d entries exceeds cap of %d", + targetID, len(in), middleware.MaxMiddlewaresPerChain) + in = in[:middleware.MaxMiddlewaresPerChain] + } + + out := make([]middleware.Spec, 0, len(in)) + for _, cfg := range in { + spec, ok := translateMiddlewareConfig(targetID, cfg, registry) + if !ok { + continue + } + out = append(out, spec) + } + if len(out) == 0 { + return nil + } + return out +} + +// translateMiddlewareConfig validates and converts a single +// MiddlewareConfig. The second return value is false when the entry +// must be dropped from the chain. +func translateMiddlewareConfig(targetID string, cfg *proto.MiddlewareConfig, registry *middleware.Registry) (middleware.Spec, bool) { + if cfg == nil { + return middleware.Spec{}, false + } + id := cfg.GetId() + if id == "" { + log.Warnf("middleware config for target %q dropped: empty middleware id", targetID) + return middleware.Spec{}, false + } + if registry != nil && !registry.IsKnown(id) { + log.Warnf("unknown middleware %q configured for target %s; dropping", id, targetID) + return middleware.Spec{}, false + } + slot, ok := protoToMiddlewareSlot(cfg.GetSlot()) + if !ok { + log.Warnf("middleware %q on target %q dropped: slot is unspecified", id, targetID) + return middleware.Spec{}, false + } + + var rawConfig []byte + if src := cfg.GetConfigJson(); len(src) > 0 { + rawConfig = append([]byte(nil), src...) + } + + return middleware.Spec{ + ID: id, + Slot: slot, + Enabled: cfg.GetEnabled(), + FailMode: protoToMiddlewareFailMode(cfg.GetFailMode()), + Timeout: clampMiddlewareTimeout(id, cfg.GetTimeout().AsDuration()), + RawConfig: rawConfig, + CanMutate: cfg.GetCanMutate(), + }, true +} + +// protoToMiddlewareSlot maps the proto slot enum onto the internal +// middleware.Slot. Returns ok=false for the UNSPECIFIED value so the +// translator can drop the entry. +func protoToMiddlewareSlot(s proto.MiddlewareSlot) (middleware.Slot, bool) { + switch s { + case proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST: + return middleware.SlotOnRequest, true + case proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE: + return middleware.SlotOnResponse, true + case proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL: + return middleware.SlotTerminal, true + default: + return 0, false + } +} + +// protoToMiddlewareFailMode maps the proto FailMode enum onto the +// internal middleware.FailMode, defaulting to FailOpen for any value +// other than FAIL_CLOSED. +func protoToMiddlewareFailMode(m proto.MiddlewareConfig_FailMode) middleware.FailMode { + if m == proto.MiddlewareConfig_FAIL_CLOSED { + return middleware.FailClosed + } + return middleware.FailOpen +} + +// clampMiddlewareTimeout enforces the proxy-wide [MinTimeout, MaxTimeout] +// bounds and substitutes DefaultTimeout for zero inputs. A warn is logged +// only on an actual clamp, not when filling the default. +func clampMiddlewareTimeout(id string, d time.Duration) time.Duration { + if d <= 0 { + return middleware.DefaultTimeout + } + if d < middleware.MinTimeout { + log.Debugf("middleware %s timeout %s clamped to %s", id, d, middleware.MinTimeout) + return middleware.MinTimeout + } + if d > middleware.MaxTimeout { + log.Debugf("middleware %s timeout %s clamped to %s", id, d, middleware.MaxTimeout) + return middleware.MaxTimeout + } + return d +} diff --git a/proxy/middleware_translate_test.go b/proxy/middleware_translate_test.go new file mode 100644 index 000000000..1a956090c --- /dev/null +++ b/proxy/middleware_translate_test.go @@ -0,0 +1,246 @@ +package proxy + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// stubFactory builds a stub Middleware so the registry's IsKnown check +// passes for the configured id. The translator never invokes the +// middleware, so the methods only need to satisfy the interface. +type stubFactory struct { + id string + slot middleware.Slot +} + +func (f stubFactory) ID() string { return f.id } +func (f stubFactory) New(_ []byte) (middleware.Middleware, error) { + return stubMiddleware(f), nil +} + +type stubMiddleware struct { + id string + slot middleware.Slot +} + +func (m stubMiddleware) ID() string { return m.id } +func (m stubMiddleware) Version() string { return "test" } +func (m stubMiddleware) Slot() middleware.Slot { return m.slot } +func (m stubMiddleware) AcceptedContentTypes() []string { return nil } +func (m stubMiddleware) MetadataKeys() []string { return nil } +func (m stubMiddleware) MutationsSupported() bool { return false } +func (m stubMiddleware) Close() error { return nil } +func (m stubMiddleware) Invoke(context.Context, *middleware.Input) (*middleware.Output, error) { + panic("stubMiddleware.Invoke must not be called in translator tests") +} + +// newTestRegistry returns a fresh registry pre-populated with the given +// middleware ids in the matching slot. +func newTestRegistry(t *testing.T, entries map[string]middleware.Slot) *middleware.Registry { + t.Helper() + r := middleware.NewRegistry() + for id, slot := range entries { + require.NoError(t, r.Register(stubFactory{id: id, slot: slot}), "stub registration must succeed") + } + return r +} + +func TestTranslateMiddlewareConfigs_EmptyInput(t *testing.T) { + assert.Nil(t, translateMiddlewareConfigs(context.Background(), "target-a", nil, nil), + "nil input should translate to nil") + assert.Nil(t, translateMiddlewareConfigs(context.Background(), "target-a", []*proto.MiddlewareConfig{}, nil), + "empty input should translate to nil") +} + +func TestTranslateMiddlewareConfigs_KnownIDs(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + "llm_response_parser": middleware.SlotOnResponse, + }) + in := []*proto.MiddlewareConfig{ + { + Id: "llm_request_parser", + Enabled: true, + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + ConfigJson: []byte(`{"foo":"bar"}`), + FailMode: proto.MiddlewareConfig_FAIL_OPEN, + Timeout: durationpb.New(250 * time.Millisecond), + CanMutate: true, + }, + { + Id: "llm_response_parser", + Enabled: false, + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, + ConfigJson: nil, + FailMode: proto.MiddlewareConfig_FAIL_CLOSED, + Timeout: durationpb.New(50 * time.Millisecond), + }, + } + + out := translateMiddlewareConfigs(context.Background(), "target-a", in, registry) + require.Len(t, out, 2, "two known middlewares should produce two specs") + + assert.Equal(t, "llm_request_parser", out[0].ID, "first id should match") + assert.Equal(t, middleware.SlotOnRequest, out[0].Slot, "first slot should be on_request") + assert.True(t, out[0].Enabled, "first spec should be enabled") + assert.Equal(t, middleware.FailOpen, out[0].FailMode, "first spec should be fail-open") + assert.Equal(t, 250*time.Millisecond, out[0].Timeout, "first spec timeout should pass through") + assert.True(t, out[0].CanMutate, "first spec should permit mutations") + assert.Equal(t, []byte(`{"foo":"bar"}`), out[0].RawConfig, "first spec raw config should match") + + assert.Equal(t, "llm_response_parser", out[1].ID, "second id should match") + assert.Equal(t, middleware.SlotOnResponse, out[1].Slot, "second slot should be on_response") + assert.False(t, out[1].Enabled, "second spec should be disabled") + assert.Equal(t, middleware.FailClosed, out[1].FailMode, "second spec should be fail-closed") + assert.Equal(t, 50*time.Millisecond, out[1].Timeout, "second spec timeout should pass through") + assert.Nil(t, out[1].RawConfig, "second spec raw config should be nil") +} + +func TestTranslateMiddlewareConfigs_UnknownIDSkipped(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "not_registered", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + } + out := translateMiddlewareConfigs(context.Background(), "target-unknown", in, registry) + require.Len(t, out, 1, "unknown id must be skipped") + assert.Equal(t, "llm_request_parser", out[0].ID, "remaining entry should be the known one") +} + +func TestTranslateMiddlewareConfigs_NilRegistrySkipsValidation(t *testing.T) { + in := []*proto.MiddlewareConfig{ + {Id: "anything_goes", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + } + out := translateMiddlewareConfigs(context.Background(), "target-nilreg", in, nil) + require.Len(t, out, 1, "nil registry must accept any non-empty id") + assert.Equal(t, "anything_goes", out[0].ID, "id should pass through unchecked") +} + +func TestTranslateMiddlewareConfigs_TimeoutClamps(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: nil}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: durationpb.New(time.Microsecond)}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: durationpb.New(time.Hour)}, + } + out := translateMiddlewareConfigs(context.Background(), "target-clamp", in, registry) + require.Len(t, out, 3, "clamping must keep all three entries") + assert.Equal(t, middleware.DefaultTimeout, out[0].Timeout, "zero timeout should default") + assert.Equal(t, middleware.MinTimeout, out[1].Timeout, "below-min timeout should clamp up") + assert.Equal(t, middleware.MaxTimeout, out[2].Timeout, "above-max timeout should clamp down") +} + +func TestTranslateMiddlewareConfigs_FailModeMapping(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, FailMode: proto.MiddlewareConfig_FAIL_CLOSED}, + } + out := translateMiddlewareConfigs(context.Background(), "target-failmode", in, registry) + require.Len(t, out, 2, "both entries should translate") + assert.Equal(t, middleware.FailOpen, out[0].FailMode, "default fail mode should be open") + assert.Equal(t, middleware.FailClosed, out[1].FailMode, "explicit fail closed should map") +} + +func TestTranslateMiddlewareConfigs_SlotMapping(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "req": middleware.SlotOnRequest, + "resp": middleware.SlotOnResponse, + "term": middleware.SlotTerminal, + }) + in := []*proto.MiddlewareConfig{ + {Id: "req", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "resp", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE}, + {Id: "term", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL}, + {Id: "req", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED}, + } + out := translateMiddlewareConfigs(context.Background(), "target-slot", in, registry) + require.Len(t, out, 3, "unspecified slot entry must be skipped") + assert.Equal(t, middleware.SlotOnRequest, out[0].Slot, "on_request slot mapping") + assert.Equal(t, middleware.SlotOnResponse, out[1].Slot, "on_response slot mapping") + assert.Equal(t, middleware.SlotTerminal, out[2].Slot, "terminal slot mapping") +} + +func TestTranslateMiddlewareConfigs_EmptyIDSkipped(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + } + out := translateMiddlewareConfigs(context.Background(), "target-empty-id", in, registry) + require.Len(t, out, 1, "empty id must be dropped") + assert.Equal(t, "llm_request_parser", out[0].ID, "remaining entry should be valid") +} + +// TestTranslateMiddlewareConfigs_TruncatesAboveCap proves the translator +// truncates lists that exceed MaxMiddlewaresPerChain rather than dropping +// the whole slice, matching the documented G3 behaviour. +func TestTranslateMiddlewareConfigs_TruncatesAboveCap(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + overCap := middleware.MaxMiddlewaresPerChain + 1 + in := make([]*proto.MiddlewareConfig, 0, overCap) + for i := 0; i < overCap; i++ { + in = append(in, &proto.MiddlewareConfig{ + Id: "llm_request_parser", + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + }) + } + out := translateMiddlewareConfigs(context.Background(), "target-truncate", in, registry) + assert.Len(t, out, middleware.MaxMiddlewaresPerChain, "over-cap input must be truncated to MaxMiddlewaresPerChain") +} + +func TestTranslateMiddlewareConfigs_AllowsListAtCap(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := make([]*proto.MiddlewareConfig, 0, middleware.MaxMiddlewaresPerChain) + for i := 0; i < middleware.MaxMiddlewaresPerChain; i++ { + in = append(in, &proto.MiddlewareConfig{ + Id: "llm_request_parser", + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + }) + } + out := translateMiddlewareConfigs(context.Background(), "target-cap", in, registry) + assert.Len(t, out, middleware.MaxMiddlewaresPerChain, "list at the cap boundary must translate fully") +} + +func TestProtoToMiddlewareSlot(t *testing.T) { + cases := []struct { + name string + in proto.MiddlewareSlot + want middleware.Slot + wantOk bool + }{ + {"unspecified", proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED, 0, false}, + {"on_request", proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, middleware.SlotOnRequest, true}, + {"on_response", proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, middleware.SlotOnResponse, true}, + {"terminal", proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL, middleware.SlotTerminal, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := protoToMiddlewareSlot(tc.in) + assert.Equal(t, tc.wantOk, ok, "ok flag for %s", tc.name) + if tc.wantOk { + assert.Equal(t, tc.want, got, "slot mapping for %s", tc.name) + } + }) + } +} diff --git a/proxy/server.go b/proxy/server.go index 1d8a2451b..f28d580bd 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -55,6 +55,8 @@ import ( "github.com/netbirdio/netbird/proxy/internal/health" "github.com/netbirdio/netbird/proxy/internal/k8s" proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/middleware" + mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" "github.com/netbirdio/netbird/proxy/internal/netutil" "github.com/netbirdio/netbird/proxy/internal/proxy" "github.com/netbirdio/netbird/proxy/internal/restrict" @@ -77,29 +79,36 @@ type portRouter struct { type Server struct { ctx context.Context - mgmtClient proto.ProxyServiceClient - proxy *proxy.ReverseProxy - netbird *roundtrip.NetBird - acme *acme.Manager + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager staticCertWatcher *certwatch.Watcher - auth *auth.Middleware - http *http.Server - https *http.Server - debug *http.Server - healthServer *health.Server - healthChecker *health.Checker - meter *proxymetrics.Metrics - accessLog *accesslog.Logger - mainRouter *nbtcp.Router - mainPort uint16 - udpMu sync.Mutex - udpRelays map[types.ServiceID]*udprelay.Relay - udpRelayWg sync.WaitGroup - portMu sync.RWMutex - portRouters map[uint16]*portRouter - svcPorts map[types.ServiceID][]uint16 - lastMappings map[types.ServiceID]*proto.ProxyMapping - portRouterWg sync.WaitGroup + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger + // middlewareManager drives per-target middleware dispatch. Always + // constructed during boot; an empty registry produces empty chains and + // the reverse-proxy stays on the no-capture fast path. + middlewareManager *middleware.Manager + // middlewareRegistry is the source of registered middleware factories. + // Concrete middlewares register themselves through init(). + middlewareRegistry *middleware.Registry + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) // so they can be closed during graceful shutdown, since http.Server.Shutdown @@ -236,8 +245,20 @@ type Server struct { // in processMappings before the receive loop reconnects to resync. // Zero uses defaultMappingBatchWatchdog. MappingBatchWatchdog time.Duration + // MiddlewareDataDir is the base directory the middleware system uses to + // resolve file-backed configuration (e.g. the cost_meter pricing table). + // Empty means any middleware that requires a file fails at configure time. + MiddlewareDataDir string + // MiddlewareCaptureBudgetBytes overrides the proxy-wide in-flight capture + // budget passed to middleware.NewManager. Zero or negative values fall + // back to defaultMiddlewareCaptureBudgetBytes (256 MiB). + MiddlewareCaptureBudgetBytes int64 } +// defaultMiddlewareCaptureBudgetBytes is the proxy-wide in-flight capture cap +// passed to middleware.NewManager when MiddlewareCaptureBudgetBytes is unset. +const defaultMiddlewareCaptureBudgetBytes = 256 << 20 + // clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured. func (s *Server) clampIdleTimeout(d time.Duration) time.Duration { if s.MaxSessionIdleTimeout > 0 && d > s.MaxSessionIdleTimeout { @@ -343,6 +364,15 @@ func (s *Server) Start(ctx context.Context) error { return err } + // Management client must be initialised BEFORE the middleware manager — + // initMiddlewareManager passes s.mgmtClient into the builtin FactoryContext + // that the limit-check / limit-record middlewares pull from. Reversed + // order would silently disable enforcement (mgmt=nil → allow-without- + // attribution + no-record). + if err := s.initMiddlewareManager(ctx); err != nil { + return fmt.Errorf("init middleware manager: %w", err) + } + runCtx, runCancel := context.WithCancel(ctx) s.runCancel = runCancel @@ -562,7 +592,11 @@ func (s *Server) initNetBirdClient() { // proxy host's resolver instead of the tunnel's DNS. func (s *Server) initReverseProxy() { upstreamRT := roundtrip.NewMultiTransport(s.netbird, s.Logger) - s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger) + var rpOpts []proxy.Option + if s.middlewareManager != nil { + rpOpts = append(rpOpts, proxy.WithMiddlewareManager(s.middlewareManager)) + } + s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger, rpOpts...) } // initGeoLookup configures the GeoLite2 lookup used for country-based @@ -2047,9 +2081,94 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) m := s.protoToMapping(ctx, mapping) s.proxy.AddMapping(m) s.meter.AddMapping(m) + s.rebuildMiddlewareChains(svcID, m) return nil } +// initMiddlewareManager wires the middleware subsystem at boot. It configures +// the per-process FactoryContext concrete middlewares consult, installs the +// live-service check, and binds the resolver to the registry concrete +// middlewares register themselves into via init(). +func (s *Server) initMiddlewareManager(ctx context.Context) error { + if s.meter == nil { + return fmt.Errorf("middleware manager requires metrics bundle") + } + otelMeter := s.meter.Meter() + mwbuiltin.Configure(ctx, s.MiddlewareDataDir, otelMeter, s.Logger, s.mgmtClient) + + mwMetrics, err := middleware.NewMetrics(otelMeter) + if err != nil { + return fmt.Errorf("init middleware metrics: %w", err) + } + budgetBytes := s.MiddlewareCaptureBudgetBytes + if budgetBytes <= 0 { + budgetBytes = defaultMiddlewareCaptureBudgetBytes + } + + registry := mwbuiltin.DefaultRegistry() + mgr := middleware.NewManager(budgetBytes, mwMetrics, s.Logger) + mgr.SetResolver(middleware.NewResolver(registry)) + mgr.SetLiveServiceCheck(s.isLiveService) + + s.middlewareRegistry = registry + s.middlewareManager = mgr + ids := registry.IDs() + s.Logger.Infof("middleware system enabled: %d built-in middlewares registered %v, capture budget %d bytes", + len(ids), ids, budgetBytes) + return nil +} + +// rebuildMiddlewareChains converts m into per-path bindings and calls +// Manager.Rebuild. Short-circuits when the middleware manager is unset. +func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) { + if s.middlewareManager == nil { + return + } + bindings := buildMiddlewareBindings(svcID, m) + if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil { + s.Logger.WithError(err).WithField("service_id", svcID).Error("failed to rebuild middleware chains") + } +} + +// isLiveService reports whether svcID is currently present in the live +// mapping cache. Used by the middleware manager to confirm a chain is still +// referenced before rebuilding it from cached bindings. +func (s *Server) isLiveService(svcID string) bool { + s.portMu.RLock() + defer s.portMu.RUnlock() + _, ok := s.lastMappings[types.ServiceID(svcID)] + return ok +} + +// invalidateMiddlewareChains drops every middleware chain registered for svcID. +func (s *Server) invalidateMiddlewareChains(svcID types.ServiceID) { + if s.middlewareManager == nil { + return + } + s.middlewareManager.Invalidate(string(svcID)) +} + +// buildMiddlewareBindings converts the path targets of m into the per-path +// binding list the middleware manager's Rebuild expects. Targets without any +// middleware specs are skipped. +func buildMiddlewareBindings(svcID types.ServiceID, m proxy.Mapping) []middleware.PathTargetBinding { + if len(m.Paths) == 0 { + return nil + } + bindings := make([]middleware.PathTargetBinding, 0, len(m.Paths)) + for pathID, pt := range m.Paths { + if pt == nil || len(pt.Middlewares) == 0 { + continue + } + bindings = append(bindings, middleware.PathTargetBinding{ + ServiceID: string(svcID), + PathID: pathID, + Specs: pt.Middlewares, + }) + } + return bindings +} + // removeMapping tears down routes/relays and the NetBird peer for a service. // Uses the stored mapping state when available to ensure all previously // configured routes are cleaned up. @@ -2085,6 +2204,8 @@ func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) { svcID := types.ServiceID(mapping.GetId()) host := mapping.GetDomain() + s.invalidateMiddlewareChains(svcID) + // HTTP/TLS cleanup (only relevant when a domain is set). if host != "" { d := domain.Domain(host) @@ -2192,6 +2313,12 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping pt.RequestTimeout = d.AsDuration() } pt.DirectUpstream = opts.GetDirectUpstream() + // Agent-network middleware specs + capture config + flag ride on + // the same per-target options. + pt.CaptureConfig = translateMiddlewareCaptureConfig(mapping.GetId(), opts) + pt.Middlewares = translateMiddlewareConfigs(ctx, mapping.GetId(), opts.GetMiddlewares(), s.middlewareRegistry) + pt.AgentNetwork = opts.GetAgentNetwork() + pt.DisableAccessLog = opts.GetDisableAccessLog() } pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout) paths[pathMapping.GetPath()] = pt diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 196a0c6b1..dffb7d7de 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5069,6 +5069,1054 @@ components: type: string description: A human-readable error message. example: "couldn't parse JSON request" + AgentNetworkProvider: + type: object + properties: + id: + type: string + description: Provider ID + example: "ainp_d1m3kebd9pcs0c1pnu7g" + provider_id: + type: string + description: Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). + example: "openai_api" + name: + type: string + description: Display name shown in the dashboard. + example: "OpenAI API" + upstream_url: + type: string + description: Full upstream URL (with scheme) that NetBird forwards traffic to. + example: "https://api.openai.com" + models: + type: array + description: Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. + items: + $ref: '#/components/schemas/AgentNetworkProviderModel' + extra_values: + type: object + description: | + Operator-typed values for catalog-declared extra headers. Keys are wire header names (e.g. `x-portkey-config`); values are the strings the proxy stamps on every upstream request to this provider. Catalog (AgentNetworkCatalogProvider.extra_headers) declares which keys are accepted; values not declared by the catalog are ignored at synth time. Empty / missing values mean no header stamped. + additionalProperties: + type: string + example: + x-portkey-config: "pc-prod-3f2a" + identity_header_user_id: + type: string + description: | + Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). + example: "x-bf-dim-netbird_user_id" + identity_header_groups: + type: string + description: | + Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`. + example: "x-bf-dim-netbird_groups" + enabled: + type: boolean + description: Whether the provider is enabled. + example: true + created_at: + type: string + format: date-time + description: Timestamp when the provider was created. + readOnly: true + example: "2026-04-26T10:30:00Z" + updated_at: + type: string + format: date-time + description: Timestamp when the provider was last updated. + readOnly: true + example: "2026-04-26T10:30:00Z" + required: + - id + - provider_id + - name + - upstream_url + - models + - enabled + - created_at + - updated_at + AgentNetworkProviderRequest: + type: object + properties: + provider_id: + type: string + description: Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). + example: "openai_api" + name: + type: string + description: Display name for the provider. + example: "OpenAI API" + upstream_url: + type: string + description: Full upstream URL (with scheme) that NetBird forwards traffic to. + example: "https://api.openai.com" + bootstrap_cluster: + type: string + description: Proxy cluster used to bootstrap the per-account agent-network endpoint when the first provider is created. Ignored on subsequent creates and on updates because the cluster is pinned on the account-level Settings row. + example: "eu.proxy.netbird.io" + api_key: + type: string + description: Upstream provider API key. Sealed at rest on the management server and never returned in responses. Required on create; optional on update (omit to keep the existing key). + example: "sk-..." + models: + type: array + description: Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. + items: + $ref: '#/components/schemas/AgentNetworkProviderModel' + extra_values: + type: object + description: | + Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key. + additionalProperties: + type: string + example: + x-portkey-config: "pc-prod-3f2a" + identity_header_user_id: + type: string + description: | + Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension). + example: "x-bf-dim-netbird_user_id" + identity_header_groups: + type: string + description: | + Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`. + example: "x-bf-dim-netbird_groups" + enabled: + type: boolean + description: Whether the provider is enabled. Defaults to true on create. + example: true + required: + - provider_id + - name + - upstream_url + AgentNetworkProviderModel: + type: object + description: A model exposed by the provider, with the operator's per-1k input/output prices in USD. + properties: + id: + type: string + description: Model identifier (e.g. "gpt-4o-mini"). + example: "gpt-4o-mini" + input_per_1k: + type: number + format: double + description: Cost per 1k input tokens, in USD. + example: 0.00015 + output_per_1k: + type: number + format: double + description: Cost per 1k output tokens, in USD. + example: 0.0006 + required: + - id + - input_per_1k + - output_per_1k + AgentNetworkCatalogModel: + type: object + properties: + id: + type: string + description: Catalog model identifier as exposed by the upstream provider. + example: "gpt-4o" + label: + type: string + description: Human-friendly model name for the dashboard. + example: "GPT-4o" + input_per_1k: + type: number + format: double + description: Input token price per 1k tokens, in USD. + example: 0.005 + output_per_1k: + type: number + format: double + description: Output token price per 1k tokens, in USD. + example: 0.015 + context_window: + type: integer + description: Maximum context window in tokens. + example: 128000 + required: + - id + - label + - input_per_1k + - output_per_1k + - context_window + AgentNetworkCatalogProvider: + type: object + properties: + id: + type: string + description: Catalog provider identifier (referenced by AgentNetworkProvider.provider_id). + example: "openai_api" + name: + type: string + description: Display name for the provider. + example: "OpenAI API" + description: + type: string + description: Short description shown in the provider picker. + example: "GPT, Responses API, and Embeddings" + default_host: + type: string + description: Default upstream host suggested when adding a provider of this type. + example: "api.openai.com" + auth_header_template: + type: string + description: Template the proxy uses to inject the API key (the literal string ${API_KEY} is replaced at request time). + example: "Bearer ${API_KEY}" + default_content_type: + type: string + description: Default Content-Type for upstream requests. + example: "application/json" + brand_color: + type: string + description: Hex brand color used to render the provider badge in the dashboard. + example: "#10A37F" + kind: + type: string + description: | + Presentation grouping for the provider Select on the dashboard. + "provider" — first-party vendor API (OpenAI, Anthropic, …); the upstream is the model itself. + "gateway" — routing/aggregation layer in front of multiple providers (LiteLLM, Portkey, …); typically pairs with NetBird identity stamping. + "custom" — generic OpenAI-compatible self-hosted endpoint catch-all. + enum: [provider, gateway, custom] + example: "provider" + extra_headers: + type: array + description: | + Catalog-declared list of optional per-provider routing/config headers the proxy stamps on every upstream request. Each entry surfaces an input on the dashboard's provider modal (one per item, labeled with `label`). Operators fill any subset; values land on the provider record's `extra_values` map keyed by `name`. Used by gateways like Portkey for `x-portkey-config: pc-...` (saved-config id resolving upstream provider + virtual key). + items: + $ref: '#/components/schemas/AgentNetworkCatalogExtraHeader' + identity_injection: + $ref: '#/components/schemas/AgentNetworkCatalogIdentityInjection' + models: + type: array + description: Catalog models available for this provider. + items: + $ref: '#/components/schemas/AgentNetworkCatalogModel' + required: + - id + - name + - description + - default_host + - auth_header_template + - default_content_type + - brand_color + - kind + - models + AgentNetworkCatalogIdentityInjection: + type: object + description: | + Catalog-declared identity-injection shape. Present when this provider supports stamping the caller's NetBird identity onto upstream requests. Exactly one of `header_pair` or `json_metadata` is set per provider entry. The dashboard reads the `customizable` flag on whichever shape is present to decide whether to surface the labels as editable inputs (true → editable with the catalog values shown as placeholders; false → fixed and read-only). + properties: + header_pair: + $ref: '#/components/schemas/AgentNetworkCatalogHeaderPairInjection' + json_metadata: + $ref: '#/components/schemas/AgentNetworkCatalogJSONMetadataInjection' + AgentNetworkCatalogHeaderPairInjection: + type: object + description: HeaderPair identity-injection shape — separate per-dimension headers (LiteLLM-style, Bifrost). + properties: + customizable: + type: boolean + description: When true, the wire header names are operator-overridable per provider record (Bifrost). When false, the catalog values are authoritative (LiteLLM and similar gateways with a fixed wire protocol). + example: true + end_user_id_header: + type: string + description: Wire header name for the caller's display identity. Default placeholder when `customizable` is true. + example: "x-bf-dim-netbird_user_id" + tags_header: + type: string + description: Wire header name for the caller's groups CSV. Default placeholder when `customizable` is true. + example: "x-bf-dim-netbird_groups" + required: + - customizable + - end_user_id_header + - tags_header + AgentNetworkCatalogJSONMetadataInjection: + type: object + description: JSONMetadata identity-injection shape — one wire header carrying a JSON object whose keys label each dimension (Portkey-style, Cloudflare AI Gateway). + properties: + customizable: + type: boolean + description: When true, the JSON keys are operator-overridable per provider record (Cloudflare). The wire header itself stays catalog-owned. When false, the catalog values are authoritative (Portkey and similar gateways with a fixed JSON schema). + example: true + header: + type: string + description: Wire header name carrying the JSON metadata payload. Catalog-owned (not customizable per provider record). + example: "cf-aig-metadata" + user_key: + type: string + description: JSON key for the caller's display identity. Default placeholder when `customizable` is true. + example: "netbird_user_id" + groups_key: + type: string + description: JSON key for the caller's groups CSV. Default placeholder when `customizable` is true. + example: "netbird_groups" + required: + - customizable + - header + - user_key + - groups_key + AgentNetworkCatalogExtraHeader: + type: object + description: One optional per-provider routing/config header surfaced on the dashboard. Operator-typed value lives on the provider record's `extra_values` map keyed by `name`. UI copy (input label, helper line, tooltip) is owned by the dashboard, keyed by `name`. + properties: + name: + type: string + description: Wire header name the proxy stamps with the operator-typed value. + example: "x-portkey-config" + required: + - name + AgentNetworkPolicy: + type: object + properties: + id: + type: string + description: Policy ID + example: "ainpol_d1m3kebd9pcs0c1pnu7g" + name: + type: string + description: Display name for the policy. + example: "Engineering → OpenAI" + description: + type: string + description: Optional human-readable description. + example: "Engineers can call OpenAI under production guardrails." + enabled: + type: boolean + description: Whether the policy is enabled. + example: true + source_groups: + type: array + description: NetBird group ids whose members are allowed to call the destination providers. + items: + type: string + example: ["ch8vp3o6lnna9hg0sd8g"] + destination_provider_ids: + type: array + description: Agent Network provider ids (returned by the providers API) the source groups can reach. + items: + type: string + example: ["ainp_d1m3kebd9pcs0c1pnu7g"] + guardrail_ids: + type: array + description: Agent Network guardrail ids attached to this policy. + items: + type: string + example: [] + limits: + $ref: '#/components/schemas/AgentNetworkPolicyLimits' + created_at: + type: string + format: date-time + description: Timestamp when the policy was created. + readOnly: true + example: "2026-04-26T10:30:00Z" + updated_at: + type: string + format: date-time + description: Timestamp when the policy was last updated. + readOnly: true + example: "2026-04-26T10:30:00Z" + required: + - id + - name + - description + - enabled + - source_groups + - destination_provider_ids + - guardrail_ids + - limits + - created_at + - updated_at + AgentNetworkPolicyRequest: + type: object + properties: + name: + type: string + description: Display name for the policy. + example: "Engineering → OpenAI" + description: + type: string + description: Optional human-readable description. + example: "Engineers can call OpenAI under production guardrails." + enabled: + type: boolean + description: Whether the policy is enabled. Defaults to true on create. + example: true + source_groups: + type: array + description: NetBird group ids whose members are allowed to call the destination providers. + items: + type: string + minItems: 1 + example: ["ch8vp3o6lnna9hg0sd8g"] + destination_provider_ids: + type: array + description: Agent Network provider ids the source groups can reach. + items: + type: string + minItems: 1 + example: ["ainp_d1m3kebd9pcs0c1pnu7g"] + guardrail_ids: + type: array + description: Agent Network guardrail ids to attach to this policy. + items: + type: string + example: [] + limits: + $ref: '#/components/schemas/AgentNetworkPolicyLimits' + required: + - name + - source_groups + - destination_provider_ids + AgentNetworkPolicyTokenLimit: + type: object + description: Per-policy token cap. `group_cap` is applied to each source group independently — every group in the policy's `source_groups` gets its own bucket of this size. `user_cap` is applied independently to each individual user. Caps reset to zero at the start of each window. + properties: + enabled: + type: boolean + example: true + group_cap: + type: integer + format: int64 + minimum: 0 + description: Tokens allowed per source group within the window (each group has its own bucket of this size). 0 means uncapped. + example: 10000000 + user_cap: + type: integer + format: int64 + minimum: 0 + description: Tokens allowed per individual user within the window. 0 means uncapped. + example: 1000000 + window_seconds: + type: integer + format: int64 + minimum: 60 + description: Reset frequency in seconds. The cap counter resets to zero at the start of each window. Minimum 60 (one minute) when the limit is enabled. + example: 2592000 + required: + - enabled + - group_cap + - user_cap + - window_seconds + AgentNetworkPolicyBudgetLimit: + type: object + description: Per-policy USD spend cap. `group_cap_usd` is applied to each source group independently — every group in the policy's `source_groups` gets its own bucket of this size. `user_cap_usd` is applied independently to each individual user. Caps reset to zero at the start of each window. + properties: + enabled: + type: boolean + example: true + group_cap_usd: + type: number + format: double + minimum: 0 + description: USD allowed per source group within the window (each group has its own bucket of this size). 0 means uncapped. + example: 1000 + user_cap_usd: + type: number + format: double + minimum: 0 + description: USD allowed per individual user within the window. 0 means uncapped. + example: 100 + window_seconds: + type: integer + format: int64 + minimum: 60 + description: Reset frequency in seconds. Caps reset at the start of each window. Minimum 60 (one minute) when the limit is enabled. + example: 2592000 + required: + - enabled + - group_cap_usd + - user_cap_usd + - window_seconds + AgentNetworkPolicyLimits: + type: object + description: Token and budget caps attached directly to the policy. These compose with any guardrail-level checks. + properties: + token_limit: + $ref: '#/components/schemas/AgentNetworkPolicyTokenLimit' + budget_limit: + $ref: '#/components/schemas/AgentNetworkPolicyBudgetLimit' + required: + - token_limit + - budget_limit + AgentNetworkGuardrailChecks: + type: object + description: Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert. + properties: + model_allowlist: + type: object + properties: + enabled: + type: boolean + example: true + models: + type: array + description: Allowed catalog model ids. Requests for any other model are denied. + items: + type: string + example: ["gpt-4o-mini", "claude-haiku-4-5"] + required: + - enabled + - models + prompt_capture: + type: object + properties: + enabled: + type: boolean + example: true + redact_pii: + type: boolean + example: true + required: + - enabled + - redact_pii + required: + - model_allowlist + - prompt_capture + AgentNetworkGuardrail: + type: object + properties: + id: + type: string + description: Guardrail ID + example: "ainguard_d1m3kebd9pcs0c1pnu7g" + name: + type: string + description: Display name for the guardrail. + example: "Strict — Production" + description: + type: string + description: Optional human-readable description. + example: "Tight model allowlist, PII redaction, hard monthly budget." + checks: + $ref: '#/components/schemas/AgentNetworkGuardrailChecks' + created_at: + type: string + format: date-time + description: Timestamp when the guardrail was created. + readOnly: true + example: "2026-04-26T10:30:00Z" + updated_at: + type: string + format: date-time + description: Timestamp when the guardrail was last updated. + readOnly: true + example: "2026-04-26T10:30:00Z" + required: + - id + - name + - description + - checks + - created_at + - updated_at + AgentNetworkGuardrailRequest: + type: object + properties: + name: + type: string + description: Display name for the guardrail. + example: "Strict — Production" + description: + type: string + description: Optional human-readable description. + example: "Tight model allowlist, PII redaction, hard monthly budget." + checks: + $ref: '#/components/schemas/AgentNetworkGuardrailChecks' + required: + - name + - checks + AgentNetworkConsumption: + type: object + description: One per-(dimension, window) consumption counter row. The proxy ticks one row per dimension on every served LLM request; the dashboard reads this listing to surface live counter growth. + properties: + dimension_kind: + type: string + enum: [user, group] + description: Whether this row counts a single end user or a single source group across every member. + dimension_id: + type: string + description: NetBird user id (when `dimension_kind=user`) or NetBird group id (when `dimension_kind=group`). + example: "grp-engineers" + window_seconds: + type: integer + format: int64 + description: Length of the aligned window this counter covers, in seconds. Distinct window lengths produce independent counters even on the same dimension. + example: 86400 + window_start_utc: + type: string + format: date-time + description: UTC start of the aligned window this counter covers. Aligned to the unix epoch so every node computes the same boundary. + example: "2026-05-05T12:00:00Z" + tokens_input: + type: integer + format: int64 + description: Total input tokens consumed within the window. + example: 12000 + tokens_output: + type: integer + format: int64 + description: Total output tokens consumed within the window. + example: 6500 + cost_usd: + type: number + format: double + description: Total USD spend booked against this dimension for the window. + example: 0.4231 + updated_at: + type: string + format: date-time + description: Timestamp of the last increment recorded for this row. + readOnly: true + example: "2026-05-05T12:34:56Z" + required: + - dimension_kind + - dimension_id + - window_seconds + - window_start_utc + - tokens_input + - tokens_output + - cost_usd + AgentNetworkAccessLog: + type: object + description: One per-request agent-network (LLM) access log entry with flattened, queryable LLM dimensions. + properties: + id: + type: string + description: Unique identifier for the access log entry. + example: "ch8i4ug6lnn4g9hqv7m0" + service_id: + type: string + description: ID of the synthesised agent-network service that handled the request. + timestamp: + type: string + format: date-time + description: Timestamp when the request was made. + example: "2026-05-05T12:34:56Z" + status_code: + type: integer + description: HTTP status code returned upstream. + example: 200 + duration_ms: + type: integer + description: Duration of the request in milliseconds. + example: 850 + user_id: + type: string + description: NetBird user id of the authenticated caller, if applicable. + source_ip: + type: string + description: Source IP of the request. Empty when log collection is disabled. + method: + type: string + description: HTTP method of the request. + example: "POST" + host: + type: string + description: Upstream host the request was routed to. Empty when log collection is disabled. + path: + type: string + description: Request path. Empty when log collection is disabled. + provider: + type: string + description: LLM provider vendor (e.g. openai, anthropic). + example: "openai" + model: + type: string + description: Requested LLM model. + example: "gpt-4o" + session_id: + type: string + description: Conversation / coding-session identifier that groups related requests. Sourced from the client's session marker (e.g. OpenAI Codex client_metadata.session_id, Claude Code metadata.user_id). Empty for clients that send none. + example: "019eeb72-ab7c-7cd2-aa05-6e8eb834afcb" + resolved_provider_id: + type: string + description: NetBird agent-network provider id that served the request. + selected_policy_id: + type: string + description: Agent-network policy id that authorised (or denied) the request. + decision: + type: string + description: Policy decision for the request (e.g. allow, deny). + example: "allow" + deny_reason: + type: string + description: Raw deny reason code when the request was blocked (e.g. llm_policy.token_cap_exceeded). + input_tokens: + type: integer + format: int64 + description: Input (prompt) tokens consumed. + example: 1200 + output_tokens: + type: integer + format: int64 + description: Output (completion) tokens produced. + example: 640 + total_tokens: + type: integer + format: int64 + description: Total tokens consumed. + example: 1840 + cost_usd: + type: number + format: double + description: Estimated USD cost of the request. + example: 0.0231 + stream: + type: boolean + description: Whether the request was a streaming completion. + group_ids: + type: array + items: + type: string + description: NetBird group ids that authorised the request (the caller's groups intersected with the policy's source groups). + request_prompt: + type: string + description: Captured request prompt. Present only when prompt collection is enabled. + response_completion: + type: string + description: Captured response completion. Present only when prompt collection is enabled. + required: + - id + - service_id + - timestamp + - status_code + - duration_ms + - input_tokens + - output_tokens + - total_tokens + - cost_usd + AgentNetworkAccessLogsResponse: + type: object + properties: + data: + type: array + description: List of agent-network access log entries. + items: + $ref: "#/components/schemas/AgentNetworkAccessLog" + page: + type: integer + description: Current page number. + example: 1 + page_size: + type: integer + description: Number of items per page. + example: 50 + total_records: + type: integer + description: Total number of log records matching the filter. + example: 523 + total_pages: + type: integer + description: Total number of pages available. + example: 11 + required: + - data + - page + - page_size + - total_records + - total_pages + AgentNetworkAccessLogSession: + type: object + description: A session-grouped view of agent-network access logs — all requests sharing a session id (or a single session-less request) folded into one summary plus its ordered entries. + properties: + session_id: + type: string + description: Conversation / coding-session identifier shared by the entries. Empty for a session-less (singleton) request grouped on its own id. + example: "019eeb72-ab7c-7cd2-aa05-6e8eb834afcb" + user_id: + type: string + description: NetBird user id of the session's caller. + group_ids: + type: array + items: + type: string + description: Union of the authorising group ids across the session's entries. + started_at: + type: string + format: date-time + description: Timestamp of the session's earliest request. + example: "2026-05-05T12:30:00Z" + ended_at: + type: string + format: date-time + description: Timestamp of the session's latest request. + example: "2026-05-05T12:34:56Z" + request_count: + type: integer + description: Number of requests in the session. + example: 7 + input_tokens: + type: integer + format: int64 + description: Total input (prompt) tokens across the session. + example: 8400 + output_tokens: + type: integer + format: int64 + description: Total output (completion) tokens across the session. + example: 4480 + total_tokens: + type: integer + format: int64 + description: Total tokens across the session. + example: 12880 + cost_usd: + type: number + format: double + description: Total estimated USD cost across the session. + example: 0.1617 + providers: + type: array + items: + type: string + description: Distinct LLM provider vendors seen in the session. + models: + type: array + items: + type: string + description: Distinct models seen in the session. + decision: + type: string + description: Session decision — "deny" if any request was denied, otherwise "allow". + example: "allow" + entries: + type: array + description: The session's access-log entries, oldest first. + items: + $ref: "#/components/schemas/AgentNetworkAccessLog" + required: + - started_at + - ended_at + - request_count + - input_tokens + - output_tokens + - total_tokens + - cost_usd + - decision + - entries + AgentNetworkAccessLogSessionsResponse: + type: object + properties: + data: + type: array + description: List of session-grouped agent-network access logs. + items: + $ref: "#/components/schemas/AgentNetworkAccessLogSession" + page: + type: integer + description: Current page number. + example: 1 + page_size: + type: integer + description: Number of sessions per page. + example: 50 + total_records: + type: integer + description: Total number of sessions matching the filter. + example: 124 + total_pages: + type: integer + description: Total number of pages available. + example: 3 + required: + - data + - page + - page_size + - total_records + - total_pages + AgentNetworkUsageBucket: + type: object + description: One aggregated agent-network usage time bucket (UTC). The bucket width is set by the request's granularity. + properties: + period_start: + type: string + description: Start of the bucket in YYYY-MM-DD (UTC) — the day, the week start (Monday), or the month start, depending on granularity. + example: "2026-05-05" + input_tokens: + type: integer + format: int64 + description: Total input (prompt) tokens in the bucket. + example: 120000 + output_tokens: + type: integer + format: int64 + description: Total output (completion) tokens in the bucket. + example: 64000 + total_tokens: + type: integer + format: int64 + description: Total tokens in the bucket. + example: 184000 + cost_usd: + type: number + format: double + description: Total estimated USD spend in the bucket. + example: 2.31 + required: + - period_start + - input_tokens + - output_tokens + - total_tokens + - cost_usd + AgentNetworkSettings: + type: object + description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter. + properties: + cluster: + type: string + description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. + example: "eu.proxy.netbird.io" + subdomain: + type: string + description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. + example: "violet" + endpoint: + type: string + description: Bare hostname agents call for this account, computed as `.`. + example: "violet.eu.proxy.netbird.io" + enable_log_collection: + type: boolean + description: Whether per-request access-log entries are collected for this account's agent-network traffic. + example: false + enable_prompt_collection: + type: boolean + description: Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it. + example: false + redact_pii: + type: boolean + description: Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting. + example: false + access_log_retention_days: + type: integer + description: Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently. + example: 30 + created_at: + type: string + format: date-time + description: Timestamp when the settings row was created. + readOnly: true + example: "2026-04-26T10:30:00Z" + updated_at: + type: string + format: date-time + description: Timestamp when the settings row was last updated. + readOnly: true + example: "2026-04-26T10:30:00Z" + required: + - cluster + - subdomain + - endpoint + - enable_log_collection + - enable_prompt_collection + - redact_pii + - created_at + - updated_at + AgentNetworkSettingsRequest: + type: object + description: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here. + properties: + enable_log_collection: + type: boolean + description: Whether per-request access-log entries are collected for this account's agent-network traffic. + example: true + enable_prompt_collection: + type: boolean + description: Master switch for request/response prompt capture. + example: true + redact_pii: + type: boolean + description: Whether captured prompts have PII redacted. + example: true + access_log_retention_days: + type: integer + description: Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. + example: 30 + required: + - enable_log_collection + - enable_prompt_collection + - redact_pii + AgentNetworkBudgetRule: + type: object + description: Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller. + properties: + id: + type: string + description: Budget rule ID. + example: "ainbud_d1m3kebd9pcs0c1pnu7g" + name: + type: string + description: Display name for the budget rule. + example: "Org monthly ceiling" + enabled: + type: boolean + description: Whether the rule is enforced. + example: true + target_groups: + type: array + description: NetBird group ids the rule binds. Empty plus empty target_users means account-wide. + items: + type: string + example: ["ch8vp3o6lnna9hg0sd8g"] + target_users: + type: array + description: NetBird user ids the rule binds directly. + items: + type: string + example: [] + limits: + $ref: '#/components/schemas/AgentNetworkPolicyLimits' + created_at: + type: string + format: date-time + readOnly: true + example: "2026-04-26T10:30:00Z" + updated_at: + type: string + format: date-time + readOnly: true + example: "2026-04-26T10:30:00Z" + required: + - id + - name + - enabled + - target_groups + - target_users + - limits + - created_at + - updated_at + AgentNetworkBudgetRuleRequest: + type: object + properties: + name: + type: string + description: Display name for the budget rule. + example: "Org monthly ceiling" + enabled: + type: boolean + description: Whether the rule is enforced. Defaults to true on create. + example: true + target_groups: + type: array + description: NetBird group ids the rule binds. Empty plus empty target_users means account-wide. + items: + type: string + example: ["ch8vp3o6lnna9hg0sd8g"] + target_users: + type: array + description: NetBird user ids the rule binds directly. + items: + type: string + example: [] + limits: + $ref: '#/components/schemas/AgentNetworkPolicyLimits' + required: + - name + - limits responses: not_found: description: Resource not found @@ -12068,3 +13116,1016 @@ paths: "$ref": "#/components/responses/not_found" '500': "$ref": "#/components/responses/internal_error" + /api/agent-network/access-logs: + get: + summary: List Agent Network access logs + description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + minimum: 1 + description: Page number for pagination (1-indexed). + - in: query + name: page_size + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + description: Number of items per page (max 100). + - in: query + name: sort_by + schema: + type: string + enum: [timestamp, model, provider, status_code, duration, cost_usd, total_tokens, user_id, decision] + default: timestamp + description: Field to sort by. + - in: query + name: sort_order + schema: + type: string + enum: [asc, desc] + default: desc + description: Sort order (ascending or descending). + - in: query + name: search + schema: + type: string + description: General search across log ID, host, path, model, and user email/name. + - in: query + name: user_id + schema: + type: string + description: Filter by authenticated user ID. + - in: query + name: session_id + schema: + type: string + description: Filter to a single conversation / coding session id (groups all requests of one session). + - in: query + name: group_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by authorising group id. Repeat for multiple (matches any). + - in: query + name: provider_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by resolved provider id. Repeat for multiple (matches any). + - in: query + name: model + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by model. Repeat for multiple (matches any). + - in: query + name: decision + schema: + type: string + description: Filter by policy decision (e.g. allow, deny). + - in: query + name: path + schema: + type: string + description: Filter by request path prefix (matches entries whose path starts with this value). + - in: query + name: start_date + schema: + type: string + format: date-time + description: Filter by timestamp >= start_date (RFC3339 format). + - in: query + name: end_date + schema: + type: string + format: date-time + description: Filter by timestamp <= end_date (RFC3339 format). + responses: + '200': + description: Paginated list of agent-network access logs + content: + application/json: + schema: + $ref: "#/components/schemas/AgentNetworkAccessLogsResponse" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/access-log-sessions: + get: + summary: List Agent Network access logs grouped by session + description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + minimum: 1 + description: Page number for pagination (1-indexed). + - in: query + name: page_size + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + description: Number of sessions per page (max 100). + - in: query + name: sort_by + schema: + type: string + enum: [timestamp, started_at, cost_usd, total_tokens, duration, request_count, status_code, user_id, decision] + default: timestamp + description: Session-level field to sort by. "timestamp" is the session's last activity, "started_at" its first. + - in: query + name: sort_order + schema: + type: string + enum: [asc, desc] + default: desc + description: Sort order (ascending or descending). + - in: query + name: search + schema: + type: string + description: General search across log ID, host, path, model, and user email/name. + - in: query + name: user_id + schema: + type: string + description: Filter by authenticated user ID. + - in: query + name: session_id + schema: + type: string + description: Filter to a single conversation / coding session id. + - in: query + name: group_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by authorising group id. Repeat for multiple (matches any). + - in: query + name: provider_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by resolved provider id. Repeat for multiple (matches any). + - in: query + name: model + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by model. Repeat for multiple (matches any). + - in: query + name: decision + schema: + type: string + description: Filter by policy decision (e.g. allow, deny). + - in: query + name: path + schema: + type: string + description: Filter by request path prefix (matches entries whose path starts with this value). + - in: query + name: start_date + schema: + type: string + format: date-time + description: Filter by timestamp >= start_date (RFC3339 format). + - in: query + name: end_date + schema: + type: string + format: date-time + description: Filter by timestamp <= end_date (RFC3339 format). + responses: + '200': + description: Paginated list of session-grouped agent-network access logs + content: + application/json: + schema: + $ref: "#/components/schemas/AgentNetworkAccessLogSessionsResponse" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/usage/overview: + get: + summary: Agent Network usage overview + description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection). + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: query + name: granularity + schema: + type: string + enum: [day, week, month] + default: day + description: Time bucket width. Defaults to day. + - in: query + name: start_date + schema: + type: string + format: date-time + description: Filter by timestamp >= start_date (RFC3339 format). + - in: query + name: end_date + schema: + type: string + format: date-time + description: Filter by timestamp <= end_date (RFC3339 format). + - in: query + name: user_id + schema: + type: string + description: Filter by user ID. + - in: query + name: session_id + schema: + type: string + description: Filter to a single conversation / coding session id. + - in: query + name: group_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by authorising group id. Repeat for multiple (matches any). + - in: query + name: provider_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by resolved provider id. Repeat for multiple (matches any). + - in: query + name: model + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by model. Repeat for multiple (matches any). + responses: + '200': + description: A JSON array of aggregated usage buckets, ordered oldest-first. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkUsageBucket' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/consumption: + get: + summary: List Agent Network consumption counters + description: Returns every per-(dimension, window) consumption counter recorded for the account, ordered window-newest-first. Empty list when nothing has been consumed yet. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of consumption counter rows + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkConsumption' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/settings: + get: + summary: Retrieve Agent Network settings + description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: Agent Network settings for the account + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkSettings' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update Agent Network settings + description: Updates the mutable account-level Agent Network settings (collection toggles). Cluster and subdomain are immutable and ignored if sent. Returns 404 when settings have not been bootstrapped (no provider created yet). + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: Settings update request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkSettingsRequest' + responses: + '200': + description: Updated Agent Network settings + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkSettings' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/budget-rules: + get: + summary: List all Agent Network budget rules + description: Returns all account-level budget rules. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of Agent Network budget rules + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkBudgetRule' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create an Agent Network budget rule + description: Creates a new account-level budget rule. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: New budget rule request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkBudgetRuleRequest' + responses: + '200': + description: Budget rule created + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkBudgetRule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/budget-rules/{ruleId}: + get: + summary: Retrieve an Agent Network budget rule + description: Get a specific account-level budget rule. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: ruleId + required: true + schema: + type: string + description: The unique identifier of a budget rule + responses: + '200': + description: An Agent Network budget rule object + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkBudgetRule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update an Agent Network budget rule + description: Updates an existing account-level budget rule. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: ruleId + required: true + schema: + type: string + description: The unique identifier of a budget rule + requestBody: + description: Budget rule update request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkBudgetRuleRequest' + responses: + '200': + description: Budget rule updated + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkBudgetRule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete an Agent Network budget rule + description: Deletes an account-level budget rule. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: ruleId + required: true + schema: + type: string + description: The unique identifier of a budget rule + responses: + '200': + description: Budget rule deleted + content: + application/json: + schema: + type: object + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/catalog/providers: + get: + summary: List Agent Network catalog providers + description: Returns the static catalog of supported Agent Network providers (OpenAI, Anthropic, …) along with their default upstream host, auth header template, brand color, and known models. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of catalog providers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkCatalogProvider' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/providers: + get: + summary: List all Agent Network Providers + description: Returns a list of all Agent Network AI providers configured for the account. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of Agent Network providers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkProvider' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create an Agent Network Provider + description: Connects a new Agent Network AI provider for the account. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: New provider request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkProviderRequest' + responses: + '200': + description: Provider created + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkProvider' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '409': + "$ref": "#/components/responses/conflict" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/providers/{providerId}: + get: + summary: Retrieve an Agent Network Provider + description: Get information about a specific Agent Network AI provider. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: providerId + required: true + schema: + type: string + description: The unique identifier of an Agent Network provider + responses: + '200': + description: An Agent Network provider object + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkProvider' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update an Agent Network Provider + description: Update an existing Agent Network AI provider. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: providerId + required: true + schema: + type: string + description: The unique identifier of an Agent Network provider + requestBody: + description: Provider update request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkProviderRequest' + responses: + '200': + description: Provider updated + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkProvider' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '409': + "$ref": "#/components/responses/conflict" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete an Agent Network Provider + description: Delete an existing Agent Network AI provider. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: providerId + required: true + schema: + type: string + description: The unique identifier of an Agent Network provider + responses: + '200': + description: Provider deleted + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/policies: + get: + summary: List all Agent Network Policies + description: Returns a list of all Agent Network policies for the account. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of Agent Network policies + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkPolicy' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create an Agent Network Policy + description: Creates a new Agent Network policy binding source groups to destination providers, optionally enforced by guardrails. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: New policy request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkPolicyRequest' + responses: + '200': + description: Policy created + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkPolicy' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '409': + "$ref": "#/components/responses/conflict" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/policies/{policyId}: + get: + summary: Retrieve an Agent Network Policy + description: Get information about a specific Agent Network policy. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: policyId + required: true + schema: + type: string + description: The unique identifier of an Agent Network policy + responses: + '200': + description: An Agent Network policy object + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkPolicy' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update an Agent Network Policy + description: Update an existing Agent Network policy. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: policyId + required: true + schema: + type: string + description: The unique identifier of an Agent Network policy + requestBody: + description: Policy update request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkPolicyRequest' + responses: + '200': + description: Policy updated + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkPolicy' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '409': + "$ref": "#/components/responses/conflict" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete an Agent Network Policy + description: Delete an existing Agent Network policy. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: policyId + required: true + schema: + type: string + description: The unique identifier of an Agent Network policy + responses: + '200': + description: Policy deleted + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/guardrails: + get: + summary: List all Agent Network Guardrails + description: Returns a list of all Agent Network guardrails for the account. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of Agent Network guardrails + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentNetworkGuardrail' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create an Agent Network Guardrail + description: Creates a new Agent Network guardrail that can be attached to one or more policies. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: New guardrail request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkGuardrailRequest' + responses: + '200': + description: Guardrail created + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkGuardrail' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '409': + "$ref": "#/components/responses/conflict" + '500': + "$ref": "#/components/responses/internal_error" + /api/agent-network/guardrails/{guardrailId}: + get: + summary: Retrieve an Agent Network Guardrail + description: Get information about a specific Agent Network guardrail. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: guardrailId + required: true + schema: + type: string + description: The unique identifier of an Agent Network guardrail + responses: + '200': + description: An Agent Network guardrail object + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkGuardrail' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update an Agent Network Guardrail + description: Update an existing Agent Network guardrail. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: guardrailId + required: true + schema: + type: string + description: The unique identifier of an Agent Network guardrail + requestBody: + description: Guardrail update request + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkGuardrailRequest' + responses: + '200': + description: Guardrail updated + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkGuardrail' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '409': + "$ref": "#/components/responses/conflict" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete an Agent Network Guardrail + description: Delete an existing Agent Network guardrail. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: guardrailId + required: true + schema: + type: string + description: The unique identifier of an Agent Network guardrail + responses: + '200': + description: Guardrail deleted + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index ed5060a86..7ea9514c0 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -38,6 +38,45 @@ func (e AccessRestrictionsCrowdsecMode) Valid() bool { } } +// Defines values for AgentNetworkCatalogProviderKind. +const ( + AgentNetworkCatalogProviderKindCustom AgentNetworkCatalogProviderKind = "custom" + AgentNetworkCatalogProviderKindGateway AgentNetworkCatalogProviderKind = "gateway" + AgentNetworkCatalogProviderKindProvider AgentNetworkCatalogProviderKind = "provider" +) + +// Valid indicates whether the value is a known member of the AgentNetworkCatalogProviderKind enum. +func (e AgentNetworkCatalogProviderKind) Valid() bool { + switch e { + case AgentNetworkCatalogProviderKindCustom: + return true + case AgentNetworkCatalogProviderKindGateway: + return true + case AgentNetworkCatalogProviderKindProvider: + return true + default: + return false + } +} + +// Defines values for AgentNetworkConsumptionDimensionKind. +const ( + AgentNetworkConsumptionDimensionKindGroup AgentNetworkConsumptionDimensionKind = "group" + AgentNetworkConsumptionDimensionKindUser AgentNetworkConsumptionDimensionKind = "user" +) + +// Valid indicates whether the value is a known member of the AgentNetworkConsumptionDimensionKind enum. +func (e AgentNetworkConsumptionDimensionKind) Valid() bool { + switch e { + case AgentNetworkConsumptionDimensionKindGroup: + return true + case AgentNetworkConsumptionDimensionKindUser: + return true + default: + return false + } +} + // Defines values for CreateAzureIntegrationRequestHost. const ( CreateAzureIntegrationRequestHostMicrosoftCom CreateAzureIntegrationRequestHost = "microsoft.com" @@ -1163,6 +1202,141 @@ func (e WorkloadType) Valid() bool { } } +// Defines values for GetApiAgentNetworkAccessLogSessionsParamsSortBy. +const ( + GetApiAgentNetworkAccessLogSessionsParamsSortByCostUsd GetApiAgentNetworkAccessLogSessionsParamsSortBy = "cost_usd" + GetApiAgentNetworkAccessLogSessionsParamsSortByDecision GetApiAgentNetworkAccessLogSessionsParamsSortBy = "decision" + GetApiAgentNetworkAccessLogSessionsParamsSortByDuration GetApiAgentNetworkAccessLogSessionsParamsSortBy = "duration" + GetApiAgentNetworkAccessLogSessionsParamsSortByRequestCount GetApiAgentNetworkAccessLogSessionsParamsSortBy = "request_count" + GetApiAgentNetworkAccessLogSessionsParamsSortByStartedAt GetApiAgentNetworkAccessLogSessionsParamsSortBy = "started_at" + GetApiAgentNetworkAccessLogSessionsParamsSortByStatusCode GetApiAgentNetworkAccessLogSessionsParamsSortBy = "status_code" + GetApiAgentNetworkAccessLogSessionsParamsSortByTimestamp GetApiAgentNetworkAccessLogSessionsParamsSortBy = "timestamp" + GetApiAgentNetworkAccessLogSessionsParamsSortByTotalTokens GetApiAgentNetworkAccessLogSessionsParamsSortBy = "total_tokens" + GetApiAgentNetworkAccessLogSessionsParamsSortByUserId GetApiAgentNetworkAccessLogSessionsParamsSortBy = "user_id" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkAccessLogSessionsParamsSortBy enum. +func (e GetApiAgentNetworkAccessLogSessionsParamsSortBy) Valid() bool { + switch e { + case GetApiAgentNetworkAccessLogSessionsParamsSortByCostUsd: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByDecision: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByDuration: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByRequestCount: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByStartedAt: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByStatusCode: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByTimestamp: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByTotalTokens: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByUserId: + return true + default: + return false + } +} + +// Defines values for GetApiAgentNetworkAccessLogSessionsParamsSortOrder. +const ( + GetApiAgentNetworkAccessLogSessionsParamsSortOrderAsc GetApiAgentNetworkAccessLogSessionsParamsSortOrder = "asc" + GetApiAgentNetworkAccessLogSessionsParamsSortOrderDesc GetApiAgentNetworkAccessLogSessionsParamsSortOrder = "desc" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkAccessLogSessionsParamsSortOrder enum. +func (e GetApiAgentNetworkAccessLogSessionsParamsSortOrder) Valid() bool { + switch e { + case GetApiAgentNetworkAccessLogSessionsParamsSortOrderAsc: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortOrderDesc: + return true + default: + return false + } +} + +// Defines values for GetApiAgentNetworkAccessLogsParamsSortBy. +const ( + GetApiAgentNetworkAccessLogsParamsSortByCostUsd GetApiAgentNetworkAccessLogsParamsSortBy = "cost_usd" + GetApiAgentNetworkAccessLogsParamsSortByDecision GetApiAgentNetworkAccessLogsParamsSortBy = "decision" + GetApiAgentNetworkAccessLogsParamsSortByDuration GetApiAgentNetworkAccessLogsParamsSortBy = "duration" + GetApiAgentNetworkAccessLogsParamsSortByModel GetApiAgentNetworkAccessLogsParamsSortBy = "model" + GetApiAgentNetworkAccessLogsParamsSortByProvider GetApiAgentNetworkAccessLogsParamsSortBy = "provider" + GetApiAgentNetworkAccessLogsParamsSortByStatusCode GetApiAgentNetworkAccessLogsParamsSortBy = "status_code" + GetApiAgentNetworkAccessLogsParamsSortByTimestamp GetApiAgentNetworkAccessLogsParamsSortBy = "timestamp" + GetApiAgentNetworkAccessLogsParamsSortByTotalTokens GetApiAgentNetworkAccessLogsParamsSortBy = "total_tokens" + GetApiAgentNetworkAccessLogsParamsSortByUserId GetApiAgentNetworkAccessLogsParamsSortBy = "user_id" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkAccessLogsParamsSortBy enum. +func (e GetApiAgentNetworkAccessLogsParamsSortBy) Valid() bool { + switch e { + case GetApiAgentNetworkAccessLogsParamsSortByCostUsd: + return true + case GetApiAgentNetworkAccessLogsParamsSortByDecision: + return true + case GetApiAgentNetworkAccessLogsParamsSortByDuration: + return true + case GetApiAgentNetworkAccessLogsParamsSortByModel: + return true + case GetApiAgentNetworkAccessLogsParamsSortByProvider: + return true + case GetApiAgentNetworkAccessLogsParamsSortByStatusCode: + return true + case GetApiAgentNetworkAccessLogsParamsSortByTimestamp: + return true + case GetApiAgentNetworkAccessLogsParamsSortByTotalTokens: + return true + case GetApiAgentNetworkAccessLogsParamsSortByUserId: + return true + default: + return false + } +} + +// Defines values for GetApiAgentNetworkAccessLogsParamsSortOrder. +const ( + GetApiAgentNetworkAccessLogsParamsSortOrderAsc GetApiAgentNetworkAccessLogsParamsSortOrder = "asc" + GetApiAgentNetworkAccessLogsParamsSortOrderDesc GetApiAgentNetworkAccessLogsParamsSortOrder = "desc" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkAccessLogsParamsSortOrder enum. +func (e GetApiAgentNetworkAccessLogsParamsSortOrder) Valid() bool { + switch e { + case GetApiAgentNetworkAccessLogsParamsSortOrderAsc: + return true + case GetApiAgentNetworkAccessLogsParamsSortOrderDesc: + return true + default: + return false + } +} + +// Defines values for GetApiAgentNetworkUsageOverviewParamsGranularity. +const ( + GetApiAgentNetworkUsageOverviewParamsGranularityDay GetApiAgentNetworkUsageOverviewParamsGranularity = "day" + GetApiAgentNetworkUsageOverviewParamsGranularityMonth GetApiAgentNetworkUsageOverviewParamsGranularity = "month" + GetApiAgentNetworkUsageOverviewParamsGranularityWeek GetApiAgentNetworkUsageOverviewParamsGranularity = "week" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkUsageOverviewParamsGranularity enum. +func (e GetApiAgentNetworkUsageOverviewParamsGranularity) Valid() bool { + switch e { + case GetApiAgentNetworkUsageOverviewParamsGranularityDay: + return true + case GetApiAgentNetworkUsageOverviewParamsGranularityMonth: + return true + case GetApiAgentNetworkUsageOverviewParamsGranularityWeek: + return true + default: + return false + } +} + // Defines values for GetApiEventsNetworkTrafficParamsType. const ( GetApiEventsNetworkTrafficParamsTypeTYPEDROP GetApiEventsNetworkTrafficParamsType = "TYPE_DROP" @@ -1541,6 +1715,627 @@ type AccountSettings struct { RoutingPeerDnsResolutionEnabled *bool `json:"routing_peer_dns_resolution_enabled,omitempty"` } +// AgentNetworkAccessLog One per-request agent-network (LLM) access log entry with flattened, queryable LLM dimensions. +type AgentNetworkAccessLog struct { + // CostUsd Estimated USD cost of the request. + CostUsd float64 `json:"cost_usd"` + + // Decision Policy decision for the request (e.g. allow, deny). + Decision *string `json:"decision,omitempty"` + + // DenyReason Raw deny reason code when the request was blocked (e.g. llm_policy.token_cap_exceeded). + DenyReason *string `json:"deny_reason,omitempty"` + + // DurationMs Duration of the request in milliseconds. + DurationMs int `json:"duration_ms"` + + // GroupIds NetBird group ids that authorised the request (the caller's groups intersected with the policy's source groups). + GroupIds *[]string `json:"group_ids,omitempty"` + + // Host Upstream host the request was routed to. Empty when log collection is disabled. + Host *string `json:"host,omitempty"` + + // Id Unique identifier for the access log entry. + Id string `json:"id"` + + // InputTokens Input (prompt) tokens consumed. + InputTokens int64 `json:"input_tokens"` + + // Method HTTP method of the request. + Method *string `json:"method,omitempty"` + + // Model Requested LLM model. + Model *string `json:"model,omitempty"` + + // OutputTokens Output (completion) tokens produced. + OutputTokens int64 `json:"output_tokens"` + + // Path Request path. Empty when log collection is disabled. + Path *string `json:"path,omitempty"` + + // Provider LLM provider vendor (e.g. openai, anthropic). + Provider *string `json:"provider,omitempty"` + + // RequestPrompt Captured request prompt. Present only when prompt collection is enabled. + RequestPrompt *string `json:"request_prompt,omitempty"` + + // ResolvedProviderId NetBird agent-network provider id that served the request. + ResolvedProviderId *string `json:"resolved_provider_id,omitempty"` + + // ResponseCompletion Captured response completion. Present only when prompt collection is enabled. + ResponseCompletion *string `json:"response_completion,omitempty"` + + // SelectedPolicyId Agent-network policy id that authorised (or denied) the request. + SelectedPolicyId *string `json:"selected_policy_id,omitempty"` + + // ServiceId ID of the synthesised agent-network service that handled the request. + ServiceId string `json:"service_id"` + + // SessionId Conversation / coding-session identifier that groups related requests. Sourced from the client's session marker (e.g. OpenAI Codex client_metadata.session_id, Claude Code metadata.user_id). Empty for clients that send none. + SessionId *string `json:"session_id,omitempty"` + + // SourceIp Source IP of the request. Empty when log collection is disabled. + SourceIp *string `json:"source_ip,omitempty"` + + // StatusCode HTTP status code returned upstream. + StatusCode int `json:"status_code"` + + // Stream Whether the request was a streaming completion. + Stream *bool `json:"stream,omitempty"` + + // Timestamp Timestamp when the request was made. + Timestamp time.Time `json:"timestamp"` + + // TotalTokens Total tokens consumed. + TotalTokens int64 `json:"total_tokens"` + + // UserId NetBird user id of the authenticated caller, if applicable. + UserId *string `json:"user_id,omitempty"` +} + +// AgentNetworkAccessLogSession A session-grouped view of agent-network access logs — all requests sharing a session id (or a single session-less request) folded into one summary plus its ordered entries. +type AgentNetworkAccessLogSession struct { + // CostUsd Total estimated USD cost across the session. + CostUsd float64 `json:"cost_usd"` + + // Decision Session decision — "deny" if any request was denied, otherwise "allow". + Decision string `json:"decision"` + + // EndedAt Timestamp of the session's latest request. + EndedAt time.Time `json:"ended_at"` + + // Entries The session's access-log entries, oldest first. + Entries []AgentNetworkAccessLog `json:"entries"` + + // GroupIds Union of the authorising group ids across the session's entries. + GroupIds *[]string `json:"group_ids,omitempty"` + + // InputTokens Total input (prompt) tokens across the session. + InputTokens int64 `json:"input_tokens"` + + // Models Distinct models seen in the session. + Models *[]string `json:"models,omitempty"` + + // OutputTokens Total output (completion) tokens across the session. + OutputTokens int64 `json:"output_tokens"` + + // Providers Distinct LLM provider vendors seen in the session. + Providers *[]string `json:"providers,omitempty"` + + // RequestCount Number of requests in the session. + RequestCount int `json:"request_count"` + + // SessionId Conversation / coding-session identifier shared by the entries. Empty for a session-less (singleton) request grouped on its own id. + SessionId *string `json:"session_id,omitempty"` + + // StartedAt Timestamp of the session's earliest request. + StartedAt time.Time `json:"started_at"` + + // TotalTokens Total tokens across the session. + TotalTokens int64 `json:"total_tokens"` + + // UserId NetBird user id of the session's caller. + UserId *string `json:"user_id,omitempty"` +} + +// AgentNetworkAccessLogSessionsResponse defines model for AgentNetworkAccessLogSessionsResponse. +type AgentNetworkAccessLogSessionsResponse struct { + // Data List of session-grouped agent-network access logs. + Data []AgentNetworkAccessLogSession `json:"data"` + + // Page Current page number. + Page int `json:"page"` + + // PageSize Number of sessions per page. + PageSize int `json:"page_size"` + + // TotalPages Total number of pages available. + TotalPages int `json:"total_pages"` + + // TotalRecords Total number of sessions matching the filter. + TotalRecords int `json:"total_records"` +} + +// AgentNetworkAccessLogsResponse defines model for AgentNetworkAccessLogsResponse. +type AgentNetworkAccessLogsResponse struct { + // Data List of agent-network access log entries. + Data []AgentNetworkAccessLog `json:"data"` + + // Page Current page number. + Page int `json:"page"` + + // PageSize Number of items per page. + PageSize int `json:"page_size"` + + // TotalPages Total number of pages available. + TotalPages int `json:"total_pages"` + + // TotalRecords Total number of log records matching the filter. + TotalRecords int `json:"total_records"` +} + +// AgentNetworkBudgetRule Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller. +type AgentNetworkBudgetRule struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + + // Enabled Whether the rule is enforced. + Enabled bool `json:"enabled"` + + // Id Budget rule ID. + Id string `json:"id"` + + // Limits Token and budget caps attached directly to the policy. These compose with any guardrail-level checks. + Limits AgentNetworkPolicyLimits `json:"limits"` + + // Name Display name for the budget rule. + Name string `json:"name"` + + // TargetGroups NetBird group ids the rule binds. Empty plus empty target_users means account-wide. + TargetGroups []string `json:"target_groups"` + + // TargetUsers NetBird user ids the rule binds directly. + TargetUsers []string `json:"target_users"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// AgentNetworkBudgetRuleRequest defines model for AgentNetworkBudgetRuleRequest. +type AgentNetworkBudgetRuleRequest struct { + // Enabled Whether the rule is enforced. Defaults to true on create. + Enabled *bool `json:"enabled,omitempty"` + + // Limits Token and budget caps attached directly to the policy. These compose with any guardrail-level checks. + Limits AgentNetworkPolicyLimits `json:"limits"` + + // Name Display name for the budget rule. + Name string `json:"name"` + + // TargetGroups NetBird group ids the rule binds. Empty plus empty target_users means account-wide. + TargetGroups *[]string `json:"target_groups,omitempty"` + + // TargetUsers NetBird user ids the rule binds directly. + TargetUsers *[]string `json:"target_users,omitempty"` +} + +// AgentNetworkCatalogExtraHeader One optional per-provider routing/config header surfaced on the dashboard. Operator-typed value lives on the provider record's `extra_values` map keyed by `name`. UI copy (input label, helper line, tooltip) is owned by the dashboard, keyed by `name`. +type AgentNetworkCatalogExtraHeader struct { + // Name Wire header name the proxy stamps with the operator-typed value. + Name string `json:"name"` +} + +// AgentNetworkCatalogHeaderPairInjection HeaderPair identity-injection shape — separate per-dimension headers (LiteLLM-style, Bifrost). +type AgentNetworkCatalogHeaderPairInjection struct { + // Customizable When true, the wire header names are operator-overridable per provider record (Bifrost). When false, the catalog values are authoritative (LiteLLM and similar gateways with a fixed wire protocol). + Customizable bool `json:"customizable"` + + // EndUserIdHeader Wire header name for the caller's display identity. Default placeholder when `customizable` is true. + EndUserIdHeader string `json:"end_user_id_header"` + + // TagsHeader Wire header name for the caller's groups CSV. Default placeholder when `customizable` is true. + TagsHeader string `json:"tags_header"` +} + +// AgentNetworkCatalogIdentityInjection Catalog-declared identity-injection shape. Present when this provider supports stamping the caller's NetBird identity onto upstream requests. Exactly one of `header_pair` or `json_metadata` is set per provider entry. The dashboard reads the `customizable` flag on whichever shape is present to decide whether to surface the labels as editable inputs (true → editable with the catalog values shown as placeholders; false → fixed and read-only). +type AgentNetworkCatalogIdentityInjection struct { + // HeaderPair HeaderPair identity-injection shape — separate per-dimension headers (LiteLLM-style, Bifrost). + HeaderPair *AgentNetworkCatalogHeaderPairInjection `json:"header_pair,omitempty"` + + // JsonMetadata JSONMetadata identity-injection shape — one wire header carrying a JSON object whose keys label each dimension (Portkey-style, Cloudflare AI Gateway). + JsonMetadata *AgentNetworkCatalogJSONMetadataInjection `json:"json_metadata,omitempty"` +} + +// AgentNetworkCatalogJSONMetadataInjection JSONMetadata identity-injection shape — one wire header carrying a JSON object whose keys label each dimension (Portkey-style, Cloudflare AI Gateway). +type AgentNetworkCatalogJSONMetadataInjection struct { + // Customizable When true, the JSON keys are operator-overridable per provider record (Cloudflare). The wire header itself stays catalog-owned. When false, the catalog values are authoritative (Portkey and similar gateways with a fixed JSON schema). + Customizable bool `json:"customizable"` + + // GroupsKey JSON key for the caller's groups CSV. Default placeholder when `customizable` is true. + GroupsKey string `json:"groups_key"` + + // Header Wire header name carrying the JSON metadata payload. Catalog-owned (not customizable per provider record). + Header string `json:"header"` + + // UserKey JSON key for the caller's display identity. Default placeholder when `customizable` is true. + UserKey string `json:"user_key"` +} + +// AgentNetworkCatalogModel defines model for AgentNetworkCatalogModel. +type AgentNetworkCatalogModel struct { + // ContextWindow Maximum context window in tokens. + ContextWindow int `json:"context_window"` + + // Id Catalog model identifier as exposed by the upstream provider. + Id string `json:"id"` + + // InputPer1k Input token price per 1k tokens, in USD. + InputPer1k float64 `json:"input_per_1k"` + + // Label Human-friendly model name for the dashboard. + Label string `json:"label"` + + // OutputPer1k Output token price per 1k tokens, in USD. + OutputPer1k float64 `json:"output_per_1k"` +} + +// AgentNetworkCatalogProvider defines model for AgentNetworkCatalogProvider. +type AgentNetworkCatalogProvider struct { + // AuthHeaderTemplate Template the proxy uses to inject the API key (the literal string ${API_KEY} is replaced at request time). + AuthHeaderTemplate string `json:"auth_header_template"` + + // BrandColor Hex brand color used to render the provider badge in the dashboard. + BrandColor string `json:"brand_color"` + + // DefaultContentType Default Content-Type for upstream requests. + DefaultContentType string `json:"default_content_type"` + + // DefaultHost Default upstream host suggested when adding a provider of this type. + DefaultHost string `json:"default_host"` + + // Description Short description shown in the provider picker. + Description string `json:"description"` + + // ExtraHeaders Catalog-declared list of optional per-provider routing/config headers the proxy stamps on every upstream request. Each entry surfaces an input on the dashboard's provider modal (one per item, labeled with `label`). Operators fill any subset; values land on the provider record's `extra_values` map keyed by `name`. Used by gateways like Portkey for `x-portkey-config: pc-...` (saved-config id resolving upstream provider + virtual key). + ExtraHeaders *[]AgentNetworkCatalogExtraHeader `json:"extra_headers,omitempty"` + + // Id Catalog provider identifier (referenced by AgentNetworkProvider.provider_id). + Id string `json:"id"` + + // IdentityInjection Catalog-declared identity-injection shape. Present when this provider supports stamping the caller's NetBird identity onto upstream requests. Exactly one of `header_pair` or `json_metadata` is set per provider entry. The dashboard reads the `customizable` flag on whichever shape is present to decide whether to surface the labels as editable inputs (true → editable with the catalog values shown as placeholders; false → fixed and read-only). + IdentityInjection *AgentNetworkCatalogIdentityInjection `json:"identity_injection,omitempty"` + + // Kind Presentation grouping for the provider Select on the dashboard. + // "provider" — first-party vendor API (OpenAI, Anthropic, …); the upstream is the model itself. + // "gateway" — routing/aggregation layer in front of multiple providers (LiteLLM, Portkey, …); typically pairs with NetBird identity stamping. + // "custom" — generic OpenAI-compatible self-hosted endpoint catch-all. + Kind AgentNetworkCatalogProviderKind `json:"kind"` + + // Models Catalog models available for this provider. + Models []AgentNetworkCatalogModel `json:"models"` + + // Name Display name for the provider. + Name string `json:"name"` +} + +// AgentNetworkCatalogProviderKind Presentation grouping for the provider Select on the dashboard. +// "provider" — first-party vendor API (OpenAI, Anthropic, …); the upstream is the model itself. +// "gateway" — routing/aggregation layer in front of multiple providers (LiteLLM, Portkey, …); typically pairs with NetBird identity stamping. +// "custom" — generic OpenAI-compatible self-hosted endpoint catch-all. +type AgentNetworkCatalogProviderKind string + +// AgentNetworkConsumption One per-(dimension, window) consumption counter row. The proxy ticks one row per dimension on every served LLM request; the dashboard reads this listing to surface live counter growth. +type AgentNetworkConsumption struct { + // CostUsd Total USD spend booked against this dimension for the window. + CostUsd float64 `json:"cost_usd"` + + // DimensionId NetBird user id (when `dimension_kind=user`) or NetBird group id (when `dimension_kind=group`). + DimensionId string `json:"dimension_id"` + + // DimensionKind Whether this row counts a single end user or a single source group across every member. + DimensionKind AgentNetworkConsumptionDimensionKind `json:"dimension_kind"` + + // TokensInput Total input tokens consumed within the window. + TokensInput int64 `json:"tokens_input"` + + // TokensOutput Total output tokens consumed within the window. + TokensOutput int64 `json:"tokens_output"` + + // UpdatedAt Timestamp of the last increment recorded for this row. + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // WindowSeconds Length of the aligned window this counter covers, in seconds. Distinct window lengths produce independent counters even on the same dimension. + WindowSeconds int64 `json:"window_seconds"` + + // WindowStartUtc UTC start of the aligned window this counter covers. Aligned to the unix epoch so every node computes the same boundary. + WindowStartUtc time.Time `json:"window_start_utc"` +} + +// AgentNetworkConsumptionDimensionKind Whether this row counts a single end user or a single source group across every member. +type AgentNetworkConsumptionDimensionKind string + +// AgentNetworkGuardrail defines model for AgentNetworkGuardrail. +type AgentNetworkGuardrail struct { + // Checks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert. + Checks AgentNetworkGuardrailChecks `json:"checks"` + + // CreatedAt Timestamp when the guardrail was created. + CreatedAt *time.Time `json:"created_at,omitempty"` + + // Description Optional human-readable description. + Description string `json:"description"` + + // Id Guardrail ID + Id string `json:"id"` + + // Name Display name for the guardrail. + Name string `json:"name"` + + // UpdatedAt Timestamp when the guardrail was last updated. + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// AgentNetworkGuardrailChecks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert. +type AgentNetworkGuardrailChecks struct { + ModelAllowlist struct { + Enabled bool `json:"enabled"` + + // Models Allowed catalog model ids. Requests for any other model are denied. + Models []string `json:"models"` + } `json:"model_allowlist"` + PromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` + } `json:"prompt_capture"` +} + +// AgentNetworkGuardrailRequest defines model for AgentNetworkGuardrailRequest. +type AgentNetworkGuardrailRequest struct { + // Checks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert. + Checks AgentNetworkGuardrailChecks `json:"checks"` + + // Description Optional human-readable description. + Description *string `json:"description,omitempty"` + + // Name Display name for the guardrail. + Name string `json:"name"` +} + +// AgentNetworkPolicy defines model for AgentNetworkPolicy. +type AgentNetworkPolicy struct { + // CreatedAt Timestamp when the policy was created. + CreatedAt *time.Time `json:"created_at,omitempty"` + + // Description Optional human-readable description. + Description string `json:"description"` + + // DestinationProviderIds Agent Network provider ids (returned by the providers API) the source groups can reach. + DestinationProviderIds []string `json:"destination_provider_ids"` + + // Enabled Whether the policy is enabled. + Enabled bool `json:"enabled"` + + // GuardrailIds Agent Network guardrail ids attached to this policy. + GuardrailIds []string `json:"guardrail_ids"` + + // Id Policy ID + Id string `json:"id"` + + // Limits Token and budget caps attached directly to the policy. These compose with any guardrail-level checks. + Limits AgentNetworkPolicyLimits `json:"limits"` + + // Name Display name for the policy. + Name string `json:"name"` + + // SourceGroups NetBird group ids whose members are allowed to call the destination providers. + SourceGroups []string `json:"source_groups"` + + // UpdatedAt Timestamp when the policy was last updated. + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// AgentNetworkPolicyBudgetLimit Per-policy USD spend cap. `group_cap_usd` is applied to each source group independently — every group in the policy's `source_groups` gets its own bucket of this size. `user_cap_usd` is applied independently to each individual user. Caps reset to zero at the start of each window. +type AgentNetworkPolicyBudgetLimit struct { + Enabled bool `json:"enabled"` + + // GroupCapUsd USD allowed per source group within the window (each group has its own bucket of this size). 0 means uncapped. + GroupCapUsd float64 `json:"group_cap_usd"` + + // UserCapUsd USD allowed per individual user within the window. 0 means uncapped. + UserCapUsd float64 `json:"user_cap_usd"` + + // WindowSeconds Reset frequency in seconds. Caps reset at the start of each window. Minimum 60 (one minute) when the limit is enabled. + WindowSeconds int64 `json:"window_seconds"` +} + +// AgentNetworkPolicyLimits Token and budget caps attached directly to the policy. These compose with any guardrail-level checks. +type AgentNetworkPolicyLimits struct { + // BudgetLimit Per-policy USD spend cap. `group_cap_usd` is applied to each source group independently — every group in the policy's `source_groups` gets its own bucket of this size. `user_cap_usd` is applied independently to each individual user. Caps reset to zero at the start of each window. + BudgetLimit AgentNetworkPolicyBudgetLimit `json:"budget_limit"` + + // TokenLimit Per-policy token cap. `group_cap` is applied to each source group independently — every group in the policy's `source_groups` gets its own bucket of this size. `user_cap` is applied independently to each individual user. Caps reset to zero at the start of each window. + TokenLimit AgentNetworkPolicyTokenLimit `json:"token_limit"` +} + +// AgentNetworkPolicyRequest defines model for AgentNetworkPolicyRequest. +type AgentNetworkPolicyRequest struct { + // Description Optional human-readable description. + Description *string `json:"description,omitempty"` + + // DestinationProviderIds Agent Network provider ids the source groups can reach. + DestinationProviderIds []string `json:"destination_provider_ids"` + + // Enabled Whether the policy is enabled. Defaults to true on create. + Enabled *bool `json:"enabled,omitempty"` + + // GuardrailIds Agent Network guardrail ids to attach to this policy. + GuardrailIds *[]string `json:"guardrail_ids,omitempty"` + + // Limits Token and budget caps attached directly to the policy. These compose with any guardrail-level checks. + Limits *AgentNetworkPolicyLimits `json:"limits,omitempty"` + + // Name Display name for the policy. + Name string `json:"name"` + + // SourceGroups NetBird group ids whose members are allowed to call the destination providers. + SourceGroups []string `json:"source_groups"` +} + +// AgentNetworkPolicyTokenLimit Per-policy token cap. `group_cap` is applied to each source group independently — every group in the policy's `source_groups` gets its own bucket of this size. `user_cap` is applied independently to each individual user. Caps reset to zero at the start of each window. +type AgentNetworkPolicyTokenLimit struct { + Enabled bool `json:"enabled"` + + // GroupCap Tokens allowed per source group within the window (each group has its own bucket of this size). 0 means uncapped. + GroupCap int64 `json:"group_cap"` + + // UserCap Tokens allowed per individual user within the window. 0 means uncapped. + UserCap int64 `json:"user_cap"` + + // WindowSeconds Reset frequency in seconds. The cap counter resets to zero at the start of each window. Minimum 60 (one minute) when the limit is enabled. + WindowSeconds int64 `json:"window_seconds"` +} + +// AgentNetworkProvider defines model for AgentNetworkProvider. +type AgentNetworkProvider struct { + // CreatedAt Timestamp when the provider was created. + CreatedAt *time.Time `json:"created_at,omitempty"` + + // Enabled Whether the provider is enabled. + Enabled bool `json:"enabled"` + + // ExtraValues Operator-typed values for catalog-declared extra headers. Keys are wire header names (e.g. `x-portkey-config`); values are the strings the proxy stamps on every upstream request to this provider. Catalog (AgentNetworkCatalogProvider.extra_headers) declares which keys are accepted; values not declared by the catalog are ignored at synth time. Empty / missing values mean no header stamped. + ExtraValues *map[string]string `json:"extra_values,omitempty"` + + // Id Provider ID + Id string `json:"id"` + + // IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`. + IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"` + + // IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). + IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + + // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. + Models []AgentNetworkProviderModel `json:"models"` + + // Name Display name shown in the dashboard. + Name string `json:"name"` + + // ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). + ProviderId string `json:"provider_id"` + + // UpdatedAt Timestamp when the provider was last updated. + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to. + UpstreamUrl string `json:"upstream_url"` +} + +// AgentNetworkProviderModel A model exposed by the provider, with the operator's per-1k input/output prices in USD. +type AgentNetworkProviderModel struct { + // Id Model identifier (e.g. "gpt-4o-mini"). + Id string `json:"id"` + + // InputPer1k Cost per 1k input tokens, in USD. + InputPer1k float64 `json:"input_per_1k"` + + // OutputPer1k Cost per 1k output tokens, in USD. + OutputPer1k float64 `json:"output_per_1k"` +} + +// AgentNetworkProviderRequest defines model for AgentNetworkProviderRequest. +type AgentNetworkProviderRequest struct { + // ApiKey Upstream provider API key. Sealed at rest on the management server and never returned in responses. Required on create; optional on update (omit to keep the existing key). + ApiKey *string `json:"api_key,omitempty"` + + // BootstrapCluster Proxy cluster used to bootstrap the per-account agent-network endpoint when the first provider is created. Ignored on subsequent creates and on updates because the cluster is pinned on the account-level Settings row. + BootstrapCluster *string `json:"bootstrap_cluster,omitempty"` + + // Enabled Whether the provider is enabled. Defaults to true on create. + Enabled *bool `json:"enabled,omitempty"` + + // ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key. + ExtraValues *map[string]string `json:"extra_values,omitempty"` + + // IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`. + IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"` + + // IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension). + IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + + // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. + Models *[]AgentNetworkProviderModel `json:"models,omitempty"` + + // Name Display name for the provider. + Name string `json:"name"` + + // ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). + ProviderId string `json:"provider_id"` + + // UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to. + UpstreamUrl string `json:"upstream_url"` +} + +// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter. +type AgentNetworkSettings struct { + // AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently. + AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"` + + // Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. + Cluster string `json:"cluster"` + + // CreatedAt Timestamp when the settings row was created. + CreatedAt *time.Time `json:"created_at,omitempty"` + + // EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic. + EnableLogCollection bool `json:"enable_log_collection"` + + // EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it. + EnablePromptCollection bool `json:"enable_prompt_collection"` + + // Endpoint Bare hostname agents call for this account, computed as `.`. + Endpoint string `json:"endpoint"` + + // RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting. + RedactPii bool `json:"redact_pii"` + + // Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. + Subdomain string `json:"subdomain"` + + // UpdatedAt Timestamp when the settings row was last updated. + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here. +type AgentNetworkSettingsRequest struct { + // AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. + AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"` + + // EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic. + EnableLogCollection bool `json:"enable_log_collection"` + + // EnablePromptCollection Master switch for request/response prompt capture. + EnablePromptCollection bool `json:"enable_prompt_collection"` + + // RedactPii Whether captured prompts have PII redacted. + RedactPii bool `json:"redact_pii"` +} + +// AgentNetworkUsageBucket One aggregated agent-network usage time bucket (UTC). The bucket width is set by the request's granularity. +type AgentNetworkUsageBucket struct { + // CostUsd Total estimated USD spend in the bucket. + CostUsd float64 `json:"cost_usd"` + + // InputTokens Total input (prompt) tokens in the bucket. + InputTokens int64 `json:"input_tokens"` + + // OutputTokens Total output (completion) tokens in the bucket. + OutputTokens int64 `json:"output_tokens"` + + // PeriodStart Start of the bucket in YYYY-MM-DD (UTC) — the day, the week start (Monday), or the month start, depending on granularity. + PeriodStart string `json:"period_start"` + + // TotalTokens Total tokens in the bucket. + TotalTokens int64 `json:"total_tokens"` +} + // AvailablePorts defines model for AvailablePorts. type AvailablePorts struct { // Tcp Number of available TCP ports left on the ingress peer @@ -4892,6 +5687,138 @@ type bearerAuthContextKey string // tokenAuthContextKey is the context key for TokenAuth security scheme type tokenAuthContextKey string +// GetApiAgentNetworkAccessLogSessionsParams defines parameters for GetApiAgentNetworkAccessLogSessions. +type GetApiAgentNetworkAccessLogSessionsParams struct { + // Page Page number for pagination (1-indexed). + Page *int `form:"page,omitempty" json:"page,omitempty"` + + // PageSize Number of sessions per page (max 100). + PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"` + + // SortBy Session-level field to sort by. "timestamp" is the session's last activity, "started_at" its first. + SortBy *GetApiAgentNetworkAccessLogSessionsParamsSortBy `form:"sort_by,omitempty" json:"sort_by,omitempty"` + + // SortOrder Sort order (ascending or descending). + SortOrder *GetApiAgentNetworkAccessLogSessionsParamsSortOrder `form:"sort_order,omitempty" json:"sort_order,omitempty"` + + // Search General search across log ID, host, path, model, and user email/name. + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // UserId Filter by authenticated user ID. + UserId *string `form:"user_id,omitempty" json:"user_id,omitempty"` + + // SessionId Filter to a single conversation / coding session id. + SessionId *string `form:"session_id,omitempty" json:"session_id,omitempty"` + + // GroupId Filter by authorising group id. Repeat for multiple (matches any). + GroupId *[]string `form:"group_id,omitempty" json:"group_id,omitempty"` + + // ProviderId Filter by resolved provider id. Repeat for multiple (matches any). + ProviderId *[]string `form:"provider_id,omitempty" json:"provider_id,omitempty"` + + // Model Filter by model. Repeat for multiple (matches any). + Model *[]string `form:"model,omitempty" json:"model,omitempty"` + + // Decision Filter by policy decision (e.g. allow, deny). + Decision *string `form:"decision,omitempty" json:"decision,omitempty"` + + // Path Filter by request path prefix (matches entries whose path starts with this value). + Path *string `form:"path,omitempty" json:"path,omitempty"` + + // StartDate Filter by timestamp >= start_date (RFC3339 format). + StartDate *time.Time `form:"start_date,omitempty" json:"start_date,omitempty"` + + // EndDate Filter by timestamp <= end_date (RFC3339 format). + EndDate *time.Time `form:"end_date,omitempty" json:"end_date,omitempty"` +} + +// GetApiAgentNetworkAccessLogSessionsParamsSortBy defines parameters for GetApiAgentNetworkAccessLogSessions. +type GetApiAgentNetworkAccessLogSessionsParamsSortBy string + +// GetApiAgentNetworkAccessLogSessionsParamsSortOrder defines parameters for GetApiAgentNetworkAccessLogSessions. +type GetApiAgentNetworkAccessLogSessionsParamsSortOrder string + +// GetApiAgentNetworkAccessLogsParams defines parameters for GetApiAgentNetworkAccessLogs. +type GetApiAgentNetworkAccessLogsParams struct { + // Page Page number for pagination (1-indexed). + Page *int `form:"page,omitempty" json:"page,omitempty"` + + // PageSize Number of items per page (max 100). + PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"` + + // SortBy Field to sort by. + SortBy *GetApiAgentNetworkAccessLogsParamsSortBy `form:"sort_by,omitempty" json:"sort_by,omitempty"` + + // SortOrder Sort order (ascending or descending). + SortOrder *GetApiAgentNetworkAccessLogsParamsSortOrder `form:"sort_order,omitempty" json:"sort_order,omitempty"` + + // Search General search across log ID, host, path, model, and user email/name. + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // UserId Filter by authenticated user ID. + UserId *string `form:"user_id,omitempty" json:"user_id,omitempty"` + + // SessionId Filter to a single conversation / coding session id (groups all requests of one session). + SessionId *string `form:"session_id,omitempty" json:"session_id,omitempty"` + + // GroupId Filter by authorising group id. Repeat for multiple (matches any). + GroupId *[]string `form:"group_id,omitempty" json:"group_id,omitempty"` + + // ProviderId Filter by resolved provider id. Repeat for multiple (matches any). + ProviderId *[]string `form:"provider_id,omitempty" json:"provider_id,omitempty"` + + // Model Filter by model. Repeat for multiple (matches any). + Model *[]string `form:"model,omitempty" json:"model,omitempty"` + + // Decision Filter by policy decision (e.g. allow, deny). + Decision *string `form:"decision,omitempty" json:"decision,omitempty"` + + // Path Filter by request path prefix (matches entries whose path starts with this value). + Path *string `form:"path,omitempty" json:"path,omitempty"` + + // StartDate Filter by timestamp >= start_date (RFC3339 format). + StartDate *time.Time `form:"start_date,omitempty" json:"start_date,omitempty"` + + // EndDate Filter by timestamp <= end_date (RFC3339 format). + EndDate *time.Time `form:"end_date,omitempty" json:"end_date,omitempty"` +} + +// GetApiAgentNetworkAccessLogsParamsSortBy defines parameters for GetApiAgentNetworkAccessLogs. +type GetApiAgentNetworkAccessLogsParamsSortBy string + +// GetApiAgentNetworkAccessLogsParamsSortOrder defines parameters for GetApiAgentNetworkAccessLogs. +type GetApiAgentNetworkAccessLogsParamsSortOrder string + +// GetApiAgentNetworkUsageOverviewParams defines parameters for GetApiAgentNetworkUsageOverview. +type GetApiAgentNetworkUsageOverviewParams struct { + // Granularity Time bucket width. Defaults to day. + Granularity *GetApiAgentNetworkUsageOverviewParamsGranularity `form:"granularity,omitempty" json:"granularity,omitempty"` + + // StartDate Filter by timestamp >= start_date (RFC3339 format). + StartDate *time.Time `form:"start_date,omitempty" json:"start_date,omitempty"` + + // EndDate Filter by timestamp <= end_date (RFC3339 format). + EndDate *time.Time `form:"end_date,omitempty" json:"end_date,omitempty"` + + // UserId Filter by user ID. + UserId *string `form:"user_id,omitempty" json:"user_id,omitempty"` + + // SessionId Filter to a single conversation / coding session id. + SessionId *string `form:"session_id,omitempty" json:"session_id,omitempty"` + + // GroupId Filter by authorising group id. Repeat for multiple (matches any). + GroupId *[]string `form:"group_id,omitempty" json:"group_id,omitempty"` + + // ProviderId Filter by resolved provider id. Repeat for multiple (matches any). + ProviderId *[]string `form:"provider_id,omitempty" json:"provider_id,omitempty"` + + // Model Filter by model. Repeat for multiple (matches any). + Model *[]string `form:"model,omitempty" json:"model,omitempty"` +} + +// GetApiAgentNetworkUsageOverviewParamsGranularity defines parameters for GetApiAgentNetworkUsageOverview. +type GetApiAgentNetworkUsageOverviewParamsGranularity string + // GetApiEventsNetworkTrafficParams defines parameters for GetApiEventsNetworkTraffic. type GetApiEventsNetworkTrafficParams struct { // Page Page number @@ -5090,6 +6017,33 @@ type GetApiUsersParams struct { // PutApiAccountsAccountIdJSONRequestBody defines body for PutApiAccountsAccountId for application/json ContentType. type PutApiAccountsAccountIdJSONRequestBody = AccountRequest +// PostApiAgentNetworkBudgetRulesJSONRequestBody defines body for PostApiAgentNetworkBudgetRules for application/json ContentType. +type PostApiAgentNetworkBudgetRulesJSONRequestBody = AgentNetworkBudgetRuleRequest + +// PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody defines body for PutApiAgentNetworkBudgetRulesRuleId for application/json ContentType. +type PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody = AgentNetworkBudgetRuleRequest + +// PostApiAgentNetworkGuardrailsJSONRequestBody defines body for PostApiAgentNetworkGuardrails for application/json ContentType. +type PostApiAgentNetworkGuardrailsJSONRequestBody = AgentNetworkGuardrailRequest + +// PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody defines body for PutApiAgentNetworkGuardrailsGuardrailId for application/json ContentType. +type PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody = AgentNetworkGuardrailRequest + +// PostApiAgentNetworkPoliciesJSONRequestBody defines body for PostApiAgentNetworkPolicies for application/json ContentType. +type PostApiAgentNetworkPoliciesJSONRequestBody = AgentNetworkPolicyRequest + +// PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody defines body for PutApiAgentNetworkPoliciesPolicyId for application/json ContentType. +type PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody = AgentNetworkPolicyRequest + +// PostApiAgentNetworkProvidersJSONRequestBody defines body for PostApiAgentNetworkProviders for application/json ContentType. +type PostApiAgentNetworkProvidersJSONRequestBody = AgentNetworkProviderRequest + +// PutApiAgentNetworkProvidersProviderIdJSONRequestBody defines body for PutApiAgentNetworkProvidersProviderId for application/json ContentType. +type PutApiAgentNetworkProvidersProviderIdJSONRequestBody = AgentNetworkProviderRequest + +// PutApiAgentNetworkSettingsJSONRequestBody defines body for PutApiAgentNetworkSettings for application/json ContentType. +type PutApiAgentNetworkSettingsJSONRequestBody = AgentNetworkSettingsRequest + // PostApiDnsNameserversJSONRequestBody defines body for PostApiDnsNameservers for application/json ContentType. type PostApiDnsNameserversJSONRequestBody = NameserverGroupRequest diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 5dd529407..8027c0db6 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -843,10 +843,14 @@ type SyncResponse struct { NetworkMap *NetworkMap `protobuf:"bytes,5,opt,name=NetworkMap,proto3" json:"NetworkMap,omitempty"` // Posture checks to be evaluated by client Checks []*Checks `protobuf:"bytes,6,rep,name=Checks,proto3" json:"Checks,omitempty"` - // Absolute UTC instant at which the peer's SSO session expires. - // Unset when the peer is not SSO-registered or login expiration is disabled. - // Carried on every Sync snapshot so admin-side changes propagate live without - // a client reconnect. + // 3-state session deadline. Carried on every Sync snapshot so admin-side + // changes propagate live without a client reconnect. + // + // field unset (nil) → snapshot carries no info; client keeps the + // deadline it already had + // set, seconds=0 nanos=0 → explicit "expiry disabled" or peer is not + // SSO-registered; client clears its anchor + // set, valid timestamp → new absolute UTC deadline SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` } @@ -1608,8 +1612,11 @@ type LoginResponse struct { PeerConfig *PeerConfig `protobuf:"bytes,2,opt,name=peerConfig,proto3" json:"peerConfig,omitempty"` // Posture checks to be evaluated by client Checks []*Checks `protobuf:"bytes,3,rep,name=Checks,proto3" json:"Checks,omitempty"` - // Absolute UTC instant at which the peer's SSO session expires. - // Unset when the peer is not SSO-registered or login expiration is disabled. + // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt. + // + // field unset (nil) → no info; client keeps any deadline it had + // set, seconds=0 nanos=0 → explicit "expiry disabled" / non-SSO peer + // set, valid timestamp → new absolute UTC deadline SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` } @@ -1739,7 +1746,10 @@ type ExtendAuthSessionResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Absolute UTC instant at which the peer's SSO session now expires. + // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt. + // In practice ExtendAuthSession only succeeds for SSO peers with expiry + // enabled, so this carries a valid timestamp on the success path. The + // 3-state encoding is documented here for symmetry with Login/Sync. SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` } diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 22c215074..df42d78ff 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -117,6 +117,60 @@ func (PathRewriteMode) EnumDescriptor() ([]byte, []int) { return file_proxy_service_proto_rawDescGZIP(), []int{1} } +// MiddlewareSlot identifies where in the request lifecycle a middleware +// runs. Mirrors proxy/internal/middleware.Slot. +type MiddlewareSlot int32 + +const ( + MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED MiddlewareSlot = 0 + MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST MiddlewareSlot = 1 + MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE MiddlewareSlot = 2 + MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL MiddlewareSlot = 3 +) + +// Enum value maps for MiddlewareSlot. +var ( + MiddlewareSlot_name = map[int32]string{ + 0: "MIDDLEWARE_SLOT_UNSPECIFIED", + 1: "MIDDLEWARE_SLOT_ON_REQUEST", + 2: "MIDDLEWARE_SLOT_ON_RESPONSE", + 3: "MIDDLEWARE_SLOT_TERMINAL", + } + MiddlewareSlot_value = map[string]int32{ + "MIDDLEWARE_SLOT_UNSPECIFIED": 0, + "MIDDLEWARE_SLOT_ON_REQUEST": 1, + "MIDDLEWARE_SLOT_ON_RESPONSE": 2, + "MIDDLEWARE_SLOT_TERMINAL": 3, + } +) + +func (x MiddlewareSlot) Enum() *MiddlewareSlot { + p := new(MiddlewareSlot) + *p = x + return p +} + +func (x MiddlewareSlot) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MiddlewareSlot) Descriptor() protoreflect.EnumDescriptor { + return file_proxy_service_proto_enumTypes[2].Descriptor() +} + +func (MiddlewareSlot) Type() protoreflect.EnumType { + return &file_proxy_service_proto_enumTypes[2] +} + +func (x MiddlewareSlot) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MiddlewareSlot.Descriptor instead. +func (MiddlewareSlot) EnumDescriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{2} +} + type ProxyStatus int32 const ( @@ -159,11 +213,11 @@ func (x ProxyStatus) String() string { } func (ProxyStatus) Descriptor() protoreflect.EnumDescriptor { - return file_proxy_service_proto_enumTypes[2].Descriptor() + return file_proxy_service_proto_enumTypes[3].Descriptor() } func (ProxyStatus) Type() protoreflect.EnumType { - return &file_proxy_service_proto_enumTypes[2] + return &file_proxy_service_proto_enumTypes[3] } func (x ProxyStatus) Number() protoreflect.EnumNumber { @@ -172,7 +226,53 @@ func (x ProxyStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ProxyStatus.Descriptor instead. func (ProxyStatus) EnumDescriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{2} + return file_proxy_service_proto_rawDescGZIP(), []int{3} +} + +type MiddlewareConfig_FailMode int32 + +const ( + MiddlewareConfig_FAIL_OPEN MiddlewareConfig_FailMode = 0 + MiddlewareConfig_FAIL_CLOSED MiddlewareConfig_FailMode = 1 +) + +// Enum value maps for MiddlewareConfig_FailMode. +var ( + MiddlewareConfig_FailMode_name = map[int32]string{ + 0: "FAIL_OPEN", + 1: "FAIL_CLOSED", + } + MiddlewareConfig_FailMode_value = map[string]int32{ + "FAIL_OPEN": 0, + "FAIL_CLOSED": 1, + } +) + +func (x MiddlewareConfig_FailMode) Enum() *MiddlewareConfig_FailMode { + p := new(MiddlewareConfig_FailMode) + *p = x + return p +} + +func (x MiddlewareConfig_FailMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MiddlewareConfig_FailMode) Descriptor() protoreflect.EnumDescriptor { + return file_proxy_service_proto_enumTypes[4].Descriptor() +} + +func (MiddlewareConfig_FailMode) Type() protoreflect.EnumType { + return &file_proxy_service_proto_enumTypes[4] +} + +func (x MiddlewareConfig_FailMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MiddlewareConfig_FailMode.Descriptor instead. +func (MiddlewareConfig_FailMode) EnumDescriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{4, 0} } // ProxyCapabilities describes what a proxy can handle. @@ -422,6 +522,25 @@ type PathTargetOptions struct { // reachable without WireGuard (public APIs, LAN services, localhost // sidecars). Defaults to false — embedded client is the standard path. DirectUpstream bool `protobuf:"varint,7,opt,name=direct_upstream,json=directUpstream,proto3" json:"direct_upstream,omitempty"` + // Proxy clamps to [0, proxy-wide max (1 MiB)] at apply time. Agent-network + // synthesized targets only; private services leave these zero. + CaptureMaxRequestBytes int64 `protobuf:"varint,8,opt,name=capture_max_request_bytes,json=captureMaxRequestBytes,proto3" json:"capture_max_request_bytes,omitempty"` + // Proxy clamps to [0, proxy-wide max (1 MiB)] at apply time. + CaptureMaxResponseBytes int64 `protobuf:"varint,9,opt,name=capture_max_response_bytes,json=captureMaxResponseBytes,proto3" json:"capture_max_response_bytes,omitempty"` + // Content types eligible for body capture (e.g. "application/json"). + CaptureContentTypes []string `protobuf:"bytes,10,rep,name=capture_content_types,json=captureContentTypes,proto3" json:"capture_content_types,omitempty"` + // Per-target middleware configurations populated by the agent-network + // synthesizer. Validated and clamped by the proxy at apply time. + Middlewares []*MiddlewareConfig `protobuf:"bytes,11,rep,name=middlewares,proto3" json:"middlewares,omitempty"` + // When true, the proxy stamps agent_network=true on access-log entries + // for this target so management routes them to the agent-network log + // surface. + AgentNetwork bool `protobuf:"varint,12,opt,name=agent_network,json=agentNetwork,proto3" json:"agent_network,omitempty"` + // When true, the proxy suppresses the per-request access-log emission for + // this target. Defaults false to preserve existing access-log behavior for + // every non-agent-network target. The agent-network synth target sets this + // true only when the account's EnableLogCollection toggle is off. + DisableAccessLog bool `protobuf:"varint,13,opt,name=disable_access_log,json=disableAccessLog,proto3" json:"disable_access_log,omitempty"` } func (x *PathTargetOptions) Reset() { @@ -505,6 +624,154 @@ func (x *PathTargetOptions) GetDirectUpstream() bool { return false } +func (x *PathTargetOptions) GetCaptureMaxRequestBytes() int64 { + if x != nil { + return x.CaptureMaxRequestBytes + } + return 0 +} + +func (x *PathTargetOptions) GetCaptureMaxResponseBytes() int64 { + if x != nil { + return x.CaptureMaxResponseBytes + } + return 0 +} + +func (x *PathTargetOptions) GetCaptureContentTypes() []string { + if x != nil { + return x.CaptureContentTypes + } + return nil +} + +func (x *PathTargetOptions) GetMiddlewares() []*MiddlewareConfig { + if x != nil { + return x.Middlewares + } + return nil +} + +func (x *PathTargetOptions) GetAgentNetwork() bool { + if x != nil { + return x.AgentNetwork + } + return false +} + +func (x *PathTargetOptions) GetDisableAccessLog() bool { + if x != nil { + return x.DisableAccessLog + } + return false +} + +// MiddlewareConfig is the per-target configuration for a single middleware. +// The proxy validates every incoming MiddlewareConfig at apply time: +// unknown ids are rejected, timeout is clamped to [10ms, 5s], and the +// declared slot must match the registered middleware's slot. +type MiddlewareConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Middleware id; must match the proxy-local compiled-in registry. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"` + Slot MiddlewareSlot `protobuf:"varint,3,opt,name=slot,proto3,enum=management.MiddlewareSlot" json:"slot,omitempty"` + // Free-form JSON unmarshalled by the middleware factory into its own typed + // config struct. Empty / null / {} are valid (zero-value config). + ConfigJson []byte `protobuf:"bytes,4,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + FailMode MiddlewareConfig_FailMode `protobuf:"varint,5,opt,name=fail_mode,json=failMode,proto3,enum=management.MiddlewareConfig_FailMode" json:"fail_mode,omitempty"` + // Clamped to [10ms, 5s] at apply time; zero → 500ms default. + Timeout *durationpb.Duration `protobuf:"bytes,6,opt,name=timeout,proto3" json:"timeout,omitempty"` + // When true, the middleware may mutate request headers or body (subject to + // policy). Honoured only when the implementation also declares + // MutationsSupported. + CanMutate bool `protobuf:"varint,7,opt,name=can_mutate,json=canMutate,proto3" json:"can_mutate,omitempty"` +} + +func (x *MiddlewareConfig) Reset() { + *x = MiddlewareConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MiddlewareConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MiddlewareConfig) ProtoMessage() {} + +func (x *MiddlewareConfig) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MiddlewareConfig.ProtoReflect.Descriptor instead. +func (*MiddlewareConfig) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{4} +} + +func (x *MiddlewareConfig) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *MiddlewareConfig) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *MiddlewareConfig) GetSlot() MiddlewareSlot { + if x != nil { + return x.Slot + } + return MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED +} + +func (x *MiddlewareConfig) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +func (x *MiddlewareConfig) GetFailMode() MiddlewareConfig_FailMode { + if x != nil { + return x.FailMode + } + return MiddlewareConfig_FAIL_OPEN +} + +func (x *MiddlewareConfig) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +func (x *MiddlewareConfig) GetCanMutate() bool { + if x != nil { + return x.CanMutate + } + return false +} + type PathMapping struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -518,7 +785,7 @@ type PathMapping struct { func (x *PathMapping) Reset() { *x = PathMapping{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[4] + mi := &file_proxy_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -531,7 +798,7 @@ func (x *PathMapping) String() string { func (*PathMapping) ProtoMessage() {} func (x *PathMapping) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[4] + mi := &file_proxy_service_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -544,7 +811,7 @@ func (x *PathMapping) ProtoReflect() protoreflect.Message { // Deprecated: Use PathMapping.ProtoReflect.Descriptor instead. func (*PathMapping) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{4} + return file_proxy_service_proto_rawDescGZIP(), []int{5} } func (x *PathMapping) GetPath() string { @@ -582,7 +849,7 @@ type HeaderAuth struct { func (x *HeaderAuth) Reset() { *x = HeaderAuth{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[5] + mi := &file_proxy_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -595,7 +862,7 @@ func (x *HeaderAuth) String() string { func (*HeaderAuth) ProtoMessage() {} func (x *HeaderAuth) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[5] + mi := &file_proxy_service_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -608,7 +875,7 @@ func (x *HeaderAuth) ProtoReflect() protoreflect.Message { // Deprecated: Use HeaderAuth.ProtoReflect.Descriptor instead. func (*HeaderAuth) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{5} + return file_proxy_service_proto_rawDescGZIP(), []int{6} } func (x *HeaderAuth) GetHeader() string { @@ -641,7 +908,7 @@ type Authentication struct { func (x *Authentication) Reset() { *x = Authentication{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[6] + mi := &file_proxy_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -654,7 +921,7 @@ func (x *Authentication) String() string { func (*Authentication) ProtoMessage() {} func (x *Authentication) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[6] + mi := &file_proxy_service_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -667,7 +934,7 @@ func (x *Authentication) ProtoReflect() protoreflect.Message { // Deprecated: Use Authentication.ProtoReflect.Descriptor instead. func (*Authentication) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{6} + return file_proxy_service_proto_rawDescGZIP(), []int{7} } func (x *Authentication) GetSessionKey() string { @@ -728,7 +995,7 @@ type AccessRestrictions struct { func (x *AccessRestrictions) Reset() { *x = AccessRestrictions{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[7] + mi := &file_proxy_service_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -741,7 +1008,7 @@ func (x *AccessRestrictions) String() string { func (*AccessRestrictions) ProtoMessage() {} func (x *AccessRestrictions) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[7] + mi := &file_proxy_service_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -754,7 +1021,7 @@ func (x *AccessRestrictions) ProtoReflect() protoreflect.Message { // Deprecated: Use AccessRestrictions.ProtoReflect.Descriptor instead. func (*AccessRestrictions) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{7} + return file_proxy_service_proto_rawDescGZIP(), []int{8} } func (x *AccessRestrictions) GetAllowedCidrs() []string { @@ -822,7 +1089,7 @@ type ProxyMapping struct { func (x *ProxyMapping) Reset() { *x = ProxyMapping{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[8] + mi := &file_proxy_service_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -835,7 +1102,7 @@ func (x *ProxyMapping) String() string { func (*ProxyMapping) ProtoMessage() {} func (x *ProxyMapping) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[8] + mi := &file_proxy_service_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -848,7 +1115,7 @@ func (x *ProxyMapping) ProtoReflect() protoreflect.Message { // Deprecated: Use ProxyMapping.ProtoReflect.Descriptor instead. func (*ProxyMapping) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{8} + return file_proxy_service_proto_rawDescGZIP(), []int{9} } func (x *ProxyMapping) GetType() ProxyMappingUpdateType { @@ -954,7 +1221,7 @@ type SendAccessLogRequest struct { func (x *SendAccessLogRequest) Reset() { *x = SendAccessLogRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[9] + mi := &file_proxy_service_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -967,7 +1234,7 @@ func (x *SendAccessLogRequest) String() string { func (*SendAccessLogRequest) ProtoMessage() {} func (x *SendAccessLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[9] + mi := &file_proxy_service_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -980,7 +1247,7 @@ func (x *SendAccessLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendAccessLogRequest.ProtoReflect.Descriptor instead. func (*SendAccessLogRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{9} + return file_proxy_service_proto_rawDescGZIP(), []int{10} } func (x *SendAccessLogRequest) GetLog() *AccessLog { @@ -1000,7 +1267,7 @@ type SendAccessLogResponse struct { func (x *SendAccessLogResponse) Reset() { *x = SendAccessLogResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[10] + mi := &file_proxy_service_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1013,7 +1280,7 @@ func (x *SendAccessLogResponse) String() string { func (*SendAccessLogResponse) ProtoMessage() {} func (x *SendAccessLogResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[10] + mi := &file_proxy_service_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1026,7 +1293,7 @@ func (x *SendAccessLogResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendAccessLogResponse.ProtoReflect.Descriptor instead. func (*SendAccessLogResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{10} + return file_proxy_service_proto_rawDescGZIP(), []int{11} } type AccessLog struct { @@ -1052,12 +1319,16 @@ type AccessLog struct { Protocol string `protobuf:"bytes,16,opt,name=protocol,proto3" json:"protocol,omitempty"` // Extra key-value metadata for the access log entry (e.g. crowdsec_verdict, scenario). Metadata map[string]string `protobuf:"bytes,17,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // When true, the entry was emitted by an agent-network synth service. + // Management routes these to the agent-network access-log surface instead + // of the standard service log. + AgentNetwork bool `protobuf:"varint,18,opt,name=agent_network,json=agentNetwork,proto3" json:"agent_network,omitempty"` } func (x *AccessLog) Reset() { *x = AccessLog{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[11] + mi := &file_proxy_service_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1070,7 +1341,7 @@ func (x *AccessLog) String() string { func (*AccessLog) ProtoMessage() {} func (x *AccessLog) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[11] + mi := &file_proxy_service_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1083,7 +1354,7 @@ func (x *AccessLog) ProtoReflect() protoreflect.Message { // Deprecated: Use AccessLog.ProtoReflect.Descriptor instead. func (*AccessLog) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{11} + return file_proxy_service_proto_rawDescGZIP(), []int{12} } func (x *AccessLog) GetTimestamp() *timestamppb.Timestamp { @@ -1205,6 +1476,13 @@ func (x *AccessLog) GetMetadata() map[string]string { return nil } +func (x *AccessLog) GetAgentNetwork() bool { + if x != nil { + return x.AgentNetwork + } + return false +} + type AuthenticateRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1223,7 +1501,7 @@ type AuthenticateRequest struct { func (x *AuthenticateRequest) Reset() { *x = AuthenticateRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[12] + mi := &file_proxy_service_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1236,7 +1514,7 @@ func (x *AuthenticateRequest) String() string { func (*AuthenticateRequest) ProtoMessage() {} func (x *AuthenticateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[12] + mi := &file_proxy_service_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1249,7 +1527,7 @@ func (x *AuthenticateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthenticateRequest.ProtoReflect.Descriptor instead. func (*AuthenticateRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{12} + return file_proxy_service_proto_rawDescGZIP(), []int{13} } func (x *AuthenticateRequest) GetId() string { @@ -1328,7 +1606,7 @@ type HeaderAuthRequest struct { func (x *HeaderAuthRequest) Reset() { *x = HeaderAuthRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[13] + mi := &file_proxy_service_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1341,7 +1619,7 @@ func (x *HeaderAuthRequest) String() string { func (*HeaderAuthRequest) ProtoMessage() {} func (x *HeaderAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[13] + mi := &file_proxy_service_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1354,7 +1632,7 @@ func (x *HeaderAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HeaderAuthRequest.ProtoReflect.Descriptor instead. func (*HeaderAuthRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{13} + return file_proxy_service_proto_rawDescGZIP(), []int{14} } func (x *HeaderAuthRequest) GetHeaderValue() string { @@ -1382,7 +1660,7 @@ type PasswordRequest struct { func (x *PasswordRequest) Reset() { *x = PasswordRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[14] + mi := &file_proxy_service_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1395,7 +1673,7 @@ func (x *PasswordRequest) String() string { func (*PasswordRequest) ProtoMessage() {} func (x *PasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[14] + mi := &file_proxy_service_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1408,7 +1686,7 @@ func (x *PasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PasswordRequest.ProtoReflect.Descriptor instead. func (*PasswordRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{14} + return file_proxy_service_proto_rawDescGZIP(), []int{15} } func (x *PasswordRequest) GetPassword() string { @@ -1429,7 +1707,7 @@ type PinRequest struct { func (x *PinRequest) Reset() { *x = PinRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[15] + mi := &file_proxy_service_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1442,7 +1720,7 @@ func (x *PinRequest) String() string { func (*PinRequest) ProtoMessage() {} func (x *PinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[15] + mi := &file_proxy_service_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1455,7 +1733,7 @@ func (x *PinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PinRequest.ProtoReflect.Descriptor instead. func (*PinRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{15} + return file_proxy_service_proto_rawDescGZIP(), []int{16} } func (x *PinRequest) GetPin() string { @@ -1477,7 +1755,7 @@ type AuthenticateResponse struct { func (x *AuthenticateResponse) Reset() { *x = AuthenticateResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[16] + mi := &file_proxy_service_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1490,7 +1768,7 @@ func (x *AuthenticateResponse) String() string { func (*AuthenticateResponse) ProtoMessage() {} func (x *AuthenticateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[16] + mi := &file_proxy_service_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1503,7 +1781,7 @@ func (x *AuthenticateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthenticateResponse.ProtoReflect.Descriptor instead. func (*AuthenticateResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{16} + return file_proxy_service_proto_rawDescGZIP(), []int{17} } func (x *AuthenticateResponse) GetSuccess() bool { @@ -1532,7 +1810,7 @@ type SendStatusUpdateRequest struct { CertificateIssued bool `protobuf:"varint,4,opt,name=certificate_issued,json=certificateIssued,proto3" json:"certificate_issued,omitempty"` ErrorMessage *string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` // Per-account inbound listener state for the account that owns - // service_id. Populated only when --private-inbound is enabled and the + // service_id. Populated only when --private is enabled and the // embedded client for the account is up. Field numbers >=50 reserved // for observability extensions. InboundListener *ProxyInboundListener `protobuf:"bytes,50,opt,name=inbound_listener,json=inboundListener,proto3,oneof" json:"inbound_listener,omitempty"` @@ -1541,7 +1819,7 @@ type SendStatusUpdateRequest struct { func (x *SendStatusUpdateRequest) Reset() { *x = SendStatusUpdateRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[17] + mi := &file_proxy_service_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1554,7 +1832,7 @@ func (x *SendStatusUpdateRequest) String() string { func (*SendStatusUpdateRequest) ProtoMessage() {} func (x *SendStatusUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[17] + mi := &file_proxy_service_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1567,7 +1845,7 @@ func (x *SendStatusUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendStatusUpdateRequest.ProtoReflect.Descriptor instead. func (*SendStatusUpdateRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{17} + return file_proxy_service_proto_rawDescGZIP(), []int{18} } func (x *SendStatusUpdateRequest) GetServiceId() string { @@ -1633,7 +1911,7 @@ type ProxyInboundListener struct { func (x *ProxyInboundListener) Reset() { *x = ProxyInboundListener{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[18] + mi := &file_proxy_service_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1646,7 +1924,7 @@ func (x *ProxyInboundListener) String() string { func (*ProxyInboundListener) ProtoMessage() {} func (x *ProxyInboundListener) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[18] + mi := &file_proxy_service_proto_msgTypes[19] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1659,7 +1937,7 @@ func (x *ProxyInboundListener) ProtoReflect() protoreflect.Message { // Deprecated: Use ProxyInboundListener.ProtoReflect.Descriptor instead. func (*ProxyInboundListener) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{18} + return file_proxy_service_proto_rawDescGZIP(), []int{19} } func (x *ProxyInboundListener) GetTunnelIp() string { @@ -1693,7 +1971,7 @@ type SendStatusUpdateResponse struct { func (x *SendStatusUpdateResponse) Reset() { *x = SendStatusUpdateResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[19] + mi := &file_proxy_service_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1706,7 +1984,7 @@ func (x *SendStatusUpdateResponse) String() string { func (*SendStatusUpdateResponse) ProtoMessage() {} func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[19] + mi := &file_proxy_service_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1719,7 +1997,7 @@ func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendStatusUpdateResponse.ProtoReflect.Descriptor instead. func (*SendStatusUpdateResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{19} + return file_proxy_service_proto_rawDescGZIP(), []int{20} } // CreateProxyPeerRequest is sent by the proxy to create a peer connection @@ -1739,7 +2017,7 @@ type CreateProxyPeerRequest struct { func (x *CreateProxyPeerRequest) Reset() { *x = CreateProxyPeerRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[20] + mi := &file_proxy_service_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1752,7 +2030,7 @@ func (x *CreateProxyPeerRequest) String() string { func (*CreateProxyPeerRequest) ProtoMessage() {} func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[20] + mi := &file_proxy_service_proto_msgTypes[21] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1765,7 +2043,7 @@ func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProxyPeerRequest.ProtoReflect.Descriptor instead. func (*CreateProxyPeerRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{20} + return file_proxy_service_proto_rawDescGZIP(), []int{21} } func (x *CreateProxyPeerRequest) GetServiceId() string { @@ -1816,7 +2094,7 @@ type CreateProxyPeerResponse struct { func (x *CreateProxyPeerResponse) Reset() { *x = CreateProxyPeerResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[21] + mi := &file_proxy_service_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1829,7 +2107,7 @@ func (x *CreateProxyPeerResponse) String() string { func (*CreateProxyPeerResponse) ProtoMessage() {} func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[21] + mi := &file_proxy_service_proto_msgTypes[22] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1842,7 +2120,7 @@ func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProxyPeerResponse.ProtoReflect.Descriptor instead. func (*CreateProxyPeerResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{21} + return file_proxy_service_proto_rawDescGZIP(), []int{22} } func (x *CreateProxyPeerResponse) GetSuccess() bool { @@ -1872,7 +2150,7 @@ type GetOIDCURLRequest struct { func (x *GetOIDCURLRequest) Reset() { *x = GetOIDCURLRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[22] + mi := &file_proxy_service_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1885,7 +2163,7 @@ func (x *GetOIDCURLRequest) String() string { func (*GetOIDCURLRequest) ProtoMessage() {} func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[22] + mi := &file_proxy_service_proto_msgTypes[23] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1898,7 +2176,7 @@ func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOIDCURLRequest.ProtoReflect.Descriptor instead. func (*GetOIDCURLRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{22} + return file_proxy_service_proto_rawDescGZIP(), []int{23} } func (x *GetOIDCURLRequest) GetId() string { @@ -1933,7 +2211,7 @@ type GetOIDCURLResponse struct { func (x *GetOIDCURLResponse) Reset() { *x = GetOIDCURLResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[23] + mi := &file_proxy_service_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1946,7 +2224,7 @@ func (x *GetOIDCURLResponse) String() string { func (*GetOIDCURLResponse) ProtoMessage() {} func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[23] + mi := &file_proxy_service_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1959,7 +2237,7 @@ func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOIDCURLResponse.ProtoReflect.Descriptor instead. func (*GetOIDCURLResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{23} + return file_proxy_service_proto_rawDescGZIP(), []int{24} } func (x *GetOIDCURLResponse) GetUrl() string { @@ -1981,7 +2259,7 @@ type ValidateSessionRequest struct { func (x *ValidateSessionRequest) Reset() { *x = ValidateSessionRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[24] + mi := &file_proxy_service_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1994,7 +2272,7 @@ func (x *ValidateSessionRequest) String() string { func (*ValidateSessionRequest) ProtoMessage() {} func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[24] + mi := &file_proxy_service_proto_msgTypes[25] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2007,7 +2285,7 @@ func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionRequest.ProtoReflect.Descriptor instead. func (*ValidateSessionRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{24} + return file_proxy_service_proto_rawDescGZIP(), []int{25} } func (x *ValidateSessionRequest) GetDomain() string { @@ -2047,7 +2325,7 @@ type ValidateSessionResponse struct { func (x *ValidateSessionResponse) Reset() { *x = ValidateSessionResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[25] + mi := &file_proxy_service_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2060,7 +2338,7 @@ func (x *ValidateSessionResponse) String() string { func (*ValidateSessionResponse) ProtoMessage() {} func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[25] + mi := &file_proxy_service_proto_msgTypes[26] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2073,7 +2351,7 @@ func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionResponse.ProtoReflect.Descriptor instead. func (*ValidateSessionResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{25} + return file_proxy_service_proto_rawDescGZIP(), []int{26} } func (x *ValidateSessionResponse) GetValid() bool { @@ -2133,7 +2411,7 @@ type ValidateTunnelPeerRequest struct { func (x *ValidateTunnelPeerRequest) Reset() { *x = ValidateTunnelPeerRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[26] + mi := &file_proxy_service_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2146,7 +2424,7 @@ func (x *ValidateTunnelPeerRequest) String() string { func (*ValidateTunnelPeerRequest) ProtoMessage() {} func (x *ValidateTunnelPeerRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[26] + mi := &file_proxy_service_proto_msgTypes[27] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2159,7 +2437,7 @@ func (x *ValidateTunnelPeerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateTunnelPeerRequest.ProtoReflect.Descriptor instead. func (*ValidateTunnelPeerRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{26} + return file_proxy_service_proto_rawDescGZIP(), []int{27} } func (x *ValidateTunnelPeerRequest) GetTunnelIp() string { @@ -2213,7 +2491,7 @@ type ValidateTunnelPeerResponse struct { func (x *ValidateTunnelPeerResponse) Reset() { *x = ValidateTunnelPeerResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[27] + mi := &file_proxy_service_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2226,7 +2504,7 @@ func (x *ValidateTunnelPeerResponse) String() string { func (*ValidateTunnelPeerResponse) ProtoMessage() {} func (x *ValidateTunnelPeerResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[27] + mi := &file_proxy_service_proto_msgTypes[28] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2239,7 +2517,7 @@ func (x *ValidateTunnelPeerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateTunnelPeerResponse.ProtoReflect.Descriptor instead. func (*ValidateTunnelPeerResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{27} + return file_proxy_service_proto_rawDescGZIP(), []int{28} } func (x *ValidateTunnelPeerResponse) GetValid() bool { @@ -2309,7 +2587,7 @@ type SyncMappingsRequest struct { func (x *SyncMappingsRequest) Reset() { *x = SyncMappingsRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[28] + mi := &file_proxy_service_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2322,7 +2600,7 @@ func (x *SyncMappingsRequest) String() string { func (*SyncMappingsRequest) ProtoMessage() {} func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[28] + mi := &file_proxy_service_proto_msgTypes[29] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2335,7 +2613,7 @@ func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsRequest.ProtoReflect.Descriptor instead. func (*SyncMappingsRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{28} + return file_proxy_service_proto_rawDescGZIP(), []int{29} } func (m *SyncMappingsRequest) GetMsg() isSyncMappingsRequest_Msg { @@ -2392,7 +2670,7 @@ type SyncMappingsInit struct { func (x *SyncMappingsInit) Reset() { *x = SyncMappingsInit{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[29] + mi := &file_proxy_service_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2405,7 +2683,7 @@ func (x *SyncMappingsInit) String() string { func (*SyncMappingsInit) ProtoMessage() {} func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[29] + mi := &file_proxy_service_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2418,7 +2696,7 @@ func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsInit.ProtoReflect.Descriptor instead. func (*SyncMappingsInit) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{29} + return file_proxy_service_proto_rawDescGZIP(), []int{30} } func (x *SyncMappingsInit) GetProxyId() string { @@ -2467,7 +2745,7 @@ type SyncMappingsAck struct { func (x *SyncMappingsAck) Reset() { *x = SyncMappingsAck{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[30] + mi := &file_proxy_service_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2480,7 +2758,7 @@ func (x *SyncMappingsAck) String() string { func (*SyncMappingsAck) ProtoMessage() {} func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[30] + mi := &file_proxy_service_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2493,7 +2771,7 @@ func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsAck.ProtoReflect.Descriptor instead. func (*SyncMappingsAck) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{30} + return file_proxy_service_proto_rawDescGZIP(), []int{31} } // SyncMappingsResponse is a batch of mappings sent by management. @@ -2511,7 +2789,7 @@ type SyncMappingsResponse struct { func (x *SyncMappingsResponse) Reset() { *x = SyncMappingsResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[31] + mi := &file_proxy_service_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2524,7 +2802,7 @@ func (x *SyncMappingsResponse) String() string { func (*SyncMappingsResponse) ProtoMessage() {} func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[31] + mi := &file_proxy_service_proto_msgTypes[32] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2537,7 +2815,7 @@ func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsResponse.ProtoReflect.Descriptor instead. func (*SyncMappingsResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{31} + return file_proxy_service_proto_rawDescGZIP(), []int{32} } func (x *SyncMappingsResponse) GetMapping() []*ProxyMapping { @@ -2554,6 +2832,338 @@ func (x *SyncMappingsResponse) GetInitialSyncComplete() bool { return false } +// CheckLLMPolicyLimitsRequest carries the resolved caller identity and the +// upstream provider already chosen by llm_router. Management computes which +// policies authorise the request, picks the one with the most remaining +// headroom, and returns the attribution decision. +type CheckLLMPolicyLimitsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // account_id is the netbird account the request belongs to. + AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + // user_id is the netbird user id of the caller. May be empty when the + // principal is a tunnel-peer that isn't bound to a user; group membership + // still gates the request in that case. + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + // group_ids is the caller's full group membership at request time. + GroupIds []string `protobuf:"bytes,3,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` + // provider_id is the agent-network provider record id chosen by llm_router. + ProviderId string `protobuf:"bytes,4,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + // model is the upstream model identifier extracted from the request body. + Model string `protobuf:"bytes,5,opt,name=model,proto3" json:"model,omitempty"` +} + +func (x *CheckLLMPolicyLimitsRequest) Reset() { + *x = CheckLLMPolicyLimitsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CheckLLMPolicyLimitsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckLLMPolicyLimitsRequest) ProtoMessage() {} + +func (x *CheckLLMPolicyLimitsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[33] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckLLMPolicyLimitsRequest.ProtoReflect.Descriptor instead. +func (*CheckLLMPolicyLimitsRequest) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{33} +} + +func (x *CheckLLMPolicyLimitsRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + +func (x *CheckLLMPolicyLimitsRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *CheckLLMPolicyLimitsRequest) GetGroupIds() []string { + if x != nil { + return x.GroupIds + } + return nil +} + +func (x *CheckLLMPolicyLimitsRequest) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *CheckLLMPolicyLimitsRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +// CheckLLMPolicyLimitsResponse is management's allow-or-deny decision for a +// pre-flight check. +type CheckLLMPolicyLimitsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // decision is "allow" or "deny". + Decision string `protobuf:"bytes,1,opt,name=decision,proto3" json:"decision,omitempty"` + // selected_policy_id names the policy that paid for this request. + SelectedPolicyId string `protobuf:"bytes,2,opt,name=selected_policy_id,json=selectedPolicyId,proto3" json:"selected_policy_id,omitempty"` + // attribution_group_id is the source group the request booked against. + AttributionGroupId string `protobuf:"bytes,3,opt,name=attribution_group_id,json=attributionGroupId,proto3" json:"attribution_group_id,omitempty"` + // window_seconds is the cap window length the selected policy uses. + WindowSeconds int64 `protobuf:"varint,4,opt,name=window_seconds,json=windowSeconds,proto3" json:"window_seconds,omitempty"` + // deny_code is set on decision="deny" with a stable label. + DenyCode string `protobuf:"bytes,5,opt,name=deny_code,json=denyCode,proto3" json:"deny_code,omitempty"` + // deny_reason is a short human-readable explanation paired with deny_code. + DenyReason string `protobuf:"bytes,6,opt,name=deny_reason,json=denyReason,proto3" json:"deny_reason,omitempty"` +} + +func (x *CheckLLMPolicyLimitsResponse) Reset() { + *x = CheckLLMPolicyLimitsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CheckLLMPolicyLimitsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckLLMPolicyLimitsResponse) ProtoMessage() {} + +func (x *CheckLLMPolicyLimitsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[34] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckLLMPolicyLimitsResponse.ProtoReflect.Descriptor instead. +func (*CheckLLMPolicyLimitsResponse) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{34} +} + +func (x *CheckLLMPolicyLimitsResponse) GetDecision() string { + if x != nil { + return x.Decision + } + return "" +} + +func (x *CheckLLMPolicyLimitsResponse) GetSelectedPolicyId() string { + if x != nil { + return x.SelectedPolicyId + } + return "" +} + +func (x *CheckLLMPolicyLimitsResponse) GetAttributionGroupId() string { + if x != nil { + return x.AttributionGroupId + } + return "" +} + +func (x *CheckLLMPolicyLimitsResponse) GetWindowSeconds() int64 { + if x != nil { + return x.WindowSeconds + } + return 0 +} + +func (x *CheckLLMPolicyLimitsResponse) GetDenyCode() string { + if x != nil { + return x.DenyCode + } + return "" +} + +func (x *CheckLLMPolicyLimitsResponse) GetDenyReason() string { + if x != nil { + return x.DenyReason + } + return "" +} + +// RecordLLMUsageRequest is the post-flight increment the proxy posts after +// the upstream call. Counters are keyed on (account, dimension, window). +type RecordLLMUsageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + // group_id is the selected policy's attribution group, recorded against the + // policy window (window_seconds). + GroupId string `protobuf:"bytes,3,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + WindowSeconds int64 `protobuf:"varint,4,opt,name=window_seconds,json=windowSeconds,proto3" json:"window_seconds,omitempty"` + TokensInput int64 `protobuf:"varint,5,opt,name=tokens_input,json=tokensInput,proto3" json:"tokens_input,omitempty"` + TokensOutput int64 `protobuf:"varint,6,opt,name=tokens_output,json=tokensOutput,proto3" json:"tokens_output,omitempty"` + CostUsd float64 `protobuf:"fixed64,7,opt,name=cost_usd,json=costUsd,proto3" json:"cost_usd,omitempty"` + // group_ids is the caller's full group membership, used to fan the same + // usage out to every applicable account-level budget rule's own window. + GroupIds []string `protobuf:"bytes,8,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` +} + +func (x *RecordLLMUsageRequest) Reset() { + *x = RecordLLMUsageRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RecordLLMUsageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordLLMUsageRequest) ProtoMessage() {} + +func (x *RecordLLMUsageRequest) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[35] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordLLMUsageRequest.ProtoReflect.Descriptor instead. +func (*RecordLLMUsageRequest) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{35} +} + +func (x *RecordLLMUsageRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + +func (x *RecordLLMUsageRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *RecordLLMUsageRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *RecordLLMUsageRequest) GetWindowSeconds() int64 { + if x != nil { + return x.WindowSeconds + } + return 0 +} + +func (x *RecordLLMUsageRequest) GetTokensInput() int64 { + if x != nil { + return x.TokensInput + } + return 0 +} + +func (x *RecordLLMUsageRequest) GetTokensOutput() int64 { + if x != nil { + return x.TokensOutput + } + return 0 +} + +func (x *RecordLLMUsageRequest) GetCostUsd() float64 { + if x != nil { + return x.CostUsd + } + return 0 +} + +func (x *RecordLLMUsageRequest) GetGroupIds() []string { + if x != nil { + return x.GroupIds + } + return nil +} + +type RecordLLMUsageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *RecordLLMUsageResponse) Reset() { + *x = RecordLLMUsageResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RecordLLMUsageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordLLMUsageResponse) ProtoMessage() {} + +func (x *RecordLLMUsageResponse) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[36] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordLLMUsageResponse.ProtoReflect.Descriptor instead. +func (*RecordLLMUsageResponse) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{36} +} + var File_proxy_service_proto protoreflect.FileDescriptor var file_proxy_service_proto_rawDesc = []byte{ @@ -2610,7 +3220,7 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, - 0x22, 0xf7, 0x03, 0x0a, 0x11, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, + 0x22, 0xb6, 0x06, 0x0a, 0x11, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x74, 0x6c, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x54, 0x6c, 0x73, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x42, @@ -2637,368 +3247,479 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x70, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, - 0x55, 0x70, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x72, 0x0a, 0x0b, 0x50, 0x61, - 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, - 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x16, 0x0a, - 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, - 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x47, - 0x0a, 0x0a, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, - 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x68, 0x65, 0x64, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x61, 0x73, 0x68, - 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xe5, 0x01, 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, - 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x35, 0x0a, 0x17, 0x6d, - 0x61, 0x78, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x67, 0x65, 0x5f, 0x73, - 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x6d, 0x61, - 0x78, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x41, 0x67, 0x65, 0x53, 0x65, 0x63, 0x6f, 0x6e, - 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x10, - 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x70, 0x69, 0x6e, - 0x12, 0x12, 0x0a, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, - 0x6f, 0x69, 0x64, 0x63, 0x12, 0x39, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61, - 0x75, 0x74, 0x68, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, - 0x74, 0x68, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x73, 0x22, - 0xdd, 0x01, 0x0a, 0x12, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, - 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x61, - 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x62, - 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, - 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x61, 0x6c, 0x6c, - 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x2b, 0x0a, - 0x11, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, - 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, - 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x72, - 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, 0x64, 0x65, 0x22, - 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, - 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, - 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, - 0x2b, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1d, 0x0a, 0x0a, - 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x61, - 0x75, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x10, 0x70, - 0x61, 0x73, 0x73, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x48, 0x6f, 0x73, 0x74, 0x48, - 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, - 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x10, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, - 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, - 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x4f, 0x0a, 0x13, 0x61, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, - 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, - 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, - 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, - 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x84, 0x05, 0x0a, - 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, + 0x55, 0x70, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x39, 0x0a, 0x19, 0x63, 0x61, 0x70, 0x74, + 0x75, 0x72, 0x65, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, + 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x16, 0x63, 0x61, 0x70, + 0x74, 0x75, 0x72, 0x65, 0x4d, 0x61, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x42, 0x79, + 0x74, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x1a, 0x63, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6d, + 0x61, 0x78, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, + 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x17, 0x63, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, + 0x4d, 0x61, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, + 0x12, 0x32, 0x0a, 0x15, 0x63, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x13, 0x63, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, + 0x79, 0x70, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x0b, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, + 0x72, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, + 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, + 0x61, 0x72, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x69, 0x73, + 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x6c, 0x6f, 0x67, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74, 0x6f, + 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xd1, 0x02, 0x0a, 0x10, 0x4d, 0x69, + 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, + 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x04, 0x73, 0x6c, 0x6f, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, + 0x6f, 0x74, 0x52, 0x04, 0x73, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x42, 0x0a, 0x09, 0x66, 0x61, 0x69, + 0x6c, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, + 0x77, 0x61, 0x72, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x4d, + 0x6f, 0x64, 0x65, 0x52, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x33, 0x0a, + 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, + 0x75, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x61, 0x6e, 0x5f, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x65, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x61, 0x6e, 0x4d, 0x75, 0x74, 0x61, 0x74, + 0x65, 0x22, 0x2a, 0x0a, 0x08, 0x46, 0x61, 0x69, 0x6c, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x0d, 0x0a, + 0x09, 0x46, 0x41, 0x49, 0x4c, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, + 0x46, 0x41, 0x49, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x44, 0x10, 0x01, 0x22, 0x72, 0x0a, + 0x0b, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, 0x04, + 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, + 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, + 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x22, 0x47, 0x0a, 0x0a, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x12, + 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x68, 0x65, + 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, + 0x61, 0x73, 0x68, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xe5, 0x01, 0x0a, 0x0e, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, + 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x35, + 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x67, + 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x14, 0x6d, 0x61, 0x78, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x41, 0x67, 0x65, 0x53, 0x65, + 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, + 0x70, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x12, 0x39, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, + 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, + 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, + 0x68, 0x73, 0x22, 0xdd, 0x01, 0x0a, 0x12, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, + 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x6c, 0x6c, + 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0c, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, 0x23, + 0x0a, 0x0d, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x69, + 0x64, 0x72, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, + 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, + 0x12, 0x2b, 0x0a, 0x11, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6c, 0x6f, + 0x63, 0x6b, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, + 0x0d, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, + 0x64, 0x65, 0x22, 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, - 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, - 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, - 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, - 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, - 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, - 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, - 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, - 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, - 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, - 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, - 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, - 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, - 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, - 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, - 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, - 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, - 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, - 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02, - 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, - 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, - 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, - 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, - 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, - 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52, - 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, - 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, - 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, - 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, - 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b, - 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53, - 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, - 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, - 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, - 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, - 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, - 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, - 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, - 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, - 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, - 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, - 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, - 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, - 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, - 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, - 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, - 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, 0x0a, 0x17, 0x56, 0x61, - 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, - 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, - 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, - 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, - 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, - 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, - 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, - 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, - 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, - 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, - 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, - 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, - 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, - 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, - 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, - 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, - 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, - 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, - 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, - 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, - 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, - 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, - 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, - 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, - 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, - 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, - 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, - 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, - 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, - 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, - 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, - 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, - 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, - 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, - 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, - 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, - 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, - 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, - 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, - 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, - 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, - 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, - 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, - 0x52, 0x10, 0x05, 0x32, 0xb8, 0x06, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, - 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, - 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, - 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, - 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x12, 0x2b, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, + 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, + 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, + 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, + 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x28, + 0x0a, 0x10, 0x70, 0x61, 0x73, 0x73, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x48, 0x6f, + 0x73, 0x74, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x77, 0x72, + 0x69, 0x74, 0x65, 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x64, 0x69, + 0x72, 0x65, 0x63, 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, + 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, + 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x4f, 0x0a, 0x13, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, + 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, + 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, + 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, + 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, + 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0xa9, 0x05, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, + 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, + 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, + 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, + 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, + 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, + 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, + 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, + 0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, + 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, + 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, + 0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, + 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x1a, 0x3b, + 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, - 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, - 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, - 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, - 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, + 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, + 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, + 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, + 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, + 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, + 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, + 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, + 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, + 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, + 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, + 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, + 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, + 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, + 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, + 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, + 0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, + 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, + 0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, + 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, + 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, + 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, + 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, + 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, + 0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, + 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, + 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, + 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, + 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, + 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, + 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, + 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, + 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, - 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, - 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, - 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x22, 0xdc, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, + 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, + 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, + 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, + 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, + 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, + 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, + 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, + 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, + 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, + 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, + 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, + 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, + 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, + 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, + 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, + 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, + 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, + 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, + 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, + 0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, + 0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, + 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, + 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, + 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, + 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, + 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, + 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, + 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, + 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, + 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, + 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, + 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, + 0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, + 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, + 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, + 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, + 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, + 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, + 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, + 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, + 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, + 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, + 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, + 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, + 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, + 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, + 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, + 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, + 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, + 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, + 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, + 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, + 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, + 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, + 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, + 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, + 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, + 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, + 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, + 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, + 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, + 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, + 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, + 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, + 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, + 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, + 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, + 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, + 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, + 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, + 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, + 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, - 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, + 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -3013,99 +3734,114 @@ func file_proxy_service_proto_rawDescGZIP() []byte { return file_proxy_service_proto_rawDescData } -var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 34) +var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 39) var file_proxy_service_proto_goTypes = []interface{}{ - (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType - (PathRewriteMode)(0), // 1: management.PathRewriteMode - (ProxyStatus)(0), // 2: management.ProxyStatus - (*ProxyCapabilities)(nil), // 3: management.ProxyCapabilities - (*GetMappingUpdateRequest)(nil), // 4: management.GetMappingUpdateRequest - (*GetMappingUpdateResponse)(nil), // 5: management.GetMappingUpdateResponse - (*PathTargetOptions)(nil), // 6: management.PathTargetOptions - (*PathMapping)(nil), // 7: management.PathMapping - (*HeaderAuth)(nil), // 8: management.HeaderAuth - (*Authentication)(nil), // 9: management.Authentication - (*AccessRestrictions)(nil), // 10: management.AccessRestrictions - (*ProxyMapping)(nil), // 11: management.ProxyMapping - (*SendAccessLogRequest)(nil), // 12: management.SendAccessLogRequest - (*SendAccessLogResponse)(nil), // 13: management.SendAccessLogResponse - (*AccessLog)(nil), // 14: management.AccessLog - (*AuthenticateRequest)(nil), // 15: management.AuthenticateRequest - (*HeaderAuthRequest)(nil), // 16: management.HeaderAuthRequest - (*PasswordRequest)(nil), // 17: management.PasswordRequest - (*PinRequest)(nil), // 18: management.PinRequest - (*AuthenticateResponse)(nil), // 19: management.AuthenticateResponse - (*SendStatusUpdateRequest)(nil), // 20: management.SendStatusUpdateRequest - (*ProxyInboundListener)(nil), // 21: management.ProxyInboundListener - (*SendStatusUpdateResponse)(nil), // 22: management.SendStatusUpdateResponse - (*CreateProxyPeerRequest)(nil), // 23: management.CreateProxyPeerRequest - (*CreateProxyPeerResponse)(nil), // 24: management.CreateProxyPeerResponse - (*GetOIDCURLRequest)(nil), // 25: management.GetOIDCURLRequest - (*GetOIDCURLResponse)(nil), // 26: management.GetOIDCURLResponse - (*ValidateSessionRequest)(nil), // 27: management.ValidateSessionRequest - (*ValidateSessionResponse)(nil), // 28: management.ValidateSessionResponse - (*ValidateTunnelPeerRequest)(nil), // 29: management.ValidateTunnelPeerRequest - (*ValidateTunnelPeerResponse)(nil), // 30: management.ValidateTunnelPeerResponse - (*SyncMappingsRequest)(nil), // 31: management.SyncMappingsRequest - (*SyncMappingsInit)(nil), // 32: management.SyncMappingsInit - (*SyncMappingsAck)(nil), // 33: management.SyncMappingsAck - (*SyncMappingsResponse)(nil), // 34: management.SyncMappingsResponse - nil, // 35: management.PathTargetOptions.CustomHeadersEntry - nil, // 36: management.AccessLog.MetadataEntry - (*timestamppb.Timestamp)(nil), // 37: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 38: google.protobuf.Duration + (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType + (PathRewriteMode)(0), // 1: management.PathRewriteMode + (MiddlewareSlot)(0), // 2: management.MiddlewareSlot + (ProxyStatus)(0), // 3: management.ProxyStatus + (MiddlewareConfig_FailMode)(0), // 4: management.MiddlewareConfig.FailMode + (*ProxyCapabilities)(nil), // 5: management.ProxyCapabilities + (*GetMappingUpdateRequest)(nil), // 6: management.GetMappingUpdateRequest + (*GetMappingUpdateResponse)(nil), // 7: management.GetMappingUpdateResponse + (*PathTargetOptions)(nil), // 8: management.PathTargetOptions + (*MiddlewareConfig)(nil), // 9: management.MiddlewareConfig + (*PathMapping)(nil), // 10: management.PathMapping + (*HeaderAuth)(nil), // 11: management.HeaderAuth + (*Authentication)(nil), // 12: management.Authentication + (*AccessRestrictions)(nil), // 13: management.AccessRestrictions + (*ProxyMapping)(nil), // 14: management.ProxyMapping + (*SendAccessLogRequest)(nil), // 15: management.SendAccessLogRequest + (*SendAccessLogResponse)(nil), // 16: management.SendAccessLogResponse + (*AccessLog)(nil), // 17: management.AccessLog + (*AuthenticateRequest)(nil), // 18: management.AuthenticateRequest + (*HeaderAuthRequest)(nil), // 19: management.HeaderAuthRequest + (*PasswordRequest)(nil), // 20: management.PasswordRequest + (*PinRequest)(nil), // 21: management.PinRequest + (*AuthenticateResponse)(nil), // 22: management.AuthenticateResponse + (*SendStatusUpdateRequest)(nil), // 23: management.SendStatusUpdateRequest + (*ProxyInboundListener)(nil), // 24: management.ProxyInboundListener + (*SendStatusUpdateResponse)(nil), // 25: management.SendStatusUpdateResponse + (*CreateProxyPeerRequest)(nil), // 26: management.CreateProxyPeerRequest + (*CreateProxyPeerResponse)(nil), // 27: management.CreateProxyPeerResponse + (*GetOIDCURLRequest)(nil), // 28: management.GetOIDCURLRequest + (*GetOIDCURLResponse)(nil), // 29: management.GetOIDCURLResponse + (*ValidateSessionRequest)(nil), // 30: management.ValidateSessionRequest + (*ValidateSessionResponse)(nil), // 31: management.ValidateSessionResponse + (*ValidateTunnelPeerRequest)(nil), // 32: management.ValidateTunnelPeerRequest + (*ValidateTunnelPeerResponse)(nil), // 33: management.ValidateTunnelPeerResponse + (*SyncMappingsRequest)(nil), // 34: management.SyncMappingsRequest + (*SyncMappingsInit)(nil), // 35: management.SyncMappingsInit + (*SyncMappingsAck)(nil), // 36: management.SyncMappingsAck + (*SyncMappingsResponse)(nil), // 37: management.SyncMappingsResponse + (*CheckLLMPolicyLimitsRequest)(nil), // 38: management.CheckLLMPolicyLimitsRequest + (*CheckLLMPolicyLimitsResponse)(nil), // 39: management.CheckLLMPolicyLimitsResponse + (*RecordLLMUsageRequest)(nil), // 40: management.RecordLLMUsageRequest + (*RecordLLMUsageResponse)(nil), // 41: management.RecordLLMUsageResponse + nil, // 42: management.PathTargetOptions.CustomHeadersEntry + nil, // 43: management.AccessLog.MetadataEntry + (*timestamppb.Timestamp)(nil), // 44: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 45: google.protobuf.Duration } var file_proxy_service_proto_depIdxs = []int32{ - 37, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp - 3, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities - 11, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping - 38, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration + 44, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp + 5, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities + 14, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping + 45, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration 1, // 4: management.PathTargetOptions.path_rewrite:type_name -> management.PathRewriteMode - 35, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry - 38, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration - 6, // 7: management.PathMapping.options:type_name -> management.PathTargetOptions - 8, // 8: management.Authentication.header_auths:type_name -> management.HeaderAuth - 0, // 9: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType - 7, // 10: management.ProxyMapping.path:type_name -> management.PathMapping - 9, // 11: management.ProxyMapping.auth:type_name -> management.Authentication - 10, // 12: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions - 14, // 13: management.SendAccessLogRequest.log:type_name -> management.AccessLog - 37, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp - 36, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry - 17, // 16: management.AuthenticateRequest.password:type_name -> management.PasswordRequest - 18, // 17: management.AuthenticateRequest.pin:type_name -> management.PinRequest - 16, // 18: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest - 2, // 19: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus - 21, // 20: management.SendStatusUpdateRequest.inbound_listener:type_name -> management.ProxyInboundListener - 32, // 21: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit - 33, // 22: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck - 37, // 23: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp - 3, // 24: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities - 11, // 25: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping - 4, // 26: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest - 31, // 27: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest - 12, // 28: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest - 15, // 29: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest - 20, // 30: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest - 23, // 31: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest - 25, // 32: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest - 27, // 33: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest - 29, // 34: management.ProxyService.ValidateTunnelPeer:input_type -> management.ValidateTunnelPeerRequest - 5, // 35: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse - 34, // 36: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse - 13, // 37: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse - 19, // 38: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse - 22, // 39: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse - 24, // 40: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse - 26, // 41: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse - 28, // 42: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse - 30, // 43: management.ProxyService.ValidateTunnelPeer:output_type -> management.ValidateTunnelPeerResponse - 35, // [35:44] is the sub-list for method output_type - 26, // [26:35] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 42, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry + 45, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration + 9, // 7: management.PathTargetOptions.middlewares:type_name -> management.MiddlewareConfig + 2, // 8: management.MiddlewareConfig.slot:type_name -> management.MiddlewareSlot + 4, // 9: management.MiddlewareConfig.fail_mode:type_name -> management.MiddlewareConfig.FailMode + 45, // 10: management.MiddlewareConfig.timeout:type_name -> google.protobuf.Duration + 8, // 11: management.PathMapping.options:type_name -> management.PathTargetOptions + 11, // 12: management.Authentication.header_auths:type_name -> management.HeaderAuth + 0, // 13: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType + 10, // 14: management.ProxyMapping.path:type_name -> management.PathMapping + 12, // 15: management.ProxyMapping.auth:type_name -> management.Authentication + 13, // 16: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions + 17, // 17: management.SendAccessLogRequest.log:type_name -> management.AccessLog + 44, // 18: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp + 43, // 19: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry + 20, // 20: management.AuthenticateRequest.password:type_name -> management.PasswordRequest + 21, // 21: management.AuthenticateRequest.pin:type_name -> management.PinRequest + 19, // 22: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest + 3, // 23: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus + 24, // 24: management.SendStatusUpdateRequest.inbound_listener:type_name -> management.ProxyInboundListener + 35, // 25: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit + 36, // 26: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck + 44, // 27: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp + 5, // 28: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities + 14, // 29: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping + 6, // 30: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest + 34, // 31: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest + 15, // 32: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest + 18, // 33: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest + 23, // 34: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest + 26, // 35: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest + 28, // 36: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest + 30, // 37: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest + 32, // 38: management.ProxyService.ValidateTunnelPeer:input_type -> management.ValidateTunnelPeerRequest + 38, // 39: management.ProxyService.CheckLLMPolicyLimits:input_type -> management.CheckLLMPolicyLimitsRequest + 40, // 40: management.ProxyService.RecordLLMUsage:input_type -> management.RecordLLMUsageRequest + 7, // 41: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse + 37, // 42: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse + 16, // 43: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse + 22, // 44: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse + 25, // 45: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse + 27, // 46: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse + 29, // 47: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse + 31, // 48: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse + 33, // 49: management.ProxyService.ValidateTunnelPeer:output_type -> management.ValidateTunnelPeerResponse + 39, // 50: management.ProxyService.CheckLLMPolicyLimits:output_type -> management.CheckLLMPolicyLimitsResponse + 41, // 51: management.ProxyService.RecordLLMUsage:output_type -> management.RecordLLMUsageResponse + 41, // [41:52] is the sub-list for method output_type + 30, // [30:41] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name } func init() { file_proxy_service_proto_init() } @@ -3163,7 +3899,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PathMapping); i { + switch v := v.(*MiddlewareConfig); i { case 0: return &v.state case 1: @@ -3175,7 +3911,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*HeaderAuth); i { + switch v := v.(*PathMapping); i { case 0: return &v.state case 1: @@ -3187,7 +3923,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Authentication); i { + switch v := v.(*HeaderAuth); i { case 0: return &v.state case 1: @@ -3199,7 +3935,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AccessRestrictions); i { + switch v := v.(*Authentication); i { case 0: return &v.state case 1: @@ -3211,7 +3947,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProxyMapping); i { + switch v := v.(*AccessRestrictions); i { case 0: return &v.state case 1: @@ -3223,7 +3959,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendAccessLogRequest); i { + switch v := v.(*ProxyMapping); i { case 0: return &v.state case 1: @@ -3235,7 +3971,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendAccessLogResponse); i { + switch v := v.(*SendAccessLogRequest); i { case 0: return &v.state case 1: @@ -3247,7 +3983,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AccessLog); i { + switch v := v.(*SendAccessLogResponse); i { case 0: return &v.state case 1: @@ -3259,7 +3995,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AuthenticateRequest); i { + switch v := v.(*AccessLog); i { case 0: return &v.state case 1: @@ -3271,7 +4007,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*HeaderAuthRequest); i { + switch v := v.(*AuthenticateRequest); i { case 0: return &v.state case 1: @@ -3283,7 +4019,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PasswordRequest); i { + switch v := v.(*HeaderAuthRequest); i { case 0: return &v.state case 1: @@ -3295,7 +4031,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PinRequest); i { + switch v := v.(*PasswordRequest); i { case 0: return &v.state case 1: @@ -3307,7 +4043,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AuthenticateResponse); i { + switch v := v.(*PinRequest); i { case 0: return &v.state case 1: @@ -3319,7 +4055,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendStatusUpdateRequest); i { + switch v := v.(*AuthenticateResponse); i { case 0: return &v.state case 1: @@ -3331,7 +4067,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProxyInboundListener); i { + switch v := v.(*SendStatusUpdateRequest); i { case 0: return &v.state case 1: @@ -3343,7 +4079,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SendStatusUpdateResponse); i { + switch v := v.(*ProxyInboundListener); i { case 0: return &v.state case 1: @@ -3355,7 +4091,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateProxyPeerRequest); i { + switch v := v.(*SendStatusUpdateResponse); i { case 0: return &v.state case 1: @@ -3367,7 +4103,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateProxyPeerResponse); i { + switch v := v.(*CreateProxyPeerRequest); i { case 0: return &v.state case 1: @@ -3379,7 +4115,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOIDCURLRequest); i { + switch v := v.(*CreateProxyPeerResponse); i { case 0: return &v.state case 1: @@ -3391,7 +4127,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOIDCURLResponse); i { + switch v := v.(*GetOIDCURLRequest); i { case 0: return &v.state case 1: @@ -3403,7 +4139,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateSessionRequest); i { + switch v := v.(*GetOIDCURLResponse); i { case 0: return &v.state case 1: @@ -3415,7 +4151,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateSessionResponse); i { + switch v := v.(*ValidateSessionRequest); i { case 0: return &v.state case 1: @@ -3427,7 +4163,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateTunnelPeerRequest); i { + switch v := v.(*ValidateSessionResponse); i { case 0: return &v.state case 1: @@ -3439,7 +4175,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateTunnelPeerResponse); i { + switch v := v.(*ValidateTunnelPeerRequest); i { case 0: return &v.state case 1: @@ -3451,7 +4187,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SyncMappingsRequest); i { + switch v := v.(*ValidateTunnelPeerResponse); i { case 0: return &v.state case 1: @@ -3463,7 +4199,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SyncMappingsInit); i { + switch v := v.(*SyncMappingsRequest); i { case 0: return &v.state case 1: @@ -3475,7 +4211,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SyncMappingsAck); i { + switch v := v.(*SyncMappingsInit); i { case 0: return &v.state case 1: @@ -3487,6 +4223,18 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SyncMappingsAck); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SyncMappingsResponse); i { case 0: return &v.state @@ -3498,16 +4246,64 @@ func file_proxy_service_proto_init() { return nil } } + file_proxy_service_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CheckLLMPolicyLimitsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CheckLLMPolicyLimitsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RecordLLMUsageRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RecordLLMUsageResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_proxy_service_proto_msgTypes[0].OneofWrappers = []interface{}{} - file_proxy_service_proto_msgTypes[12].OneofWrappers = []interface{}{ + file_proxy_service_proto_msgTypes[13].OneofWrappers = []interface{}{ (*AuthenticateRequest_Password)(nil), (*AuthenticateRequest_Pin)(nil), (*AuthenticateRequest_HeaderAuth)(nil), } - file_proxy_service_proto_msgTypes[17].OneofWrappers = []interface{}{} - file_proxy_service_proto_msgTypes[21].OneofWrappers = []interface{}{} - file_proxy_service_proto_msgTypes[28].OneofWrappers = []interface{}{ + file_proxy_service_proto_msgTypes[18].OneofWrappers = []interface{}{} + file_proxy_service_proto_msgTypes[22].OneofWrappers = []interface{}{} + file_proxy_service_proto_msgTypes[29].OneofWrappers = []interface{}{ (*SyncMappingsRequest_Init)(nil), (*SyncMappingsRequest_Ack)(nil), } @@ -3516,8 +4312,8 @@ func file_proxy_service_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proxy_service_proto_rawDesc, - NumEnums: 3, - NumMessages: 34, + NumEnums: 5, + NumMessages: 39, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index 14d188877..89d1f8749 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -43,6 +43,18 @@ service ProxyService { // issue a session cookie without redirecting through the OIDC flow. // Mirrors ValidateSession's response shape. rpc ValidateTunnelPeer(ValidateTunnelPeerRequest) returns (ValidateTunnelPeerResponse); + + // CheckLLMPolicyLimits is the pre-flight RPC the proxy calls before each + // LLM request. Management runs the per-policy headroom selection across + // every policy authorising the caller's user / groups for the resolved + // provider and returns the chosen attribution policy + group, or a deny + // when no applicable policy has headroom > 0. + rpc CheckLLMPolicyLimits(CheckLLMPolicyLimitsRequest) returns (CheckLLMPolicyLimitsResponse); + + // RecordLLMUsage is the post-flight RPC the proxy calls after the upstream + // returns. Increments the per-(dimension, window) counters for the + // attribution policy chosen by CheckLLMPolicyLimits. + rpc RecordLLMUsage(RecordLLMUsageRequest) returns (RecordLLMUsageResponse); } // ProxyCapabilities describes what a proxy can handle. @@ -107,6 +119,59 @@ message PathTargetOptions { // reachable without WireGuard (public APIs, LAN services, localhost // sidecars). Defaults to false — embedded client is the standard path. bool direct_upstream = 7; + // Proxy clamps to [0, proxy-wide max (1 MiB)] at apply time. Agent-network + // synthesized targets only; private services leave these zero. + int64 capture_max_request_bytes = 8; + // Proxy clamps to [0, proxy-wide max (1 MiB)] at apply time. + int64 capture_max_response_bytes = 9; + // Content types eligible for body capture (e.g. "application/json"). + repeated string capture_content_types = 10; + // Per-target middleware configurations populated by the agent-network + // synthesizer. Validated and clamped by the proxy at apply time. + repeated MiddlewareConfig middlewares = 11; + // When true, the proxy stamps agent_network=true on access-log entries + // for this target so management routes them to the agent-network log + // surface. + bool agent_network = 12; + // When true, the proxy suppresses the per-request access-log emission for + // this target. Defaults false to preserve existing access-log behavior for + // every non-agent-network target. The agent-network synth target sets this + // true only when the account's EnableLogCollection toggle is off. + bool disable_access_log = 13; +} + +// MiddlewareSlot identifies where in the request lifecycle a middleware +// runs. Mirrors proxy/internal/middleware.Slot. +enum MiddlewareSlot { + MIDDLEWARE_SLOT_UNSPECIFIED = 0; + MIDDLEWARE_SLOT_ON_REQUEST = 1; + MIDDLEWARE_SLOT_ON_RESPONSE = 2; + MIDDLEWARE_SLOT_TERMINAL = 3; +} + +// MiddlewareConfig is the per-target configuration for a single middleware. +// The proxy validates every incoming MiddlewareConfig at apply time: +// unknown ids are rejected, timeout is clamped to [10ms, 5s], and the +// declared slot must match the registered middleware's slot. +message MiddlewareConfig { + // Middleware id; must match the proxy-local compiled-in registry. + string id = 1; + bool enabled = 2; + MiddlewareSlot slot = 3; + // Free-form JSON unmarshalled by the middleware factory into its own typed + // config struct. Empty / null / {} are valid (zero-value config). + bytes config_json = 4; + enum FailMode { + FAIL_OPEN = 0; + FAIL_CLOSED = 1; + } + FailMode fail_mode = 5; + // Clamped to [10ms, 5s] at apply time; zero → 500ms default. + google.protobuf.Duration timeout = 6; + // When true, the middleware may mutate request headers or body (subject to + // policy). Honoured only when the implementation also declares + // MutationsSupported. + bool can_mutate = 7; } message PathMapping { @@ -190,6 +255,10 @@ message AccessLog { string protocol = 16; // Extra key-value metadata for the access log entry (e.g. crowdsec_verdict, scenario). map metadata = 17; + // When true, the entry was emitted by an agent-network synth service. + // Management routes these to the agent-network access-log surface instead + // of the standard service log. + bool agent_network = 18; } message AuthenticateRequest { @@ -376,3 +445,59 @@ message SyncMappingsResponse { bool initial_sync_complete = 2; } +// CheckLLMPolicyLimitsRequest carries the resolved caller identity and the +// upstream provider already chosen by llm_router. Management computes which +// policies authorise the request, picks the one with the most remaining +// headroom, and returns the attribution decision. +message CheckLLMPolicyLimitsRequest { + // account_id is the netbird account the request belongs to. + string account_id = 1; + // user_id is the netbird user id of the caller. May be empty when the + // principal is a tunnel-peer that isn't bound to a user; group membership + // still gates the request in that case. + string user_id = 2; + // group_ids is the caller's full group membership at request time. + repeated string group_ids = 3; + // provider_id is the agent-network provider record id chosen by llm_router. + string provider_id = 4; + // model is the upstream model identifier extracted from the request body. + string model = 5; +} + +// CheckLLMPolicyLimitsResponse is management's allow-or-deny decision for a +// pre-flight check. +message CheckLLMPolicyLimitsResponse { + // decision is "allow" or "deny". + string decision = 1; + // selected_policy_id names the policy that paid for this request. + string selected_policy_id = 2; + // attribution_group_id is the source group the request booked against. + string attribution_group_id = 3; + // window_seconds is the cap window length the selected policy uses. + int64 window_seconds = 4; + // deny_code is set on decision="deny" with a stable label. + string deny_code = 5; + // deny_reason is a short human-readable explanation paired with deny_code. + string deny_reason = 6; +} + +// RecordLLMUsageRequest is the post-flight increment the proxy posts after +// the upstream call. Counters are keyed on (account, dimension, window). +message RecordLLMUsageRequest { + string account_id = 1; + string user_id = 2; + // group_id is the selected policy's attribution group, recorded against the + // policy window (window_seconds). + string group_id = 3; + int64 window_seconds = 4; + int64 tokens_input = 5; + int64 tokens_output = 6; + double cost_usd = 7; + // group_ids is the caller's full group membership, used to fan the same + // usage out to every applicable account-level budget rule's own window. + repeated string group_ids = 8; +} + +message RecordLLMUsageResponse { +} + diff --git a/shared/management/proto/proxy_service_grpc.pb.go b/shared/management/proto/proxy_service_grpc.pb.go index 40064fe61..76c1f005f 100644 --- a/shared/management/proto/proxy_service_grpc.pb.go +++ b/shared/management/proto/proxy_service_grpc.pb.go @@ -43,6 +43,16 @@ type ProxyServiceClient interface { // issue a session cookie without redirecting through the OIDC flow. // Mirrors ValidateSession's response shape. ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error) + // CheckLLMPolicyLimits is the pre-flight RPC the proxy calls before each + // LLM request. Management runs the per-policy headroom selection across + // every policy authorising the caller's user / groups for the resolved + // provider and returns the chosen attribution policy + group, or a deny + // when no applicable policy has headroom > 0. + CheckLLMPolicyLimits(ctx context.Context, in *CheckLLMPolicyLimitsRequest, opts ...grpc.CallOption) (*CheckLLMPolicyLimitsResponse, error) + // RecordLLMUsage is the post-flight RPC the proxy calls after the upstream + // returns. Increments the per-(dimension, window) counters for the + // attribution policy chosen by CheckLLMPolicyLimits. + RecordLLMUsage(ctx context.Context, in *RecordLLMUsageRequest, opts ...grpc.CallOption) (*RecordLLMUsageResponse, error) } type proxyServiceClient struct { @@ -179,6 +189,24 @@ func (c *proxyServiceClient) ValidateTunnelPeer(ctx context.Context, in *Validat return out, nil } +func (c *proxyServiceClient) CheckLLMPolicyLimits(ctx context.Context, in *CheckLLMPolicyLimitsRequest, opts ...grpc.CallOption) (*CheckLLMPolicyLimitsResponse, error) { + out := new(CheckLLMPolicyLimitsResponse) + err := c.cc.Invoke(ctx, "/management.ProxyService/CheckLLMPolicyLimits", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *proxyServiceClient) RecordLLMUsage(ctx context.Context, in *RecordLLMUsageRequest, opts ...grpc.CallOption) (*RecordLLMUsageResponse, error) { + out := new(RecordLLMUsageResponse) + err := c.cc.Invoke(ctx, "/management.ProxyService/RecordLLMUsage", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // ProxyServiceServer is the server API for ProxyService service. // All implementations must embed UnimplementedProxyServiceServer // for forward compatibility @@ -208,6 +236,16 @@ type ProxyServiceServer interface { // issue a session cookie without redirecting through the OIDC flow. // Mirrors ValidateSession's response shape. ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error) + // CheckLLMPolicyLimits is the pre-flight RPC the proxy calls before each + // LLM request. Management runs the per-policy headroom selection across + // every policy authorising the caller's user / groups for the resolved + // provider and returns the chosen attribution policy + group, or a deny + // when no applicable policy has headroom > 0. + CheckLLMPolicyLimits(context.Context, *CheckLLMPolicyLimitsRequest) (*CheckLLMPolicyLimitsResponse, error) + // RecordLLMUsage is the post-flight RPC the proxy calls after the upstream + // returns. Increments the per-(dimension, window) counters for the + // attribution policy chosen by CheckLLMPolicyLimits. + RecordLLMUsage(context.Context, *RecordLLMUsageRequest) (*RecordLLMUsageResponse, error) mustEmbedUnimplementedProxyServiceServer() } @@ -242,6 +280,12 @@ func (UnimplementedProxyServiceServer) ValidateSession(context.Context, *Validat func (UnimplementedProxyServiceServer) ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ValidateTunnelPeer not implemented") } +func (UnimplementedProxyServiceServer) CheckLLMPolicyLimits(context.Context, *CheckLLMPolicyLimitsRequest) (*CheckLLMPolicyLimitsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckLLMPolicyLimits not implemented") +} +func (UnimplementedProxyServiceServer) RecordLLMUsage(context.Context, *RecordLLMUsageRequest) (*RecordLLMUsageResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RecordLLMUsage not implemented") +} func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {} // UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service. @@ -428,6 +472,42 @@ func _ProxyService_ValidateTunnelPeer_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } +func _ProxyService_CheckLLMPolicyLimits_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckLLMPolicyLimitsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProxyServiceServer).CheckLLMPolicyLimits(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/management.ProxyService/CheckLLMPolicyLimits", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProxyServiceServer).CheckLLMPolicyLimits(ctx, req.(*CheckLLMPolicyLimitsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProxyService_RecordLLMUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RecordLLMUsageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProxyServiceServer).RecordLLMUsage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/management.ProxyService/RecordLLMUsage", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProxyServiceServer).RecordLLMUsage(ctx, req.(*RecordLLMUsageRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -463,6 +543,14 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ValidateTunnelPeer", Handler: _ProxyService_ValidateTunnelPeer_Handler, }, + { + MethodName: "CheckLLMPolicyLimits", + Handler: _ProxyService_CheckLLMPolicyLimits_Handler, + }, + { + MethodName: "RecordLLMUsage", + Handler: _ProxyService_RecordLLMUsage_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/shared/management/status/error.go b/shared/management/status/error.go index 1957c5591..e31663450 100644 --- a/shared/management/status/error.go +++ b/shared/management/status/error.go @@ -219,6 +219,26 @@ func NewNetworkResourceNotFoundError(resourceID string) error { return Errorf(NotFound, "network resource: %s not found", resourceID) } +// NewAgentNetworkProviderNotFoundError creates a new Error with NotFound type for a missing Agent Network provider. +func NewAgentNetworkProviderNotFoundError(providerID string) error { + return Errorf(NotFound, "agent network provider: %s not found", providerID) +} + +// NewAgentNetworkPolicyNotFoundError creates a new Error with NotFound type for a missing Agent Network policy. +func NewAgentNetworkPolicyNotFoundError(policyID string) error { + return Errorf(NotFound, "agent network policy: %s not found", policyID) +} + +// NewAgentNetworkGuardrailNotFoundError creates a new Error with NotFound type for a missing Agent Network guardrail. +func NewAgentNetworkGuardrailNotFoundError(guardrailID string) error { + return Errorf(NotFound, "agent network guardrail: %s not found", guardrailID) +} + +// NewAgentNetworkBudgetRuleNotFoundError creates a new Error with NotFound type for a missing Agent Network budget rule. +func NewAgentNetworkBudgetRuleNotFoundError(ruleID string) error { + return Errorf(NotFound, "agent network budget rule: %s not found", ruleID) +} + // NewPermissionDeniedError creates a new Error with PermissionDenied type for a permission denied error. func NewPermissionDeniedError() error { return Errorf(PermissionDenied, "permission denied") From 980598ed4ab21d4ba3ec5bb45b88c7471a050864 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 1 Jul 2026 14:50:25 +0200 Subject: [PATCH 14/36] [management, client] Add management-controlled client metrics push (#5886) * [management, client] Add management-controlled client metrics push Allow enabling/disabling client metrics push from the dashboard via account settings instead of requiring env vars on every client. - Add MetricsConfig proto message to NetbirdConfig - Add MetricsPushEnabled to account Settings (DB-persisted) - Expose metrics_push_enabled in OpenAPI and dashboard API handler - Populate MetricsConfig in sync and login responses - Client dynamically starts/stops push based on management config - NB_METRICS_PUSH_ENABLED env var overrides management when explicitly set - Add activity events for metrics push enable/disable * Remove log line * [management] Fix peer update test for MetricsConfig in NetbirdConfig Update TestUpdateAccountPeers assertions: NetbirdConfig is no longer nil in peer update responses since it now carries MetricsConfig even when STUN/TURN config is absent. * Regenerate proto files with protoc v7.34.1 * [management] Read metrics push setting in Postgres account query getAccountPgx omitted settings_metrics_push_enabled from its hand-written SELECT and Scan, so the toggle was always read back as false on Postgres and never reached clients. * [client] Fix metrics push getting stuck off after engine restart Engine restarts (backoff retries within the same login session) cancel e.ctx, which the push goroutine's lifetime was tied to. The goroutine died silently but ClientMetrics.push stayed non-nil since only an explicit stop clears it, so the next UpdatePushFromMgm call saw a "push already running" state and never restarted it. Give the Engine its own metricsCtx sourced from ConnectClient.ctx, which outlives engine restarts, so handleMetricsUpdate stops tying the push to the wrong-scoped context. Additionally make ClientMetrics.push an atomic.Pointer that the push goroutine clears via CompareAndSwap on exit, so the tracked state can never drift from the goroutine's actual lifetime regardless of which context a future caller passes in. * [management] Regenerate OpenAPI types with oapi-codegen v2.7.1 types.gen.go was regenerated with a stale local v2.6.0 binary, causing the CI git-diff check against generate.sh's pinned v2.7.1 to fail. --- client/internal/connect.go | 5 + client/internal/engine.go | 13 + client/internal/metrics/env.go | 7 + client/internal/metrics/metrics.go | 74 +- .../internals/shared/grpc/conversion.go | 19 +- management/internals/shared/grpc/server.go | 2 +- management/server/account.go | 14 +- management/server/activity/codes.go | 8 + .../handlers/accounts/accounts_handler.go | 4 + .../accounts/accounts_handler_test.go | 6 + management/server/peer_test.go | 6 +- management/server/store/sql_store.go | 8 +- management/server/types/settings.go | 4 + shared/management/http/api/openapi.yml | 4 + shared/management/http/api/types.gen.go | 3 + shared/management/proto/management.pb.go | 659 ++++++++++-------- shared/management/proto/management.proto | 6 + 17 files changed, 526 insertions(+), 316 deletions(-) diff --git a/client/internal/connect.go b/client/internal/connect.go index 7cd2bab22..eff2c9489 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -314,6 +314,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true) c.statusRecorder.MarkManagementConnected() + if metricsConfig := loginResp.GetNetbirdConfig().GetMetrics(); metricsConfig != nil { + c.clientMetrics.UpdatePushFromMgm(c.ctx, metricsConfig.GetEnabled()) + } + localPeerState := peer.LocalPeerState{ IP: loginResp.GetPeerConfig().GetAddress(), PubKey: myPrivateKey.PublicKey().String(), @@ -399,6 +403,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan StateManager: stateManager, UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, + MetricsCtx: c.ctx, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine diff --git a/client/internal/engine.go b/client/internal/engine.go index de151592d..fb1d08f5e 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -172,6 +172,7 @@ type EngineServices struct { StateManager *statemanager.Manager UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics + MetricsCtx context.Context } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -264,6 +265,7 @@ type Engine struct { // clientMetrics collects and pushes metrics clientMetrics *metrics.ClientMetrics + metricsCtx context.Context jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup @@ -316,6 +318,7 @@ func NewEngine( probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), jobExecutor: jobexec.NewExecutor(), clientMetrics: services.ClientMetrics, + metricsCtx: services.MetricsCtx, updateManager: services.UpdateManager, syncStoreDir: config.StateDir, } @@ -997,6 +1000,8 @@ func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error { return fmt.Errorf("handle the flow configuration: %w", err) } + e.handleMetricsUpdate(wCfg.GetMetrics()) + if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil { log.Warnf("Failed to update DNS server config: %v", err) } @@ -1066,6 +1071,14 @@ func (e *Engine) handleFlowUpdate(config *mgmProto.FlowConfig) error { return e.flowManager.Update(flowConfig) } +func (e *Engine) handleMetricsUpdate(config *mgmProto.MetricsConfig) { + if config == nil { + return + } + log.Infof("received metrics configuration from management: enabled=%v", config.GetEnabled()) + e.clientMetrics.UpdatePushFromMgm(e.metricsCtx, config.GetEnabled()) +} + func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error) { if config.GetInterval() == nil { return nil, errors.New("flow interval is nil") diff --git a/client/internal/metrics/env.go b/client/internal/metrics/env.go index 1f06ce484..c19dcc7f1 100644 --- a/client/internal/metrics/env.go +++ b/client/internal/metrics/env.go @@ -60,6 +60,13 @@ func getMetricsInterval() time.Duration { return interval } +// isMetricsPushEnvSet returns true if NB_METRICS_PUSH_ENABLED is explicitly set (to any value). +// When set, the env var takes full precedence over management server configuration. +func isMetricsPushEnvSet() bool { + _, set := os.LookupEnv(EnvMetricsPushEnabled) + return set +} + func isForceSending() bool { force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending)) return force diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index f18082995..cfe477107 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" @@ -75,7 +76,7 @@ type ClientMetrics struct { agentInfo AgentInfo mu sync.RWMutex - push *Push + push atomic.Pointer[Push] pushMu sync.Mutex wg sync.WaitGroup pushCancel context.CancelFunc @@ -167,10 +168,7 @@ func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) { c.agentInfo = agentInfo c.mu.Unlock() - c.pushMu.Lock() - push := c.push - c.pushMu.Unlock() - if push != nil { + if push := c.push.Load(); push != nil { push.SetPeerID(agentInfo.peerID) } } @@ -184,7 +182,7 @@ func (c *ClientMetrics) Export(w io.Writer) error { return c.impl.Export(w) } -// StartPush starts periodic pushing of metrics with the given configuration +// StartPush starts periodic pushing of metrics with the given configuration. // Precedence: PushConfig.ServerAddress > remote config server_url func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { if c == nil { @@ -194,11 +192,58 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { c.pushMu.Lock() defer c.pushMu.Unlock() - if c.push != nil { + if c.push.Load() != nil { log.Warnf("metrics push already running") return } + c.startPushLocked(ctx, config) +} + +// StopPush stops the periodic metrics push. +func (c *ClientMetrics) StopPush() { + if c == nil { + return + } + c.pushMu.Lock() + defer c.pushMu.Unlock() + + c.stopPushLocked() +} + +// UpdatePushFromMgm updates metrics push based on management server configuration. +// If NB_METRICS_PUSH_ENABLED is explicitly set (true or false), management config is ignored. +// When unset, management controls whether push is enabled. +func (c *ClientMetrics) UpdatePushFromMgm(ctx context.Context, enabled bool) { + if c == nil { + return + } + + if isMetricsPushEnvSet() { + log.Debugf("ignoring management config, env var is explicitly set: %s", EnvMetricsPushEnabled) + return + } + + c.pushMu.Lock() + defer c.pushMu.Unlock() + + if enabled { + if c.push.Load() != nil { + return + } + log.Infof("enabled metrics push by management") + c.startPushLocked(ctx, PushConfigFromEnv()) + } else { + if c.push.Load() == nil { + return + } + log.Infof("disabled metrics push by management") + c.stopPushLocked() + } +} + +// startPushLocked starts push. Caller must hold pushMu. +func (c *ClientMetrics) startPushLocked(ctx context.Context, config PushConfig) { c.mu.RLock() agentVersion := c.agentInfo.Version peerID := c.agentInfo.peerID @@ -214,26 +259,23 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { ctx, cancel := context.WithCancel(ctx) c.pushCancel = cancel + c.push.Store(push) c.wg.Add(1) go func() { defer c.wg.Done() push.Start(ctx) + c.push.CompareAndSwap(push, nil) }() - c.push = push } -func (c *ClientMetrics) StopPush() { - if c == nil { - return - } - c.pushMu.Lock() - defer c.pushMu.Unlock() - if c.push == nil { +// stopPushLocked stops push. Caller must hold pushMu. +func (c *ClientMetrics) stopPushLocked() { + if c.push.Load() == nil { return } c.pushCancel() c.wg.Wait() - c.push = nil + c.push.Store(nil) } diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index ced982a30..973749eb0 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -47,9 +47,16 @@ func init() { precomputedDeprecatedRemotePeersConstraint = constraint } -func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings) *proto.NetbirdConfig { +func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig { if config == nil { - return nil + if settings == nil { + return nil + } + return &proto.NetbirdConfig{ + Metrics: &proto.MetricsConfig{ + Enabled: settings.MetricsPushEnabled, + }, + } } var stuns []*proto.HostConfig @@ -110,6 +117,12 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken Relay: relayCfg, } + if settings != nil { + nbConfig.Metrics = &proto.MetricsConfig{ + Enabled: settings.MetricsPushEnabled, + } + } + return nbConfig } @@ -166,7 +179,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb Checks: toProtocolChecks(ctx, checks), } - nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings) + nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings, settings) extendedConfig := integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings) response.NetbirdConfig = extendedConfig diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 476aaa9d6..fa06687d0 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -917,7 +917,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ - NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil), + NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings), PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH), Checks: toProtocolChecks(ctx, postureChecks), } diff --git a/management/server/account.go b/management/server/account.go index 2c57c4637..94335cf27 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -358,7 +358,8 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion || oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways || oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled || - oldSettings.PeerLoginExpiration != newSettings.PeerLoginExpiration { + oldSettings.PeerLoginExpiration != newSettings.PeerLoginExpiration || + oldSettings.MetricsPushEnabled != newSettings.MetricsPushEnabled { // Session deadline is derived from LastLogin + PeerLoginExpiration // on every Login/Sync response. Without a fan-out push, connected // peers keep the deadline they received at login time and only see @@ -409,6 +410,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco am.handleAutoUpdateVersionSettings(ctx, oldSettings, newSettings, userID, accountID) am.handleAutoUpdateAlwaysSettings(ctx, oldSettings, newSettings, userID, accountID) am.handlePeerExposeSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handleMetricsPushSettings(ctx, oldSettings, newSettings, userID, accountID) if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { return nil, err } @@ -563,6 +565,16 @@ func (am *DefaultAccountManager) handleLazyConnectionSettings(ctx context.Contex } } +func (am *DefaultAccountManager) handleMetricsPushSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.MetricsPushEnabled != newSettings.MetricsPushEnabled { + if newSettings.MetricsPushEnabled { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountMetricsPushEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountMetricsPushDisabled, nil) + } + } +} + func (am *DefaultAccountManager) handlePeerLoginExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { if oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled { event := activity.AccountPeerLoginExpirationEnabled diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index cfd809871..eaa638fac 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -276,6 +276,11 @@ const ( // AgentNetworkSettingsUpdated indicates that a user updated Agent Network account settings AgentNetworkSettingsUpdated Activity = 139 + // AccountMetricsPushEnabled indicates that a user enabled metrics push for the account + AccountMetricsPushEnabled Activity = 140 + // AccountMetricsPushDisabled indicates that a user disabled metrics push for the account + AccountMetricsPushDisabled Activity = 141 + AccountDeleted Activity = 99999 ) @@ -449,6 +454,9 @@ var activityMap = map[Activity]Code{ AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"}, + AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"}, + AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"}, + DomainAdded: {"Domain added", "domain.add"}, DomainDeleted: {"Domain deleted", "domain.delete"}, DomainValidated: {"Domain validated", "domain.validate"}, diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 209d593bd..d4342bf57 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -283,6 +283,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.Ipv6EnabledGroups != nil { returnSettings.IPv6EnabledGroups = *req.Settings.Ipv6EnabledGroups } + if req.Settings.MetricsPushEnabled != nil { + returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled + } return returnSettings, nil } @@ -413,6 +416,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A AutoUpdateVersion: &settings.AutoUpdateVersion, AutoUpdateAlways: &settings.AutoUpdateAlways, Ipv6EnabledGroups: &settings.IPv6EnabledGroups, + MetricsPushEnabled: &settings.MetricsPushEnabled, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, LocalMfaEnabled: &settings.LocalMfaEnabled, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 8db76719c..df89fde9a 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -129,6 +129,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { DnsDomain: sr(""), AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -156,6 +157,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { DnsDomain: sr(""), AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -183,6 +185,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { DnsDomain: sr(""), AutoUpdateAlways: br(false), AutoUpdateVersion: sr("latest"), + MetricsPushEnabled: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -210,6 +213,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { DnsDomain: sr(""), AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -237,6 +241,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { DnsDomain: sr(""), AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -264,6 +269,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { DnsDomain: sr(""), AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 6f139e43f..6c243c4c7 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1048,7 +1048,11 @@ func testUpdateAccountPeers(t *testing.T) { for _, channel := range peerChannels { update := <-channel - assert.Nil(t, update.Update.NetbirdConfig) + assert.NotNil(t, update.Update.NetbirdConfig) + assert.Nil(t, update.Update.NetbirdConfig.Stuns) + assert.Nil(t, update.Update.NetbirdConfig.Turns) + assert.Nil(t, update.Update.NetbirdConfig.Signal) + assert.Nil(t, update.Update.NetbirdConfig.Relay) assert.Equal(t, tc.peers, len(update.Update.NetworkMap.RemotePeers)) assert.Equal(t, tc.peers*2, len(update.Update.NetworkMap.FirewallRules)) } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 18be1b6ed..69efe65f1 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1605,7 +1605,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, - settings_local_mfa_enabled, + settings_local_mfa_enabled, settings_metrics_push_enabled, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1628,6 +1628,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sIPv6EnabledGroups sql.NullString sLazyConnectionEnabled sql.NullBool sLocalMFAEnabled sql.NullBool + sMetricsPushEnabled sql.NullBool sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1650,7 +1651,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, - &sLocalMFAEnabled, + &sLocalMFAEnabled, &sMetricsPushEnabled, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1716,6 +1717,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sLocalMFAEnabled.Valid { account.Settings.LocalMfaEnabled = sLocalMFAEnabled.Bool } + if sMetricsPushEnabled.Valid { + account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 97ffa5e76..d17d0ef2b 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -73,6 +73,9 @@ type Settings struct { // For new accounts this defaults to the All group. IPv6EnabledGroups []string `gorm:"serializer:json"` + // MetricsPushEnabled globally enables or disables client metrics push for the account + MetricsPushEnabled bool `gorm:"default:false"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -110,6 +113,7 @@ func (s *Settings) Copy() *Settings { AutoUpdateVersion: s.AutoUpdateVersion, AutoUpdateAlways: s.AutoUpdateAlways, IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), + MetricsPushEnabled: s.MetricsPushEnabled, EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, LocalMfaEnabled: s.LocalMfaEnabled, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index dffb7d7de..f11eb2c0a 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -371,6 +371,10 @@ components: description: When true, updates are installed automatically in the background. When false, updates require user interaction from the UI. type: boolean example: false + metrics_push_enabled: + description: Enables or disables client metrics push for all peers in the account + type: boolean + example: false embedded_idp_enabled: description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field. type: boolean diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 7ea9514c0..2a766b845 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1684,6 +1684,9 @@ type AccountSettings struct { // LocalMfaEnabled Enables or disables TOTP multi-factor authentication for local users. Only applicable when the embedded identity provider is enabled. LocalMfaEnabled *bool `json:"local_mfa_enabled,omitempty"` + // MetricsPushEnabled Enables or disables client metrics push for all peers in the account + MetricsPushEnabled *bool `json:"metrics_push_enabled,omitempty"` + // NetworkRange Allows to define a custom network range for the account in CIDR format NetworkRange *string `json:"network_range,omitempty"` diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 8027c0db6..faf21e60f 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -424,7 +424,7 @@ func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber { // Deprecated: Use DeviceAuthorizationFlowProvider.Descriptor instead. func (DeviceAuthorizationFlowProvider) EnumDescriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{33, 0} + return file_management_proto_rawDescGZIP(), []int{34, 0} } type EncryptedMessage struct { @@ -1907,9 +1907,10 @@ type NetbirdConfig struct { // a list of TURN servers Turns []*ProtectedHostConfig `protobuf:"bytes,2,rep,name=turns,proto3" json:"turns,omitempty"` // a Signal server config - Signal *HostConfig `protobuf:"bytes,3,opt,name=signal,proto3" json:"signal,omitempty"` - Relay *RelayConfig `protobuf:"bytes,4,opt,name=relay,proto3" json:"relay,omitempty"` - Flow *FlowConfig `protobuf:"bytes,5,opt,name=flow,proto3" json:"flow,omitempty"` + Signal *HostConfig `protobuf:"bytes,3,opt,name=signal,proto3" json:"signal,omitempty"` + Relay *RelayConfig `protobuf:"bytes,4,opt,name=relay,proto3" json:"relay,omitempty"` + Flow *FlowConfig `protobuf:"bytes,5,opt,name=flow,proto3" json:"flow,omitempty"` + Metrics *MetricsConfig `protobuf:"bytes,6,opt,name=metrics,proto3" json:"metrics,omitempty"` } func (x *NetbirdConfig) Reset() { @@ -1979,6 +1980,13 @@ func (x *NetbirdConfig) GetFlow() *FlowConfig { return nil } +func (x *NetbirdConfig) GetMetrics() *MetricsConfig { + if x != nil { + return x.Metrics + } + return nil +} + // HostConfig describes connection properties of some server (e.g. STUN, Signal, Management) type HostConfig struct { state protoimpl.MessageState @@ -2205,6 +2213,53 @@ func (x *FlowConfig) GetDnsCollection() bool { return false } +type MetricsConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` +} + +func (x *MetricsConfig) Reset() { + *x = MetricsConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_management_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MetricsConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricsConfig) ProtoMessage() {} + +func (x *MetricsConfig) ProtoReflect() protoreflect.Message { + mi := &file_management_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricsConfig.ProtoReflect.Descriptor instead. +func (*MetricsConfig) Descriptor() ([]byte, []int) { + return file_management_proto_rawDescGZIP(), []int{23} +} + +func (x *MetricsConfig) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + // JWTConfig represents JWT authentication configuration for validating tokens. type JWTConfig struct { state protoimpl.MessageState @@ -2224,7 +2279,7 @@ type JWTConfig struct { func (x *JWTConfig) Reset() { *x = JWTConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[23] + mi := &file_management_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2237,7 +2292,7 @@ func (x *JWTConfig) String() string { func (*JWTConfig) ProtoMessage() {} func (x *JWTConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[23] + mi := &file_management_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2250,7 +2305,7 @@ func (x *JWTConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use JWTConfig.ProtoReflect.Descriptor instead. func (*JWTConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{23} + return file_management_proto_rawDescGZIP(), []int{24} } func (x *JWTConfig) GetIssuer() string { @@ -2303,7 +2358,7 @@ type ProtectedHostConfig struct { func (x *ProtectedHostConfig) Reset() { *x = ProtectedHostConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[24] + mi := &file_management_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2316,7 +2371,7 @@ func (x *ProtectedHostConfig) String() string { func (*ProtectedHostConfig) ProtoMessage() {} func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[24] + mi := &file_management_proto_msgTypes[25] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2329,7 +2384,7 @@ func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ProtectedHostConfig.ProtoReflect.Descriptor instead. func (*ProtectedHostConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{24} + return file_management_proto_rawDescGZIP(), []int{25} } func (x *ProtectedHostConfig) GetHostConfig() *HostConfig { @@ -2380,7 +2435,7 @@ type PeerConfig struct { func (x *PeerConfig) Reset() { *x = PeerConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[25] + mi := &file_management_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2393,7 +2448,7 @@ func (x *PeerConfig) String() string { func (*PeerConfig) ProtoMessage() {} func (x *PeerConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[25] + mi := &file_management_proto_msgTypes[26] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2406,7 +2461,7 @@ func (x *PeerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerConfig.ProtoReflect.Descriptor instead. func (*PeerConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{25} + return file_management_proto_rawDescGZIP(), []int{26} } func (x *PeerConfig) GetAddress() string { @@ -2486,7 +2541,7 @@ type AutoUpdateSettings struct { func (x *AutoUpdateSettings) Reset() { *x = AutoUpdateSettings{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[26] + mi := &file_management_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2499,7 +2554,7 @@ func (x *AutoUpdateSettings) String() string { func (*AutoUpdateSettings) ProtoMessage() {} func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[26] + mi := &file_management_proto_msgTypes[27] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2512,7 +2567,7 @@ func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message { // Deprecated: Use AutoUpdateSettings.ProtoReflect.Descriptor instead. func (*AutoUpdateSettings) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{26} + return file_management_proto_rawDescGZIP(), []int{27} } func (x *AutoUpdateSettings) GetVersion() string { @@ -2567,7 +2622,7 @@ type NetworkMap struct { func (x *NetworkMap) Reset() { *x = NetworkMap{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[27] + mi := &file_management_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2580,7 +2635,7 @@ func (x *NetworkMap) String() string { func (*NetworkMap) ProtoMessage() {} func (x *NetworkMap) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[27] + mi := &file_management_proto_msgTypes[28] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2593,7 +2648,7 @@ func (x *NetworkMap) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkMap.ProtoReflect.Descriptor instead. func (*NetworkMap) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{27} + return file_management_proto_rawDescGZIP(), []int{28} } func (x *NetworkMap) GetSerial() uint64 { @@ -2703,7 +2758,7 @@ type SSHAuth struct { func (x *SSHAuth) Reset() { *x = SSHAuth{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[28] + mi := &file_management_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2716,7 +2771,7 @@ func (x *SSHAuth) String() string { func (*SSHAuth) ProtoMessage() {} func (x *SSHAuth) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[28] + mi := &file_management_proto_msgTypes[29] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2729,7 +2784,7 @@ func (x *SSHAuth) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHAuth.ProtoReflect.Descriptor instead. func (*SSHAuth) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{28} + return file_management_proto_rawDescGZIP(), []int{29} } func (x *SSHAuth) GetUserIDClaim() string { @@ -2764,7 +2819,7 @@ type MachineUserIndexes struct { func (x *MachineUserIndexes) Reset() { *x = MachineUserIndexes{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[29] + mi := &file_management_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2777,7 +2832,7 @@ func (x *MachineUserIndexes) String() string { func (*MachineUserIndexes) ProtoMessage() {} func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[29] + mi := &file_management_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2790,7 +2845,7 @@ func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message { // Deprecated: Use MachineUserIndexes.ProtoReflect.Descriptor instead. func (*MachineUserIndexes) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{29} + return file_management_proto_rawDescGZIP(), []int{30} } func (x *MachineUserIndexes) GetIndexes() []uint32 { @@ -2821,7 +2876,7 @@ type RemotePeerConfig struct { func (x *RemotePeerConfig) Reset() { *x = RemotePeerConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[30] + mi := &file_management_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2834,7 +2889,7 @@ func (x *RemotePeerConfig) String() string { func (*RemotePeerConfig) ProtoMessage() {} func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[30] + mi := &file_management_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2847,7 +2902,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead. func (*RemotePeerConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{30} + return file_management_proto_rawDescGZIP(), []int{31} } func (x *RemotePeerConfig) GetWgPubKey() string { @@ -2902,7 +2957,7 @@ type SSHConfig struct { func (x *SSHConfig) Reset() { *x = SSHConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[31] + mi := &file_management_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2915,7 +2970,7 @@ func (x *SSHConfig) String() string { func (*SSHConfig) ProtoMessage() {} func (x *SSHConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[31] + mi := &file_management_proto_msgTypes[32] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2928,7 +2983,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead. func (*SSHConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{31} + return file_management_proto_rawDescGZIP(), []int{32} } func (x *SSHConfig) GetSshEnabled() bool { @@ -2962,7 +3017,7 @@ type DeviceAuthorizationFlowRequest struct { func (x *DeviceAuthorizationFlowRequest) Reset() { *x = DeviceAuthorizationFlowRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[32] + mi := &file_management_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2975,7 +3030,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string { func (*DeviceAuthorizationFlowRequest) ProtoMessage() {} func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[32] + mi := &file_management_proto_msgTypes[33] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2988,7 +3043,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{32} + return file_management_proto_rawDescGZIP(), []int{33} } // DeviceAuthorizationFlow represents Device Authorization Flow information @@ -3007,7 +3062,7 @@ type DeviceAuthorizationFlow struct { func (x *DeviceAuthorizationFlow) Reset() { *x = DeviceAuthorizationFlow{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[33] + mi := &file_management_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3020,7 +3075,7 @@ func (x *DeviceAuthorizationFlow) String() string { func (*DeviceAuthorizationFlow) ProtoMessage() {} func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[33] + mi := &file_management_proto_msgTypes[34] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3033,7 +3088,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{33} + return file_management_proto_rawDescGZIP(), []int{34} } func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider { @@ -3060,7 +3115,7 @@ type PKCEAuthorizationFlowRequest struct { func (x *PKCEAuthorizationFlowRequest) Reset() { *x = PKCEAuthorizationFlowRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[34] + mi := &file_management_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3073,7 +3128,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string { func (*PKCEAuthorizationFlowRequest) ProtoMessage() {} func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[34] + mi := &file_management_proto_msgTypes[35] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3086,7 +3141,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{34} + return file_management_proto_rawDescGZIP(), []int{35} } // PKCEAuthorizationFlow represents Authorization Code Flow information @@ -3103,7 +3158,7 @@ type PKCEAuthorizationFlow struct { func (x *PKCEAuthorizationFlow) Reset() { *x = PKCEAuthorizationFlow{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[35] + mi := &file_management_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3116,7 +3171,7 @@ func (x *PKCEAuthorizationFlow) String() string { func (*PKCEAuthorizationFlow) ProtoMessage() {} func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[35] + mi := &file_management_proto_msgTypes[36] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3129,7 +3184,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{35} + return file_management_proto_rawDescGZIP(), []int{36} } func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig { @@ -3177,7 +3232,7 @@ type ProviderConfig struct { func (x *ProviderConfig) Reset() { *x = ProviderConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[36] + mi := &file_management_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3190,7 +3245,7 @@ func (x *ProviderConfig) String() string { func (*ProviderConfig) ProtoMessage() {} func (x *ProviderConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[36] + mi := &file_management_proto_msgTypes[37] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3203,7 +3258,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead. func (*ProviderConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{36} + return file_management_proto_rawDescGZIP(), []int{37} } func (x *ProviderConfig) GetClientID() string { @@ -3312,7 +3367,7 @@ type Route struct { func (x *Route) Reset() { *x = Route{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[37] + mi := &file_management_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3325,7 +3380,7 @@ func (x *Route) String() string { func (*Route) ProtoMessage() {} func (x *Route) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[37] + mi := &file_management_proto_msgTypes[38] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3338,7 +3393,7 @@ func (x *Route) ProtoReflect() protoreflect.Message { // Deprecated: Use Route.ProtoReflect.Descriptor instead. func (*Route) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{37} + return file_management_proto_rawDescGZIP(), []int{38} } func (x *Route) GetID() string { @@ -3427,7 +3482,7 @@ type DNSConfig struct { func (x *DNSConfig) Reset() { *x = DNSConfig{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[38] + mi := &file_management_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3440,7 +3495,7 @@ func (x *DNSConfig) String() string { func (*DNSConfig) ProtoMessage() {} func (x *DNSConfig) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[38] + mi := &file_management_proto_msgTypes[39] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3453,7 +3508,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead. func (*DNSConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{38} + return file_management_proto_rawDescGZIP(), []int{39} } func (x *DNSConfig) GetServiceEnable() bool { @@ -3502,7 +3557,7 @@ type CustomZone struct { func (x *CustomZone) Reset() { *x = CustomZone{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[39] + mi := &file_management_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3515,7 +3570,7 @@ func (x *CustomZone) String() string { func (*CustomZone) ProtoMessage() {} func (x *CustomZone) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[39] + mi := &file_management_proto_msgTypes[40] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3528,7 +3583,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message { // Deprecated: Use CustomZone.ProtoReflect.Descriptor instead. func (*CustomZone) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{39} + return file_management_proto_rawDescGZIP(), []int{40} } func (x *CustomZone) GetDomain() string { @@ -3575,7 +3630,7 @@ type SimpleRecord struct { func (x *SimpleRecord) Reset() { *x = SimpleRecord{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[40] + mi := &file_management_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3588,7 +3643,7 @@ func (x *SimpleRecord) String() string { func (*SimpleRecord) ProtoMessage() {} func (x *SimpleRecord) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[40] + mi := &file_management_proto_msgTypes[41] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3601,7 +3656,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead. func (*SimpleRecord) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{40} + return file_management_proto_rawDescGZIP(), []int{41} } func (x *SimpleRecord) GetName() string { @@ -3654,7 +3709,7 @@ type NameServerGroup struct { func (x *NameServerGroup) Reset() { *x = NameServerGroup{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[41] + mi := &file_management_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3667,7 +3722,7 @@ func (x *NameServerGroup) String() string { func (*NameServerGroup) ProtoMessage() {} func (x *NameServerGroup) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[41] + mi := &file_management_proto_msgTypes[42] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3680,7 +3735,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead. func (*NameServerGroup) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{41} + return file_management_proto_rawDescGZIP(), []int{42} } func (x *NameServerGroup) GetNameServers() []*NameServer { @@ -3725,7 +3780,7 @@ type NameServer struct { func (x *NameServer) Reset() { *x = NameServer{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[42] + mi := &file_management_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3738,7 +3793,7 @@ func (x *NameServer) String() string { func (*NameServer) ProtoMessage() {} func (x *NameServer) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[42] + mi := &file_management_proto_msgTypes[43] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3751,7 +3806,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServer.ProtoReflect.Descriptor instead. func (*NameServer) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{42} + return file_management_proto_rawDescGZIP(), []int{43} } func (x *NameServer) GetIP() string { @@ -3802,7 +3857,7 @@ type FirewallRule struct { func (x *FirewallRule) Reset() { *x = FirewallRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[43] + mi := &file_management_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3815,7 +3870,7 @@ func (x *FirewallRule) String() string { func (*FirewallRule) ProtoMessage() {} func (x *FirewallRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[43] + mi := &file_management_proto_msgTypes[44] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3828,7 +3883,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead. func (*FirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{43} + return file_management_proto_rawDescGZIP(), []int{44} } // Deprecated: Do not use. @@ -3907,7 +3962,7 @@ type NetworkAddress struct { func (x *NetworkAddress) Reset() { *x = NetworkAddress{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[44] + mi := &file_management_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3920,7 +3975,7 @@ func (x *NetworkAddress) String() string { func (*NetworkAddress) ProtoMessage() {} func (x *NetworkAddress) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[44] + mi := &file_management_proto_msgTypes[45] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3933,7 +3988,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead. func (*NetworkAddress) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{44} + return file_management_proto_rawDescGZIP(), []int{45} } func (x *NetworkAddress) GetNetIP() string { @@ -3961,7 +4016,7 @@ type Checks struct { func (x *Checks) Reset() { *x = Checks{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[45] + mi := &file_management_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3974,7 +4029,7 @@ func (x *Checks) String() string { func (*Checks) ProtoMessage() {} func (x *Checks) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[45] + mi := &file_management_proto_msgTypes[46] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3987,7 +4042,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message { // Deprecated: Use Checks.ProtoReflect.Descriptor instead. func (*Checks) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{45} + return file_management_proto_rawDescGZIP(), []int{46} } func (x *Checks) GetFiles() []string { @@ -4012,7 +4067,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[46] + mi := &file_management_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4025,7 +4080,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[46] + mi := &file_management_proto_msgTypes[47] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4038,7 +4093,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{46} + return file_management_proto_rawDescGZIP(), []int{47} } func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -4109,7 +4164,7 @@ type RouteFirewallRule struct { func (x *RouteFirewallRule) Reset() { *x = RouteFirewallRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[47] + mi := &file_management_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4122,7 +4177,7 @@ func (x *RouteFirewallRule) String() string { func (*RouteFirewallRule) ProtoMessage() {} func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[47] + mi := &file_management_proto_msgTypes[48] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4135,7 +4190,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead. func (*RouteFirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{47} + return file_management_proto_rawDescGZIP(), []int{48} } func (x *RouteFirewallRule) GetSourceRanges() []string { @@ -4226,7 +4281,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[48] + mi := &file_management_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4239,7 +4294,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[48] + mi := &file_management_proto_msgTypes[49] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4252,7 +4307,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{48} + return file_management_proto_rawDescGZIP(), []int{49} } func (x *ForwardingRule) GetProtocol() RuleProtocol { @@ -4301,7 +4356,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[49] + mi := &file_management_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4314,7 +4369,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[49] + mi := &file_management_proto_msgTypes[50] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4327,7 +4382,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{49} + return file_management_proto_rawDescGZIP(), []int{50} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -4400,7 +4455,7 @@ type ExposeServiceResponse struct { func (x *ExposeServiceResponse) Reset() { *x = ExposeServiceResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[50] + mi := &file_management_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4413,7 +4468,7 @@ func (x *ExposeServiceResponse) String() string { func (*ExposeServiceResponse) ProtoMessage() {} func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[50] + mi := &file_management_proto_msgTypes[51] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4426,7 +4481,7 @@ func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceResponse.ProtoReflect.Descriptor instead. func (*ExposeServiceResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{50} + return file_management_proto_rawDescGZIP(), []int{51} } func (x *ExposeServiceResponse) GetServiceName() string { @@ -4468,7 +4523,7 @@ type RenewExposeRequest struct { func (x *RenewExposeRequest) Reset() { *x = RenewExposeRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[51] + mi := &file_management_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4481,7 +4536,7 @@ func (x *RenewExposeRequest) String() string { func (*RenewExposeRequest) ProtoMessage() {} func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[51] + mi := &file_management_proto_msgTypes[52] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4494,7 +4549,7 @@ func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeRequest.ProtoReflect.Descriptor instead. func (*RenewExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{51} + return file_management_proto_rawDescGZIP(), []int{52} } func (x *RenewExposeRequest) GetDomain() string { @@ -4513,7 +4568,7 @@ type RenewExposeResponse struct { func (x *RenewExposeResponse) Reset() { *x = RenewExposeResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[52] + mi := &file_management_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4526,7 +4581,7 @@ func (x *RenewExposeResponse) String() string { func (*RenewExposeResponse) ProtoMessage() {} func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[52] + mi := &file_management_proto_msgTypes[53] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4539,7 +4594,7 @@ func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeResponse.ProtoReflect.Descriptor instead. func (*RenewExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{52} + return file_management_proto_rawDescGZIP(), []int{53} } type StopExposeRequest struct { @@ -4553,7 +4608,7 @@ type StopExposeRequest struct { func (x *StopExposeRequest) Reset() { *x = StopExposeRequest{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[53] + mi := &file_management_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4566,7 +4621,7 @@ func (x *StopExposeRequest) String() string { func (*StopExposeRequest) ProtoMessage() {} func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[53] + mi := &file_management_proto_msgTypes[54] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4579,7 +4634,7 @@ func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeRequest.ProtoReflect.Descriptor instead. func (*StopExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{53} + return file_management_proto_rawDescGZIP(), []int{54} } func (x *StopExposeRequest) GetDomain() string { @@ -4598,7 +4653,7 @@ type StopExposeResponse struct { func (x *StopExposeResponse) Reset() { *x = StopExposeResponse{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[54] + mi := &file_management_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4611,7 +4666,7 @@ func (x *StopExposeResponse) String() string { func (*StopExposeResponse) ProtoMessage() {} func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[54] + mi := &file_management_proto_msgTypes[55] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4624,7 +4679,7 @@ func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeResponse.ProtoReflect.Descriptor instead. func (*StopExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{54} + return file_management_proto_rawDescGZIP(), []int{55} } type PortInfo_Range struct { @@ -4639,7 +4694,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} if protoimpl.UnsafeEnabled { - mi := &file_management_proto_msgTypes[56] + mi := &file_management_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4652,7 +4707,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_management_proto_msgTypes[56] + mi := &file_management_proto_msgTypes[57] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4665,7 +4720,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{46, 0} + return file_management_proto_rawDescGZIP(), []int{47, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -4915,7 +4970,7 @@ var file_management_proto_rawDesc = []byte{ 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, - 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, + 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, @@ -4931,43 +4986,49 @@ var file_management_proto_rawDesc = []byte{ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, - 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, - 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, - 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, - 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, - 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, - 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, - 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, - 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, - 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, - 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07, 0x6d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x22, 0x98, + 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, + 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, + 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, + 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, + 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, + 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, + 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, + 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, + 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, + 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, + 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, + 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, + 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, + 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, + 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, + 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, + 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, + 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, + 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, @@ -5435,7 +5496,7 @@ func file_management_proto_rawDescGZIP() []byte { } var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 57) +var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 58) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus (PeerCapability)(0), // 1: management.PeerCapability @@ -5468,42 +5529,43 @@ var file_management_proto_goTypes = []interface{}{ (*HostConfig)(nil), // 28: management.HostConfig (*RelayConfig)(nil), // 29: management.RelayConfig (*FlowConfig)(nil), // 30: management.FlowConfig - (*JWTConfig)(nil), // 31: management.JWTConfig - (*ProtectedHostConfig)(nil), // 32: management.ProtectedHostConfig - (*PeerConfig)(nil), // 33: management.PeerConfig - (*AutoUpdateSettings)(nil), // 34: management.AutoUpdateSettings - (*NetworkMap)(nil), // 35: management.NetworkMap - (*SSHAuth)(nil), // 36: management.SSHAuth - (*MachineUserIndexes)(nil), // 37: management.MachineUserIndexes - (*RemotePeerConfig)(nil), // 38: management.RemotePeerConfig - (*SSHConfig)(nil), // 39: management.SSHConfig - (*DeviceAuthorizationFlowRequest)(nil), // 40: management.DeviceAuthorizationFlowRequest - (*DeviceAuthorizationFlow)(nil), // 41: management.DeviceAuthorizationFlow - (*PKCEAuthorizationFlowRequest)(nil), // 42: management.PKCEAuthorizationFlowRequest - (*PKCEAuthorizationFlow)(nil), // 43: management.PKCEAuthorizationFlow - (*ProviderConfig)(nil), // 44: management.ProviderConfig - (*Route)(nil), // 45: management.Route - (*DNSConfig)(nil), // 46: management.DNSConfig - (*CustomZone)(nil), // 47: management.CustomZone - (*SimpleRecord)(nil), // 48: management.SimpleRecord - (*NameServerGroup)(nil), // 49: management.NameServerGroup - (*NameServer)(nil), // 50: management.NameServer - (*FirewallRule)(nil), // 51: management.FirewallRule - (*NetworkAddress)(nil), // 52: management.NetworkAddress - (*Checks)(nil), // 53: management.Checks - (*PortInfo)(nil), // 54: management.PortInfo - (*RouteFirewallRule)(nil), // 55: management.RouteFirewallRule - (*ForwardingRule)(nil), // 56: management.ForwardingRule - (*ExposeServiceRequest)(nil), // 57: management.ExposeServiceRequest - (*ExposeServiceResponse)(nil), // 58: management.ExposeServiceResponse - (*RenewExposeRequest)(nil), // 59: management.RenewExposeRequest - (*RenewExposeResponse)(nil), // 60: management.RenewExposeResponse - (*StopExposeRequest)(nil), // 61: management.StopExposeRequest - (*StopExposeResponse)(nil), // 62: management.StopExposeResponse - nil, // 63: management.SSHAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 64: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 65: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 66: google.protobuf.Duration + (*MetricsConfig)(nil), // 31: management.MetricsConfig + (*JWTConfig)(nil), // 32: management.JWTConfig + (*ProtectedHostConfig)(nil), // 33: management.ProtectedHostConfig + (*PeerConfig)(nil), // 34: management.PeerConfig + (*AutoUpdateSettings)(nil), // 35: management.AutoUpdateSettings + (*NetworkMap)(nil), // 36: management.NetworkMap + (*SSHAuth)(nil), // 37: management.SSHAuth + (*MachineUserIndexes)(nil), // 38: management.MachineUserIndexes + (*RemotePeerConfig)(nil), // 39: management.RemotePeerConfig + (*SSHConfig)(nil), // 40: management.SSHConfig + (*DeviceAuthorizationFlowRequest)(nil), // 41: management.DeviceAuthorizationFlowRequest + (*DeviceAuthorizationFlow)(nil), // 42: management.DeviceAuthorizationFlow + (*PKCEAuthorizationFlowRequest)(nil), // 43: management.PKCEAuthorizationFlowRequest + (*PKCEAuthorizationFlow)(nil), // 44: management.PKCEAuthorizationFlow + (*ProviderConfig)(nil), // 45: management.ProviderConfig + (*Route)(nil), // 46: management.Route + (*DNSConfig)(nil), // 47: management.DNSConfig + (*CustomZone)(nil), // 48: management.CustomZone + (*SimpleRecord)(nil), // 49: management.SimpleRecord + (*NameServerGroup)(nil), // 50: management.NameServerGroup + (*NameServer)(nil), // 51: management.NameServer + (*FirewallRule)(nil), // 52: management.FirewallRule + (*NetworkAddress)(nil), // 53: management.NetworkAddress + (*Checks)(nil), // 54: management.Checks + (*PortInfo)(nil), // 55: management.PortInfo + (*RouteFirewallRule)(nil), // 56: management.RouteFirewallRule + (*ForwardingRule)(nil), // 57: management.ForwardingRule + (*ExposeServiceRequest)(nil), // 58: management.ExposeServiceRequest + (*ExposeServiceResponse)(nil), // 59: management.ExposeServiceResponse + (*RenewExposeRequest)(nil), // 60: management.RenewExposeRequest + (*RenewExposeResponse)(nil), // 61: management.RenewExposeResponse + (*StopExposeRequest)(nil), // 62: management.StopExposeRequest + (*StopExposeResponse)(nil), // 63: management.StopExposeResponse + nil, // 64: management.SSHAuth.MachineUsersEntry + (*PortInfo_Range)(nil), // 65: management.PortInfo.Range + (*timestamppb.Timestamp)(nil), // 66: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 67: google.protobuf.Duration } var file_management_proto_depIdxs = []int32{ 11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters @@ -5511,99 +5573,100 @@ var file_management_proto_depIdxs = []int32{ 12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult 21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig - 33, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig - 38, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig - 35, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 53, // 8: management.SyncResponse.Checks:type_name -> management.Checks - 65, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 34, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig + 39, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 36, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap + 54, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 66, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp 21, // 10: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta 21, // 11: management.LoginRequest.meta:type_name -> management.PeerSystemMeta 17, // 12: management.LoginRequest.peerKeys:type_name -> management.PeerKeys - 52, // 13: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress + 53, // 13: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress 18, // 14: management.PeerSystemMeta.environment:type_name -> management.Environment 19, // 15: management.PeerSystemMeta.files:type_name -> management.File 20, // 16: management.PeerSystemMeta.flags:type_name -> management.Flags 1, // 17: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability 27, // 18: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig - 33, // 19: management.LoginResponse.peerConfig:type_name -> management.PeerConfig - 53, // 20: management.LoginResponse.Checks:type_name -> management.Checks - 65, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 34, // 19: management.LoginResponse.peerConfig:type_name -> management.PeerConfig + 54, // 20: management.LoginResponse.Checks:type_name -> management.Checks + 66, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp 21, // 22: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta - 65, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 65, // 24: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp + 66, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 66, // 24: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp 28, // 25: management.NetbirdConfig.stuns:type_name -> management.HostConfig - 32, // 26: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig + 33, // 26: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig 28, // 27: management.NetbirdConfig.signal:type_name -> management.HostConfig 29, // 28: management.NetbirdConfig.relay:type_name -> management.RelayConfig 30, // 29: management.NetbirdConfig.flow:type_name -> management.FlowConfig - 6, // 30: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol - 66, // 31: management.FlowConfig.interval:type_name -> google.protobuf.Duration - 28, // 32: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 39, // 33: management.PeerConfig.sshConfig:type_name -> management.SSHConfig - 34, // 34: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings - 33, // 35: management.NetworkMap.peerConfig:type_name -> management.PeerConfig - 38, // 36: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 45, // 37: management.NetworkMap.Routes:type_name -> management.Route - 46, // 38: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 38, // 39: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 51, // 40: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 55, // 41: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 56, // 42: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule - 36, // 43: management.NetworkMap.sshAuth:type_name -> management.SSHAuth - 63, // 44: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 39, // 45: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 31, // 46: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 7, // 47: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 44, // 48: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 44, // 49: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 49, // 50: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 47, // 51: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 48, // 52: management.CustomZone.Records:type_name -> management.SimpleRecord - 50, // 53: management.NameServerGroup.NameServers:type_name -> management.NameServer - 3, // 54: management.FirewallRule.Direction:type_name -> management.RuleDirection - 4, // 55: management.FirewallRule.Action:type_name -> management.RuleAction - 2, // 56: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 54, // 57: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 64, // 58: management.PortInfo.range:type_name -> management.PortInfo.Range - 4, // 59: management.RouteFirewallRule.action:type_name -> management.RuleAction - 2, // 60: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 54, // 61: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 2, // 62: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 54, // 63: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 54, // 64: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 5, // 65: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 37, // 66: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 8, // 67: management.ManagementService.Login:input_type -> management.EncryptedMessage - 8, // 68: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 26, // 69: management.ManagementService.GetServerKey:input_type -> management.Empty - 26, // 70: management.ManagementService.isHealthy:input_type -> management.Empty - 8, // 71: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 72: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 73: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 8, // 74: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 8, // 75: management.ManagementService.Job:input_type -> management.EncryptedMessage - 8, // 76: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage - 8, // 77: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 8, // 78: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 8, // 79: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 8, // 80: management.ManagementService.Login:output_type -> management.EncryptedMessage - 8, // 81: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 25, // 82: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 26, // 83: management.ManagementService.isHealthy:output_type -> management.Empty - 8, // 84: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 8, // 85: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 26, // 86: management.ManagementService.SyncMeta:output_type -> management.Empty - 26, // 87: management.ManagementService.Logout:output_type -> management.Empty - 8, // 88: management.ManagementService.Job:output_type -> management.EncryptedMessage - 8, // 89: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage - 8, // 90: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 8, // 91: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 8, // 92: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 80, // [80:93] is the sub-list for method output_type - 67, // [67:80] is the sub-list for method input_type - 67, // [67:67] is the sub-list for extension type_name - 67, // [67:67] is the sub-list for extension extendee - 0, // [0:67] is the sub-list for field type_name + 31, // 30: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig + 6, // 31: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol + 67, // 32: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 28, // 33: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig + 40, // 34: management.PeerConfig.sshConfig:type_name -> management.SSHConfig + 35, // 35: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings + 34, // 36: management.NetworkMap.peerConfig:type_name -> management.PeerConfig + 39, // 37: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 46, // 38: management.NetworkMap.Routes:type_name -> management.Route + 47, // 39: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 39, // 40: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 52, // 41: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 56, // 42: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 57, // 43: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 37, // 44: management.NetworkMap.sshAuth:type_name -> management.SSHAuth + 64, // 45: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 40, // 46: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 32, // 47: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 48: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 45, // 49: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 45, // 50: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 50, // 51: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 48, // 52: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 49, // 53: management.CustomZone.Records:type_name -> management.SimpleRecord + 51, // 54: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 55: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 56: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 57: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 55, // 58: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 65, // 59: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 60: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 61: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 55, // 62: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 63: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 55, // 64: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 55, // 65: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 66: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 38, // 67: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 8, // 68: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 69: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 26, // 70: management.ManagementService.GetServerKey:input_type -> management.Empty + 26, // 71: management.ManagementService.isHealthy:input_type -> management.Empty + 8, // 72: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 73: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 8, // 74: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 8, // 75: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 8, // 76: management.ManagementService.Job:input_type -> management.EncryptedMessage + 8, // 77: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage + 8, // 78: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 79: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 80: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 81: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 82: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 25, // 83: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 26, // 84: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 85: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 86: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 26, // 87: management.ManagementService.SyncMeta:output_type -> management.Empty + 26, // 88: management.ManagementService.Logout:output_type -> management.Empty + 8, // 89: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 90: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage + 8, // 91: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 92: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 93: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 81, // [81:94] is the sub-list for method output_type + 68, // [68:81] is the sub-list for method input_type + 68, // [68:68] is the sub-list for extension type_name + 68, // [68:68] is the sub-list for extension extendee + 0, // [0:68] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -5889,7 +5952,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*JWTConfig); i { + switch v := v.(*MetricsConfig); i { case 0: return &v.state case 1: @@ -5901,7 +5964,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProtectedHostConfig); i { + switch v := v.(*JWTConfig); i { case 0: return &v.state case 1: @@ -5913,7 +5976,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PeerConfig); i { + switch v := v.(*ProtectedHostConfig); i { case 0: return &v.state case 1: @@ -5925,7 +5988,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AutoUpdateSettings); i { + switch v := v.(*PeerConfig); i { case 0: return &v.state case 1: @@ -5937,7 +6000,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkMap); i { + switch v := v.(*AutoUpdateSettings); i { case 0: return &v.state case 1: @@ -5949,7 +6012,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SSHAuth); i { + switch v := v.(*NetworkMap); i { case 0: return &v.state case 1: @@ -5961,7 +6024,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MachineUserIndexes); i { + switch v := v.(*SSHAuth); i { case 0: return &v.state case 1: @@ -5973,7 +6036,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RemotePeerConfig); i { + switch v := v.(*MachineUserIndexes); i { case 0: return &v.state case 1: @@ -5985,7 +6048,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SSHConfig); i { + switch v := v.(*RemotePeerConfig); i { case 0: return &v.state case 1: @@ -5997,7 +6060,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlowRequest); i { + switch v := v.(*SSHConfig); i { case 0: return &v.state case 1: @@ -6009,7 +6072,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlow); i { + switch v := v.(*DeviceAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -6021,7 +6084,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlowRequest); i { + switch v := v.(*DeviceAuthorizationFlow); i { case 0: return &v.state case 1: @@ -6033,7 +6096,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlow); i { + switch v := v.(*PKCEAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -6045,7 +6108,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProviderConfig); i { + switch v := v.(*PKCEAuthorizationFlow); i { case 0: return &v.state case 1: @@ -6057,7 +6120,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Route); i { + switch v := v.(*ProviderConfig); i { case 0: return &v.state case 1: @@ -6069,7 +6132,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DNSConfig); i { + switch v := v.(*Route); i { case 0: return &v.state case 1: @@ -6081,7 +6144,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CustomZone); i { + switch v := v.(*DNSConfig); i { case 0: return &v.state case 1: @@ -6093,7 +6156,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SimpleRecord); i { + switch v := v.(*CustomZone); i { case 0: return &v.state case 1: @@ -6105,7 +6168,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServerGroup); i { + switch v := v.(*SimpleRecord); i { case 0: return &v.state case 1: @@ -6117,7 +6180,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServer); i { + switch v := v.(*NameServerGroup); i { case 0: return &v.state case 1: @@ -6129,7 +6192,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FirewallRule); i { + switch v := v.(*NameServer); i { case 0: return &v.state case 1: @@ -6141,7 +6204,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkAddress); i { + switch v := v.(*FirewallRule); i { case 0: return &v.state case 1: @@ -6153,7 +6216,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Checks); i { + switch v := v.(*NetworkAddress); i { case 0: return &v.state case 1: @@ -6165,7 +6228,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PortInfo); i { + switch v := v.(*Checks); i { case 0: return &v.state case 1: @@ -6177,7 +6240,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RouteFirewallRule); i { + switch v := v.(*PortInfo); i { case 0: return &v.state case 1: @@ -6189,7 +6252,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ForwardingRule); i { + switch v := v.(*RouteFirewallRule); i { case 0: return &v.state case 1: @@ -6201,7 +6264,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceRequest); i { + switch v := v.(*ForwardingRule); i { case 0: return &v.state case 1: @@ -6213,7 +6276,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[50].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceResponse); i { + switch v := v.(*ExposeServiceRequest); i { case 0: return &v.state case 1: @@ -6225,7 +6288,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeRequest); i { + switch v := v.(*ExposeServiceResponse); i { case 0: return &v.state case 1: @@ -6237,7 +6300,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[52].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeResponse); i { + switch v := v.(*RenewExposeRequest); i { case 0: return &v.state case 1: @@ -6249,7 +6312,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[53].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopExposeRequest); i { + switch v := v.(*RenewExposeResponse); i { case 0: return &v.state case 1: @@ -6261,6 +6324,18 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[54].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopExposeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_management_proto_msgTypes[55].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StopExposeResponse); i { case 0: return &v.state @@ -6272,7 +6347,7 @@ func file_management_proto_init() { return nil } } - file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} { + file_management_proto_msgTypes[57].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PortInfo_Range); i { case 0: return &v.state @@ -6291,7 +6366,7 @@ func file_management_proto_init() { file_management_proto_msgTypes[2].OneofWrappers = []interface{}{ (*JobResponse_Bundle)(nil), } - file_management_proto_msgTypes[46].OneofWrappers = []interface{}{ + file_management_proto_msgTypes[47].OneofWrappers = []interface{}{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } @@ -6301,7 +6376,7 @@ func file_management_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, NumEnums: 8, - NumMessages: 57, + NumMessages: 58, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 990a72a63..6b41a78d0 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -312,6 +312,8 @@ message NetbirdConfig { RelayConfig relay = 4; FlowConfig flow = 5; + + MetricsConfig metrics = 6; } // HostConfig describes connection properties of some server (e.g. STUN, Signal, Management) @@ -350,6 +352,10 @@ message FlowConfig { bool dnsCollection = 8; } +message MetricsConfig { + bool enabled = 1; +} + // JWTConfig represents JWT authentication configuration for validating tokens. message JWTConfig { string issuer = 1; From ff04ffb534cfb308e70c2b3eb3c0595db8ea490f Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 1 Jul 2026 14:51:06 +0200 Subject: [PATCH 15/36] [client] Fix pointer comparisons in profile config apply (#6622) apply() compared several *bool/*int ConfigInput fields against the Config fields by pointer identity instead of by value, so any non-nil input always looked "changed" and triggered a spurious log line plus an unconditional config rewrite even when the value was unchanged. --- client/internal/profilemanager/config.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 5a71a981e..8ffcb16f2 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -386,7 +386,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor { + if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor updated = true @@ -454,7 +454,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot { + if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") } else { @@ -464,7 +464,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHSFTP != nil && input.EnableSSHSFTP != config.EnableSSHSFTP { + if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { if *input.EnableSSHSFTP { log.Infof("enabling SSH SFTP subsystem") } else { @@ -474,7 +474,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHLocalPortForwarding != nil && input.EnableSSHLocalPortForwarding != config.EnableSSHLocalPortForwarding { + if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { if *input.EnableSSHLocalPortForwarding { log.Infof("enabling SSH local port forwarding") } else { @@ -484,7 +484,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRemotePortForwarding != nil && input.EnableSSHRemotePortForwarding != config.EnableSSHRemotePortForwarding { + if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { if *input.EnableSSHRemotePortForwarding { log.Infof("enabling SSH remote port forwarding") } else { @@ -494,7 +494,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableSSHAuth != nil && input.DisableSSHAuth != config.DisableSSHAuth { + if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { if *input.DisableSSHAuth { log.Infof("disabling SSH authentication") } else { @@ -504,7 +504,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SSHJWTCacheTTL != nil && input.SSHJWTCacheTTL != config.SSHJWTCacheTTL { + if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) { log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL) config.SSHJWTCacheTTL = input.SSHJWTCacheTTL updated = true @@ -587,7 +587,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications { + if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { if *input.DisableNotifications { log.Infof("disabling notifications") } else { From 2ab99eefa627ce92d9117cdece532f68a9b13cf4 Mon Sep 17 00:00:00 2001 From: Denis Ivanov <74763652+den-dw@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:53:13 +0300 Subject: [PATCH 16/36] [management] detach JWT group sync write from request cancellation (#6621) The HTTP auth middleware runs syncUserJWTGroups in the request context. The dashboard SPA routinely aborts in-flight requests on re-render or navigation, which cancels the request context mid-write and rolls back the group-sync DB transaction. The error is logged but swallowed, so the synced groups silently never persist (users.auto_groups stays empty) while the failing log line repeats on every request. Detach the sync from the request's cancellation with context.WithoutCancel so the write can commit regardless of the client connection; the store already bounds the transaction with its own timeout. Add a regression test asserting the sync receives a non-cancelled context even when the originating request is cancelled. --- .../server/http/middleware/auth_middleware.go | 6 +- .../http/middleware/auth_middleware_test.go | 60 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 34df0de23..ba8c66241 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -152,7 +152,11 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts [] return err } - err = m.syncUserJWTGroups(ctx, userAuth) + // Detach the group-sync write from the request's cancellation: the dashboard + // SPA aborts in-flight requests on re-render, which would otherwise cancel the + // DB transaction mid-write and silently drop the synced groups. Context values + // (request id, logger) are preserved; the store bounds the tx with its own timeout. + err = m.syncUserJWTGroups(context.WithoutCancel(ctx), userAuth) if err != nil { log.WithContext(ctx).Errorf("HTTP server failed to sync user JWT groups: %s", err) } diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index 24cf8fce5..a34554660 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -241,6 +241,66 @@ func TestAuthMiddleware_Handler(t *testing.T) { } } +// TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation ensures the +// JWT group sync write is not bound to the request context. The dashboard SPA +// routinely aborts in-flight requests on re-render/navigation; if the sync ran in +// the request context, the cancellation would roll back the DB transaction and the +// synced groups would silently never persist. The sync must receive a context that +// is not cancelled even when the originating request is. +func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *testing.T) { + var ( + syncCalled bool + syncCtxErr error + ) + + mockAuth := &auth.MockManager{ + ValidateAndParseTokenFunc: mockValidateAndParseToken, + EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups, + MarkPATUsedFunc: mockMarkPATUsed, + GetPATInfoFunc: mockGetAccountInfoFromPAT, + } + + disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter.SetEnabled(false) + + authMiddleware := NewAuthMiddleware( + mockAuth, + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { + return userAuth.AccountId, userAuth.UserId, nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) error { + syncCalled = true + syncCtxErr = ctx.Err() + return nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { + return &types.User{}, nil + }, + disabledLimiter, + nil, + func(_ context.Context, _, _, _ string) bool { return false }, + ) + + handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + // Simulate the dashboard aborting the request: it arrives already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + req := httptest.NewRequest("GET", "http://testing/test", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer "+JWT) + rec := httptest.NewRecorder() + + handlerToTest.ServeHTTP(rec, req) + + if !syncCalled { + t.Fatal("syncUserJWTGroups was not called") + } + if syncCtxErr != nil { + t.Fatalf("syncUserJWTGroups received a cancelled context (%v); the group-sync write must be detached from request cancellation", syncCtxErr) + } +} + func TestAuthMiddleware_RateLimiting(t *testing.T) { mockAuth := &auth.MockManager{ ValidateAndParseTokenFunc: mockValidateAndParseToken, From 7c0d8cbae06ba2d30444af022f6727ae91a36a85 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 1 Jul 2026 17:23:50 +0200 Subject: [PATCH 17/36] [misc] Run agent-network e2e nightly + on manual dispatch (#6629) The suite builds combined/proxy/client from source and drives live provider traffic, so running it per push/PR is too costly. Switch to a nightly schedule (03:00 UTC) plus workflow_dispatch, and drop the now-unneeded fork guard that only mattered for pull_request runs. --- .github/workflows/agent-network-e2e.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index c041bfbfa..d78e3bbd3 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -1,10 +1,10 @@ name: Agent Network E2E on: - push: - branches: - - main - pull_request: + # Nightly at 03:00 UTC, plus on demand from the Actions tab. + schedule: + - cron: "0 3 * * *" + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -13,7 +13,6 @@ concurrency: jobs: e2e: name: Agent Network E2E - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 45 steps: From 0aa0f7c76b58d9bec8655b5558190419227dbd43 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:10:50 +0200 Subject: [PATCH 18/36] [client] wire client -> mgmt is healthy check to proper gRPC API (#6421) --- shared/management/client/grpc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 6f5172376..781e66a3e 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -536,7 +536,7 @@ func (c *GrpcClient) IsHealthy() bool { ctx, cancel := context.WithTimeout(c.ctx, healthCheckTimeout) defer cancel() - _, err := c.realClient.GetServerKey(ctx, &proto.Empty{}) + _, err := c.realClient.IsHealthy(ctx, &proto.Empty{}) if err != nil { c.notifyDisconnected(err) log.Warnf("health check returned: %s", err) From eb422a5cd3c50a401873e704c673a353ea59abd8 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 1 Jul 2026 20:43:15 +0200 Subject: [PATCH 19/36] [management,proxy] Add per-provider skip_tls_verification for agent-network (#6630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [management,proxy] Add per-provider skip_tls_verification for agent-network Let agent-network providers opt into skipping upstream TLS verification for self-hosted / internal gateways behind a private or self-signed cert. - provider: add SkipTLSVerification (persisted via AutoMigrate) with request/response mapping (nil on update preserves, explicit false clears). - openapi: skip_tls_verification on the provider request + response; types regenerated. - synthesizer: carry the flag into the llm_router route config so it reaches the proxy. - proxy: llm_router sets it on the UpstreamRewrite mutation, and the reverse proxy applies roundtrip.WithSkipTLSVerify per selected route when forwarding upstream (the router dials per provider, so a per-target flag alone wouldn't cover it). - tests: synthesizer route config carries the flag, router rewrite propagates it, and the request/response round-trip incl. update semantics. * [e2e] Validate per-provider skip_tls_verification end to end Add a self-signed HTTPS upstream (nginx) to the harness and a test that provisions two providers on that same upstream — one with skip_tls_verification=true, one false — behind one proxy + client. The skip=true provider's chat reaches the upstream (200); the skip=false provider's fails the TLS handshake (5xx). Same upstream, opposite outcome, which proves the flag is honoured per provider (a single target-level flag could not, since all of an account's providers share one synthesised target). * [e2e] WaitProxyPeer: require >=1 connected peer, not exact 1/1 Each proxy container registers a fresh WireGuard key and its peer is not removed on teardown, so proxy peers from earlier tests linger in the account as disconnected. WaitProxyPeer matched the exact string "1/1 Connected", which failed once a second proxy-using test ran in the same package (status "1/2"). Parse the "Peers count: X/Y Connected" line and wait for X>=1 instead: only the live proxy can be connected, and the caller's subsequent chat is the real end-to-end assertion. Fixes the CI failure of TestProviderSkipTLSVerification (runs after TestProvidersMatrix). --- e2e/agentnetwork/skiptls_test.go | 140 ++++++++++++++++++ e2e/harness/client.go | 44 +++++- e2e/harness/upstream.go | 107 +++++++++++++ .../modules/agentnetwork/synthesizer.go | 5 + .../modules/agentnetwork/synthesizer_test.go | 35 +++++ .../modules/agentnetwork/types/provider.go | 25 +++- .../agentnetwork/types/provider_test.go | 44 ++++++ .../middleware/builtin/llm_router/factory.go | 4 + .../builtin/llm_router/middleware.go | 5 +- .../builtin/llm_router/middleware_test.go | 35 +++++ proxy/internal/middleware/types.go | 4 + proxy/internal/proxy/reverseproxy.go | 5 + shared/management/http/api/openapi.yml | 9 ++ shared/management/http/api/types.gen.go | 6 + 14 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 e2e/agentnetwork/skiptls_test.go create mode 100644 e2e/harness/upstream.go create mode 100644 management/internals/modules/agentnetwork/types/provider_test.go diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go new file mode 100644 index 000000000..077fd6005 --- /dev/null +++ b/e2e/agentnetwork/skiptls_test.go @@ -0,0 +1,140 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestProviderSkipTLSVerification proves skip_tls_verification is per-provider: +// two providers share one self-signed upstream, one skipping TLS verification +// and one not. The skip=true provider's chat reaches the upstream and returns +// 200; the skip=false provider's chat fails at the TLS handshake — same +// upstream, opposite outcome. This is the behaviour a target-level flag could +// not give, since all of an account's providers share one synthesised target. +func TestProviderSkipTLSVerification(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + up, err := harness.StartFakeUpstream(ctx, srv) + require.NoError(t, err, "start self-signed upstream") + t.Cleanup(func() { _ = up.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-skiptls"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-skiptls-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + const ( + insecureModel = "insecure-model" + secureModel = "secure-model" + ) + + // Two providers on the SAME self-signed upstream, distinguished only by their + // skip_tls_verification and a unique model string so the router picks each + // unambiguously. + newReq := func(name, model string, skip bool) api.AgentNetworkProviderRequest { + key := "sk-dummy-e2e" + return api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: up.URL, + ApiKey: &key, + Enabled: ptr(true), + SkipTlsVerification: ptr(skip), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + } + } + + // First create bootstraps the account cluster. + insecureReq := newReq("skip-tls", insecureModel, true) + insecureReq.BootstrapCluster = ptr(harness.AgentNetworkCluster) + insecureProv, err := srv.CreateProvider(ctx, insecureReq) + require.NoError(t, err, "create skip-tls provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) }) + require.True(t, insecureProv.SkipTlsVerification, "response must echo skip_tls_verification=true") + + secureProv, err := srv.CreateProvider(ctx, newReq("verify-tls", secureModel, false)) + require.NoError(t, err, "create verify-tls provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), secureProv.Id) }) + require.False(t, secureProv.SkipTlsVerification, "response must echo skip_tls_verification=false") + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-skiptls-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{insecureProv.Id, secureProv.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-skiptls-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + + // Positive: skip=true reaches the self-signed upstream. Retry to absorb + // tunnel/DNS jitter on the first call; success also proves the path works. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, insecureModel, "Reply with exactly: pong", "e2e-skiptls-insecure") + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "skip_tls_verification=true must reach the self-signed upstream; body: %s\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + body, up.Logs(context.Background()), px.Logs(context.Background())) + + // Negative: skip=false must fail the TLS handshake to the SAME upstream. The + // path is already proven working, so a non-200 here is the cert rejection. + secureCode, secureBody, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, secureModel, "Reply with exactly: pong", "e2e-skiptls-secure") + require.NoError(t, cerr, "the chat call itself must complete (proxy returns an error status, not a transport error)") + require.NotEqual(t, 200, secureCode, + "skip_tls_verification=false must NOT reach the self-signed upstream; got %d, body: %s", secureCode, secureBody) + require.GreaterOrEqual(t, secureCode, 500, + "a TLS verification failure should surface as a 5xx from the proxy; got %d, body: %s", secureCode, secureBody) +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go index cf7ef8945..1ce8c0f6e 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "strconv" "strings" "time" @@ -108,9 +109,48 @@ func (cl *Client) WaitConnected(ctx context.Context, timeout time.Duration) erro return cl.pollStatus(ctx, timeout, "Management: Connected") } -// WaitProxyPeer polls until the client sees the proxy peer connected (1/1). +// WaitProxyPeer polls until the client sees at least one connected peer — the +// proxy serving the agent-network endpoint. It requires ">=1 connected" rather +// than an exact "1/1" because proxy peers from earlier tests linger in the +// account as disconnected (each proxy container registers a fresh WireGuard key +// and the peer is not removed on teardown), so the count is e.g. "1/2". Only the +// live proxy can be connected, and the caller's subsequent chat is the real +// end-to-end assertion. func (cl *Client) WaitProxyPeer(ctx context.Context, timeout time.Duration) error { - return cl.pollStatus(ctx, timeout, "1/1 Connected") + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + out, _ := cl.Status(ctx) + last = out + if connectedPeers(out) >= 1 { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for a connected proxy peer; last status:\n%s", last) +} + +// connectedPeers parses the "Peers count: X/Y Connected" line from `netbird +// status` and returns X (the connected count), or 0 when absent/unparseable. +func connectedPeers(status string) int { + for _, line := range strings.Split(status, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "Peers count:") + if !ok { + continue + } + rest = strings.TrimSpace(rest) + slash := strings.IndexByte(rest, '/') + if slash <= 0 { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(rest[:slash])) + if err != nil { + return 0 + } + return n + } + return 0 } func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want string) error { diff --git a/e2e/harness/upstream.go b/e2e/harness/upstream.go new file mode 100644 index 000000000..cdffe63b9 --- /dev/null +++ b/e2e/harness/upstream.go @@ -0,0 +1,107 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + fakeUpstreamImage = "nginx:alpine" + fakeUpstreamAlias = "fakeupstream" + fakeUpstreamPort = "443/tcp" +) + +// fakeUpstreamNginxConf serves a canned OpenAI-shaped chat completion for any +// path over a self-signed certificate, so the proxy reaches it only when the +// provider opts into skipping TLS verification. +const fakeUpstreamNginxConf = `pid /tmp/nginx.pid; +events {} +http { + server { + listen 443 ssl; + ssl_certificate /certs/tls.crt; + ssl_certificate_key /certs/tls.key; + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-e2e","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}'; + } + } +} +` + +// FakeUpstream is a self-signed HTTPS server on the combined server's network, +// used to exercise provider skip_tls_verification: a proxy that verifies the +// certificate rejects it, one that skips verification reaches it. +type FakeUpstream struct { + container testcontainers.Container + workDir string + // URL is the upstream URL providers point at (https://). + URL string +} + +// StartFakeUpstream runs the self-signed upstream on the shared network. +func StartFakeUpstream(ctx context.Context, c *Combined) (*FakeUpstream, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-upstream-*") + if err != nil { + return nil, fmt.Errorf("create upstream work dir: %w", err) + } + // Widen so the (non-root worker) nginx container can traverse the bind mount. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir + return nil, fmt.Errorf("chmod upstream dir: %w", err) + } + if err := writeSelfSignedCert(workDir, []string{fakeUpstreamAlias}); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(fakeUpstreamNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + return nil, fmt.Errorf("write nginx conf: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: fakeUpstreamImage, + ExposedPorts: []string{fakeUpstreamPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {fakeUpstreamAlias}}, + Cmd: []string{"nginx", "-c", "/certs/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/certs:ro") + }, + WaitingFor: wait.ForListeningPort(fakeUpstreamPort).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("start fake upstream container: %w", err) + } + + return &FakeUpstream{container: ctr, workDir: workDir, URL: "https://" + fakeUpstreamAlias}, nil +} + +// Logs returns the upstream container logs, for diagnostics on failure. +func (u *FakeUpstream) Logs(ctx context.Context) string { + return containerLogs(ctx, u.container) +} + +// Terminate stops the upstream container and cleans its work dir. +func (u *FakeUpstream) Terminate(ctx context.Context) error { + var err error + if u.container != nil { + err = u.container.Terminate(ctx) + } + if u.workDir != "" { + _ = os.RemoveAll(u.workDir) + } + return err +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 9814d1a11..74ac91845 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -366,6 +366,10 @@ type routerProviderRoute struct { // + refreshes the OAuth token at request time instead of injecting a static // AuthHeaderValue. GCPServiceAccountKeyB64 string `json:"gcp_sa_key_b64,omitempty"` + // SkipTLSVerify disables upstream TLS certificate verification when the + // proxy dials this provider's upstream. For self-hosted / internal gateways + // behind a private or self-signed certificate. + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` } // indexProviderGroups walks the enabled policies and returns, per @@ -450,6 +454,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][] Vertex: catalog.IsVertexPathStyle(p.ProviderID), Bedrock: catalog.IsBedrockPathStyle(p.ProviderID), GCPServiceAccountKeyB64: gcpSAKeyB64, + SkipTLSVerify: p.SkipTLSVerification, }) } out, err := json.Marshal(cfg) diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 0b07f27b3..9d55bddf1 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -1057,6 +1057,41 @@ func TestSynthesizeServices_UpstreamURLPath_FlowsToRouter(t *testing.T) { "upstream path must be carried so the router can disambiguate same-model providers; trailing slash trimmed for stable string-prefix matching") } +func TestSynthesizeServices_SkipTLSVerification_FlowsToRouter(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + // A provider fronting a self-hosted / internal gateway opts into skipping + // upstream TLS verification; the synthesiser must carry it into the router + // route so the proxy dials that upstream insecurely. + provider := newSynthTestProvider() + provider.SkipTLSVerification = true + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + assert.True(t, routerCfg.Providers[0].SkipTLSVerify, + "provider skip_tls_verification must flow into the router route") +} + func TestSynthesizeServices_UnknownProviderID_FailsClosed(t *testing.T) { ctx := context.Background() ctrl := gomock.NewController(t) diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index 28c8a94e2..2e3195481 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -46,6 +46,11 @@ type Provider struct { // Empty means all catalog models are allowed at catalog prices. Models []ProviderModel `gorm:"serializer:json"` Enabled bool + // SkipTLSVerification disables upstream TLS certificate verification for + // this provider's URL. For self-hosted / internal gateways fronted by a + // private or self-signed certificate. The synthesiser propagates it into + // the router route so the proxy dials that provider's upstream insecurely. + SkipTLSVerification bool `gorm:"column:skip_tls_verification"` // SessionPrivateKey + SessionPublicKey are the ed25519 keypair the // synthesised reverse-proxy service uses to sign / verify session // JWTs after a successful OIDC handshake. Generated once on @@ -129,6 +134,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { if req.Enabled != nil { p.Enabled = *req.Enabled } + if req.SkipTlsVerification != nil { + p.SkipTLSVerification = *req.SkipTlsVerification + } // Identity-header overrides for catalogs flagged Customizable. // nil pointer = "field omitted on the wire" → leave the stored // value untouched (per the openapi description). Empty string is @@ -155,14 +163,15 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { created := p.CreatedAt updated := p.UpdatedAt resp := &api.AgentNetworkProvider{ - Id: p.ID, - ProviderId: p.ProviderID, - Name: p.Name, - UpstreamUrl: p.UpstreamURL, - Models: models, - Enabled: p.Enabled, - CreatedAt: &created, - UpdatedAt: &updated, + Id: p.ID, + ProviderId: p.ProviderID, + Name: p.Name, + UpstreamUrl: p.UpstreamURL, + Models: models, + Enabled: p.Enabled, + SkipTlsVerification: p.SkipTLSVerification, + CreatedAt: &created, + UpdatedAt: &updated, } if len(p.ExtraValues) > 0 { out := make(map[string]string, len(p.ExtraValues)) diff --git a/management/internals/modules/agentnetwork/types/provider_test.go b/management/internals/modules/agentnetwork/types/provider_test.go new file mode 100644 index 000000000..1195499e7 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/provider_test.go @@ -0,0 +1,44 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestProvider_SkipTLSVerification_RoundTrip covers the request→provider→ +// response mapping of skip_tls_verification, including the update semantics +// (nil pointer preserves, explicit false clears). +func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) { + enable := true + disable := false + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "openai_api", + Name: "internal", + UpstreamUrl: "https://gw.internal", + } + } + + p := NewProvider("acc-1") + + req := base() + req.SkipTlsVerification = &enable + p.FromAPIRequest(req) + assert.True(t, p.SkipTLSVerification, "create with skip_tls_verification=true must set the field") + assert.True(t, p.ToAPIResponse().SkipTlsVerification, "response must surface skip_tls_verification") + + // Omitting the field on update leaves the stored value untouched. + p.FromAPIRequest(base()) + assert.True(t, p.SkipTLSVerification, "omitting skip_tls_verification on update must preserve it") + + // Explicit false clears it. + req = base() + req.SkipTlsVerification = &disable + p.FromAPIRequest(req) + assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification") + assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value") +} diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go index 3c3b607ac..938a23ebe 100644 --- a/proxy/internal/middleware/builtin/llm_router/factory.go +++ b/proxy/internal/middleware/builtin/llm_router/factory.go @@ -59,6 +59,10 @@ type ProviderRoute struct { // (instead of the static AuthHeaderValue) — so the gateway holds a durable // Vertex credential rather than a 1-hour token. GCPServiceAccountKeyB64 string `json:"gcp_sa_key_b64,omitempty"` + // SkipTLSVerify disables upstream TLS certificate verification when dialing + // this route's upstream. For self-hosted / internal gateways behind a + // private or self-signed certificate. + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` } // Config is the on-wire configuration accepted by the factory. An diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 73cc59c95..2aaeb1089 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -615,8 +615,9 @@ func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *m // path is silently dropped and the gateway returns a 4xx for // the malformed URL. Empty value leaves the original // target's path untouched. - Path: route.UpstreamPath, - StripHeaders: append([]string(nil), strippedAuthHeaders...), + Path: route.UpstreamPath, + StripHeaders: append([]string(nil), strippedAuthHeaders...), + SkipTLSVerify: route.SkipTLSVerify, } authValue := route.AuthHeaderValue if route.GCPServiceAccountKeyB64 != "" { diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index 8ae03c5ba..425c383c1 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -107,6 +107,41 @@ func TestRouter_HappyPath(t *testing.T) { assert.Equal(t, "allow", dec, "decision metadata must be allow on a match") } +func TestRouter_SkipTLSVerifyPropagates(t *testing.T) { + base := ProviderRoute{ + ID: "internal-gw", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "gateway.internal", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer sk-test-123", + } + + t.Run("enabled", func(t *testing.T) { + route := base + route.SkipTLSVerify = true + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out.Mutations, "matched route must emit mutations") + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + assert.True(t, out.Mutations.RewriteUpstream.SkipTLSVerify, + "skip_tls_verify on the route must ride on the upstream rewrite") + }) + + t.Run("default off", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{base}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + assert.False(t, out.Mutations.RewriteUpstream.SkipTLSVerify, + "skip_tls_verify must default to false when the route does not set it") + }) +} + func TestRouter_MissingModel(t *testing.T) { mw := New(Config{Providers: []ProviderRoute{{ ID: "openai-prod", diff --git a/proxy/internal/middleware/types.go b/proxy/internal/middleware/types.go index 1b49e6159..1ed5c9d88 100644 --- a/proxy/internal/middleware/types.go +++ b/proxy/internal/middleware/types.go @@ -243,6 +243,10 @@ type UpstreamRewrite struct { StripPathPrefix string AuthHeader *AuthHeader StripHeaders []string + // SkipTLSVerify, when true, makes the proxy dial the rewritten upstream + // without verifying its TLS certificate. Set by llm_router from the + // provider's skip_tls_verification for self-hosted / internal gateways. + SkipTLSVerify bool } // AuthHeader is a single name/value pair the proxy injects on the diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 2c0304ecd..835a1c0b2 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -346,6 +346,11 @@ func (p *ReverseProxy) forwardUpstream(respWriter http.ResponseWriter, r *http.R r.Host = effectiveURL.Host applyUpstreamHeaders(r, upstreamRewrite) stripUpstreamPathPrefix(r, upstreamRewrite.StripPathPrefix) + // A router-selected route (e.g. agent-network provider) can opt into + // skipping upstream TLS verification per its provider config. + if upstreamRewrite.SkipTLSVerify { + ctx = roundtrip.WithSkipTLSVerify(ctx) + } } rp := &httputil.ReverseProxy{ diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index f11eb2c0a..f746b31f4 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5119,6 +5119,10 @@ components: type: boolean description: Whether the provider is enabled. example: true + skip_tls_verification: + type: boolean + description: Whether upstream TLS certificate verification is skipped when the proxy dials this provider's URL. Intended for self-hosted / internal gateways behind a private or self-signed certificate. + example: false created_at: type: string format: date-time @@ -5138,6 +5142,7 @@ components: - upstream_url - models - enabled + - skip_tls_verification - created_at - updated_at AgentNetworkProviderRequest: @@ -5190,6 +5195,10 @@ components: type: boolean description: Whether the provider is enabled. Defaults to true on create. example: true + skip_tls_verification: + type: boolean + description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged. + example: false required: - provider_id - name diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 2a766b845..3b587c4bf 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2224,6 +2224,9 @@ type AgentNetworkProvider struct { // ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). ProviderId string `json:"provider_id"` + // SkipTlsVerification Whether upstream TLS certificate verification is skipped when the proxy dials this provider's URL. Intended for self-hosted / internal gateways behind a private or self-signed certificate. + SkipTlsVerification bool `json:"skip_tls_verification"` + // UpdatedAt Timestamp when the provider was last updated. UpdatedAt *time.Time `json:"updated_at,omitempty"` @@ -2272,6 +2275,9 @@ type AgentNetworkProviderRequest struct { // ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom). ProviderId string `json:"provider_id"` + // SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged. + SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"` + // UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to. UpstreamUrl string `json:"upstream_url"` } From 06839a4731a64fea2ef7fb0d4857c356ca0eb60f Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 1 Jul 2026 22:08:23 +0200 Subject: [PATCH 20/36] [client] Fix race between WG watcher initial handshake read and endpoint creation (#6626) * [client] Fix race between WG watcher initial handshake read and endpoint config The watcher's initial handshake read ran in a separate goroutine with no ordering guarantee relative to the WireGuard endpoint configuration, so it would sometimes race with the peer being added to the interface. Split enabling into a synchronous PrepareInitialHandshake, called before the endpoint is configured, and an EnableWgWatcher that only runs the monitoring loop, making the baseline read deterministic and keeping it correct for reconnects where the peer's WireGuard entry survives. * [client] Skip WG watcher disconnect callback when context is cancelled A superseded or cancelled watcher whose handshake-check timer fires before it observes ctx.Done() would still invoke onDisconnectedFn, tearing down a now-healthy connection. Re-check ctx before firing the disconnect and handshake-success callbacks and stand down silently if it was cancelled. --- client/internal/peer/conn.go | 18 ++++++----- client/internal/peer/wg_watcher.go | 41 ++++++++++++++----------- client/internal/peer/wg_watcher_test.go | 10 ++++++ 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 85e54ba5f..fb468696f 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -803,15 +803,17 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { } func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { - if !conn.wgWatcher.IsEnabled() { - wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) - conn.wgWatcherCancel = wgWatcherCancel - conn.wgWatcherWg.Add(1) - go func() { - defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) - }() + if !conn.wgWatcher.PrepareInitialHandshake() { + return } + + wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) + conn.wgWatcherCancel = wgWatcherCancel + conn.wgWatcherWg.Add(1) + go func() { + defer conn.wgWatcherWg.Done() + conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) + }() } func (conn *Conn) disableWgWatcherIfNeeded() { diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 805a6f24a..4fc883d17 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -31,7 +31,9 @@ type WGWatcher struct { stateDump *stateDump enabled bool - muEnabled sync.RWMutex + muEnabled sync.Mutex + // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently. + initialHandshake time.Time resetCh chan struct{} } @@ -46,38 +48,38 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin } } -// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing. -// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { +// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard +// handshake time. It must be called before the peer is (re)configured on the WireGuard +// interface, so the captured baseline reflects the state prior to this connection attempt +// instead of racing with that configuration. Returns ok=false if the watcher is already +// running, in which case EnableWgWatcher must not be called. +func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { w.muEnabled.Lock() if w.enabled { w.muEnabled.Unlock() - return + return false } w.log.Debugf("enable WireGuard watcher") w.enabled = true w.muEnabled.Unlock() - initialHandshake, err := w.wgState() - if err != nil { - w.log.Warnf("failed to read initial wg stats: %v", err) - } + handshake, _ := w.wgState() + w.initialHandshake = handshake + return true +} - w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake) +// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by +// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible +// for context lifecycle management. +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake) w.muEnabled.Lock() w.enabled = false w.muEnabled.Unlock() } -// IsEnabled returns true if the WireGuard watcher is currently enabled -func (w *WGWatcher) IsEnabled() bool { - w.muEnabled.RLock() - defer w.muEnabled.RUnlock() - return w.enabled -} - // Reset signals the watcher that the WireGuard peer has been reset and a new // handshake is expected. This restarts the handshake timeout from scratch. func (w *WGWatcher) Reset() { @@ -101,13 +103,16 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn case <-timer.C: handshake, ok := w.handshakeCheck(lastHandshake) if !ok { + if ctx.Err() != nil { + return + } onDisconnectedFn() return } if lastHandshake.IsZero() { elapsed := calcElapsed(enabledTime, *handshake) w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake) - if onHandshakeSuccessFn != nil { + if onHandshakeSuccessFn != nil && ctx.Err() == nil { onHandshakeSuccessFn(*handshake) } } diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 3ce91cd46..634d7974f 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -7,6 +7,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/iface/configurer" ) @@ -34,6 +35,9 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + ok := watcher.PrepareInitialHandshake() + require.True(t, ok, "watcher should not be enabled yet") + onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { mlog.Infof("onDisconnectedFn") @@ -62,6 +66,9 @@ func TestWGWatcher_ReEnable(t *testing.T) { watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{})) ctx, cancel := context.WithCancel(context.Background()) + ok := watcher.PrepareInitialHandshake() + require.True(t, ok, "watcher should not be enabled yet") + wg := &sync.WaitGroup{} wg.Add(1) go func() { @@ -76,6 +83,9 @@ func TestWGWatcher_ReEnable(t *testing.T) { ctx, cancel = context.WithCancel(context.Background()) defer cancel() + ok = watcher.PrepareInitialHandshake() + require.True(t, ok, "watcher should be re-enabled after the previous run stopped") + onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} From 7d4736de5579fecbef172e203ec7f6a424ac2885 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 1 Jul 2026 22:08:43 +0200 Subject: [PATCH 21/36] [management] Enable lazy connections by default on new accounts (#6571) With improvements in userspace lazy connection handling, we should be able to enable it for new accounts with less impact on users. These connections are cheaper and only target traffic that should go through the tunnels, leaving all other tunnels in an idle state. --- management/server/account.go | 1 + 1 file changed, 1 insertion(+) diff --git a/management/server/account.go b/management/server/account.go index 94335cf27..9d2759cb7 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -2057,6 +2057,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain, email, nam Extra: &types.ExtraSettings{ UserApprovalRequired: true, }, + LazyConnectionEnabled: true, }, Onboarding: types.AccountOnboarding{ OnboardingFlowPending: true, From 1d8b5f6e5cf08290b02af1f1300b33d0de723c4f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:58:16 +0900 Subject: [PATCH 22/36] [client] Make lazy connections opt-out via NB_LAZY_CONN (#6617) --- client/android/env_list.go | 2 +- client/cmd/root.go | 17 ++-- client/cmd/up.go | 10 --- client/internal/auth/auth.go | 1 - client/internal/conn_mgr.go | 55 ++++++++++--- client/internal/conn_mgr_test.go | 40 +++++++++ client/internal/connect.go | 4 +- client/internal/debug/debug.go | 2 +- client/internal/debug/debug_test.go | 2 +- client/internal/engine.go | 7 +- client/internal/lazyconn/env.go | 57 ++++++++++--- client/internal/lazyconn/env_test.go | 45 +++++++++++ client/internal/profilemanager/config.go | 21 ++--- .../profilemanager/config_mdm_test.go | 31 +++++++ client/ios/NetBirdSDK/env_list.go | 2 +- client/mdm/canonical_loaders.go | 1 + client/mdm/policy.go | 14 +++- client/mdm/policy_test.go | 13 ++- client/server/mdm.go | 3 - client/server/server.go | 3 - client/server/setconfig_test.go | 81 +++++++++---------- client/system/info.go | 6 +- client/ui/client_ui.go | 35 +++----- client/ui/const.go | 1 - client/ui/event_handler.go | 11 --- shared/management/client/grpc.go | 2 - 26 files changed, 312 insertions(+), 154 deletions(-) create mode 100644 client/internal/conn_mgr_test.go create mode 100644 client/internal/lazyconn/env_test.go diff --git a/client/android/env_list.go b/client/android/env_list.go index a0a4d7040..d0e0a1e78 100644 --- a/client/android/env_list.go +++ b/client/android/env_list.go @@ -10,7 +10,7 @@ var ( EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay // EnvKeyNBLazyConn Exported for Android java client to configure lazy connection - EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn + EnvKeyNBLazyConn = lazyconn.EnvLazyConn // EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold diff --git a/client/cmd/root.go b/client/cmd/root.go index f3fde2f1c..f1ef32717 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -71,12 +71,14 @@ var ( extraIFaceBlackList []string anonymizeFlag bool dnsRouteInterval time.Duration - lazyConnEnabled bool - mtu uint16 - profilesDisabled bool - updateSettingsDisabled bool - captureEnabled bool - networksDisabled bool + // lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection + // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead). + lazyConnEnabled bool + mtu uint16 + profilesDisabled bool + updateSettingsDisabled bool + captureEnabled bool + networksDisabled bool rootCmd = &cobra.Command{ Use: "netbird", @@ -210,7 +212,8 @@ func init() { upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") - upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand. Note: this setting may be overridden by management configuration.") + upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") + _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") } diff --git a/client/cmd/up.go b/client/cmd/up.go index 0506bc65b..8b3de3c66 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -479,10 +479,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.DisableIpv6 = &disableIPv6 } - if cmd.Flag(enableLazyConnectionFlag).Changed { - req.LazyConnectionEnabled = &lazyConnEnabled - } - return &req } @@ -600,9 +596,6 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.DisableIPv6 = &disableIPv6 } - if cmd.Flag(enableLazyConnectionFlag).Changed { - ic.LazyConnectionEnabled = &lazyConnEnabled - } return &ic, nil } @@ -718,9 +711,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.DisableIpv6 = &disableIPv6 } - if cmd.Flag(enableLazyConnectionFlag).Changed { - loginRequest.LazyConnectionEnabled = &lazyConnEnabled - } return &loginRequest, nil } diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index afc8ee77f..850e0706d 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -322,7 +322,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.BlockLANAccess, a.config.BlockInbound, a.config.DisableIPv6, - a.config.LazyConnectionEnabled, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, a.config.EnableSSHLocalPortForwarding, diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 112559132..a82a4ca8b 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -16,6 +16,16 @@ import ( "github.com/netbirdio/netbird/route" ) +// lazyForce is the resolved local decision for lazy connections, layered above the +// management feature flag. lazyForceNone defers to management. +type lazyForce int + +const ( + lazyForceNone lazyForce = iota + lazyForceOn + lazyForceOff +) + // ConnMgr coordinates both lazy connections (established on-demand) and permanent peer connections. // // The connection manager is responsible for: @@ -28,7 +38,7 @@ type ConnMgr struct { peerStore *peerstore.Store statusRecorder *peer.Status iface lazyconn.WGIface - enabledLocally bool + force lazyForce rosenpassEnabled bool lazyConnMgr *manager.Manager @@ -43,28 +53,34 @@ func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerSto peerStore: peerStore, statusRecorder: statusRecorder, iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), rosenpassEnabled: engineConfig.RosenpassEnabled, } - if engineConfig.LazyConnectionEnabled || lazyconn.IsLazyConnEnabledByEnv() { - e.enabledLocally = true - } return e } -// Start initializes the connection manager and starts the lazy connection manager if enabled by env var or cmd line option. +// Start initializes the connection manager. It starts the lazy connection manager when a +// local override forces it on; with no local override it waits for the management feature flag. func (e *ConnMgr) Start(ctx context.Context) { if e.lazyConnMgr != nil { log.Errorf("lazy connection manager is already started") return } - if !e.enabledLocally { - log.Infof("lazy connection manager is disabled") + switch e.force { + case lazyForceOff: + log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn) + e.statusRecorder.UpdateLazyConnection(false) + return + case lazyForceNone: + log.Infof("lazy connection manager is managed by the management feature flag") + e.statusRecorder.UpdateLazyConnection(false) return } if e.rosenpassEnabled { log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started") + e.statusRecorder.UpdateLazyConnection(false) return } @@ -76,8 +92,8 @@ func (e *ConnMgr) Start(ctx context.Context) { // If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again. // If disabled, then it closes the lazy connection manager and open the connections to all peers. func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error { - // do not disable lazy connection manager if it was enabled by env var - if e.enabledLocally { + // a local override (NB_LAZY_CONN or local config) takes precedence over management + if e.force != lazyForceNone { return nil } @@ -89,6 +105,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er if e.rosenpassEnabled { log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started") + e.statusRecorder.UpdateLazyConnection(false) return nil } @@ -98,6 +115,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er return e.addPeersToLazyConnManager() } else { if e.lazyConnMgr == nil { + e.statusRecorder.UpdateLazyConnection(false) return nil } log.Infof("lazy connection manager is disabled by management feature flag") @@ -309,6 +327,25 @@ func (e *ConnMgr) isStartedWithLazyMgr() bool { return e.lazyConnMgr != nil && e.lazyCtxCancel != nil } +// resolveLazyForce determines the local override. NB_LAZY_CONN takes precedence; when it +// is unset the MDM policy override (mdmState) applies. Either wins in both directions over +// the management feature flag; StateUnset for both defers to management. +func resolveLazyForce(mdmState lazyconn.State) lazyForce { + state := lazyconn.EnvState() + if state == lazyconn.StateUnset { + state = mdmState + } + + switch state { + case lazyconn.StateOn: + return lazyForceOn + case lazyconn.StateOff: + return lazyForceOff + default: + return lazyForceNone + } +} + func inactivityThresholdEnv() *time.Duration { envValue := os.Getenv(lazyconn.EnvInactivityThreshold) if envValue == "" { diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go new file mode 100644 index 000000000..5e2c53e35 --- /dev/null +++ b/client/internal/conn_mgr_test.go @@ -0,0 +1,40 @@ +package internal + +import ( + "os" + "testing" + + "github.com/netbirdio/netbird/client/internal/lazyconn" +) + +func TestResolveLazyForce(t *testing.T) { + tests := []struct { + name string + env string + envSet bool + mdm lazyconn.State + want lazyForce + }{ + {name: "env unset, mdm unset -> defer to management", mdm: lazyconn.StateUnset, want: lazyForceNone}, + {name: "env on -> force on", env: "on", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOn}, + {name: "env off -> force off", env: "off", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOff}, + {name: "env unset, mdm on -> force on", mdm: lazyconn.StateOn, want: lazyForceOn}, + {name: "env unset, mdm off -> force off", mdm: lazyconn.StateOff, want: lazyForceOff}, + {name: "env on beats mdm off", env: "on", envSet: true, mdm: lazyconn.StateOff, want: lazyForceOn}, + {name: "env off beats mdm on", env: "off", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOff}, + {name: "unrecognized env, mdm on -> mdm wins", env: "auto", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOn}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, tt.env) + if !tt.envSet { + os.Unsetenv(lazyconn.EnvLazyConn) + } + + if got := resolveLazyForce(tt.mdm); got != tt.want { + t.Fatalf("resolveLazyForce(%v) = %v, want %v", tt.mdm, got, tt.want) + } + }) + } +} diff --git a/client/internal/connect.go b/client/internal/connect.go index eff2c9489..93467b09a 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/peer" @@ -601,7 +602,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf BlockInbound: config.BlockInbound, DisableIPv6: config.DisableIPv6, - LazyConnectionEnabled: config.LazyConnectionEnabled, + LazyConnection: lazyconn.ParseState(config.LazyConnection), MTU: selectMTU(config.MTU, peerConfig.Mtu), LogPath: logPath, @@ -675,7 +676,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.BlockLANAccess, config.BlockInbound, config.DisableIPv6, - config.LazyConnectionEnabled, config.EnableSSHRoot, config.EnableSSHSFTP, config.EnableSSHLocalPortForwarding, diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index a65d8bd05..5700b05de 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -681,7 +681,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath)) } - configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled)) + configContent.WriteString(fmt.Sprintf("LazyConnection: %q\n", g.internalConfig.LazyConnection)) configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU)) } diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index ca7785d35..8286f6852 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -885,7 +885,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { DNSRouteInterval: 5 * time.Second, ClientCertPath: "/tmp/cert", ClientCertKeyPath: "/tmp/key", - LazyConnectionEnabled: true, + LazyConnection: "on", MTU: 1280, } diff --git a/client/internal/engine.go b/client/internal/engine.go index fb1d08f5e..a08bea31b 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -40,6 +40,7 @@ import ( "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/ingressgw" + "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -147,7 +148,9 @@ type EngineConfig struct { BlockInbound bool DisableIPv6 bool - LazyConnectionEnabled bool + // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to + // the env var and management feature flag. + LazyConnection lazyconn.State MTU uint16 @@ -1130,7 +1133,6 @@ func (e *Engine) applyInfoFlags(info *system.Info) { e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, - e.config.LazyConnectionEnabled, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, @@ -1999,7 +2001,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, - e.config.LazyConnectionEnabled, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, diff --git a/client/internal/lazyconn/env.go b/client/internal/lazyconn/env.go index 649d1cd65..d408083e7 100644 --- a/client/internal/lazyconn/env.go +++ b/client/internal/lazyconn/env.go @@ -3,24 +3,57 @@ package lazyconn import ( "os" "strconv" + "strings" log "github.com/sirupsen/logrus" ) const ( - EnvEnableLazyConn = "NB_ENABLE_EXPERIMENTAL_LAZY_CONN" + EnvLazyConn = "NB_LAZY_CONN" EnvInactivityThreshold = "NB_LAZY_CONN_INACTIVITY_THRESHOLD" ) -func IsLazyConnEnabledByEnv() bool { - val := os.Getenv(EnvEnableLazyConn) - if val == "" { - return false - } - enabled, err := strconv.ParseBool(val) - if err != nil { - log.Warnf("failed to parse %s: %v", EnvEnableLazyConn, err) - return false - } - return enabled +// State is the tri-state local override for lazy connections read from the environment. +type State int + +const ( + // StateUnset means no local override; defer to the management feature flag. + StateUnset State = iota + // StateOn forces lazy connections on, overriding management. + StateOn + // StateOff forces lazy connections off, overriding management. + StateOff +) + +// EnvState reads NB_LAZY_CONN and returns the local override state. +func EnvState() State { + return ParseState(os.Getenv(EnvLazyConn)) +} + +// ParseState interprets a lazy-connection override value (from the environment or an MDM +// policy). It accepts the on/off aliases plus any value strconv.ParseBool understands +// (true/false/1/0). An empty or unrecognized value returns StateUnset so that the +// management feature flag remains in control. +func ParseState(raw string) State { + if raw == "" { + return StateUnset + } + + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "on": + return StateOn + case "off": + return StateOff + } + + enabled, err := strconv.ParseBool(normalized) + if err != nil { + log.Warnf("failed to parse lazy connection value %q (from %s env or MDM policy): %v", raw, EnvLazyConn, err) + return StateUnset + } + if enabled { + return StateOn + } + return StateOff } diff --git a/client/internal/lazyconn/env_test.go b/client/internal/lazyconn/env_test.go new file mode 100644 index 000000000..59ee40c4b --- /dev/null +++ b/client/internal/lazyconn/env_test.go @@ -0,0 +1,45 @@ +package lazyconn + +import ( + "os" + "testing" +) + +func TestEnvState(t *testing.T) { + tests := []struct { + value string + set bool + want State + }{ + {set: false, want: StateUnset}, + {value: "", set: true, want: StateUnset}, + {value: "on", set: true, want: StateOn}, + {value: "ON", set: true, want: StateOn}, + {value: "true", set: true, want: StateOn}, + {value: "1", set: true, want: StateOn}, + {value: " on ", set: true, want: StateOn}, + {value: "off", set: true, want: StateOff}, + {value: "OFF", set: true, want: StateOff}, + {value: "false", set: true, want: StateOff}, + {value: "0", set: true, want: StateOff}, + {value: "auto", set: true, want: StateUnset}, + {value: "garbage", set: true, want: StateUnset}, + } + + for _, tt := range tests { + name := tt.value + if !tt.set { + name = "unset" + } + t.Run(name, func(t *testing.T) { + t.Setenv(EnvLazyConn, tt.value) + if !tt.set { + os.Unsetenv(EnvLazyConn) + } + + if got := EnvState(); got != tt.want { + t.Fatalf("EnvState() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 8ffcb16f2..ed2f21999 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -101,8 +101,6 @@ type ConfigInput struct { DNSLabels domain.List - LazyConnectionEnabled *bool - MTU *uint16 } @@ -180,7 +178,9 @@ type Config struct { ClientCertKeyPair *tls.Certificate `json:"-"` - LazyConnectionEnabled bool + // LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/""). + // Runtime-only: re-derived from MDM policy on each load, never persisted. + LazyConnection string `json:"-"` MTU uint16 @@ -632,12 +632,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.LazyConnectionEnabled != nil && *input.LazyConnectionEnabled != config.LazyConnectionEnabled { - log.Infof("switching lazy connection to %t", *input.LazyConnectionEnabled) - config.LazyConnectionEnabled = *input.LazyConnectionEnabled - updated = true - } - if input.MTU != nil && *input.MTU != config.MTU { log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU) config.MTU = *input.MTU @@ -728,6 +722,15 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v) } } + + if v, ok := policy.GetBool(mdm.KeyLazyConnection); ok { + state := "off" + if v { + state = "on" + } + config.LazyConnection = state + logApplied(mdm.KeyLazyConnection, state) + } } // parseURL parses and validates the URL for the named service. The URL diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 6a201235e..c6a688ab2 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -130,6 +130,37 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMLazyConnection(t *testing.T) { + cases := []struct { + name string + raw any + want string + }{ + {"native true", true, "on"}, + {"native false", false, "off"}, + {"string on", "on", "on"}, + {"string off", "off", "off"}, + {"string yes", "yes", "on"}, + {"string no", "no", "off"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyLazyConnection: c.raw, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, c.want, cfg.LazyConnection) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) + }) + } +} + func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { const maskSentinel = "**********" diff --git a/client/ios/NetBirdSDK/env_list.go b/client/ios/NetBirdSDK/env_list.go index 88ac97957..a3ffa0ebe 100644 --- a/client/ios/NetBirdSDK/env_list.go +++ b/client/ios/NetBirdSDK/env_list.go @@ -38,7 +38,7 @@ func GetEnvKeyNBForceRelay() string { // GetEnvKeyNBLazyConn Exports the environment variable for the iOS client func GetEnvKeyNBLazyConn() string { - return lazyconn.EnvEnableLazyConn + return lazyconn.EnvLazyConn } // GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index 6e7ab19cb..b20a823fb 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -27,6 +27,7 @@ var allKeys = []string{ KeyWireguardPort, KeySplitTunnelMode, KeySplitTunnelApps, + KeyLazyConnection, } // canonicalKey maps the lowercase form of a managed-config value name to diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 109fb322e..67b126101 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -11,6 +11,7 @@ package mdm import ( "sort" "strconv" + "strings" log "github.com/sirupsen/logrus" ) @@ -41,6 +42,11 @@ const ( // construction — only one mode can be set at a time. KeySplitTunnelMode = "splitTunnelMode" KeySplitTunnelApps = "splitTunnelApps" + + // KeyLazyConnection forces the lazy-connection feature on or off, overriding + // the management feature flag. Read as a bool (native bool, or on/off, + // true/false, 1/0, yes/no); absent = defer to management. + KeyLazyConnection = "lazyConnection" ) // Split-tunnel mode literals (KeySplitTunnelMode values). @@ -62,12 +68,13 @@ var boolStringLiterals = map[string]bool{ "true": true, "1": true, "yes": true, + "on": true, "false": false, "0": false, "no": false, + "off": false, } - // Policy holds MDM-managed settings read from the platform source. A nil or // empty Policy means no enforcement is active. type Policy struct { @@ -150,7 +157,8 @@ func (p *Policy) GetString(key string) (string, bool) { } // GetBool returns the managed value for key coerced to bool, and whether the -// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0". +// key was set. Accepts native bool and string literals (true/false, 1/0, +// yes/no, on/off), case-insensitively and trimmed of surrounding whitespace. func (p *Policy) GetBool(key string) (bool, bool) { if p == nil { return false, false @@ -163,7 +171,7 @@ func (p *Policy) GetBool(key string) (bool, bool) { case bool: return t, true case string: - b, known := boolStringLiterals[t] + b, known := boolStringLiterals[strings.ToLower(strings.TrimSpace(t))] return b, known case int: return t != 0, true diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 47a6ed2c9..6cbe69776 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -31,8 +31,8 @@ func TestPolicy_Empty(t *testing.T) { func TestPolicy_HasKey(t *testing.T) { p := NewPolicy(map[string]any{ - KeyManagementURL: "https://corp.example.com", - KeyDisableProfiles: true, + KeyManagementURL: "https://corp.example.com", + KeyDisableProfiles: true, }) assert.False(t, p.IsEmpty()) assert.True(t, p.HasKey(KeyManagementURL)) @@ -53,8 +53,8 @@ func TestPolicy_ManagedKeysSorted(t *testing.T) { func TestPolicy_GetString(t *testing.T) { p := NewPolicy(map[string]any{ KeyManagementURL: "https://corp.example.com", - KeyDisableProfiles: true, // wrong type for GetString - KeyPreSharedKey: "", // empty rejected + KeyDisableProfiles: true, // wrong type for GetString + KeyPreSharedKey: "", // empty rejected }) v, ok := p.GetString(KeyManagementURL) assert.True(t, ok) @@ -85,6 +85,11 @@ func TestPolicy_GetBool(t *testing.T) { {"string 0", "0", false, true}, {"string yes", "yes", true, true}, {"string no", "no", false, true}, + {"string on", "on", true, true}, + {"string off", "off", false, true}, + {"mixed case On", "On", true, true}, + {"upper TRUE", "TRUE", true, true}, + {"padded yes", " yes ", true, true}, {"int nonzero", 1, true, true}, {"int zero", 0, false, true}, {"int64 nonzero", int64(2), true, true}, diff --git a/client/server/mdm.go b/client/server/mdm.go index 0da0ec5d1..db7db2759 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -152,7 +152,6 @@ func (s *Server) restartEngineForMDMLocked() error { s.config = config s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) - s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) ctx, cancel := context.WithCancel(s.rootCtx) s.actCancel = cancel @@ -305,7 +304,6 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.DisableFirewall != nil || msg.BlockLanAccess != nil || msg.DisableNotifications != nil || - msg.LazyConnectionEnabled != nil || msg.BlockInbound != nil || msg.DisableIpv6 != nil || msg.EnableSSHRoot != nil || @@ -348,7 +346,6 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.BlockLanAccess != nil || msg.DisableNotifications != nil || len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.LazyConnectionEnabled != nil || msg.BlockInbound != nil } diff --git a/client/server/server.go b/client/server/server.go index 3f6dabc56..e8ef2f96e 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -214,7 +214,6 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) - s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -463,7 +462,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.DisableFirewall = msg.DisableFirewall config.BlockLANAccess = msg.BlockLanAccess config.DisableNotifications = msg.DisableNotifications - config.LazyConnectionEnabled = msg.LazyConnectionEnabled config.BlockInbound = msg.BlockInbound config.DisableIPv6 = msg.DisableIpv6 config.EnableSSHRoot = msg.EnableSSHRoot @@ -1647,7 +1645,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p ServerSSHAllowed: *cfg.ServerSSHAllowed, RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, - LazyConnectionEnabled: cfg.LazyConnectionEnabled, BlockInbound: cfg.BlockInbound, DisableNotifications: disableNotifications, NetworkMonitor: networkMonitor, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7c85d16ce..0e55257a9 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -69,43 +69,41 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableFirewall := true blockLANAccess := true disableNotifications := true - lazyConnectionEnabled := true blockInbound := true disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) req := &proto.SetConfigRequest{ - ProfileName: profName, - Username: currUser.Username, - ManagementUrl: "https://new-api.netbird.io:443", - AdminURL: "https://new-admin.netbird.io", - RosenpassEnabled: &rosenpassEnabled, - RosenpassPermissive: &rosenpassPermissive, - ServerSSHAllowed: &serverSSHAllowed, - InterfaceName: &interfaceName, - WireguardPort: &wireguardPort, - OptionalPreSharedKey: &preSharedKey, - DisableAutoConnect: &disableAutoConnect, - NetworkMonitor: &networkMonitor, - DisableClientRoutes: &disableClientRoutes, - DisableServerRoutes: &disableServerRoutes, - DisableDns: &disableDNS, - DisableFirewall: &disableFirewall, - BlockLanAccess: &blockLANAccess, - DisableNotifications: &disableNotifications, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - DisableIpv6: &disableIPv6, - NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, - CleanNATExternalIPs: false, - CustomDNSAddress: []byte("1.1.1.1:53"), - ExtraIFaceBlacklist: []string{"eth1", "eth2"}, - DnsLabels: []string{"label1", "label2"}, - CleanDNSLabels: false, - DnsRouteInterval: durationpb.New(2 * time.Minute), - Mtu: &mtu, - SshJWTCacheTTL: &sshJWTCacheTTL, + ProfileName: profName, + Username: currUser.Username, + ManagementUrl: "https://new-api.netbird.io:443", + AdminURL: "https://new-admin.netbird.io", + RosenpassEnabled: &rosenpassEnabled, + RosenpassPermissive: &rosenpassPermissive, + ServerSSHAllowed: &serverSSHAllowed, + InterfaceName: &interfaceName, + WireguardPort: &wireguardPort, + OptionalPreSharedKey: &preSharedKey, + DisableAutoConnect: &disableAutoConnect, + NetworkMonitor: &networkMonitor, + DisableClientRoutes: &disableClientRoutes, + DisableServerRoutes: &disableServerRoutes, + DisableDns: &disableDNS, + DisableFirewall: &disableFirewall, + BlockLanAccess: &blockLANAccess, + DisableNotifications: &disableNotifications, + BlockInbound: &blockInbound, + DisableIpv6: &disableIPv6, + NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, + CleanNATExternalIPs: false, + CustomDNSAddress: []byte("1.1.1.1:53"), + ExtraIFaceBlacklist: []string{"eth1", "eth2"}, + DnsLabels: []string{"label1", "label2"}, + CleanDNSLabels: false, + DnsRouteInterval: durationpb.New(2 * time.Minute), + Mtu: &mtu, + SshJWTCacheTTL: &sshJWTCacheTTL, } _, err = s.SetConfig(ctx, req) @@ -140,7 +138,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, blockLANAccess, cfg.BlockLANAccess) require.NotNil(t, cfg.DisableNotifications) require.Equal(t, disableNotifications, *cfg.DisableNotifications) - require.Equal(t, lazyConnectionEnabled, cfg.LazyConnectionEnabled) require.Equal(t, blockInbound, cfg.BlockInbound) require.Equal(t, disableIPv6, cfg.DisableIPv6) require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs) @@ -164,13 +161,14 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { t.Helper() metadataFields := map[string]bool{ - "state": true, // protobuf internal - "sizeCache": true, // protobuf internal - "unknownFields": true, // protobuf internal - "Username": true, // metadata - "ProfileName": true, // metadata - "CleanNATExternalIPs": true, // control flag for clearing - "CleanDNSLabels": true, // control flag for clearing + "state": true, // protobuf internal + "sizeCache": true, // protobuf internal + "unknownFields": true, // protobuf internal + "Username": true, // metadata + "ProfileName": true, // metadata + "CleanNATExternalIPs": true, // control flag for clearing + "CleanDNSLabels": true, // control flag for clearing + "LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied } expectedFields := map[string]bool{ @@ -190,7 +188,6 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "DisableFirewall": true, "BlockLanAccess": true, "DisableNotifications": true, - "LazyConnectionEnabled": true, "BlockInbound": true, "DisableIpv6": true, "NatExternalIPs": true, @@ -252,7 +249,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "block-lan-access": "BlockLanAccess", "block-inbound": "BlockInbound", "disable-ipv6": "DisableIpv6", - "enable-lazy-connection": "LazyConnectionEnabled", "external-ip-map": "NatExternalIPs", "dns-resolver-address": "CustomDNSAddress", "extra-iface-blacklist": "ExtraIFaceBlacklist", @@ -269,7 +265,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { // SetConfigRequest fields that don't have CLI flags (settable only via UI or other means). fieldsWithoutCLIFlags := map[string]bool{ - "DisableNotifications": true, // Only settable via UI + "DisableNotifications": true, // Only settable via UI + "LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN) } // Get all SetConfigRequest fields to verify our map is complete. diff --git a/client/system/info.go b/client/system/info.go index 496b478a3..1838204b8 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -74,8 +74,6 @@ type Info struct { BlockInbound bool DisableIPv6 bool - LazyConnectionEnabled bool - EnableSSHRoot bool EnableSSHSFTP bool EnableSSHLocalPortForwarding bool @@ -87,7 +85,7 @@ func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, ) { @@ -105,8 +103,6 @@ func (i *Info) SetFlags( i.BlockInbound = blockInbound i.DisableIPv6 = disableIPv6 - i.LazyConnectionEnabled = lazyConnectionEnabled - if enableSSHRoot != nil { i.EnableSSHRoot = *enableSSHRoot } diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 40fb4169d..2b19c2bf5 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -266,7 +266,6 @@ type serviceClient struct { mAllowSSH *systray.MenuItem mAutoConnect *systray.MenuItem mEnableRosenpass *systray.MenuItem - mLazyConnEnabled *systray.MenuItem mBlockInbound *systray.MenuItem mNotifications *systray.MenuItem mAdvancedSettings *systray.MenuItem @@ -336,11 +335,11 @@ type serviceClient struct { // mNetworks + mExitNode submenu items. Combines features.DisableNetworks // AND s.connected — both must be true for the menus to be active. // Zero value (false) matches the Disable() call at AddMenuItem time. - networksMenuEnabled bool - showNetworks bool - wNetworks fyne.Window - wProfiles fyne.Window - wQuickActions fyne.Window + networksMenuEnabled bool + showNetworks bool + wNetworks fyne.Window + wProfiles fyne.Window + wQuickActions fyne.Window eventManager *event.Manager @@ -1094,7 +1093,6 @@ func (s *serviceClient) onTrayReady() { s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false) s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false) s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false) - s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false) s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false) s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false) s.mSettings.AddSeparator() @@ -1578,7 +1576,6 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { config.RosenpassEnabled = cfg.RosenpassEnabled config.RosenpassPermissive = cfg.RosenpassPermissive config.DisableNotifications = &cfg.DisableNotifications - config.LazyConnectionEnabled = cfg.LazyConnectionEnabled config.BlockInbound = cfg.BlockInbound config.NetworkMonitor = &cfg.NetworkMonitor config.DisableDNS = cfg.DisableDns @@ -1682,12 +1679,6 @@ func (s *serviceClient) loadSettings() { s.mEnableRosenpass.Uncheck() } - if cfg.LazyConnectionEnabled { - s.mLazyConnEnabled.Check() - } else { - s.mLazyConnEnabled.Uncheck() - } - if cfg.BlockInbound { s.mBlockInbound.Check() } else { @@ -1833,7 +1824,6 @@ func (s *serviceClient) updateConfig() error { disableAutoStart := !s.mAutoConnect.Checked() sshAllowed := s.mAllowSSH.Checked() rosenpassEnabled := s.mEnableRosenpass.Checked() - lazyConnectionEnabled := s.mLazyConnEnabled.Checked() blockInbound := s.mBlockInbound.Checked() notificationsDisabled := !s.mNotifications.Checked() @@ -1856,14 +1846,13 @@ func (s *serviceClient) updateConfig() error { } req := proto.SetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - DisableAutoConnect: &disableAutoStart, - ServerSSHAllowed: &sshAllowed, - RosenpassEnabled: &rosenpassEnabled, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - DisableNotifications: ¬ificationsDisabled, + ProfileName: activeProf.ID.String(), + Username: currUser.Username, + DisableAutoConnect: &disableAutoStart, + ServerSSHAllowed: &sshAllowed, + RosenpassEnabled: &rosenpassEnabled, + BlockInbound: &blockInbound, + DisableNotifications: ¬ificationsDisabled, } if _, err := conn.SetConfig(s.ctx, &req); err != nil { diff --git a/client/ui/const.go b/client/ui/const.go index 48619be75..ce7a9a294 100644 --- a/client/ui/const.go +++ b/client/ui/const.go @@ -4,7 +4,6 @@ const ( allowSSHMenuDescr = "Allow SSH connections" autoConnectMenuDescr = "Connect automatically when the service starts" quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass" - lazyConnMenuDescr = "[Experimental] Enable lazy connections" blockInboundMenuDescr = "Block inbound connections to the local machine and routed networks" notificationsMenuDescr = "Enable notifications" advancedSettingsMenuDescr = "Advanced settings of the application" diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go index 876fcef5f..902082308 100644 --- a/client/ui/event_handler.go +++ b/client/ui/event_handler.go @@ -43,8 +43,6 @@ func (h *eventHandler) listen(ctx context.Context) { h.handleAutoConnectClick() case <-h.client.mEnableRosenpass.ClickedCh: h.handleRosenpassClick() - case <-h.client.mLazyConnEnabled.ClickedCh: - h.handleLazyConnectionClick() case <-h.client.mBlockInbound.ClickedCh: h.handleBlockInboundClick() case <-h.client.mAdvancedSettings.ClickedCh: @@ -152,15 +150,6 @@ func (h *eventHandler) handleRosenpassClick() { } } -func (h *eventHandler) handleLazyConnectionClick() { - h.toggleCheckbox(h.client.mLazyConnEnabled) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update lazy connection settings") - } -} - func (h *eventHandler) handleBlockInboundClick() { h.toggleCheckbox(h.client.mBlockInbound) if err := h.updateConfigWithErr(); err != nil { diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 781e66a3e..bd4585455 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -1030,8 +1030,6 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { BlockLANAccess: info.BlockLANAccess, BlockInbound: info.BlockInbound, DisableIPv6: info.DisableIPv6, - - LazyConnectionEnabled: info.LazyConnectionEnabled, }, Capabilities: peerCapabilities(*info), From 167be3a30fb4b90f5f22e1f1986fa15df58c1a8c Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 2 Jul 2026 12:15:57 +0200 Subject: [PATCH 23/36] [ci] Run privileged client tests natively with sudo on Linux (#6635) Restore the pre-split native, sudo-based run for the Linux Client / Unit job: build with the privileged tag and run under sudo, matching the darwin job. Excludes the dockertest harness (client/testutil/privileged) so it does not recurse into a container spawn. The Docker privileged job is kept as-is. --- .github/workflows/golang-test-linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 34b215c60..ce53261a4 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -158,7 +158,7 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged) - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' From e203e0f42a14855fa30be0b79eb70397839552d5 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 2 Jul 2026 14:20:23 +0200 Subject: [PATCH 24/36] [self-hosted] Remove unused server/proxy image override logic in getting-started.sh (#6636) --- infrastructure_files/getting-started.sh | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 46bef5a1f..837cc42e6 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -351,11 +351,6 @@ initialize_default_values() { NETBIRD_STUN_PORT=3478 # Docker images - # Record whether the operator explicitly pinned the server/proxy images via - # env vars, so the agent-network preset can pick its own defaults without - # clobbering an explicit override. - NETBIRD_SERVER_IMAGE_EXPLICIT=${NETBIRD_SERVER_IMAGE:+true} - NETBIRD_PROXY_IMAGE_EXPLICIT=${NETBIRD_PROXY_IMAGE:+true} DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"} # Combined server replaces separate signal, relay, and management containers NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"} @@ -415,15 +410,6 @@ apply_agent_network_preset() { ENABLE_PROXY="true" ENABLE_CROWDSEC="false" - # Agent-network ships dedicated server/proxy images. Honor an explicit - # env override; otherwise pin the agent-network builds. - if [[ "${NETBIRD_SERVER_IMAGE_EXPLICIT}" != "true" ]]; then - NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:0.74.0-rc.2" - fi - if [[ "${NETBIRD_PROXY_IMAGE_EXPLICIT}" != "true" ]]; then - NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:0.74.0-rc.2" - fi - if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL}" ]]; then TRAEFIK_ACME_EMAIL="${NETBIRD_LETSENCRYPT_EMAIL}" else From e40cb294f6fe62b34ba5105f6ac726853dfbae6b Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Thu, 2 Jul 2026 14:45:24 +0200 Subject: [PATCH 25/36] [management] Add vLLM to Agent Network (#6643) --- .../modules/agentnetwork/catalog/catalog.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index baf622778..962b30250 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -627,6 +627,21 @@ var providers = []Provider{ }, Models: []Model{}, }, + { + // vLLM is an OpenAI-compatible self-hosted server. It behaves like + // the generic custom entry; it gets its own catalog id purely so it + // surfaces as a named "vLLM" choice in the provider picker. + ID: "vllm", + Kind: KindCustom, + Name: "vLLM", + Description: "Self-hosted vLLM (OpenAI-compatible)", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#30A2FF", + Models: []Model{}, + }, { ID: "custom", Kind: KindCustom, From 859fe19fff6661ed0ba7904b42ed8b12d72c36f5 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 2 Jul 2026 14:55:55 +0200 Subject: [PATCH 26/36] [management] return nil when config is not set (#6642) * [management] return nil when config is not set * [management] add relay invariant test and enforce config behavior --- .../internals/shared/grpc/conversion.go | 13 +++---- .../internals/shared/grpc/conversion_test.go | 38 +++++++++++++++++++ management/server/peer_test.go | 6 +-- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 973749eb0..bdb4c8cf4 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -47,16 +47,13 @@ func init() { precomputedDeprecatedRemotePeersConstraint = constraint } +// toNetbirdConfig converts the server configuration to the wire representation. It returns +// nil when no server config is set (the fan-out network-map path) because clients treat any +// non-nil config as authoritative: a config without a relay section is interpreted as relay +// disabled and wipes the clients' relay URLs. func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig { if config == nil { - if settings == nil { - return nil - } - return &proto.NetbirdConfig{ - Metrics: &proto.MetricsConfig{ - Enabled: settings.MetricsPushEnabled, - }, - } + return nil } var stuns []*proto.HostConfig diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index 01a67e4fa..c81bef25c 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -8,11 +8,13 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/types" ) func TestToProtocolDNSConfigWithCache(t *testing.T) { @@ -263,3 +265,39 @@ func TestEncodeSessionExpiresAt(t *testing.T) { assert.True(t, got.AsTime().Equal(deadline)) }) } + +// TestToNetbirdConfig_RelayInvariant guards against the v0.74.0 relay-wipe regression. +// Clients treat any non-nil NetbirdConfig as authoritative and interpret a missing relay +// section as relay disabled, wiping their relay URLs. toNetbirdConfig must therefore +// return nil when no server config is set (the fan-out network-map path) instead of a +// partial config, and a result built from a relay-enabled config must carry the relay +// section. +func TestToNetbirdConfig_RelayInvariant(t *testing.T) { + settings := &types.Settings{MetricsPushEnabled: true} + + t.Run("nil server config returns nil config", func(t *testing.T) { + nbCfg := toNetbirdConfig(nil, nil, nil, nil, settings) + assert.Nil(t, nbCfg, "fan-out updates must not carry a partial NetbirdConfig even when settings are present") + }) + + t.Run("relay-enabled config carries relay section", func(t *testing.T) { + cfg := &nbconfig.Config{ + Stuns: []*nbconfig.Host{{Proto: nbconfig.UDP, URI: "stun:stun.example.com:3478"}}, + TURNConfig: &nbconfig.TURNConfig{ + Turns: []*nbconfig.Host{{Proto: nbconfig.UDP, URI: "turn:turn.example.com:3478", Username: "user", Password: "pass"}}, + }, + Relay: &nbconfig.Relay{Addresses: []string{"rels://relay.example.com:443"}}, + Signal: &nbconfig.Host{Proto: nbconfig.HTTP, URI: "signal.example.com:10000"}, + } + relayToken := &Token{Payload: "token-payload", Signature: "token-signature"} + + nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, settings) + require.NotNil(t, nbCfg) + require.NotNil(t, nbCfg.Relay, "non-nil NetbirdConfig must include the relay section") + assert.Equal(t, cfg.Relay.Addresses, nbCfg.Relay.Urls, "relay URLs should match the server config") + assert.Equal(t, relayToken.Payload, nbCfg.Relay.TokenPayload, "relay token payload should be set") + assert.Equal(t, relayToken.Signature, nbCfg.Relay.TokenSignature, "relay token signature should be set") + require.NotNil(t, nbCfg.Metrics) + assert.True(t, nbCfg.Metrics.Enabled, "metrics flag should carry the settings value") + }) +} diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 6c243c4c7..d471a1302 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1048,11 +1048,7 @@ func testUpdateAccountPeers(t *testing.T) { for _, channel := range peerChannels { update := <-channel - assert.NotNil(t, update.Update.NetbirdConfig) - assert.Nil(t, update.Update.NetbirdConfig.Stuns) - assert.Nil(t, update.Update.NetbirdConfig.Turns) - assert.Nil(t, update.Update.NetbirdConfig.Signal) - assert.Nil(t, update.Update.NetbirdConfig.Relay) + assert.Nil(t, update.Update.NetbirdConfig, "fan-out updates must not carry a NetbirdConfig; clients treat a config without relay as relay disabled and wipe their relay URLs") assert.Equal(t, tc.peers, len(update.Update.NetworkMap.RemotePeers)) assert.Equal(t, tc.peers*2, len(update.Update.NetworkMap.FirewallRules)) } From 1dfa85a917eb47e23e4dc4c142056e67966a1c03 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Thu, 2 Jul 2026 15:36:51 +0200 Subject: [PATCH 27/36] [management] Add vLLM e2e test (#6649) * Add vLLM to Agent Network * Add vllm e2e test --- e2e/agentnetwork/vllm_test.go | 171 ++++++++++++++++++++++++++++++++++ e2e/harness/vllm.go | 113 ++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 e2e/agentnetwork/vllm_test.go create mode 100644 e2e/harness/vllm.go diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go new file mode 100644 index 000000000..329994ca9 --- /dev/null +++ b/e2e/agentnetwork/vllm_test.go @@ -0,0 +1,171 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestVLLMProvider proves the proxy supports a self-hosted vLLM backend. vLLM is +// OpenAI-compatible, so it uses the "vllm" catalog entry (KindCustom) and is +// reached over plain HTTP — no TLS anywhere on the path: +// +// client --tunnel--> netbird proxy --http--> vllm (:8000, OpenAI-compatible) +// +// The mock vLLM server answers /v1/chat/completions with an OpenAI-shaped +// completion carrying a non-zero usage block. The test asserts the chat returns +// 200 with the completion, that the request is recorded in the access log by its +// session id, and that vLLM's usage block is metered into a consumption row — +// which together prove request routing, response parsing, and token accounting +// all work for a self-hosted OpenAI-compatible provider. +// +// It needs no external credentials (the mock ignores auth), so it always runs. +func TestVLLMProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM server") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-vllm"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-vllm-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // vLLM provider pointed at the mock over plain HTTP. The mock ignores auth, + // so a dummy key satisfies the "Bearer ${API_KEY}" template. The served model + // is enumerated so the router dispatches this model string to this provider. + dummyKey := "sk-vllm-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "vllm", + ProviderId: "vllm", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create vllm provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Token limit far above the handful of tokens this test drives, so it never + // blocks but switches on usage metering — the switch that makes consumption + // rows get recorded. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-vllm-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-vllm-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + + before, _ := srv.ListAccessLogs(ctx) + sessionID := "e2e-session-vllm" + + // Retry to absorb tunnel/DNS jitter on the first call. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, harness.VLLMModel, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "chat through the vLLM provider must return 200; body: %s\n=== vllm logs ===\n%s\n=== proxy logs ===\n%s", + body, vllm.Logs(context.Background()), px.Logs(context.Background())) + require.True(t, strings.Contains(body, "chat.completion"), + "body should be an OpenAI-compatible chat completion; got: %s", body) + + // The request must surface as an access-log row carrying our session id. + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + return lerr == nil && logs.TotalRecords > before.TotalRecords + }, 30*time.Second, 2*time.Second, "an access-log row should be ingested for the vLLM provider") + + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID) + + // vLLM's usage block (prompt_tokens=11, completion_tokens=2) must be parsed + // and metered into a consumption row with positive token counts. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "vLLM usage must be metered into a consumption row") +} diff --git a/e2e/harness/vllm.go b/e2e/harness/vllm.go new file mode 100644 index 000000000..2f3d306cc --- /dev/null +++ b/e2e/harness/vllm.go @@ -0,0 +1,113 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + vllmImage = "nginx:alpine" + vllmAlias = "vllm" + vllmPort = "8000/tcp" + // VLLMModel is the served model id the mock advertises and echoes back. It + // matches a real small model commonly served by vLLM so the provider's + // enumerated model and the client's request line up. + VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct" +) + +// vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's +// default: no TLS, port 8000). It answers /v1/models with a one-model list and +// any chat/completions path with a canned OpenAI-shaped chat completion carrying +// a non-zero usage block, so the proxy's OpenAI parser records real token +// consumption. Running actual vLLM in CI is infeasible (GPU + multi-GB model +// download), so this stands in for the wire contract the proxy depends on. +const vllmNginxConf = `pid /tmp/nginx.pid; +events {} +http { + server { + listen 8000; + location = /v1/models { + default_type application/json; + return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"}]}'; + } + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}'; + } + } +} +` + +// VLLM is a mock vLLM OpenAI-compatible server on the combined server's network, +// reachable at http://vllm:8000. A "vllm" provider points at it to exercise the +// proxy's support for self-hosted OpenAI-compatible backends. +type VLLM struct { + container testcontainers.Container + workDir string + // URL is the upstream URL the vllm provider points at (http://:8000). + URL string +} + +// StartVLLM runs the mock vLLM server on the shared network over plain HTTP. +func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-vllm-*") + if err != nil { + return nil, fmt.Errorf("create vllm work dir: %w", err) + } + // Widen so the (non-root worker) nginx container can traverse the bind mount. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e config dir + return nil, fmt.Errorf("chmod vllm dir: %w", err) + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(vllmNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + return nil, fmt.Errorf("write nginx conf: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: vllmImage, + ExposedPorts: []string{vllmPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}}, + Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/conf:ro") + }, + WaitingFor: wait.ForListeningPort(vllmPort).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("start vllm container: %w", err) + } + + return &VLLM{container: ctr, workDir: workDir, URL: "http://" + vllmAlias + ":8000"}, nil +} + +// Logs returns the vLLM container logs, for diagnostics on failure. +func (v *VLLM) Logs(ctx context.Context) string { + return containerLogs(ctx, v.container) +} + +// Terminate stops the vLLM container and cleans its work dir. +func (v *VLLM) Terminate(ctx context.Context) error { + var err error + if v.container != nil { + err = v.container.Terminate(ctx) + } + if v.workDir != "" { + _ = os.RemoveAll(v.workDir) + } + return err +} From 21aa9335846114319229ecf28e7faea13bf8fae5 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 2 Jul 2026 17:21:06 +0200 Subject: [PATCH 28/36] [misc] Fix GHCR image push after dockers_v2 migration (#6653) --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16eae31fb..cd431a389 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -293,8 +293,11 @@ jobs: ${{ steps.goreleaser.outputs.artifacts }} JSON + # dockers_v2 artifacts have no top-level goarch field, so match the + # per-platform -amd64 tag suffix instead; it works for both the old + # dockers and the new dockers_v2 image naming. mapfile -t src_images < <( - jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json + jq -r '.[] | select(.type == "Docker Image") | .name | select(startswith("ghcr.io/") and endswith("-amd64"))' /tmp/goreleaser-artifacts.json ) for src in "${src_images[@]}"; do From 8e3b284f4bb374c830bd85041260cf3dcde5666e Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:50:18 +0200 Subject: [PATCH 29/36] [client] Increase mgmt grpc buff size to 16MB (#6641) --- shared/management/client/grpc.go | 15 ++++++++++----- shared/management/client/grpc_test.go | 8 ++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index bd4585455..e3ba259c6 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -33,10 +33,15 @@ const ConnectTimeout = 10 * time.Second const healthCheckTimeout = 5 * time.Second const ( - // EnvMaxRecvMsgSize overrides the default gRPC max receive message size (4 MB) + // EnvMaxRecvMsgSize overrides the default gRPC max receive message size // for the management client connection. Value is in bytes. EnvMaxRecvMsgSize = "NB_MANAGEMENT_GRPC_MAX_MSG_SIZE" + // defaultMaxRecvMsgSize is the max gRPC receive message size used for the + // management client connection when EnvMaxRecvMsgSize is unset or invalid. + // It overrides the gRPC library default of 4 MB. + defaultMaxRecvMsgSize = 1024 * 1024 * 16 + errMsgMgmtPublicKey = "failed getting Management Service public key: %s" errMsgNoMgmtConnection = "no connection to management" ) @@ -84,22 +89,22 @@ type ExposeResponse struct { } // MaxRecvMsgSize returns the configured max gRPC receive message size from -// the environment, or 0 if unset (which uses the gRPC default of 4 MB). +// the environment, or defaultMaxRecvMsgSize (16 MB) if unset or invalid. func MaxRecvMsgSize() int { val := os.Getenv(EnvMaxRecvMsgSize) if val == "" { - return 0 + return defaultMaxRecvMsgSize } size, err := strconv.Atoi(val) if err != nil { log.Warnf("invalid %s value %q, using default: %v", EnvMaxRecvMsgSize, val, err) - return 0 + return defaultMaxRecvMsgSize } if size <= 0 { log.Warnf("invalid %s value %d, must be positive, using default", EnvMaxRecvMsgSize, size) - return 0 + return defaultMaxRecvMsgSize } return size diff --git a/shared/management/client/grpc_test.go b/shared/management/client/grpc_test.go index 462cc43af..c947130fd 100644 --- a/shared/management/client/grpc_test.go +++ b/shared/management/client/grpc_test.go @@ -21,11 +21,11 @@ func TestMaxRecvMsgSize(t *testing.T) { envValue string expected int }{ - {name: "unset returns 0", envValue: "", expected: 0}, + {name: "unset returns default", envValue: "", expected: defaultMaxRecvMsgSize}, {name: "valid value", envValue: "10485760", expected: 10485760}, - {name: "non-numeric returns 0", envValue: "abc", expected: 0}, - {name: "negative returns 0", envValue: "-1", expected: 0}, - {name: "zero returns 0", envValue: "0", expected: 0}, + {name: "non-numeric returns default", envValue: "abc", expected: defaultMaxRecvMsgSize}, + {name: "negative returns default", envValue: "-1", expected: defaultMaxRecvMsgSize}, + {name: "zero returns default", envValue: "0", expected: defaultMaxRecvMsgSize}, } for _, tt := range tests { From 4b3dd9103dcf4009fbb6b088fbc44fc0db760128 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 2 Jul 2026 20:42:43 +0200 Subject: [PATCH 30/36] [client] Fix slow wg operations (#6633) * [iface] Drop redundant device dump in kernel configure() wgctrl.ConfigureDevice already returns an error when the interface is missing, so the preceding wg.Device() existence check is redundant. That check dumps the entire device (all peers) on every configure() call, making it O(peers) per call and turning bulk peer insertion into O(peers^2): inserting N peers one by one re-parsed the whole growing peer list N times. Removing it keeps each peer write constant-time regardless of how many peers are already configured. * [iface] Cache WireGuard stats to collapse per-peer device dumps Each peer runs a WGWatcher that polls GetStats(), and every call dumps the whole device, so with N peers the watchers perform O(N) full dumps per poll cycle (O(N^2) work) while each keeps only its own peer's entry. Wrap the kernel and userspace configurer GetStats() in a short-TTL cache with singleflight: the staggered per-peer calls share a single device dump per window and concurrent misses collapse into one dump. The kernel and userspace WireGuard APIs have no per-peer stats query (a get always returns the whole device), so a shared cached snapshot avoids the repeated full dumps. * Ignore .claude directory --- .gitignore | 1 + client/iface/configurer/kernel_unix.go | 23 +++---- client/iface/configurer/stats_cache.go | 52 +++++++++++++++ client/iface/configurer/stats_cache_test.go | 70 +++++++++++++++++++++ client/iface/configurer/usp.go | 10 ++- 5 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 client/iface/configurer/stats_cache.go create mode 100644 client/iface/configurer/stats_cache_test.go diff --git a/.gitignore b/.gitignore index 783fe77f3..305f3cb50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude .idea .run *.iml diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go index a29fe181a..da69c2a35 100644 --- a/client/iface/configurer/kernel_unix.go +++ b/client/iface/configurer/kernel_unix.go @@ -17,12 +17,15 @@ import ( type KernelConfigurer struct { deviceName string + statsCache *statsCache } func NewKernelConfigurer(deviceName string) *KernelConfigurer { - return &KernelConfigurer{ + c := &KernelConfigurer{ deviceName: deviceName, } + c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats) + return c } func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error { @@ -246,12 +249,6 @@ func (c *KernelConfigurer) configure(config wgtypes.Config) error { } }() - // validate if device with name exists - _, err = wg.Device(c.deviceName) - if err != nil { - return err - } - return wg.ConfigureDevice(c.deviceName, config) } @@ -300,6 +297,14 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) { } func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) { + return c.statsCache.get() +} + +func (c *KernelConfigurer) LastActivities() map[string]monotime.Time { + return nil +} + +func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) { stats := make(map[string]WGStats) wg, err := wgctrl.New() if err != nil { @@ -326,7 +331,3 @@ func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) { } return stats, nil } - -func (c *KernelConfigurer) LastActivities() map[string]monotime.Time { - return nil -} diff --git a/client/iface/configurer/stats_cache.go b/client/iface/configurer/stats_cache.go new file mode 100644 index 000000000..71a4e88fc --- /dev/null +++ b/client/iface/configurer/stats_cache.go @@ -0,0 +1,52 @@ +package configurer + +import ( + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const statsCacheTTL = 1 * time.Second + +type statsCache struct { + ttl time.Duration + fetch func() (map[string]WGStats, error) + + mu sync.RWMutex + value map[string]WGStats + expireAt time.Time + + sf singleflight.Group +} + +func newStatsCache(ttl time.Duration, fetch func() (map[string]WGStats, error)) *statsCache { + return &statsCache{ttl: ttl, fetch: fetch} +} + +func (c *statsCache) get() (map[string]WGStats, error) { + c.mu.RLock() + if c.value != nil && time.Now().Before(c.expireAt) { + value := c.value + c.mu.RUnlock() + return value, nil + } + c.mu.RUnlock() + + value, err, _ := c.sf.Do("stats", func() (interface{}, error) { + res, err := c.fetch() + if err != nil { + return nil, err + } + + c.mu.Lock() + c.value = res + c.expireAt = time.Now().Add(c.ttl) + c.mu.Unlock() + return res, nil + }) + if err != nil { + return nil, err + } + return value.(map[string]WGStats), nil +} diff --git a/client/iface/configurer/stats_cache_test.go b/client/iface/configurer/stats_cache_test.go new file mode 100644 index 000000000..bcee5cd52 --- /dev/null +++ b/client/iface/configurer/stats_cache_test.go @@ -0,0 +1,70 @@ +package configurer + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStatsCache_CachesWithinTTL(t *testing.T) { + var calls atomic.Int64 + c := newStatsCache(50*time.Millisecond, func() (map[string]WGStats, error) { + calls.Add(1) + return map[string]WGStats{"p": {}}, nil + }) + + for i := 0; i < 10; i++ { + _, err := c.get() + require.NoError(t, err) + } + require.Equal(t, int64(1), calls.Load(), "within TTL only one underlying fetch") + + time.Sleep(60 * time.Millisecond) + _, err := c.get() + require.NoError(t, err) + require.Equal(t, int64(2), calls.Load(), "after TTL expiry a fresh fetch happens") +} + +func TestStatsCache_SingleFlight(t *testing.T) { + var calls atomic.Int64 + release := make(chan struct{}) + c := newStatsCache(time.Minute, func() (map[string]WGStats, error) { + calls.Add(1) + <-release + return map[string]WGStats{}, nil + }) + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + _, _ = c.get() + }() + } + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + + require.Equal(t, int64(1), calls.Load(), "concurrent misses collapse into one fetch") +} + +func TestStatsCache_ErrorNotCached(t *testing.T) { + var calls atomic.Int64 + wantErr := errors.New("dump failed") + c := newStatsCache(time.Minute, func() (map[string]WGStats, error) { + calls.Add(1) + return nil, wantErr + }) + + _, err := c.get() + require.ErrorIs(t, err, wantErr) + _, err = c.get() + require.ErrorIs(t, err, wantErr) + require.Equal(t, int64(2), calls.Load(), "errors are not cached; each call retries") +} diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 9b070aab8..0a25c55bc 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -40,6 +40,7 @@ type WGUSPConfigurer struct { device *device.Device deviceName string activityRecorder *bind.ActivityRecorder + statsCache *statsCache uapiListener net.Listener } @@ -50,16 +51,19 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder deviceName: deviceName, activityRecorder: activityRecorder, } + wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) wgCfg.startUAPI() return wgCfg } func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { - return &WGUSPConfigurer{ + wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, } + wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) + return wgCfg } func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error { @@ -348,6 +352,10 @@ func (t *WGUSPConfigurer) Close() { } func (t *WGUSPConfigurer) GetStats() (map[string]WGStats, error) { + return t.statsCache.get() +} + +func (t *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) { ipc, err := t.device.IpcGet() if err != nil { return nil, fmt.Errorf("ipc get: %w", err) From f6900fb07c68cfcececc48cdc0de1e42bf97b5e2 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 3 Jul 2026 10:31:06 +0200 Subject: [PATCH 31/36] [client] backport enforce a single selected exit node (#6640) * routemanager: enforce a single selected exit node Backport of the exit-node exclusivity reconcile from the 0.75.0 line (upstream commit 966fbec11) onto v0.74.0. Exit nodes are mutually exclusive, but the RouteSelector stores routes with default-on semantics, so every available exit node reported as selected at once. Reconcile exit-node selection on each network map: keep at most one selected -- the user's persisted pick, else whatever management marks for auto-apply (SkipAutoApply=false), else none. Never auto-activate an exit node the map does not request. Carries over only the manager/routeselector logic and its test; the desktop-only client/server changes and the BumpNetworksRevision UI-push feature from the original commit are intentionally excluded. * routeselector: make exit-node reconciliation atomic enforceSingleExitNode took the RouteSelector lock three separate times (IsDeselectAll, then DeselectRoutes, then SelectRoutes), so a concurrent DeselectAllRoutes could interleave and be silently undone: SelectRoutes on its deselectAll branch clears the flag and re-selects the preferred exit node, overriding the user's "all off". Move the whole reconciliation into a single locked RouteSelector method (SetExclusiveExitNode) that checks deselectAll inside the critical section, so a deselect-all either fully precedes the reconcile (left untouched) or fully follows it (honoured). No interleaving is possible. --- .../routemanager/exit_node_selection_test.go | 191 ++++++++++++++++++ client/internal/routemanager/manager.go | 105 ++++++---- .../internal/routeselector/routeselector.go | 33 ++- 3 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 client/internal/routemanager/exit_node_selection_test.go diff --git a/client/internal/routemanager/exit_node_selection_test.go b/client/internal/routemanager/exit_node_selection_test.go new file mode 100644 index 000000000..28dd0a640 --- /dev/null +++ b/client/internal/routemanager/exit_node_selection_test.go @@ -0,0 +1,191 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func newExitNodeTestManager() *DefaultManager { + return &DefaultManager{routeSelector: routeselector.NewRouteSelector()} +} + +func exitRoute(netID, peer string, skipAutoApply bool) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("0.0.0.0/0"), + Peer: peer, + SkipAutoApply: skipAutoApply, + } +} + +func TestPickPreferredExitNode(t *testing.T) { + tests := []struct { + name string + info exitNodeInfo + want route.NetID + }{ + { + name: "persisted user selection wins over management", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + userSelected: []route.NetID{"b"}, + selectedByManagement: []route.NetID{"a"}, + }, + want: "b", + }, + { + name: "multiple user-selected self-heal to deterministic min", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + userSelected: []route.NetID{"c", "a"}, + }, + want: "a", + }, + { + name: "explicit opt-out keeps none", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b"}, + userDeselected: []route.NetID{"a", "b"}, + }, + want: "", + }, + { + name: "fresh defaults to management auto-apply pick", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + selectedByManagement: []route.NetID{"b"}, + }, + want: "b", + }, + { + name: "no user pick and no management auto-apply selects none", + info: exitNodeInfo{ + allIDs: []route.NetID{"c", "a", "b"}, + }, + want: "", + }, + { + name: "user-deselect does not block a management auto-apply sibling", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b"}, + userDeselected: []route.NetID{"a"}, + selectedByManagement: []route.NetID{"b"}, + }, + want: "b", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node") + }) + } +} + +func TestEnforceSingleExitNode(t *testing.T) { + m := newExitNodeTestManager() + all := []route.NetID{"a", "b", "c"} + + m.enforceSingleExitNode("b", all) + assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected") + assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node") + assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected") + + // Switching the preferred node moves the single selection. + m.enforceSingleExitNode("c", all) + assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected") + assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected") + assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected") + + // Empty preferred turns every exit node off. + m.enforceSingleExitNode("", all) + for _, id := range all { + assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected") + } +} + +func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) { + m := newExitNodeTestManager() + m.routeSelector.DeselectAllRoutes() + + m.enforceSingleExitNode("b", []route.NetID{"a", "b"}) + + assert.True(t, m.routeSelector.IsDeselectAll(), "global deselect-all must stay in effect") + assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set") +} + +func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + "exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)}, + } + + m.updateRouteSelectorFromManagement(routes) + + // Exactly one exit node (the deterministic first) is selected. + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default") + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected") + assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected") + // Non-exit routes are left at their default-on state. + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + } + all := []route.NetID{"exitA", "exitB"} + + // Simulate the state the runtime select path leaves behind: exactly one + // exit node explicitly selected, its sibling deselected. + require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all)) + require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all)) + + m.updateRouteSelectorFromManagement(routes) + + assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected") +} + +func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + } + all := []route.NetID{"exitA", "exitB"} + + // User deselected exit nodes and selected none. + require.NoError(t, m.routeSelector.DeselectRoutes(all, all)) + + m.updateRouteSelectorFromManagement(routes) + + assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off") + assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off") +} + +func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) { + m := newExitNodeTestManager() + // SkipAutoApply=true: management offers the exit nodes but doesn't request + // auto-activation, so none should be selected until the user picks one. + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + } + + m.updateRouteSelectorFromManagement(routes) + + assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off") + assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 22458d575..32379e837 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -701,7 +701,13 @@ func resolveURLsToIPs(urls []string) []net.IP { return ips } -// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server +// updateRouteSelectorFromManagement reconciles exit-node selection on every +// network map: it keeps at most one exit node selected — the user's persisted +// pick, else whatever management marks for auto-apply (SkipAutoApply=false), +// else none. We never auto-activate an exit node the map doesn't request; it +// stays off until the user picks it. Exit nodes are mutually exclusive, but the +// RouteSelector stores routes with default-on semantics, so without this every +// available exit node would report selected at once. func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) { m.mirrorV6ExitPairSelections(clientRoutes) @@ -712,13 +718,14 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA return } - exitNodeInfo := m.collectExitNodeInfo(clientRoutes) - if len(exitNodeInfo.allIDs) == 0 { + info := m.collectExitNodeInfo(clientRoutes) + if len(info.allIDs) == 0 { return } - m.updateExitNodeSelections(exitNodeInfo) - m.logExitNodeUpdate(exitNodeInfo) + preferred := pickPreferredExitNode(info) + m.enforceSingleExitNode(preferred, info.allIDs) + m.logExitNodeUpdate(info, preferred) } // mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection @@ -746,6 +753,10 @@ type exitNodeInfo struct { userDeselected []route.NetID } +// collectExitNodeInfo categorises the available exit nodes by their persisted +// selection state. It keys on the base (v4) NetID and skips the synthesized +// "-v6" partner, which inherits its base's selection through the RouteSelector +// — counting it separately would double-count the pair. func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo { var info exitNodeInfo @@ -755,6 +766,9 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI } netID := haID.NetID() + if strings.HasSuffix(string(netID), route.V6ExitSuffix) { + continue + } info.allIDs = append(info.allIDs, netID) if m.routeSelector.HasUserSelectionForRoute(netID) { @@ -791,45 +805,52 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r } } -func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) { - routesToDeselect := m.getRoutesToDeselect(info.allIDs) - m.deselectExitNodes(routesToDeselect) - m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs) +// pickPreferredExitNode chooses the single exit node to keep selected. In order: +// - a persisted user selection wins (deterministic if several survive from +// legacy state, so the set self-heals down to one); +// - otherwise activate only what management marks for auto-apply +// (SkipAutoApply=false); the lexicographically first if it marks several. +// +// Returns "" when neither holds — we never force an arbitrary exit node on. A +// route the map doesn't auto-apply stays off until the user selects it. +// info.userDeselected is informational only: an explicit deselect simply keeps +// that route out of both lists above, so it can't be picked. +func pickPreferredExitNode(info exitNodeInfo) route.NetID { + if len(info.userSelected) > 0 { + return minNetID(info.userSelected) + } + if len(info.selectedByManagement) > 0 { + return minNetID(info.selectedByManagement) + } + return "" } -func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID { - var routesToDeselect []route.NetID - for _, netID := range allIDs { - if !m.routeSelector.HasUserSelectionForRoute(netID) { - routesToDeselect = append(routesToDeselect, netID) +// enforceSingleExitNode makes preferred the only selected exit node: every other +// available exit node is deselected and preferred (if any) is selected, without +// disturbing non-exit route selections. The whole reconciliation runs under a +// single RouteSelector lock (SetExclusiveExitNode) so a concurrent deselect-all +// cannot interleave and get undone; a global deselect-all is left untouched so +// the user's "all off" stays in effect. +func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) { + m.routeSelector.SetExclusiveExitNode(preferred, allIDs) +} + +func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) { + log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)", + len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement)) +} + +// minNetID returns the lexicographically smallest NetID, for a deterministic +// default pick that stays stable across restarts. +func minNetID(ids []route.NetID) route.NetID { + if len(ids) == 0 { + return "" + } + best := ids[0] + for _, id := range ids[1:] { + if id < best { + best = id } } - return routesToDeselect -} - -func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) { - if len(routesToDeselect) == 0 { - return - } - - err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect) - if err != nil { - log.Warnf("Failed to deselect exit nodes: %v", err) - } -} - -func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) { - if len(selectedByManagement) == 0 { - return - } - - err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs) - if err != nil { - log.Warnf("Failed to select exit nodes: %v", err) - } -} - -func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) { - log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected", - len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected)) + return best } diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 232baf746..1254b384d 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -115,7 +115,38 @@ func (rs *RouteSelector) DeselectAllRoutes() { clear(rs.selectedRoutes) } -// IsDeselectAll reports whether the user has explicitly deselected all routes. +// SetExclusiveExitNode atomically makes preferred the only selected exit node +// among exitIDs: every other ID in exitIDs is deselected and preferred (when +// non-empty) is selected, all under a single lock. Holding the lock across the +// whole reconciliation prevents a concurrent DeselectAllRoutes from interleaving +// between the deselect and select steps and being silently undone. A global +// deselect-all is left untouched so the user's "all off" stays in effect; +// non-exit routes are never referenced, so their selection is preserved. +func (rs *RouteSelector) SetExclusiveExitNode(preferred route.NetID, exitIDs []route.NetID) { + rs.mu.Lock() + defer rs.mu.Unlock() + + if rs.deselectAll { + return + } + + for _, id := range exitIDs { + if id == preferred { + continue + } + rs.deselectedRoutes[id] = struct{}{} + delete(rs.selectedRoutes, id) + } + + if preferred != "" { + delete(rs.deselectedRoutes, preferred) + rs.selectedRoutes[preferred] = struct{}{} + } +} + +// IsDeselectAll reports whether the global "deselect all" flag is set, i.e. the +// user explicitly disabled every route. Callers enforcing per-route invariants +// (e.g. single exit node) should leave the selection untouched when it is. func (rs *RouteSelector) IsDeselectAll() bool { rs.mu.RLock() defer rs.mu.RUnlock() From 3aa6c02b932db503e82f9530dddfe95d5ea212c4 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Fri, 3 Jul 2026 12:23:11 +0200 Subject: [PATCH 32/36] [client] Fix backoff.Ticker goroutine leak in reconnect guard --- client/internal/peer/guard/guard.go | 6 +- client/internal/peer/guard/guard_leak_test.go | 92 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 client/internal/peer/guard/guard_leak_test.go diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 2e5efbcc5..6c2e846a9 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -85,7 +85,11 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { defer g.srWatcher.RemoveListener(srReconnectedChan) ticker := g.initialTicker(ctx) - defer ticker.Stop() + defer func() { + // If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine. + // We have to explicitly call Stop, even if we use backoff.WithContext. + ticker.Stop() + }() tickerChannel := ticker.C diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go new file mode 100644 index 000000000..ded3e4aea --- /dev/null +++ b/client/internal/peer/guard/guard_leak_test.go @@ -0,0 +1,92 @@ +package guard + +import ( + "context" + "runtime" + "strings" + "sync" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" +) + +func newTestGuard(status connStatusFunc) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw) +} + +// countBackoffTickerGoroutines returns how many goroutines are currently sitting +// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited). +func countBackoffTickerGoroutines() int { + buf := make([]byte, 1<<25) // 32MB + n := runtime.Stack(buf, true) + return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run") +} + +// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed +// leak: after a shutdown burst, ticker run/send goroutines stay parked +// forever even though every reconnect loop has exited. +func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) { + before := countBackoffTickerGoroutines() + + const peers = 6000 + cancels := make([]context.CancelFunc, 0, peers) + var wg sync.WaitGroup + + // A status check slower than the tick cadence. This models the real + // isConnectedOnAllWay/callback doing work: while the loop is busy in the + // handler, the ticker fires the next tick and parks in send(), because + // send() never selects on ctx. + slowStatus := func() ConnStatus { + time.Sleep(70 * time.Millisecond) + return ConnStatusConnected + } + + for range peers { + g := newTestGuard(slowStatus) + ctx, cancel := context.WithCancel(context.Background()) + cancels = append(cancels, cancel) + wg.Add(1) + go func() { + defer wg.Done() + g.Start(ctx, func() {}) + }() + // Force the live ticker to be a newReconnectTicker. + g.SetRelayedConnDisconnected() + } + + // Let the replacement tickers get past their 800ms initial interval, so + // many are parked in send() waiting on the (slow) consumer when we tear + // everything down. + time.Sleep(1500 * time.Millisecond) + + // Shutdown burst: cancel every peer at once, like engine teardown. + for _, c := range cancels { + c() + } + + // Every reconnect loop must return + waitCh := make(chan struct{}) + go func() { wg.Wait(); close(waitCh) }() + select { + case <-waitCh: + case <-time.After(30 * time.Second): + t.Fatal("not all reconnect loops returned after ctx cancel") + } + + // Give any correctly-stopped ticker goroutines time to unwind. + for range 50 { + runtime.Gosched() + time.Sleep(10 * time.Millisecond) + } + + leaked := countBackoffTickerGoroutines() - before + t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked) + if leaked > 0 { + t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+ + "(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked) + } +} From c9d387bd0d75173f7599228423895a3d3a3dfdfa Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:39:20 +0200 Subject: [PATCH 33/36] [client] fix MDM managementURL conflict on default-port URL echo (#6672) * Adds failing test * Fixes Management URL normalized compare on MDM --- client/server/mdm.go | 36 ++++++++++++++++++++++++++-- client/server/setconfig_mdm_test.go | 37 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/client/server/mdm.go b/client/server/mdm.go index db7db2759..1e1005e05 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "net/url" "time" log "github.com/sirupsen/logrus" @@ -181,6 +182,37 @@ func conflictBool(key string, p *bool) conflictCheck { } } +func canonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} + +// conflictURL is conflictString for URL-typed keys: both sides are +// normalized via canonicalURL before comparison. +func conflictURL(key, got string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && canonicalURL(want) == canonicalURL(got) + }, + } +} + // conflictString builds a conflictCheck for a string MDM key. An empty // `got` is treated as "field not set" (no override requested); otherwise // the check returns true only when the policy contains the key and its @@ -256,7 +288,7 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ } return resolveConflicts(policy, []conflictCheck{ - conflictString(mdm.KeyManagementURL, msg.ManagementUrl), + conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), conflictString(mdm.KeyPreSharedKey, pskGot), conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), @@ -377,7 +409,7 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str } return resolveConflicts(policy, []conflictCheck{ - conflictString(mdm.KeyManagementURL, msg.ManagementUrl), + conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), conflictString(mdm.KeyPreSharedKey, pskGot), conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 9818f9fdf..9baf16136 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -181,6 +181,43 @@ func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { require.NotNil(t, resp) } +// TestSetConfig_MDMAllow_ManagementURLPortNormalized covers the +// regression from discussion #6483: MDM URL without explicit port vs +// UI echo with the parseURL-appended default port must be treated as +// a no-op echo, not a conflict. +func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { + tests := []struct { + name string + mdmURL string + submitURL string + }{ + {"policy_no_port_submit_with_443", "https://netbird.corp.example", "https://netbird.corp.example:443"}, + {"policy_with_443_submit_no_port", "https://netbird.corp.example:443", "https://netbird.corp.example"}, + {"http_policy_no_port_submit_with_80", "http://netbird.corp.example", "http://netbird.corp.example:80"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: tc.mdmURL, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: tc.submitURL, + RosenpassEnabled: &rosenpassEnabled, + }) + + require.NoError(t, err, "port-normalized URL echo must not trip MDM conflict gate") + require.NotNil(t, resp) + }) + } +} + func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { // No MDM policy active: any field can be written. withMDMPolicy(t, mdm.NewPolicy(nil)) From 91acb8147ccd1528f53bed1024a3d9a5309aa853 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 6 Jul 2026 13:47:16 +0200 Subject: [PATCH 34/36] [management,client] 0.75.0 release with new desktop UI (#6473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow. - **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend. - **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher. - **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements. - **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms. - **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows). - **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow. Co-authored-by: Zoltan Papp Co-authored-by: Eduard Gert Co-authored-by: braginini Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: riccardom --- .coderabbit.yaml | 18 + .devcontainer/Dockerfile | 1 - .github/workflows/frontend-ui.yml | 98 + .github/workflows/golang-test-darwin.yml | 10 +- .github/workflows/golang-test-linux.yml | 17 +- .github/workflows/golang-test-windows.yml | 9 +- .github/workflows/golangci-lint.yml | 21 +- .github/workflows/release.yml | 81 +- .github/workflows/wasm-build-validation.yml | 2 +- .golangci.yaml | 10 + .goreleaser.yaml | 2 + .goreleaser_ui.yaml | 26 +- .goreleaser_ui_darwin.yaml | 11 +- CONTRIBUTING.md | 55 +- client/cmd/login.go | 77 + client/cmd/status.go | 7 + client/embed/embed.go | 2 +- client/installer.nsis | 101 +- client/internal/auth/auth.go | 42 + client/internal/auth/auth_test.go | 80 + client/internal/auth/pending_flow.go | 89 + client/internal/auth/sessionwatch/event.go | 82 + client/internal/auth/sessionwatch/watcher.go | 387 ++ .../auth/sessionwatch/watcher_test.go | 519 ++ client/internal/connect.go | 17 + client/internal/debug/debug.go | 45 +- client/internal/debug/debug_logfiles_test.go | 25 +- client/internal/engine.go | 52 +- client/internal/engine_authsession.go | 108 + .../internal/engine_session_deadline_test.go | 78 + client/internal/engine_sessionwatch.go | 16 + client/internal/engine_sessionwatch_js.go | 44 + client/internal/peer/status.go | 201 +- client/internal/peer/status_test.go | 36 + client/internal/profilemanager/state.go | 20 + client/internal/routemanager/manager.go | 9 + .../routeselector/routeselector_test.go | 28 + client/internal/state.go | 38 +- client/mdm/canonical_loaders.go | 1 + client/mdm/policy.go | 7 + client/netbird.wxs | 57 +- client/proto/daemon.pb.go | 1379 +++-- client/proto/daemon.proto | 117 + client/proto/daemon_grpc.pb.go | 283 +- client/proto/metadata.go | 61 + client/server/debug.go | 23 +- client/server/event.go | 32 + client/server/extend_authsession_test.go | 42 + client/server/mdm.go | 11 +- client/server/network.go | 46 + client/server/network_exitnode_test.go | 26 + client/server/probe_throttle.go | 88 + client/server/probe_throttle_test.go | 109 + client/server/server.go | 475 +- client/server/server_privileged_test.go | 27 +- client/server/status_stream.go | 57 + client/ssh/server/test.go | 8 + client/status/status.go | 78 + client/status/status_test.go | 47 + client/ui/.gitignore | 8 + client/ui/Netbird.icns | Bin 80549 -> 0 bytes client/ui/Taskfile.yml | 58 + client/ui/assets/connected.png | Bin 4743 -> 0 bytes client/ui/assets/disconnected.png | Bin 10530 -> 0 bytes client/ui/assets/netbird-disconnected.ico | Bin 5056 -> 0 bytes client/ui/assets/netbird-disconnected.png | Bin 7537 -> 0 bytes client/ui/assets/netbird-menu-16.png | Bin 0 -> 526 bytes client/ui/assets/netbird-menu-24.png | Bin 0 -> 739 bytes client/ui/assets/netbird-menu-about-18.png | Bin 0 -> 838 bytes .../assets/netbird-menu-dot-connected-16.png | Bin 0 -> 508 bytes .../assets/netbird-menu-dot-connected-22.png | Bin 0 -> 615 bytes .../ui/assets/netbird-menu-dot-connected.png | Bin 0 -> 452 bytes .../assets/netbird-menu-dot-connecting-16.png | Bin 0 -> 520 bytes .../assets/netbird-menu-dot-connecting-22.png | Bin 0 -> 637 bytes .../ui/assets/netbird-menu-dot-connecting.png | Bin 0 -> 452 bytes .../ui/assets/netbird-menu-dot-error-16.png | Bin 0 -> 532 bytes .../ui/assets/netbird-menu-dot-error-22.png | Bin 0 -> 629 bytes client/ui/assets/netbird-menu-dot-error.png | Bin 0 -> 433 bytes client/ui/assets/netbird-menu-dot-idle-16.png | Bin 0 -> 490 bytes client/ui/assets/netbird-menu-dot-idle-22.png | Bin 0 -> 602 bytes client/ui/assets/netbird-menu-dot-idle.png | Bin 0 -> 483 bytes .../ui/assets/netbird-menu-dot-offline-16.png | Bin 0 -> 512 bytes .../ui/assets/netbird-menu-dot-offline-22.png | Bin 0 -> 605 bytes client/ui/assets/netbird-menu-dot-offline.png | Bin 0 -> 456 bytes .../netbird-systemtray-connected-dark.ico | Bin 105144 -> 0 bytes .../netbird-systemtray-connected-macos.png | Bin 3858 -> 3690 bytes ...netbird-systemtray-connected-mono-dark.png | Bin 0 -> 1548 bytes .../netbird-systemtray-connected-mono.png | Bin 0 -> 1388 bytes .../assets/netbird-systemtray-connected.ico | Bin 105151 -> 0 bytes .../netbird-systemtray-connecting-dark.ico | Bin 105128 -> 0 bytes .../netbird-systemtray-connecting-macos.png | Bin 3843 -> 3725 bytes ...etbird-systemtray-connecting-mono-dark.png | Bin 0 -> 1448 bytes .../netbird-systemtray-connecting-mono.png | Bin 0 -> 1274 bytes .../assets/netbird-systemtray-connecting.ico | Bin 105091 -> 0 bytes .../netbird-systemtray-disconnected-macos.png | Bin 3491 -> 3474 bytes ...bird-systemtray-disconnected-mono-dark.png | Bin 0 -> 1235 bytes .../netbird-systemtray-disconnected-mono.png | Bin 0 -> 1088 bytes .../netbird-systemtray-disconnected.ico | Bin 104575 -> 0 bytes .../assets/netbird-systemtray-error-dark.ico | Bin 105062 -> 0 bytes .../assets/netbird-systemtray-error-macos.png | Bin 3837 -> 3555 bytes .../netbird-systemtray-error-mono-dark.png | Bin 0 -> 1571 bytes .../assets/netbird-systemtray-error-mono.png | Bin 0 -> 1449 bytes client/ui/assets/netbird-systemtray-error.ico | Bin 105013 -> 0 bytes .../netbird-systemtray-needs-login-macos.png | Bin 0 -> 3555 bytes ...tbird-systemtray-needs-login-mono-dark.png | Bin 0 -> 1571 bytes .../netbird-systemtray-needs-login-mono.png | Bin 0 -> 1449 bytes .../assets/netbird-systemtray-needs-login.png | Bin 0 -> 5260 bytes ...tbird-systemtray-update-connected-dark.ico | Bin 104704 -> 0 bytes ...bird-systemtray-update-connected-macos.png | Bin 3570 -> 3328 bytes ...-systemtray-update-connected-mono-dark.png | Bin 0 -> 1553 bytes ...tbird-systemtray-update-connected-mono.png | Bin 0 -> 1405 bytes .../netbird-systemtray-update-connected.ico | Bin 104698 -> 0 bytes ...rd-systemtray-update-disconnected-dark.ico | Bin 105086 -> 0 bytes ...d-systemtray-update-disconnected-macos.png | Bin 3816 -> 3747 bytes ...stemtray-update-disconnected-mono-dark.png | Bin 0 -> 1444 bytes ...rd-systemtray-update-disconnected-mono.png | Bin 0 -> 1322 bytes ...netbird-systemtray-update-disconnected.ico | Bin 105115 -> 0 bytes client/ui/assets/netbird.ico | Bin 106176 -> 0 bytes client/ui/assets/svg/needs-login.svg | 10 + client/ui/assets/svg/netbird-menu.svg | 7 + client/ui/authsession/service.go | 116 + client/ui/authsession/warning.go | 64 + client/ui/authsession/warning_test.go | 82 + client/ui/build/Taskfile.yml | 295 + .../appicon.icon/Assets/wails_icon_vector.svg | 13 + client/ui/build/appicon.icon/icon.json | 26 + client/ui/build/appicon.png | Bin 0 -> 90556 bytes client/ui/build/build-ui-linux.sh | 5 - client/ui/build/config.yml | 78 + client/ui/build/darwin/Info.dev.plist | 38 + client/ui/build/darwin/Info.plist | 36 + client/ui/build/darwin/Taskfile.yml | 210 + client/ui/build/darwin/icons.icns | Bin 0 -> 141191 bytes client/ui/build/docker/Dockerfile.cross | 203 + client/ui/build/docker/Dockerfile.server | 41 + client/ui/build/linux/Taskfile.yml | 235 + client/ui/build/linux/appimage/build.sh | 35 + client/ui/build/linux/desktop | 13 + client/ui/build/linux/netbird-ui.desktop | 10 + client/ui/build/{ => linux}/netbird.desktop | 3 +- client/ui/build/linux/nfpm/nfpm.yaml | 70 + .../build/linux/nfpm/scripts/postinstall.sh | 21 + .../ui/build/linux/nfpm/scripts/postremove.sh | 1 + .../ui/build/linux/nfpm/scripts/preinstall.sh | 1 + .../ui/build/linux/nfpm/scripts/preremove.sh | 1 + client/ui/build/windows/Taskfile.yml | 243 + client/ui/build/windows/icon.ico | Bin 0 -> 18126 bytes client/ui/build/windows/info.json | 15 + client/ui/build/windows/msix/app_manifest.xml | 55 + client/ui/build/windows/msix/template.xml | 54 + client/ui/build/windows/nsis/project.nsi | 114 + client/ui/build/windows/nsis/wails_tools.nsh | 236 + client/ui/build/windows/wails.exe.manifest | 22 + client/ui/client_ui.go | 2016 ------- client/ui/const.go | 15 - client/ui/debug.go | 730 --- client/ui/dock_darwin.go | 70 + client/ui/dock_other.go | 7 + client/ui/event/event.go | 184 - client/ui/event_handler.go | 315 - client/ui/font_bsd.go | 30 - client/ui/font_darwin.go | 18 - client/ui/font_linux.go | 7 - client/ui/font_windows.go | 90 - client/ui/frontend/.prettierignore | 7 + client/ui/frontend/.prettierrc | 12 + client/ui/frontend/WAILS-API.md | 296 + client/ui/frontend/eslint.config.js | 75 + client/ui/frontend/index.html | 15 + client/ui/frontend/package.json | 69 + client/ui/frontend/pnpm-lock.yaml | 5240 +++++++++++++++++ client/ui/frontend/pnpm-workspace.yaml | 2 + client/ui/frontend/postcss.config.js | 6 + client/ui/frontend/src/app.tsx | 61 + .../src/assets/fonts/inter-variable.ttf | Bin 0 -> 879708 bytes .../assets/fonts/jetbrains-mono-variable.ttf | Bin 0 -> 303144 bytes .../frontend/src/assets/img/tray-darwin.png | Bin 0 -> 196162 bytes .../ui/frontend/src/assets/img/tray-linux.png | Bin 0 -> 177484 bytes .../frontend/src/assets/img/tray-windows.png | Bin 0 -> 159312 bytes .../src/assets/logos/netbird-full.svg | 19 + .../ui/frontend/src/assets/logos/netbird.svg | 5 + client/ui/frontend/src/components/Badge.tsx | 43 + .../src/components/CopyToClipboard.tsx | 126 + .../frontend/src/components/DropdownMenu.tsx | 233 + .../src/components/LanguagePicker.tsx | 235 + .../src/components/ManagementServerSwitch.tsx | 38 + .../ui/frontend/src/components/SquareIcon.tsx | 37 + client/ui/frontend/src/components/Tooltip.tsx | 98 + .../frontend/src/components/TruncatedText.tsx | 32 + .../frontend/src/components/VerticalTabs.tsx | 98 + .../src/components/buttons/Button.tsx | 195 + .../src/components/buttons/IconButton.tsx | 36 + .../src/components/dialog/ConfirmDialog.tsx | 35 + .../src/components/dialog/ConfirmModal.tsx | 84 + .../frontend/src/components/dialog/Dialog.tsx | 159 + .../src/components/dialog/DialogActions.tsx | 13 + .../components/dialog/DialogDescription.tsx | 26 + .../src/components/dialog/DialogHeading.tsx | 35 + .../empty-state/DaemonOutdatedOverlay.tsx | 50 + .../empty-state/DaemonUnavailableOverlay.tsx | 52 + .../src/components/empty-state/EmptyState.tsx | 31 + .../src/components/empty-state/NoResults.tsx | 22 + .../empty-state/NotConnectedState.tsx | 16 + .../frontend/src/components/inputs/Input.tsx | 374 ++ .../src/components/inputs/SearchInput.tsx | 59 + .../components/switches/FancyToggleSwitch.tsx | 102 + .../src/components/switches/SwitchItem.tsx | 42 + .../components/switches/SwitchItemGroup.tsx | 60 + .../src/components/switches/ToggleSwitch.tsx | 77 + .../src/components/typography/HelpText.tsx | 24 + .../src/components/typography/Label.tsx | 42 + .../src/contexts/ClientVersionContext.tsx | 114 + .../src/contexts/DebugBundleContext.tsx | 314 + .../frontend/src/contexts/DialogContext.tsx | 68 + .../src/contexts/NavSectionContext.tsx | 24 + .../frontend/src/contexts/NetworksContext.tsx | 222 + .../src/contexts/PeerDetailContext.tsx | 50 + .../frontend/src/contexts/ProfileContext.tsx | 182 + .../src/contexts/RestrictionsContext.tsx | 65 + .../frontend/src/contexts/SettingsContext.tsx | 267 + .../frontend/src/contexts/StatusContext.tsx | 106 + .../frontend/src/contexts/ViewModeContext.tsx | 89 + client/ui/frontend/src/globals.css | 45 + .../frontend/src/hooks/useAutoSizeWindow.ts | 60 + .../ui/frontend/src/hooks/useFocusVisible.ts | 49 + .../frontend/src/hooks/useKeyboardShortcut.ts | 46 + .../ui/frontend/src/hooks/useManagementUrl.ts | 143 + client/ui/frontend/src/layouts/AppLayout.tsx | 27 + .../ui/frontend/src/layouts/AppRightPanel.tsx | 38 + client/ui/frontend/src/lib/cn.ts | 6 + client/ui/frontend/src/lib/compat.ts | 19 + client/ui/frontend/src/lib/connection.ts | 121 + client/ui/frontend/src/lib/errors.ts | 60 + client/ui/frontend/src/lib/formatters.ts | 44 + client/ui/frontend/src/lib/i18n.ts | 103 + client/ui/frontend/src/lib/logs.ts | 139 + client/ui/frontend/src/lib/platform.ts | 39 + client/ui/frontend/src/lib/sorting.ts | 27 + client/ui/frontend/src/lib/welcome.ts | 25 + .../src/modules/auto-update/UpdateBadge.tsx | 27 + .../auto-update/UpdateInProgressDialog.tsx | 187 + .../modules/auto-update/UpdateVersionCard.tsx | 96 + .../src/modules/error/ErrorDialog.tsx | 64 + .../login/LoginWaitingForBrowserDialog.tsx | 90 + .../main/MainConnectionStatusSwitch.tsx | 410 ++ .../src/modules/main/MainExitNodeSwitcher.tsx | 256 + .../frontend/src/modules/main/MainHeader.tsx | 192 + .../ui/frontend/src/modules/main/MainPage.tsx | 114 + .../src/modules/main/advanced/Navigation.tsx | 134 + .../main/advanced/networks/NetworkFilters.tsx | 84 + .../main/advanced/networks/Networks.tsx | 528 ++ .../main/advanced/peers/PeerDetailPanel.tsx | 575 ++ .../main/advanced/peers/PeerFilters.tsx | 84 + .../src/modules/main/advanced/peers/Peers.tsx | 353 ++ .../src/modules/profiles/ProfileAvatar.tsx | 76 + .../modules/profiles/ProfileCreationModal.tsx | 263 + .../src/modules/profiles/ProfileDropdown.tsx | 293 + .../src/modules/profiles/ProfilesTab.tsx | 679 +++ .../session/SessionExpirationDialog.tsx | 202 + .../src/modules/settings/SettingsAbout.tsx | 169 + .../src/modules/settings/SettingsAccent.tsx | 116 + .../src/modules/settings/SettingsAdvanced.tsx | 179 + .../src/modules/settings/SettingsGeneral.tsx | 113 + .../modules/settings/SettingsNavigation.tsx | 87 + .../src/modules/settings/SettingsNetwork.tsx | 55 + .../src/modules/settings/SettingsPage.tsx | 131 + .../src/modules/settings/SettingsSSH.tsx | 119 + .../src/modules/settings/SettingsSection.tsx | 43 + .../src/modules/settings/SettingsSecurity.tsx | 61 + .../src/modules/settings/SettingsSkeleton.tsx | 27 + .../settings/SettingsTroubleshooting.tsx | 337 ++ .../src/modules/welcome/WelcomeDialog.tsx | 170 + .../modules/welcome/WelcomeStepManagement.tsx | 135 + .../src/modules/welcome/WelcomeStepTray.tsx | 58 + client/ui/frontend/src/vite-env.d.ts | 1 + client/ui/frontend/tailwind.config.ts | 177 + client/ui/frontend/tsconfig.json | 30 + client/ui/frontend/vite.config.ts | 28 + client/ui/grpc.go | 67 + client/ui/guilog/debuglog.go | 77 + client/ui/i18n/TRANSLATING.md | 133 + client/ui/i18n/bundle.go | 196 + client/ui/i18n/bundle_test.go | 156 + client/ui/i18n/locales/_index.json | 13 + client/ui/i18n/locales/de/common.json | 1325 +++++ client/ui/i18n/locales/en/common.json | 1766 ++++++ client/ui/i18n/locales/es/common.json | 1325 +++++ client/ui/i18n/locales/fr/common.json | 1325 +++++ client/ui/i18n/locales/hu/common.json | 1325 +++++ client/ui/i18n/locales/it/common.json | 1325 +++++ client/ui/i18n/locales/pt/common.json | 1325 +++++ client/ui/i18n/locales/ru/common.json | 1325 +++++ client/ui/i18n/locales/zh-CN/common.json | 1325 +++++ client/ui/icons.go | 112 +- client/ui/icons_menu_darwin.go | 23 + client/ui/icons_menu_linux.go | 22 + client/ui/icons_menu_windows.go | 24 + client/ui/icons_windows.go | 44 - client/ui/localizer.go | 130 + client/ui/main.go | 387 ++ client/ui/manifest.xml | 17 - client/ui/network.go | 707 --- client/ui/notifier/notifier.go | 27 - client/ui/notifier/notifier_other.go | 9 - client/ui/notifier/notifier_windows.go | 88 - client/ui/preferences/store.go | 267 + client/ui/preferences/store_test.go | 225 + client/ui/process/process.go | 38 - client/ui/process/process_nonwindows.go | 26 - client/ui/process/process_windows.go | 24 - client/ui/profile.go | 775 --- client/ui/quickactions.go | 349 -- client/ui/quickactions_assets.go | 23 - client/ui/recenter_linux.go | 13 + client/ui/recenter_other.go | 10 + client/ui/services/autostart.go | 52 + client/ui/services/compat.go | 40 + client/ui/services/conn.go | 13 + client/ui/services/connection.go | 223 + client/ui/services/cursor_darwin.go | 42 + client/ui/services/cursor_linux.go | 53 + client/ui/services/cursor_other.go | 9 + client/ui/services/cursor_windows.go | 17 + client/ui/services/daemon_feed.go | 590 ++ client/ui/services/debug.go | 136 + client/ui/services/debug_reveal_other.go | 20 + client/ui/services/debug_reveal_windows.go | 54 + client/ui/services/errors.go | 133 + client/ui/services/errors_test.go | 50 + client/ui/services/foreground_other.go | 11 + client/ui/services/foreground_windows.go | 43 + client/ui/services/forwarding.go | 83 + client/ui/services/i18n.go | 30 + client/ui/services/network.go | 88 + client/ui/services/preferences.go | 36 + client/ui/services/profile.go | 179 + client/ui/services/profileswitcher.go | 92 + client/ui/services/session.go | 48 + client/ui/services/settings.go | 256 + client/ui/services/uilog.go | 36 + client/ui/services/update.go | 73 + client/ui/services/version.go | 22 + client/ui/services/windowmanager.go | 576 ++ client/ui/signal_unix.go | 63 +- client/ui/signal_windows.go | 161 +- client/ui/tray.go | 531 ++ client/ui/tray_click_linux.go | 31 + client/ui/tray_click_other.go | 13 + client/ui/tray_click_windows.go | 8 + client/ui/tray_events.go | 137 + client/ui/tray_exitnodes.go | 148 + client/ui/tray_features.go | 37 + client/ui/tray_icon.go | 136 + client/ui/tray_label_other.go | 7 + client/ui/tray_label_windows.go | 11 + client/ui/tray_linux.go | 71 + client/ui/tray_notify.go | 58 + client/ui/tray_profiles.go | 194 + client/ui/tray_session.go | 271 + client/ui/tray_status.go | 126 + client/ui/tray_status_enabled_linux.go | 8 + client/ui/tray_status_enabled_other.go | 7 + client/ui/tray_status_enabled_windows.go | 7 + client/ui/tray_theme_linux.go | 134 + client/ui/tray_theme_linux_test.go | 86 + client/ui/tray_theme_other.go | 7 + client/ui/tray_theme_watcher_linux.go | 246 + client/ui/tray_update.go | 197 + client/ui/tray_watcher_linux.go | 176 + client/ui/tray_watcher_other.go | 9 + client/ui/uilogpath.go | 37 + client/ui/update.go | 140 - client/ui/update_notwindows.go | 7 - client/ui/update_windows.go | 44 - client/ui/updater/state.go | 97 + client/ui/xembed_host_linux.go | 443 ++ client/ui/xembed_tray_linux.c | 708 +++ client/ui/xembed_tray_linux.h | 81 + docs/io.netbird.client.plist | 7 + docs/netbird-macos.mobileconfig | 2 + docs/netbird-macos.sh | 2 + docs/netbird-policy.reg | Bin 1418 -> 1490 bytes docs/netbird.adml | 3 + docs/netbird.admx | 12 + go.mod | 63 +- go.sum | 136 +- shared/management/client/grpc.go | 26 +- sonar-project.properties | 2 + util/log.go | 55 +- 389 files changed, 46269 insertions(+), 6684 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .github/workflows/frontend-ui.yml create mode 100644 client/internal/auth/auth_test.go create mode 100644 client/internal/auth/pending_flow.go create mode 100644 client/internal/auth/sessionwatch/event.go create mode 100644 client/internal/auth/sessionwatch/watcher.go create mode 100644 client/internal/auth/sessionwatch/watcher_test.go create mode 100644 client/internal/engine_authsession.go create mode 100644 client/internal/engine_session_deadline_test.go create mode 100644 client/internal/engine_sessionwatch.go create mode 100644 client/internal/engine_sessionwatch_js.go create mode 100644 client/proto/metadata.go create mode 100644 client/server/extend_authsession_test.go create mode 100644 client/server/network_exitnode_test.go create mode 100644 client/server/probe_throttle.go create mode 100644 client/server/probe_throttle_test.go create mode 100644 client/server/status_stream.go create mode 100644 client/ui/.gitignore delete mode 100644 client/ui/Netbird.icns create mode 100644 client/ui/Taskfile.yml delete mode 100644 client/ui/assets/connected.png delete mode 100644 client/ui/assets/disconnected.png delete mode 100644 client/ui/assets/netbird-disconnected.ico delete mode 100644 client/ui/assets/netbird-disconnected.png create mode 100644 client/ui/assets/netbird-menu-16.png create mode 100644 client/ui/assets/netbird-menu-24.png create mode 100644 client/ui/assets/netbird-menu-about-18.png create mode 100644 client/ui/assets/netbird-menu-dot-connected-16.png create mode 100644 client/ui/assets/netbird-menu-dot-connected-22.png create mode 100644 client/ui/assets/netbird-menu-dot-connected.png create mode 100644 client/ui/assets/netbird-menu-dot-connecting-16.png create mode 100644 client/ui/assets/netbird-menu-dot-connecting-22.png create mode 100644 client/ui/assets/netbird-menu-dot-connecting.png create mode 100644 client/ui/assets/netbird-menu-dot-error-16.png create mode 100644 client/ui/assets/netbird-menu-dot-error-22.png create mode 100644 client/ui/assets/netbird-menu-dot-error.png create mode 100644 client/ui/assets/netbird-menu-dot-idle-16.png create mode 100644 client/ui/assets/netbird-menu-dot-idle-22.png create mode 100644 client/ui/assets/netbird-menu-dot-idle.png create mode 100644 client/ui/assets/netbird-menu-dot-offline-16.png create mode 100644 client/ui/assets/netbird-menu-dot-offline-22.png create mode 100644 client/ui/assets/netbird-menu-dot-offline.png delete mode 100644 client/ui/assets/netbird-systemtray-connected-dark.ico create mode 100644 client/ui/assets/netbird-systemtray-connected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-connected-mono.png delete mode 100644 client/ui/assets/netbird-systemtray-connected.ico delete mode 100644 client/ui/assets/netbird-systemtray-connecting-dark.ico create mode 100644 client/ui/assets/netbird-systemtray-connecting-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-connecting-mono.png delete mode 100644 client/ui/assets/netbird-systemtray-connecting.ico create mode 100644 client/ui/assets/netbird-systemtray-disconnected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-disconnected-mono.png delete mode 100644 client/ui/assets/netbird-systemtray-disconnected.ico delete mode 100644 client/ui/assets/netbird-systemtray-error-dark.ico create mode 100644 client/ui/assets/netbird-systemtray-error-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-error-mono.png delete mode 100644 client/ui/assets/netbird-systemtray-error.ico create mode 100644 client/ui/assets/netbird-systemtray-needs-login-macos.png create mode 100644 client/ui/assets/netbird-systemtray-needs-login-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-needs-login-mono.png create mode 100644 client/ui/assets/netbird-systemtray-needs-login.png delete mode 100644 client/ui/assets/netbird-systemtray-update-connected-dark.ico create mode 100644 client/ui/assets/netbird-systemtray-update-connected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-update-connected-mono.png delete mode 100644 client/ui/assets/netbird-systemtray-update-connected.ico delete mode 100644 client/ui/assets/netbird-systemtray-update-disconnected-dark.ico create mode 100644 client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png create mode 100644 client/ui/assets/netbird-systemtray-update-disconnected-mono.png delete mode 100644 client/ui/assets/netbird-systemtray-update-disconnected.ico delete mode 100644 client/ui/assets/netbird.ico create mode 100644 client/ui/assets/svg/needs-login.svg create mode 100644 client/ui/assets/svg/netbird-menu.svg create mode 100644 client/ui/authsession/service.go create mode 100644 client/ui/authsession/warning.go create mode 100644 client/ui/authsession/warning_test.go create mode 100644 client/ui/build/Taskfile.yml create mode 100644 client/ui/build/appicon.icon/Assets/wails_icon_vector.svg create mode 100644 client/ui/build/appicon.icon/icon.json create mode 100644 client/ui/build/appicon.png delete mode 100644 client/ui/build/build-ui-linux.sh create mode 100644 client/ui/build/config.yml create mode 100644 client/ui/build/darwin/Info.dev.plist create mode 100644 client/ui/build/darwin/Info.plist create mode 100644 client/ui/build/darwin/Taskfile.yml create mode 100644 client/ui/build/darwin/icons.icns create mode 100644 client/ui/build/docker/Dockerfile.cross create mode 100644 client/ui/build/docker/Dockerfile.server create mode 100644 client/ui/build/linux/Taskfile.yml create mode 100644 client/ui/build/linux/appimage/build.sh create mode 100644 client/ui/build/linux/desktop create mode 100755 client/ui/build/linux/netbird-ui.desktop rename client/ui/build/{ => linux}/netbird.desktop (54%) create mode 100644 client/ui/build/linux/nfpm/nfpm.yaml create mode 100644 client/ui/build/linux/nfpm/scripts/postinstall.sh create mode 100644 client/ui/build/linux/nfpm/scripts/postremove.sh create mode 100644 client/ui/build/linux/nfpm/scripts/preinstall.sh create mode 100644 client/ui/build/linux/nfpm/scripts/preremove.sh create mode 100644 client/ui/build/windows/Taskfile.yml create mode 100644 client/ui/build/windows/icon.ico create mode 100644 client/ui/build/windows/info.json create mode 100644 client/ui/build/windows/msix/app_manifest.xml create mode 100644 client/ui/build/windows/msix/template.xml create mode 100644 client/ui/build/windows/nsis/project.nsi create mode 100644 client/ui/build/windows/nsis/wails_tools.nsh create mode 100644 client/ui/build/windows/wails.exe.manifest delete mode 100644 client/ui/client_ui.go delete mode 100644 client/ui/const.go delete mode 100644 client/ui/debug.go create mode 100644 client/ui/dock_darwin.go create mode 100644 client/ui/dock_other.go delete mode 100644 client/ui/event/event.go delete mode 100644 client/ui/event_handler.go delete mode 100644 client/ui/font_bsd.go delete mode 100644 client/ui/font_darwin.go delete mode 100644 client/ui/font_linux.go delete mode 100644 client/ui/font_windows.go create mode 100644 client/ui/frontend/.prettierignore create mode 100644 client/ui/frontend/.prettierrc create mode 100644 client/ui/frontend/WAILS-API.md create mode 100644 client/ui/frontend/eslint.config.js create mode 100644 client/ui/frontend/index.html create mode 100644 client/ui/frontend/package.json create mode 100644 client/ui/frontend/pnpm-lock.yaml create mode 100644 client/ui/frontend/pnpm-workspace.yaml create mode 100644 client/ui/frontend/postcss.config.js create mode 100644 client/ui/frontend/src/app.tsx create mode 100644 client/ui/frontend/src/assets/fonts/inter-variable.ttf create mode 100644 client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf create mode 100644 client/ui/frontend/src/assets/img/tray-darwin.png create mode 100644 client/ui/frontend/src/assets/img/tray-linux.png create mode 100644 client/ui/frontend/src/assets/img/tray-windows.png create mode 100644 client/ui/frontend/src/assets/logos/netbird-full.svg create mode 100644 client/ui/frontend/src/assets/logos/netbird.svg create mode 100644 client/ui/frontend/src/components/Badge.tsx create mode 100644 client/ui/frontend/src/components/CopyToClipboard.tsx create mode 100644 client/ui/frontend/src/components/DropdownMenu.tsx create mode 100644 client/ui/frontend/src/components/LanguagePicker.tsx create mode 100644 client/ui/frontend/src/components/ManagementServerSwitch.tsx create mode 100644 client/ui/frontend/src/components/SquareIcon.tsx create mode 100644 client/ui/frontend/src/components/Tooltip.tsx create mode 100644 client/ui/frontend/src/components/TruncatedText.tsx create mode 100644 client/ui/frontend/src/components/VerticalTabs.tsx create mode 100644 client/ui/frontend/src/components/buttons/Button.tsx create mode 100644 client/ui/frontend/src/components/buttons/IconButton.tsx create mode 100644 client/ui/frontend/src/components/dialog/ConfirmDialog.tsx create mode 100644 client/ui/frontend/src/components/dialog/ConfirmModal.tsx create mode 100644 client/ui/frontend/src/components/dialog/Dialog.tsx create mode 100644 client/ui/frontend/src/components/dialog/DialogActions.tsx create mode 100644 client/ui/frontend/src/components/dialog/DialogDescription.tsx create mode 100644 client/ui/frontend/src/components/dialog/DialogHeading.tsx create mode 100644 client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx create mode 100644 client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx create mode 100644 client/ui/frontend/src/components/empty-state/EmptyState.tsx create mode 100644 client/ui/frontend/src/components/empty-state/NoResults.tsx create mode 100644 client/ui/frontend/src/components/empty-state/NotConnectedState.tsx create mode 100644 client/ui/frontend/src/components/inputs/Input.tsx create mode 100644 client/ui/frontend/src/components/inputs/SearchInput.tsx create mode 100644 client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx create mode 100644 client/ui/frontend/src/components/switches/SwitchItem.tsx create mode 100644 client/ui/frontend/src/components/switches/SwitchItemGroup.tsx create mode 100644 client/ui/frontend/src/components/switches/ToggleSwitch.tsx create mode 100644 client/ui/frontend/src/components/typography/HelpText.tsx create mode 100644 client/ui/frontend/src/components/typography/Label.tsx create mode 100644 client/ui/frontend/src/contexts/ClientVersionContext.tsx create mode 100644 client/ui/frontend/src/contexts/DebugBundleContext.tsx create mode 100644 client/ui/frontend/src/contexts/DialogContext.tsx create mode 100644 client/ui/frontend/src/contexts/NavSectionContext.tsx create mode 100644 client/ui/frontend/src/contexts/NetworksContext.tsx create mode 100644 client/ui/frontend/src/contexts/PeerDetailContext.tsx create mode 100644 client/ui/frontend/src/contexts/ProfileContext.tsx create mode 100644 client/ui/frontend/src/contexts/RestrictionsContext.tsx create mode 100644 client/ui/frontend/src/contexts/SettingsContext.tsx create mode 100644 client/ui/frontend/src/contexts/StatusContext.tsx create mode 100644 client/ui/frontend/src/contexts/ViewModeContext.tsx create mode 100644 client/ui/frontend/src/globals.css create mode 100644 client/ui/frontend/src/hooks/useAutoSizeWindow.ts create mode 100644 client/ui/frontend/src/hooks/useFocusVisible.ts create mode 100644 client/ui/frontend/src/hooks/useKeyboardShortcut.ts create mode 100644 client/ui/frontend/src/hooks/useManagementUrl.ts create mode 100644 client/ui/frontend/src/layouts/AppLayout.tsx create mode 100644 client/ui/frontend/src/layouts/AppRightPanel.tsx create mode 100644 client/ui/frontend/src/lib/cn.ts create mode 100644 client/ui/frontend/src/lib/compat.ts create mode 100644 client/ui/frontend/src/lib/connection.ts create mode 100644 client/ui/frontend/src/lib/errors.ts create mode 100644 client/ui/frontend/src/lib/formatters.ts create mode 100644 client/ui/frontend/src/lib/i18n.ts create mode 100644 client/ui/frontend/src/lib/logs.ts create mode 100644 client/ui/frontend/src/lib/platform.ts create mode 100644 client/ui/frontend/src/lib/sorting.ts create mode 100644 client/ui/frontend/src/lib/welcome.ts create mode 100644 client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx create mode 100644 client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx create mode 100644 client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx create mode 100644 client/ui/frontend/src/modules/error/ErrorDialog.tsx create mode 100644 client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx create mode 100644 client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx create mode 100644 client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx create mode 100644 client/ui/frontend/src/modules/main/MainHeader.tsx create mode 100644 client/ui/frontend/src/modules/main/MainPage.tsx create mode 100644 client/ui/frontend/src/modules/main/advanced/Navigation.tsx create mode 100644 client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx create mode 100644 client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx create mode 100644 client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx create mode 100644 client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx create mode 100644 client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx create mode 100644 client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx create mode 100644 client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx create mode 100644 client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx create mode 100644 client/ui/frontend/src/modules/profiles/ProfilesTab.tsx create mode 100644 client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsAbout.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsAccent.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsGeneral.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsNavigation.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsNetwork.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsPage.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsSSH.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsSection.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsSecurity.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx create mode 100644 client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx create mode 100644 client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx create mode 100644 client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx create mode 100644 client/ui/frontend/src/vite-env.d.ts create mode 100644 client/ui/frontend/tailwind.config.ts create mode 100644 client/ui/frontend/tsconfig.json create mode 100644 client/ui/frontend/vite.config.ts create mode 100644 client/ui/grpc.go create mode 100644 client/ui/guilog/debuglog.go create mode 100644 client/ui/i18n/TRANSLATING.md create mode 100644 client/ui/i18n/bundle.go create mode 100644 client/ui/i18n/bundle_test.go create mode 100644 client/ui/i18n/locales/_index.json create mode 100644 client/ui/i18n/locales/de/common.json create mode 100644 client/ui/i18n/locales/en/common.json create mode 100644 client/ui/i18n/locales/es/common.json create mode 100644 client/ui/i18n/locales/fr/common.json create mode 100644 client/ui/i18n/locales/hu/common.json create mode 100644 client/ui/i18n/locales/it/common.json create mode 100644 client/ui/i18n/locales/pt/common.json create mode 100644 client/ui/i18n/locales/ru/common.json create mode 100644 client/ui/i18n/locales/zh-CN/common.json create mode 100644 client/ui/icons_menu_darwin.go create mode 100644 client/ui/icons_menu_linux.go create mode 100644 client/ui/icons_menu_windows.go delete mode 100644 client/ui/icons_windows.go create mode 100644 client/ui/localizer.go create mode 100644 client/ui/main.go delete mode 100644 client/ui/manifest.xml delete mode 100644 client/ui/network.go delete mode 100644 client/ui/notifier/notifier.go delete mode 100644 client/ui/notifier/notifier_other.go delete mode 100644 client/ui/notifier/notifier_windows.go create mode 100644 client/ui/preferences/store.go create mode 100644 client/ui/preferences/store_test.go delete mode 100644 client/ui/process/process.go delete mode 100644 client/ui/process/process_nonwindows.go delete mode 100644 client/ui/process/process_windows.go delete mode 100644 client/ui/profile.go delete mode 100644 client/ui/quickactions.go delete mode 100644 client/ui/quickactions_assets.go create mode 100644 client/ui/recenter_linux.go create mode 100644 client/ui/recenter_other.go create mode 100644 client/ui/services/autostart.go create mode 100644 client/ui/services/compat.go create mode 100644 client/ui/services/conn.go create mode 100644 client/ui/services/connection.go create mode 100644 client/ui/services/cursor_darwin.go create mode 100644 client/ui/services/cursor_linux.go create mode 100644 client/ui/services/cursor_other.go create mode 100644 client/ui/services/cursor_windows.go create mode 100644 client/ui/services/daemon_feed.go create mode 100644 client/ui/services/debug.go create mode 100644 client/ui/services/debug_reveal_other.go create mode 100644 client/ui/services/debug_reveal_windows.go create mode 100644 client/ui/services/errors.go create mode 100644 client/ui/services/errors_test.go create mode 100644 client/ui/services/foreground_other.go create mode 100644 client/ui/services/foreground_windows.go create mode 100644 client/ui/services/forwarding.go create mode 100644 client/ui/services/i18n.go create mode 100644 client/ui/services/network.go create mode 100644 client/ui/services/preferences.go create mode 100644 client/ui/services/profile.go create mode 100644 client/ui/services/profileswitcher.go create mode 100644 client/ui/services/session.go create mode 100644 client/ui/services/settings.go create mode 100644 client/ui/services/uilog.go create mode 100644 client/ui/services/update.go create mode 100644 client/ui/services/version.go create mode 100644 client/ui/services/windowmanager.go create mode 100644 client/ui/tray.go create mode 100644 client/ui/tray_click_linux.go create mode 100644 client/ui/tray_click_other.go create mode 100644 client/ui/tray_click_windows.go create mode 100644 client/ui/tray_events.go create mode 100644 client/ui/tray_exitnodes.go create mode 100644 client/ui/tray_features.go create mode 100644 client/ui/tray_icon.go create mode 100644 client/ui/tray_label_other.go create mode 100644 client/ui/tray_label_windows.go create mode 100644 client/ui/tray_linux.go create mode 100644 client/ui/tray_notify.go create mode 100644 client/ui/tray_profiles.go create mode 100644 client/ui/tray_session.go create mode 100644 client/ui/tray_status.go create mode 100644 client/ui/tray_status_enabled_linux.go create mode 100644 client/ui/tray_status_enabled_other.go create mode 100644 client/ui/tray_status_enabled_windows.go create mode 100644 client/ui/tray_theme_linux.go create mode 100644 client/ui/tray_theme_linux_test.go create mode 100644 client/ui/tray_theme_other.go create mode 100644 client/ui/tray_theme_watcher_linux.go create mode 100644 client/ui/tray_update.go create mode 100644 client/ui/tray_watcher_linux.go create mode 100644 client/ui/tray_watcher_other.go create mode 100644 client/ui/uilogpath.go delete mode 100644 client/ui/update.go delete mode 100644 client/ui/update_notwindows.go delete mode 100644 client/ui/update_windows.go create mode 100644 client/ui/updater/state.go create mode 100644 client/ui/xembed_host_linux.go create mode 100644 client/ui/xembed_tray_linux.c create mode 100644 client/ui/xembed_tray_linux.h create mode 100644 sonar-project.properties diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..85ed5cd3b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + auto_review: + enabled: true + drafts: false + path_filters: + - "!**/*.tsx" + - "!**/*.ts" + - "!**/*.js" + - "!**/*.svg" +chat: + auto_reply: true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 80809e667..0661e0c71 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,7 +6,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ iptables=1.8.9-2 \ libgl1-mesa-dev=22.3.6-1+deb12u1 \ xorg-dev=1:7.7+23 \ - libayatana-appindicator3-dev=0.5.92-1 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && go install -v golang.org/x/tools/gopls@latest diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml new file mode 100644 index 000000000..bb5bb4528 --- /dev/null +++ b/.github/workflows/frontend-ui.yml @@ -0,0 +1,98 @@ +name: UI Frontend + +on: + pull_request: + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + - ".github/workflows/frontend-ui.yml" + push: + branches: + - main + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + lint-and-build: + name: Lint & Build + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: client/ui/frontend + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + + # Bindings are generated by wails3 from the Go service definitions and + # are not checked in (see client/ui/frontend/bindings/). Without them, + # typecheck/build fail on missing module imports. + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: "go.mod" + cache: false + + # wails3 CLI links against GTK4 / WebKitGTK 6.0 via its internal/operatingsystem + # package, so the dev libraries must be present before `go install`. + - name: Install Wails Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libgtk-4-dev \ + libwebkitgtk-6.0-dev + + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the daemon links against. + working-directory: ${{ github.workspace }} + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Get pnpm store directory + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Wails bindings + run: pnpm run bindings + + - name: Lint, typecheck, format + run: pnpm check + + - name: Build + run: pnpm build diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 748e3f996..420749a0e 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -45,7 +45,15 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged) + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index ce53261a4..0af506bba 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -53,7 +53,7 @@ jobs: - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: steps.cache.outputs.cache-hit != 'true' @@ -145,7 +145,7 @@ jobs: ${{ runner.os }}-gotest-cache- - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: matrix.arch == '386' @@ -158,7 +158,15 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged) + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' @@ -168,7 +176,6 @@ jobs: slug: netbirdio/netbird flags: unit,client - test_client_on_docker: name: "Client (Docker) / Unit" needs: [build-cache] @@ -229,7 +236,7 @@ jobs: sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged) + go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged) ' test_relay: diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index b61c87cf6..50a5ba4d6 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -65,8 +65,15 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the Where-Object pipeline then drops the broken package by + # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 511e54b62..b444a9900 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -22,7 +22,15 @@ jobs: uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 with: ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable - skip: go.mod,go.sum,**/proxy/web/** + # Non-English UI translations trip codespell on real foreign words + # (de: "Sie", "oder", "ist"). Only en/common.json is the source of + # truth that should be spell-checked. List each translated locale + # dir below and add new ones as languages are added under + # client/ui/i18n/locales/. Single-star globs are matched per path + # segment by codespell and behave the same across versions; the + # recursive "**" form did not take effect with the codespell shipped + # by this action. + skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md golangci: strategy: fail-fast: false @@ -54,7 +62,16 @@ jobs: cache: false - name: Install dependencies if: matrix.os == 'ubuntu-latest' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev + - name: Stub Wails frontend bundle + # client/ui/main.go has //go:embed all:frontend/dist. The + # directory is produced by `pnpm run build` and is gitignored, so + # lint-only runs (no frontend toolchain) need a placeholder file + # for the embed pattern to match. + shell: bash + run: | + mkdir -p client/ui/frontend/dist + touch client/ui/frontend/dist/.embed-placeholder - name: golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd431a389..76a5b36ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: pull_request: env: - SIGN_PIPE_VER: "v0.1.6" + SIGN_PIPE_VER: "v0.1.8" GORELEASER_VER: "v2.16.0" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" @@ -216,9 +216,9 @@ jobs: - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 - run: goversioninfo -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso + run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso - name: Run GoReleaser id: goreleaser uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 @@ -397,8 +397,18 @@ jobs: - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64 + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64 - name: Decode GPG signing key if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -417,10 +427,16 @@ jobs: echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION - name: Generate windows syso amd64 - run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso + run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso - name: Run GoReleaser uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 @@ -489,6 +505,20 @@ jobs: run: go mod tidy - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION - name: Run GoReleaser id: goreleaser uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 @@ -576,23 +606,6 @@ jobs: - name: Move wintun.dll into dist run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ - - name: Download Mesa3D (amd64 only) - id: download-mesa3d - if: matrix.arch == 'amd64' - uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - with: - url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z - destination: ${{ env.downloadPath }}\mesa3d.7z - sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9 - - - name: Extract Mesa3D driver (amd64 only) - if: matrix.arch == 'amd64' - run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z" - - - name: Move opengl32.dll into dist (amd64 only) - if: matrix.arch == 'amd64' - run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ - - name: Download EnVar plugin for NSIS uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 with: @@ -615,6 +628,28 @@ jobs: if: matrix.arch == 'amd64' run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z" + - name: Set up Go for wails3 CLI + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: false + + - name: Install wails3 CLI + # Version derived from go.mod so the bootstrapper payload always + # matches the wails runtime the binary links against. + shell: bash + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Stage WebView2 bootstrapper for installers + # Both client/installer.nsis and client/netbird.wxs reference + # client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there. + # The signing pipeline (netbirdio/sign-pipelines) does the same + # step for release builds; this mirrors it for PR sanity testing. + shell: bash + run: wails3 generate webview2bootstrapper -dir client + - name: Build NSIS installer shell: pwsh env: diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 35855918d..e8a12cdaf 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -27,7 +27,7 @@ jobs: with: go-version-file: "go.mod" - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev - name: Install golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: diff --git a/.golangci.yaml b/.golangci.yaml index 900af4ac0..e350b9de7 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -114,6 +114,16 @@ linters: - linters: - staticcheck text: "QF1012" + # client/ui/main.go uses //go:embed all:frontend/dist; the + # directory is populated by `pnpm build` in the release pipeline + # and missing at lint time, so the embed parses to "no matching + # files found" — surfaced by golangci-lint's typecheck pre-pass. + # Suppress just that one diagnostic; the rest of the package + # (services/, tray.go, grpc.go, ...) still gets linted normally. + - linters: + - typecheck + path: client/ui/main\.go + text: "pattern all:frontend/dist" paths: - third_party$ - builtin$ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a2640dc8e..0753b1012 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -212,6 +212,7 @@ nfpms: description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause + vendor: NetBird id: netbird_deb bindir: /usr/bin builds: @@ -226,6 +227,7 @@ nfpms: description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause + vendor: NetBird id: netbird_rpm bindir: /usr/bin builds: diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 6f9b7c059..197fcd440 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -2,6 +2,15 @@ version: 2 env: - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui dir: client/ui @@ -62,6 +71,8 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird id: netbird_ui_deb package_name: netbird-ui builds: @@ -71,9 +82,9 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop - dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird @@ -81,6 +92,8 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird id: netbird_ui_rpm package_name: netbird-ui builds: @@ -90,12 +103,13 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop - dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird + rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 0a0082075..96e15371a 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -1,6 +1,15 @@ version: 2 project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui-darwin dir: client/ui @@ -20,8 +29,6 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" - tags: - - load_wgnt_from_rsrc universal_binaries: - id: netbird-ui-darwin diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd1c087bb..261083783 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,13 +79,21 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.21 +#### Go 1.25 Follow the installation guide from https://go.dev/ -#### UI client - Fyne toolkit +#### UI client - Wails v3 + React -We use the fyne toolkit in our UI client. You can follow its requirement guide to have all its dependencies installed: https://developer.fyne.io/started/#prerequisites +The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need: + +- Go ≥ 1.25 +- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`) +- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` +- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest` +- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev` + +All UI build, dev-loop, and cross-compile commands are described in the [UI client](#ui-client) section below. #### gRPC You can follow the instructions from the quickstarter guide https://grpc.io/docs/languages/go/quickstart/#prerequisites and then run the `generate.sh` files located in each `proto` directory to generate changes. @@ -214,6 +222,39 @@ To start NetBird the client in the foreground: sudo ./client up --log-level debug --log-file console ``` > On Windows use a powershell with administrator privileges + +#### UI client + +The desktop UI lives in `client/ui` and is built with Wails v3 (see [Requirements](#ui-client---wails-v3--react)). All commands run from `client/ui`. + +Live-reload development (Vite + Go binary + `*.go` watcher): + +``` +cd client/ui +task dev +``` + +Pass daemon flags after `--`: + +``` +task dev -- --daemon-addr=tcp://127.0.0.1:41731 +``` + +Production build (frontend assets embedded into the binary, output in `client/ui/bin/`): + +``` +cd client/ui +task build +``` + +Cross-compile the Windows binary from Linux (requires the mingw-w64 toolchain, e.g. `sudo apt install gcc-mingw-w64-x86-64`): + +``` +CGO_ENABLED=1 task windows:build +``` + +> macOS cross-compile from Linux is not supported (signing and notarization need a real Mac). + #### Signal service To start NetBird's signal, execute: @@ -251,10 +292,10 @@ Create dist directory mkdir -p dist/netbird_windows_amd64 ``` -UI client +UI client (built with Wails v3 — see the [UI client](#ui-client) section above) ```shell -CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o netbird-ui.exe -ldflags "-s -w -H windowsgui" ./client/ui -mv netbird-ui.exe ./dist/netbird_windows_amd64/ +(cd client/ui && CGO_ENABLED=1 task windows:build) +mv client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/ ``` Client @@ -291,8 +332,6 @@ go test -exec sudo ./... ``` > On Windows use a powershell with administrator privileges -> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed - ## Checklist before submitting a PR As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests: - Keep functions as simple as possible, with a single purpose diff --git a/client/cmd/login.go b/client/cmd/login.go index a7ee960b1..ee32a3727 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -22,11 +22,19 @@ import ( "github.com/netbirdio/netbird/util" ) +// extendSessionFlag drives the `netbird login --extend` flow: refresh the +// SSO session expiry on the management server without tearing down the +// tunnel. Mutually exclusive with setup-key login (a setup-key cannot +// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer). +var extendSessionFlag bool + func init() { loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location") + loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false, + "refresh the SSO session expiry without tearing down the tunnel (requires an active connection)") } var loginCmd = &cobra.Command{ @@ -61,6 +69,16 @@ var loginCmd = &cobra.Command{ return err } + if extendSessionFlag { + if providedSetupKey != "" { + return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers") + } + if err := doExtendSession(ctx, cmd); err != nil { + return fmt.Errorf("extend session failed: %v", err) + } + return nil + } + // workaround to run without service if util.FindFirstLogPath(logFiles) == "" { if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil { @@ -152,6 +170,65 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str return nil } +// doExtendSession drives the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession pair. The user is sent through a regular SSO flow +// (browser + verification URL) and the resulting JWT is forwarded to the +// management server's ExtendAuthSession RPC. The tunnel stays up +// throughout — no Down/Up, no network-map resync. +func doExtendSession(ctx context.Context, cmd *cobra.Command) error { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + //nolint + return fmt.Errorf("failed to connect to daemon error: %v\n"+ + "If the daemon is not running please run: "+ + "\nnetbird service install \nnetbird service start\n", err) + } + defer conn.Close() + + client := proto.NewDaemonServiceClient(conn) + + req := &proto.RequestExtendAuthSessionRequest{} + // Pre-fill the IdP login hint from the active profile so the user + // doesn't have to retype their email. Best-effort: we still proceed + // without a hint if the lookup fails. + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr == nil { + if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" { + req.Hint = &profState.Email + } + } + + startResp, err := client.RequestExtendAuthSession(ctx, req) + if err != nil { + return fmt.Errorf("start extend session: %v", err) + } + + uri := startResp.GetVerificationURIComplete() + if uri == "" { + uri = startResp.GetVerificationURI() + } + openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR) + + waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{ + DeviceCode: startResp.GetDeviceCode(), + UserCode: startResp.GetUserCode(), + }) + if err != nil { + return fmt.Errorf("wait for extend session: %v", err) + } + + if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + deadline := ts.AsTime().Local() + cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST")) + } else { + // Management reported the peer is not eligible (e.g. login + // expiration disabled on the account). Surface that fact + // instead of pretending the call succeeded. + cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).") + } + return nil +} + func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) { // switch profile if provided diff --git a/client/cmd/status.go b/client/cmd/status.go index 5a7559cf1..c4057ed82 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "strings" + "time" "github.com/spf13/cobra" "google.golang.org/grpc/status" @@ -115,6 +116,11 @@ func statusFunc(cmd *cobra.Command, args []string) error { // manager only knows the active profile ID, not its display name. profName := getActiveProfileName(ctx) + var sessionExpiresAt time.Time + if ts := resp.GetSessionExpiresAt(); ts.IsValid() { + sessionExpiresAt = ts.AsTime().UTC() + } + var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ Anonymize: anonymizeFlag, DaemonVersion: resp.GetDaemonVersion(), @@ -125,6 +131,7 @@ func statusFunc(cmd *cobra.Command, args []string) error { IPsFilter: ipsFilterMap, ConnectionTypeFilter: connectionTypeFilter, ProfileName: profName, + SessionExpiresAt: sessionExpiresAt, }) var statusOutputString string switch { diff --git a/client/embed/embed.go b/client/embed/embed.go index d0d88b177..99a6b8229 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -470,7 +470,7 @@ func (c *Client) Status() (peer.FullStatus, error) { if connect != nil { engine := connect.Engine() if engine != nil { - _ = engine.RunHealthProbes(false) + _ = engine.RunHealthProbes(context.Background(), false) } } diff --git a/client/installer.nsis b/client/installer.nsis index 63bff1c5b..71699071b 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -6,7 +6,7 @@ !define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls." !define INSTALLER_NAME "netbird-installer.exe" !define MAIN_APP_EXE "Netbird" -!define ICON "ui\\assets\\netbird.ico" +!define ICON "ui\\build\\windows\\icon.ico" !define BANNER "ui\\build\\banner.bmp" !define LICENSE_DATA "..\\LICENSE" @@ -79,8 +79,6 @@ ShowInstDetails Show !insertmacro MUI_PAGE_DIRECTORY -Page custom AutostartPage AutostartPageLeave - !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH @@ -97,40 +95,12 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave !insertmacro MUI_LANGUAGE "English" -; Variables for autostart option -Var AutostartCheckbox -Var AutostartEnabled - ; Variables for uninstall data deletion option Var DeleteDataCheckbox Var DeleteDataEnabled ###################################################################### -; Function to create the autostart options page -Function AutostartPage - !insertmacro MUI_HEADER_TEXT "Startup Options" "Configure how ${APP_NAME} launches with Windows." - - nsDialogs::Create 1018 - Pop $0 - - ${If} $0 == error - Abort - ${EndIf} - - ${NSD_CreateCheckbox} 0 20u 100% 10u "Start ${APP_NAME} UI automatically when Windows starts" - Pop $AutostartCheckbox - ${NSD_Check} $AutostartCheckbox - StrCpy $AutostartEnabled "1" - - nsDialogs::Show -FunctionEnd - -; Function to handle leaving the autostart page -Function AutostartPageLeave - ${NSD_GetState} $AutostartCheckbox $AutostartEnabled -FunctionEnd - ; Function to create the uninstall data deletion page Function un.DeleteDataPage !insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data." @@ -201,8 +171,6 @@ Pop $0 Function .onInit StrCpy $INSTDIR "${INSTALL_DIR}" -; Default autostart to enabled so silent installs (/S) match the interactive default -StrCpy $AutostartEnabled "1" ; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live ; in the 32-bit view. Fall back to it so upgrades still find them. @@ -260,17 +228,12 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}" WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}" -; Create autostart registry entry based on checkbox -DetailPrint "Autostart enabled: $AutostartEnabled" -${If} $AutostartEnabled == "1" - WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"' - DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe" -${Else} - DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" - ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too. - DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" - DetailPrint "Autostart not enabled by user" -${EndIf} +; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails), +; not the installer. Drop the machine-wide entry older installers wrote so the +; toggle is the single source of truth. HKCU is left untouched -- it may hold +; the user's own toggle state, which must survive upgrades. +DetailPrint "Removing installer-managed autostart registry entry if present..." +DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" EnVar::SetHKLM EnVar::AddValueEx "path" "$INSTDIR" @@ -280,6 +243,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" SectionEnd +# Install the Microsoft Edge WebView2 runtime if it isn't already present. +# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry +# probe followed by a silent install of the embedded evergreen bootstrapper. +# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script +# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`). +!macro nb.webview2runtime + SetRegView 64 + # Per-machine install marker — populated when the runtime ships with + # Edge or has been installed by an admin previously. + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + # Per-user fallback for HKCU installs. + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + + SetDetailsPrint both + DetailPrint "Installing: WebView2 Runtime" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + webview2_ok: +!macroend + +Section -WebView2 + !insertmacro nb.webview2runtime +SectionEnd + Section -Post ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install' ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start' @@ -299,11 +299,14 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall' DetailPrint "Terminating Netbird UI process..." ExecWait `taskkill /im ${UI_APP_EXE}.exe /f` -; Remove autostart registry entry -DetailPrint "Removing autostart registry entry if exists..." +; Remove autostart registry entries +DetailPrint "Removing autostart registry entries if they exist..." +; Legacy machine-wide entry written by older installers. DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" -; Legacy: pre-HKLM installs wrote to HKCU; clean that up too. +; Per-user entry the UI toggle writes via Wails (value name is the lowercase +; app-name slug). Uninstall removes the app, so drop it too. DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" +DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird" ; Handle data deletion based on checkbox DetailPrint "Checking if user requested data deletion..." @@ -326,9 +329,9 @@ DetailPrint "Deleting application files..." Delete "$INSTDIR\${UI_APP_EXE}" Delete "$INSTDIR\${MAIN_APP_EXE}" Delete "$INSTDIR\wintun.dll" -!if ${ARCH} == "amd64" +# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove +# any leftover copy on uninstall so old upgrades don't leave it behind. Delete "$INSTDIR\opengl32.dll" -!endif DetailPrint "Removing application directory..." RmDir /r "$INSTDIR" diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index 850e0706d..51f56b644 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "net/url" + "strings" "sync" "time" @@ -21,6 +22,25 @@ import ( mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) +// peerLoginExpiredMsg is the exact phrase the management server returns +// when a previously SSO-enrolled peer's login has expired. Sourced from +// shared/management/status/error.go (NewPeerLoginExpiredError). Matched +// by substring so a future server-side rewording that keeps the phrase +// still triggers the friendly fallback in Login(). +const peerLoginExpiredMsg = "peer login has expired" + +// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the +// user runs `netbird login -k ` against a peer that was +// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status +// so callers' existing isPermissionDenied / isAuthError checks still +// classify it correctly (early-exit from retry backoff, StatusNeedsLogin +// in the server state machine). +var errSetupKeyOnSSOExpiredPeer = status.Error( + codes.PermissionDenied, + "this peer was originally enrolled via SSO and its session has expired. "+ + "Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.", +) + // Auth manages authentication operations with the management server // It maintains a long-lived connection and automatically handles reconnection with backoff type Auth struct { @@ -184,6 +204,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err log.Debugf("peer registration required") _, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey) if err != nil { + // The peer pub-key is already on file with the management + // server (originally enrolled via SSO) and the session has + // expired. The setup-key path can only enrol new peers, so + // retrying with -k will keep failing. Replace the raw mgm + // message with an actionable hint that tells the user to + // re-authenticate via SSO instead. + if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) { + err = errSetupKeyOnSSOExpiredPeer + } isAuthError = isPermissionDenied(err) return err } @@ -473,3 +502,16 @@ func isLoginNeeded(err error) bool { func isRegistrationNeeded(err error) bool { return isPermissionDenied(err) } + +// isPeerLoginExpired reports whether err is the management server's +// "peer login has expired" PermissionDenied response. Used by Login to +// detect the case where the caller passed a setup-key but the peer is +// actually an SSO-enrolled record whose session needs refreshing — the +// setup-key path cannot help there. +func isPeerLoginExpired(err error) bool { + if !isPermissionDenied(err) { + return false + } + s, _ := status.FromError(err) + return strings.Contains(s.Message(), peerLoginExpiredMsg) +} diff --git a/client/internal/auth/auth_test.go b/client/internal/auth/auth_test.go new file mode 100644 index 000000000..e393beccb --- /dev/null +++ b/client/internal/auth/auth_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "errors" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestIsPeerLoginExpired(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + { + name: "nil", + err: nil, + want: false, + }, + { + name: "plain error (not a gRPC status)", + err: errors.New("network read: connection reset"), + want: false, + }, + { + name: "PermissionDenied with different message", + err: status.Error(codes.PermissionDenied, "user is blocked"), + want: false, + }, + { + name: "Unauthenticated with the expected phrase", + // Wrong status code — must still return false. + err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"), + want: false, + }, + { + name: "exact server message", + err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"), + want: true, + }, + { + name: "phrase as substring", + // Future-proofing: if mgm reworords but keeps the phrase, + // the friendly fallback must still kick in. + err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"), + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isPeerLoginExpired(tc.err); got != tc.want { + t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) { + // Sentinel must surface as PermissionDenied so the upstream + // isPermissionDenied / isAuthError checks classify it correctly + // (short-circuit retry backoff, set StatusNeedsLogin). + if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) { + t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error") + } + + // Message must actually mention SSO and `netbird up` so it is + // actionable for the end user. Loose substring checks keep the + // test resilient to copy edits. + s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer) + msg := strings.ToLower(s.Message()) + for _, want := range []string{"sso", "netbird up"} { + if !strings.Contains(msg, want) { + t.Errorf("sentinel message should contain %q, got %q", want, s.Message()) + } + } +} diff --git a/client/internal/auth/pending_flow.go b/client/internal/auth/pending_flow.go new file mode 100644 index 000000000..daeb18bc2 --- /dev/null +++ b/client/internal/auth/pending_flow.go @@ -0,0 +1,89 @@ +package auth + +import ( + "context" + "sync" + "time" +) + +// PendingFlow stores an in-progress OAuth flow between the RPC that +// initiates it (returns the verification URI to the UI) and the RPC +// that waits for the user to complete it. The flow handle, the +// device-code info, and the absolute expiry are kept together so the +// waiting RPC can validate the device code and reuse the same flow. +// +// PendingFlow is safe for concurrent use; callers must not access the +// stored fields directly. +type PendingFlow struct { + mu sync.Mutex + flow OAuthFlow + info AuthFlowInfo + expiresAt time.Time + waitCancel context.CancelFunc +} + +// NewPendingFlow returns an empty PendingFlow ready to be populated by Set. +func NewPendingFlow() *PendingFlow { + return &PendingFlow{} +} + +// Set stores the flow and its authorization info, computing the absolute +// expiry from info.ExpiresIn (seconds, as returned by the IdP). +func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) { + p.mu.Lock() + defer p.mu.Unlock() + p.flow = flow + p.info = info + p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second) +} + +// Get returns the stored flow, info, and whether a flow is currently +// pending. Returns (nil, zero, false) after Clear or before Set. +func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.flow == nil { + return nil, AuthFlowInfo{}, false + } + return p.flow, p.info, true +} + +// ExpiresAt returns the absolute expiry of the pending flow. Returns +// the zero time when no flow is pending. +func (p *PendingFlow) ExpiresAt() time.Time { + p.mu.Lock() + defer p.mu.Unlock() + return p.expiresAt +} + +// SetWaitCancel records the cancel function for the goroutine currently +// blocked in WaitToken so a new RequestAuth can preempt it. +func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) { + p.mu.Lock() + defer p.mu.Unlock() + p.waitCancel = cancel +} + +// CancelWait invokes and clears the stored wait-cancel, if any. Safe to +// call when no wait is in progress. +func (p *PendingFlow) CancelWait() { + p.mu.Lock() + cancel := p.waitCancel + p.waitCancel = nil + p.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// Clear resets the pending flow to empty. Any stored wait-cancel is +// dropped without being invoked — call CancelWait first if the waiting +// goroutine must be stopped. +func (p *PendingFlow) Clear() { + p.mu.Lock() + defer p.mu.Unlock() + p.flow = nil + p.info = AuthFlowInfo{} + p.expiresAt = time.Time{} + p.waitCancel = nil +} diff --git a/client/internal/auth/sessionwatch/event.go b/client/internal/auth/sessionwatch/event.go new file mode 100644 index 000000000..3e55b26dd --- /dev/null +++ b/client/internal/auth/sessionwatch/event.go @@ -0,0 +1,82 @@ +package sessionwatch + +import ( + "strconv" + "time" +) + +// internal event kinds are no longer exposed: the watcher drives the Sink +// directly (NotifyStateChange on deadline change/clear, PublishEvent at +// each warning lead). Tests use a mock Sink to observe what the watcher +// emits. + +// Metadata keys attached by the daemon to session-warning SystemEvents. +// The UI tray reads these to build a locale-aware notification without +// relying on the daemon's locale-less UserMessage string, and to +// disambiguate the T-WarningLead notification from the T-FinalWarningLead +// fallback that auto-opens the SessionAboutToExpire dialog. +const ( + // MetaSessionWarning is set to "true" on both warning events (T-10 and + // T-2) so the UI can detect a session-warning SystemEvent without + // matching on the message text. Use MetaSessionFinal to distinguish + // the two. + MetaSessionWarning = "session_warning" + // MetaSessionFinal is set to "true" on the T-FinalWarningLead event + // only. Consumers that need to auto-open the SessionAboutToExpire + // dialog gate on this; T-WarningLead events leave the field unset. + MetaSessionFinal = "session_final_warning" + // MetaSessionExpiresAt carries the absolute UTC deadline encoded with + // FormatExpiresAt; consumers must decode with ParseExpiresAt so a + // future format change stays a single edit. + MetaSessionExpiresAt = "session_expires_at" + // MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead + // for the T-10 event, FinalWarningLead for the T-2 event) so the UI + // can show "expires in ~N minutes" without hardcoding either constant. + MetaSessionLeadMinutes = "lead_minutes" + // MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION + // SystemEvent the daemon emits when it discards a deadline from the + // management server (pre-epoch, too far in the future, or past the + // clock-skew tolerance). The value is the rejection reason string. + // userMessage is left empty; the UI detects the event via this key + // and builds a localized notification — same pattern as the session + // warnings above. + MetaSessionDeadlineRejected = "session_deadline_rejected" +) + +// expiresAtLayout is the wire format used for MetaSessionExpiresAt. +// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt +// so this layout stays a single source of truth. +const expiresAtLayout = time.RFC3339 + +// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always +// emits UTC so a consumer in another timezone reads the same wall-clock +// deadline. +func FormatExpiresAt(t time.Time) string { + return t.UTC().Format(expiresAtLayout) +} + +// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC +// time. Returns an error when the field is empty or malformed; the +// caller decides whether to fall back (zero value) or propagate. +func ParseExpiresAt(s string) (time.Time, error) { + t, err := time.Parse(expiresAtLayout, s) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +} + +// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes +// as the integer count of whole minutes. Sub-minute residuals are +// truncated — the field is informational ("expires in ~N minutes") and +// fractional minutes don't change what the UI displays. +func FormatLeadMinutes(d time.Duration) string { + return strconv.Itoa(int(d / time.Minute)) +} + +// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0 +// and the parse error for malformed input; consumers that prefer a +// silent fallback can simply ignore the error. +func ParseLeadMinutes(s string) (int, error) { + return strconv.Atoi(s) +} diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go new file mode 100644 index 000000000..e75a7022e --- /dev/null +++ b/client/internal/auth/sessionwatch/watcher.go @@ -0,0 +1,387 @@ +// Package sessionwatch tracks the SSO session expiry deadline that the +// management server publishes via LoginResponse / SyncResponse and fires +// two warning events at fixed lead times before expiry: an interactive +// T-WarningLead notification and a dismiss-gated T-FinalWarningLead +// fallback dialog. +// +// The watcher is idempotent: Update may be called as often as the network +// map snapshots arrive. Repeating the same deadline is a no-op; a new +// deadline reschedules the timers and arms a fresh warning cycle. +// +// Warning firing is edge-detected. Each unique deadline value fires each +// warning callback at most once. +package sessionwatch + +import ( + "errors" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + cProto "github.com/netbirdio/netbird/client/proto" +) + +const ( + // Skew tolerates a small clock difference between the management + // server and this peer before treating a deadline as "in the past". + // Slightly above typical NTP drift; tight enough that the UI doesn't + // paint a stale expiry as if it were valid. + Skew = 30 * time.Second + + // maxDeadlineHorizon caps how far in the future an accepted deadline + // can sit. A timestamp beyond this is almost certainly a protocol + // glitch, and silently arming a 100-year timer would hide the bug. + maxDeadlineHorizon = 10 * 365 * 24 * time.Hour + + // WarningLead is how far before expiry the first (interactive) + // warning fires. Drives the T-10 OS notification with + // Extend/Dismiss actions. + WarningLead = 10 * time.Minute + + // FinalWarningLead is how far before expiry the fallback final + // warning fires. Drives the auto-opened SessionAboutToExpire dialog, + // but only when the user has not dismissed the T-WarningLead warning + // for the same deadline. Must be strictly less than WarningLead. + FinalWarningLead = 2 * time.Minute +) + +var ( + // ErrDeadlineBeforeEpoch is returned by Update when the supplied + // deadline pre-dates 1970-01-01. + ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch") + + // ErrDeadlineTooFarFuture is returned by Update when the supplied + // deadline is more than maxDeadlineHorizon in the future. + ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future") + + // ErrDeadlineInPast is returned by Update when the supplied deadline + // is more than Skew in the past. + ErrDeadlineInPast = errors.New("session deadline in the past") +) + +// StatusRecorder is the side-effect surface the watcher drives on every +// state transition. Production wires this to peer.Status (SetSessionExpiresAt +// for deadline change/clear, PublishEvent for the two warnings); tests pass +// a fake recorder so the same surface is observable without an engine. +// +// The watcher is the single owner of the deadline propagated to the +// recorder: every set, clear, sanity-check rejection and Close routes the +// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI +// reads can never drift from the watcher's timer state. (SetSessionExpiresAt +// fans out its own state-change notification, so no separate notify is +// needed.) The recorder is server-scoped and outlives this engine-scoped +// watcher — without the Close-time clear a teardown (Down, or the Down+Up of +// a profile switch) would leave the next session showing the previous one's +// stale "expires in" value. +// +// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher +// composes the metadata internally so the wire format (MetaSession*) is +// owned by sessionwatch, not the caller. +type StatusRecorder interface { + SetSessionExpiresAt(deadline time.Time) + PublishEvent( + severity cProto.SystemEvent_Severity, + category cProto.SystemEvent_Category, + message string, + userMessage string, + metadata map[string]string, + ) +} + +// Watcher observes the latest session deadline and fires two warnings +// before it expires: the interactive T-WarningLead notification, and the +// fallback T-FinalWarningLead dialog (suppressed when the user dismissed +// the first one for the same deadline). Safe for concurrent use. +type Watcher struct { + lead time.Duration + finalLead time.Duration + + mu sync.Mutex + current time.Time + timer *time.Timer + finalTimer *time.Timer + firedAt time.Time // deadline value the T-WarningLead callback last fired against + finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against + dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal + closed bool + recorder StatusRecorder +} + +// New returns a watcher with the package defaults WarningLead and +// FinalWarningLead. Pass nil for recorder to silence side effects (handy +// in unit tests that exercise sanity checks without observing the publish +// path). +func New(recorder StatusRecorder) *Watcher { + return NewWithLeads(WarningLead, FinalWarningLead, recorder) +} + +// NewWithLeads returns a watcher with custom lead times. Useful for tests. +// final must be strictly less than lead; otherwise both timers fire in the +// wrong order or simultaneously and the UI flow breaks. A zero final lead +// disables the final-warning timer entirely (see armTimerLocked) so a +// millisecond-scale deadline doesn't flush both timers in one tick. +func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { + return &Watcher{ + lead: lead, + finalLead: final, + recorder: recorder, + } +} + +// Update sets the latest deadline. Pass the zero time to clear (e.g. when +// a Sync push from the server omits the field because login expiration +// was disabled). +// +// Same-value updates are no-ops. A different non-zero value cancels any +// pending timer, resets the "already fired" guard, and arms a new one. +// +// Returns one of the sentinel Err* values when the deadline fails the +// sanity checks (pre-epoch, far future, or in the past beyond Skew). +// In every error case the watcher first clears its state so it stays +// consistent with what the caller will push into its other sinks (e.g. +// applySessionDeadline forces a zero deadline into the status recorder +// after a non-nil error). +func (w *Watcher) Update(deadline time.Time) error { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return nil + } + + if deadline.IsZero() { + w.clearLocked() + return nil + } + + now := time.Now() + switch { + case deadline.Before(time.Unix(0, 0)): + w.clearLocked() + return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline) + case deadline.After(now.Add(maxDeadlineHorizon)): + w.clearLocked() + return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline) + case deadline.Before(now.Add(-Skew)): + w.clearLocked() + return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now) + } + + if deadline.Equal(w.current) { + w.mu.Unlock() + return nil + } + + w.stopTimerLocked() + w.current = deadline + // Reset every per-deadline guard so a refreshed deadline arms a fresh + // warning cycle: both edge triggers and the user Dismiss decision + // (the user agreed to the old deadline expiring; a new deadline + // restarts the contract). + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + + w.armTimerLocked(deadline) + recorder := w.recorder + w.mu.Unlock() + if recorder != nil { + recorder.SetSessionExpiresAt(deadline) + } + log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second)) + return nil +} + +// Deadline returns the most recently observed deadline. Zero when no +// deadline is currently tracked. +func (w *Watcher) Deadline() time.Time { + w.mu.Lock() + defer w.mu.Unlock() + return w.current +} + +// Dismiss records the user's "Dismiss" action against the current deadline +// and suppresses the upcoming final-warning callback for that deadline. +// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh +// deadline resets the dismissal so the final-warning cycle re-arms. +// +// No-op when the watcher holds no deadline or has been closed. +func (w *Watcher) Dismiss() { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed || w.current.IsZero() { + return + } + if w.dismissedAt.Equal(w.current) { + return + } + w.dismissedAt = w.current + // Cancel the armed final-warning timer eagerly. fireFinal would also + // gate on dismissedAt, but stopping the timer avoids a wakeup with + // nothing to do and makes the intent visible. + if w.finalTimer != nil { + w.finalTimer.Stop() + w.finalTimer = nil + } + log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339)) +} + +// Close stops any pending timer and drops the deadline on the status +// recorder. Update calls after Close are ignored. Clearing the recorder +// here is what keeps a teardown (Down, or the Down+Up of a profile switch) +// from leaving the next session showing this one's stale "expires in" +// value — the recorder is server-scoped and outlives this engine-scoped +// watcher, so nothing else drops the anchor on teardown. +func (w *Watcher) Close() { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return + } + w.closed = true + w.stopTimerLocked() + hadDeadline := !w.current.IsZero() + w.current = time.Time{} + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + recorder := w.recorder + w.mu.Unlock() + if recorder != nil && hadDeadline { + recorder.SetSessionExpiresAt(time.Time{}) + } +} + +// clearLocked drops the tracked deadline and notifies the recorder so +// downstream consumers (SubscribeStatus stream, UI) drop their anchor. +// The caller must hold w.mu; this helper releases it before invoking +// the recorder. +func (w *Watcher) clearLocked() { + if w.current.IsZero() { + w.mu.Unlock() + return + } + w.stopTimerLocked() + w.current = time.Time{} + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + recorder := w.recorder + w.mu.Unlock() + if recorder != nil { + recorder.SetSessionExpiresAt(time.Time{}) + } + log.Infof("auth session deadline cleared") +} + +func (w *Watcher) stopTimerLocked() { + if w.timer != nil { + w.timer.Stop() + w.timer = nil + } + if w.finalTimer != nil { + w.finalTimer.Stop() + w.finalTimer = nil + } +} + +func (w *Watcher) armTimerLocked(deadline time.Time) { + w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) }) + // finalLead <= 0 disables the final-warning timer entirely. Used by + // tests that predate the final-warning fallback so a millisecond-scale + // deadline does not flush both timers at once. + if w.finalLead > 0 { + w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) }) + } +} + +func (w *Watcher) fire(armedFor time.Time) { + w.mu.Lock() + if w.closed || !w.current.Equal(armedFor) { + // Deadline moved while we were waiting (e.g. a successful extend). + // The reschedule path armed a fresh timer; this one is stale. + w.mu.Unlock() + return + } + if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) { + w.mu.Unlock() + return + } + w.firedAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session expiry soon warning fired") + publishWarning(recorder, armedFor, false) +} + +// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra +// dismiss-gate: if the user dismissed the T-WarningLead notification for +// this deadline, the final warning is suppressed entirely. +func (w *Watcher) fireFinal(armedFor time.Time) { + w.mu.Lock() + if w.closed || !w.current.Equal(armedFor) { + w.mu.Unlock() + return + } + if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) { + w.mu.Unlock() + return + } + if w.dismissedAt.Equal(armedFor) { + w.mu.Unlock() + log.Infof("auth session final-warning skipped (dismissed by user)") + return + } + w.finalFiredAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session final-warning fired") + publishWarning(recorder, armedFor, true) +} + +// armOneShotLocked schedules cb at fireAt. When fireAt is already in the +// past it dispatches on the next scheduler tick so a state-change recorder +// notification (invoked after w.mu is released) lands first. Caller must +// hold w.mu. +func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer { + delay := time.Until(fireAt) + if delay <= 0 { + return time.AfterFunc(0, cb) + } + return time.AfterFunc(delay, cb) +} + +// publishWarning composes the SystemEvent for a watcher-fired warning and +// pushes it through the recorder. Severity is CRITICAL on both — bypassing +// the user's Notifications toggle is deliberate: missing the warning +// window forces the post-mortem SessionExpired flow (tunnel torn down, +// lock icon, manual re-login), which is the UX we are trying to avoid. +func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) { + lead := WarningLead + message := "session expiry warning" + meta := map[string]string{ + MetaSessionWarning: "true", + MetaSessionExpiresAt: FormatExpiresAt(deadline), + } + if final { + lead = FinalWarningLead + message = "session expiry final warning" + meta[MetaSessionFinal] = "true" + } + meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead) + + recorder.PublishEvent( + cProto.SystemEvent_CRITICAL, + cProto.SystemEvent_AUTHENTICATION, + message, + "", + meta, + ) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go new file mode 100644 index 000000000..da2b6add6 --- /dev/null +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -0,0 +1,519 @@ +package sessionwatch + +import ( + "errors" + "sync" + "testing" + "time" + + cProto "github.com/netbirdio/netbird/client/proto" +) + +// fakeRecorder satisfies StatusRecorder and records every call so tests +// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent +// land in the same ordered events slice (with the Kind distinguishing +// them) so tests that care about ordering still work. lastDeadline holds +// the most recent value passed to SetSessionExpiresAt so tests can assert +// the recorder ended up cleared/set as expected. +type fakeRecorder struct { + mu sync.Mutex + events []event + lastDeadline time.Time +} + +type eventKind int + +const ( + stateChange eventKind = iota + publish +) + +type event struct { + kind eventKind + // Set only for publish events. + severity cProto.SystemEvent_Severity + category cProto.SystemEvent_Category + message string + meta map[string]string +} + +// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op, +// a real change records the new value and fans out a state-change (the +// production recorder calls notifyStateChange internally). The baseline +// is the zero time, so an initial clear before any deadline is set emits +// nothing — matching the real recorder. +func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + if r.lastDeadline.Equal(deadline) { + return + } + r.lastDeadline = deadline + r.events = append(r.events, event{kind: stateChange}) +} + +func (r *fakeRecorder) deadline() time.Time { + r.mu.Lock() + defer r.mu.Unlock() + return r.lastDeadline +} + +func (r *fakeRecorder) PublishEvent( + severity cProto.SystemEvent_Severity, + category cProto.SystemEvent_Category, + message string, + _ string, + metadata map[string]string, +) { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, event{ + kind: publish, + severity: severity, + category: category, + message: message, + meta: metadata, + }) +} + +func (r *fakeRecorder) snapshot() []event { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]event, len(r.events)) + copy(out, r.events) + return out +} + +func (e event) isFinalWarning() bool { + return e.kind == publish && e.meta[MetaSessionFinal] == "true" +} + +func (e event) isWarning() bool { + return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true" +} + +func countWhere(events []event, pred func(event) bool) int { + n := 0 + for _, e := range events { + if pred(e) { + n++ + } + } + return n +} + +func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event { + t.Helper() + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if got := r.snapshot(); len(got) >= want { + return got + } + time.Sleep(5 * time.Millisecond) + } + got := r.snapshot() + t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got) + return nil +} + +// newWatcher builds a watcher with the final timer disabled (finalLead=0), +// matching the lead-only behaviour the pre-final-warning tests assume. +func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher { + return NewWithLeads(lead, 0, r) +} + +func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + _ = w.Update(time.Time{}) + + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events on initial zero, got %+v", got) + } +} + +func TestUpdateNonZeroFiresStateChange(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(time.Hour) + _ = w.Update(d) + + events := waitForEvents(t, r, 1) + if events[0].kind != stateChange { + t.Fatalf("expected stateChange, got %+v", events[0]) + } + if !w.Deadline().Equal(d) { + t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d) + } +} + +func TestSameDeadlineIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(time.Hour) + _ = w.Update(d) + _ = w.Update(d) + _ = w.Update(d) + + events := waitForEvents(t, r, 1) + if len(events) != 1 { + t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events) + } +} + +func TestWarningFiresOnceWithinLeadWindow(t *testing.T) { + r := &fakeRecorder{} + lead := 50 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + // Deadline 80ms out — warning should fire after ~30ms. + d := time.Now().Add(80 * time.Millisecond) + _ = w.Update(d) + + events := waitForEvents(t, r, 2) + if events[0].kind != stateChange { + t.Fatalf("event[0] should be stateChange, got %+v", events[0]) + } + if !events[1].isWarning() { + t.Fatalf("event[1] should be a warning publish, got %+v", events[1]) + } +} + +func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) // lead > delta => fire immediately + defer w.Close() + + d := time.Now().Add(10 * time.Millisecond) + _ = w.Update(d) + + events := waitForEvents(t, r, 2) + if !events[1].isWarning() { + t.Fatalf("expected immediate warning publish, got %+v", events[1]) + } +} + +func TestNewDeadlineCancelsPriorTimer(t *testing.T) { + r := &fakeRecorder{} + lead := 50 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in + _ = w.Update(first) + + // Replace with a far-future deadline before the warning fires. + time.Sleep(5 * time.Millisecond) + second := time.Now().Add(time.Hour) + _ = w.Update(second) + + // Wait past when first's warning would have fired. + time.Sleep(80 * time.Millisecond) + + if n := countWhere(r.snapshot(), event.isWarning); n != 0 { + t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot()) + } +} + +func TestRefreshAfterFireArmsNewWarning(t *testing.T) { + r := &fakeRecorder{} + lead := 30 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + first := time.Now().Add(50 * time.Millisecond) + _ = w.Update(first) + + // Wait for stateChange + warning of the first cycle. + waitForEvents(t, r, 2) + + // Simulate a successful extend: brand new deadline. + second := time.Now().Add(60 * time.Millisecond) + _ = w.Update(second) + + // 4 events total: stateChange, warning (first), stateChange, warning (second). + events := waitForEvents(t, r, 4) + if events[2].kind != stateChange { + t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2]) + } + if !events[3].isWarning() { + t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3]) + } +} + +func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + defer w.Close() + + d := time.Now().Add(2 * time.Hour) + _ = w.Update(d) + waitForEvents(t, r, 1) + + _ = w.Update(time.Time{}) + + events := waitForEvents(t, r, 2) + if events[1].kind != stateChange { + t.Fatalf("expected stateChange on clear, got %+v", events[1]) + } + if !w.Deadline().IsZero() { + t.Fatalf("Deadline should be zero after clear") + } +} + +func TestUpdateRejectsBeforeEpoch(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + + err := w.Update(time.Unix(-100, 0)) + if !errors.Is(err, ErrDeadlineBeforeEpoch) { + t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline()) + } +} + +func TestUpdateRejectsTooFarFuture(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + + err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour)) + if !errors.Is(err, ErrDeadlineTooFarFuture) { + t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline()) + } +} + +func TestUpdateInPastClearsDeadline(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + // Drain the stateChange from the seed. + waitForEvents(t, r, 1) + + err := w.Update(time.Now().Add(-1 * time.Hour)) + if !errors.Is(err, ErrDeadlineInPast) { + t.Fatalf("want ErrDeadlineInPast, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline()) + } + events := waitForEvents(t, r, 2) + if events[1].kind != stateChange { + t.Fatalf("expected stateChange on clear, got %+v", events[1]) + } +} + +func TestUpdateWithinSkewAccepted(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + // 5 seconds in the past is within the 30s Skew tolerance — accept it. + d := time.Now().Add(-5 * time.Second) + if err := w.Update(d); err != nil { + t.Fatalf("within-skew Update should succeed, got %v", err) + } + if !w.Deadline().Equal(d) { + t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d) + } +} + +func TestCloseSilencesUpdates(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + w.Close() + + _ = w.Update(time.Now().Add(time.Hour)) + + time.Sleep(20 * time.Millisecond) + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events after Close, got %+v", got) + } +} + +// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher +// holding a live deadline must zero the recorder on Close so the next +// engine's watcher (and the UI reading the shared server-scoped recorder) +// doesn't start out showing the previous session's stale "expires in". +func TestCloseClearsRecorderDeadline(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + + d := time.Now().Add(2 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("seed Update: %v", err) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Update = %v, want %v", got, d) + } + + w.Close() + + if got := r.deadline(); !got.IsZero() { + t.Fatalf("recorder deadline after Close = %v, want zero", got) + } +} + +// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric +// case: closing a watcher that never held a deadline must not emit a +// redundant clear (the recorder may legitimately hold a value written by +// some other path; the watcher only owns what it set). +func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + + w.Close() + + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events from Close on an empty watcher, got %+v", got) + } +} + +func TestFinalWarningFiresAfterRegularWarning(t *testing.T) { + r := &fakeRecorder{} + // Warning fires at deadline-80ms, final at deadline-30ms. + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Expect stateChange + warning + final-warning. + events := waitForEvents(t, r, 3) + + if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 { + t.Fatalf("expected exactly 1 stateChange, got %+v", events) + } + if countWhere(events, event.isWarning) != 1 { + t.Fatalf("expected exactly 1 warning publish, got %+v", events) + } + if countWhere(events, event.isFinalWarning) != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %+v", events) + } + + // Warning must precede final (same deadline, longer lead fires first). + var wIdx, fIdx int + for i, e := range events { + switch { + case e.isWarning(): + wIdx = i + case e.isFinalWarning(): + fIdx = i + } + } + if wIdx > fIdx { + t.Fatalf("warning must publish before final-warning, got order %+v", events) + } +} + +func TestDismissSuppressesFinalWarning(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Wait for the warning publish so we know we're inside the warning + // window, then dismiss before the final timer would fire. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isWarning) >= 1 { + break + } + time.Sleep(2 * time.Millisecond) + } + if countWhere(r.snapshot(), event.isWarning) < 1 { + t.Fatalf("warning did not publish in time, events=%+v", r.snapshot()) + } + + w.Dismiss() + + // Now wait past when the final would have fired. + time.Sleep(120 * time.Millisecond) + + if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 { + t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot()) + } +} + +func TestDismissResetByNewDeadline(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + first := time.Now().Add(100 * time.Millisecond) + _ = w.Update(first) + + // Dismiss against the first deadline. + w.Dismiss() + + // Replace with a fresh deadline before the first's timers complete. + time.Sleep(10 * time.Millisecond) + second := time.Now().Add(100 * time.Millisecond) + _ = w.Update(second) + + // The second cycle must publish a final-warning (the dismiss state + // did not carry over). + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isFinalWarning) >= 1 { + break + } + time.Sleep(5 * time.Millisecond) + } + if countWhere(r.snapshot(), event.isFinalWarning) < 1 { + t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot()) + } +} + +func TestDismissBeforeUpdateIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + // No deadline tracked yet; Dismiss must be a no-op (no panic, no state). + w.Dismiss() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Final warning should still publish — Dismiss only acts on the current + // deadline, and there was none at the time of the call. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isFinalWarning) >= 1 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot()) +} diff --git a/client/internal/connect.go b/client/internal/connect.go index 93467b09a..c2fc2fd73 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -277,6 +277,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host) mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled) if err != nil { + // On daemon shutdown / Down() the parent context is cancelled + // and the dial fails with "context canceled". Wrapping that + // into state would leave the snapshot stuck at Connecting+err + // until the backoff loop wakes up — instead let the operation + // return cleanly so the deferred state.Set(StatusIdle) takes + // effect on the next iteration. + if c.ctx.Err() != nil { + return nil + } return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err)) } mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder) @@ -415,6 +424,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return wrapErr(err) } + // Seed the session-expiry deadline from the LoginResponse. Subsequent + // changes flow in through SyncResponse and are applied in handleSync. + engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt()) + log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress()) state.Set(StatusConnected) @@ -451,6 +464,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } c.statusRecorder.ClientStart() + // Wrap the backoff with c.ctx so Down()/actCancel propagates into the + // inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry + // loop alive long after the caller asked to give up, leaving the + // status stream stuck at Connecting. err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx)) if err != nil { log.Debugf("exiting client retry loop due to unrecoverable error: %s", err) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 5700b05de..3a7c0ebff 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -229,9 +229,16 @@ scutil_dns.txt (macOS only): const ( clientLogFile = "client.log" + uiLogFile = "gui-client.log" errorLogFile = "netbird.err" stdoutLogFile = "netbird.out" + // Rotated-log glob prefixes (base log name without extension) passed to + // addRotatedLogFiles. The daemon's own log and the GUI log live in the same + // dir, so the prefixes must be disjoint to keep their rotated siblings apart. + clientLogPrefix = "client" + uiLogPrefix = "gui-client" + darwinErrorLogPath = "/var/log/netbird.out.log" darwinStdoutLogPath = "/var/log/netbird.err.log" ) @@ -249,6 +256,7 @@ type BundleGenerator struct { statusRecorder *peer.Status syncResponse *mgmProto.SyncResponse logPath string + uiLogPath string tempDir string statePath string cpuProfile []byte @@ -276,6 +284,7 @@ type GeneratorDependencies struct { StatusRecorder *peer.Status SyncResponse *mgmProto.SyncResponse LogPath string + UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one. TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. StatePath string // Path to the state file. If empty, the ServiceManager default path is used. CPUProfile []byte @@ -300,6 +309,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen statusRecorder: deps.StatusRecorder, syncResponse: deps.SyncResponse, logPath: deps.LogPath, + uiLogPath: deps.UILogPath, tempDir: deps.TempDir, statePath: deps.StatePath, cpuProfile: deps.CPUProfile, @@ -411,6 +421,10 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add logs to debug bundle: %v", err) } + if err := g.addUILog(); err != nil { + log.Errorf("failed to add UI log to debug bundle: %v", err) + } + if err := g.addUpdateLogs(); err != nil { log.Errorf("failed to add updater logs: %v", err) } @@ -986,7 +1000,7 @@ func (g *BundleGenerator) addLogfile() error { return fmt.Errorf("add client log file to zip: %w", err) } - g.addRotatedLogFiles(logDir) + g.addRotatedLogFiles(logDir, clientLogPrefix) stdErrLogPath := filepath.Join(logDir, errorLogFile) stdoutLogPath := filepath.Join(logDir, stdoutLogFile) @@ -1006,6 +1020,25 @@ func (g *BundleGenerator) addLogfile() error { return nil } +// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to +// the bundle. The path is reported by the UI via RegisterUILog; empty when no +// UI registered one (e.g. headless / server). Missing file is non-fatal — the +// UI only writes it while the daemon is in debug, so it's often absent. +func (g *BundleGenerator) addUILog() error { + if g.uiLogPath == "" { + log.Debugf("no UI log path registered, skipping in debug bundle") + return nil + } + + if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil { + return fmt.Errorf("add UI log file to zip: %w", err) + } + + g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix) + + return nil +} + // addSingleLogfile adds a single log file to the archive func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { logFile, err := os.Open(logPath) @@ -1078,14 +1111,16 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { return nil } -// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount -func (g *BundleGenerator) addRotatedLogFiles(logDir string) { +// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount. +// prefix is the base log name without extension (e.g. "client", "gui-client"); +// the glob matches both files rotated by us and by logrotate on linux. +func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) { if g.logFileCount == 0 { return } - // This regex will match both logs rotated by us and logrotate on linux - pattern := filepath.Join(logDir, "client*.log.*") + // This pattern matches both logs rotated by us and logrotate on linux + pattern := filepath.Join(logDir, prefix+"*.log.*") files, err := filepath.Glob(pattern) if err != nil { log.Warnf("failed to glob rotated logs: %v", err) diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go index f6473f979..3749b3e9b 100644 --- a/client/internal/debug/debug_logfiles_test.go +++ b/client/internal/debug/debug_logfiles_test.go @@ -40,6 +40,25 @@ func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) { require.NotContains(t, names, "other.log", "unrelated files should not be in bundle") } +// TestAddRotatedLogFiles_GUIPrefix asserts the prefix parameter scopes the glob +// to the GUI log: gui-client.log.* rotated siblings are picked up and the +// daemon's own client.log.* are not (and vice versa, covered above). This is +// the load-bearing check for the gui-client.log bundle collection — the old +// "client*.log.*" glob would have missed gui-client rotations. +func TestAddRotatedLogFiles_GUIPrefix(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "gui-client.log.1"), "gui rotated\n") + writeGzFile(t, filepath.Join(dir, "gui-client.log.2.gz"), "gui rotated gz\n") + writeFile(t, filepath.Join(dir, "client.log.1"), "daemon rotated\n") + + names := runAddRotatedLogFilesPrefix(t, dir, "gui-client", 10) + + require.Contains(t, names, "gui-client.log.1", "gui-client rotated file should be in bundle") + require.Contains(t, names, "gui-client.log.2.gz", "gui-client gz rotated file should be in bundle") + require.NotContains(t, names, "client.log.1", "daemon rotated file must not match the gui-client prefix") +} + // TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest // logFileCount rotated files are bundled, ordered by mtime. func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) { @@ -67,6 +86,10 @@ func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) { // runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory // zip writer and returns the set of entry names that ended up in the archive. func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} { + return runAddRotatedLogFilesPrefix(t, dir, "client", logFileCount) +} + +func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount uint32) map[string]struct{} { t.Helper() var buf bytes.Buffer @@ -74,7 +97,7 @@ func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[st archive: zip.NewWriter(&buf), logFileCount: logFileCount, } - g.addRotatedLogFiles(dir) + g.addRotatedLogFiles(dir, prefix) require.NoError(t, g.archive.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) diff --git a/client/internal/engine.go b/client/internal/engine.go index a08bea31b..4367f68b0 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -274,6 +274,20 @@ type Engine struct { jobExecutorWG sync.WaitGroup exposeManager *expose.Manager + + sessionWatcher sessionDeadlineWatcher +} + +// sessionDeadlineWatcher is the engine-facing surface of the SSO session +// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired +// in via newSessionWatcher, which is build-tagged so the js/wasm build links a +// no-op stub instead of pulling the full sessionwatch package (and its timer +// machinery) into the binary — the wasm client never runs the engine's +// session-warning flow. +type sessionDeadlineWatcher interface { + Update(deadline time.Time) error + Dismiss() + Close() } // Peer is an instance of the Connection Peer @@ -325,6 +339,17 @@ func NewEngine( updateManager: services.UpdateManager, syncStoreDir: config.StateDir, } + // sessionWatcher keeps the SubscribeStatus consumers in sync with the + // session expiry deadline. Deadline-change ticks come for free via + // Status.SetSessionExpiresAt; the watcher exists to push a wake-up at + // T-WarningLead and T-FinalWarningLead so the UI repaints the remaining + // time / warning state even when nothing else changed, and to publish + // two SystemEvents (the warning composition lives in sessionwatch so + // the wire format stays owned by one package): + // - T-WarningLead → interactive "Extend now / Dismiss" notification + // - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog, + // suppressed when the user dismissed the earlier warning + engine.sessionWatcher = newSessionWatcher(engine.statusRecorder) log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String()) return engine @@ -391,6 +416,10 @@ func (e *Engine) stopLocked() { e.srWatcher.Close() } + if e.sessionWatcher != nil { + e.sessionWatcher.Close() + } + if e.updateManager != nil { e.updateManager.SetDownloadOnly() } @@ -932,6 +961,8 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return e.ctx.Err() } + e.ApplySessionDeadline(update.GetSessionExpiresAt()) + if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) } @@ -1267,7 +1298,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR ClientMetrics: e.clientMetrics, DaemonVersion: version.NetbirdVersion(), RefreshStatus: func() { - e.RunHealthProbes(true) + e.RunHealthProbes(e.ctx, true) }, } @@ -2193,7 +2224,20 @@ func (e *Engine) getRosenpassAddr() string { // RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services // and updates the status recorder with the latest states. -func (e *Engine) RunHealthProbes(waitForResult bool) bool { +// +// ctx scopes the (potentially slow) STUN/TURN probing: a caller that gives up — +// e.g. a Status RPC whose client disconnected — cancels its ctx and the probe +// returns instead of running to its per-component timeout. The engine's own +// lifetime ctx still applies independently, so an engine shutdown aborts the +// probe even if the caller's ctx is context.Background(). +func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool { + // Tie the caller's ctx to the engine lifetime: either cancelling aborts + // the probe below. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(e.ctx, cancel) + defer stop() + e.syncMsgMux.Lock() signalHealthy := e.signal.IsHealthy() @@ -2216,9 +2260,9 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool { if runtime.GOOS != "js" { var results []relay.ProbeResult if waitForResult { - results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns) } else { - results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAll(ctx, stuns, turns) } e.statusRecorder.UpdateRelayStates(results) diff --git a/client/internal/engine_authsession.go b/client/internal/engine_authsession.go new file mode 100644 index 000000000..725c0903f --- /dev/null +++ b/client/internal/engine_authsession.go @@ -0,0 +1,108 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + cProto "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/system" +) + +// ApplySessionDeadline propagates the absolute SSO session deadline carried on +// LoginResponse / SyncResponse to both the watcher (for the edge-triggered +// warning) and the status recorder (for the SubscribeStatus / Status RPC +// snapshot the UI consumes). +// +// The wire field is 3-state: +// - nil → snapshot carries no info; keep the +// previously-anchored deadline (no-op) +// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is +// disabled; clear both sinks +// - valid timestamp → new deadline; arm watcher, expose on +// status recorder +// +// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected +// value is treated as a clear on both sinks: the alternative — leaving the +// previously-known deadline in place — risks the UI confidently displaying +// a stale "expires in X" while the server has actually invalidated it. +func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) { + if ts == nil { + return + } + var deadline time.Time + // Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled". + // Everything else flows through Watcher.Update, whose sanity-checks + // reject out-of-range / pre-epoch / far-future / too-stale values and + // clear on rejection. + if ts.GetSeconds() != 0 || ts.GetNanos() != 0 { + deadline = ts.AsTime().UTC() + } + if e.sessionWatcher == nil { + return + } + // Watcher.Update owns the propagation to the status recorder (the + // SubscribeStatus / Status snapshot the UI reads): a set writes the + // deadline, a clear or a sanity-check rejection writes the zero value. + // Keeping a single writer is what stops the recorder from drifting out + // of sync with the warning timers. + if err := e.sessionWatcher.Update(deadline); err != nil { + log.Errorf("auth session deadline rejected: %v, clearing", err) + e.statusRecorder.PublishEvent( + cProto.SystemEvent_ERROR, + cProto.SystemEvent_AUTHENTICATION, + "session deadline rejected", + "", + map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()}, + ) + } +} + +// DismissSessionWarning records the user's "Dismiss" click on the +// T-WarningLead interactive notification and suppresses the upcoming +// T-FinalWarningLead fallback for the current deadline. No-op when the +// watcher is not running or holds no deadline. +func (e *Engine) DismissSessionWarning() { + if e.sessionWatcher == nil { + return + } + e.sessionWatcher.Dismiss() +} + +// ExtendAuthSession asks the management server to refresh the SSO session +// expiry deadline using the supplied JWT, then mirrors the new deadline into +// the daemon's state. The tunnel is untouched; no resync, no reconnect. +// +// Returns the new absolute UTC deadline (or zero time when the server +// reports the peer is not eligible for extension). +func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) { + if jwtToken == "" { + return time.Time{}, errors.New("jwt token is required") + } + if e.mgmClient == nil { + return time.Time{}, errors.New("management client is not initialised") + } + + info, err := system.GetInfoWithChecks(ctx, e.checks) + if err != nil { + log.Warnf("failed to collect system info for session extend: %v", err) + info = system.GetInfo(ctx) + } + + resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken) + if err != nil { + return time.Time{}, fmt.Errorf("extend auth session on management: %w", err) + } + + e.ApplySessionDeadline(resp.GetSessionExpiresAt()) + + if resp.GetSessionExpiresAt().IsValid() { + return resp.GetSessionExpiresAt().AsTime().UTC(), nil + } + return time.Time{}, nil +} diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go new file mode 100644 index 000000000..6127e5bb0 --- /dev/null +++ b/client/internal/engine_session_deadline_test.go @@ -0,0 +1,78 @@ +package internal + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the +// wire field carried on LoginResponse / SyncResponse: +// +// - nil pointer → no info; previously-anchored deadline survives +// - explicit zero value → "expiry disabled" sentinel; both sinks cleared +// - valid future timestamp → new deadline propagated to both sinks +func TestApplySessionDeadline_ThreeState(t *testing.T) { + newEngine := func() *Engine { + recorder := peer.NewRecorder("") + return &Engine{ + statusRecorder: recorder, + sessionWatcher: sessionwatch.New(recorder), + } + } + + t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) { + e := newEngine() + deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(deadline)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline), + "status recorder should hold the new deadline") + }) + + t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + e.ApplySessionDeadline(nil) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded), + "nil snapshot must not disturb the existing deadline") + }) + + t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + // Explicit zero Timestamp{} (seconds=0, nanos=0) is the + // "expiry disabled / not SSO" sentinel. + e.ApplySessionDeadline(×tamppb.Timestamp{}) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), + "explicit zero sentinel must clear the deadline") + }) + + t.Run("invalid timestamp clears the deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + // Out-of-range nanos → IsValid()==false; same-meaning as the + // disabled sentinel for downstream sinks. + e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1}) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), + "invalid timestamp must clear the deadline") + }) +} diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go new file mode 100644 index 000000000..a46d73f87 --- /dev/null +++ b/client/internal/engine_sessionwatch.go @@ -0,0 +1,16 @@ +//go:build !js + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// newSessionWatcher returns the real SSO session expiry watcher for every +// non-wasm build. The js/wasm build gets a no-op stub from +// engine_sessionwatch_js.go so the sessionwatch package (and its timer +// machinery) never links into the wasm binary. +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.New(recorder) +} diff --git a/client/internal/engine_sessionwatch_js.go b/client/internal/engine_sessionwatch_js.go new file mode 100644 index 000000000..50e148ab9 --- /dev/null +++ b/client/internal/engine_sessionwatch_js.go @@ -0,0 +1,44 @@ +//go:build js + +package internal + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The +// wasm client never runs the engine's session-warning flow (the interactive +// T-WarningLead notification and the T-FinalWarningLead fallback dialog live +// in the desktop UI), so linking the full sessionwatch package (timers, event +// composition) would only bloat the binary. +// +// It still mirrors the deadline into the status recorder so the SubscribeStatus +// / Status snapshot the UI consumes stays correct — only the timer-driven +// warnings are dropped. +type noopSessionWatcher struct { + recorder *peer.Status +} + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return noopSessionWatcher{recorder: recorder} +} + +// Update mirrors the real watcher's recorder propagation without the timers or +// sanity-check sentinels: a valid deadline is exposed on the status snapshot, +// the zero time clears it. +func (w noopSessionWatcher) Update(deadline time.Time) error { + if w.recorder != nil { + w.recorder.SetSessionExpiresAt(deadline) + } + return nil +} + +func (noopSessionWatcher) Dismiss() { + // No-op: only suppresses the timer-driven final-warning, which this stub never arms. +} + +func (noopSessionWatcher) Close() { + // No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero). +} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index e48ac333c..a987482fe 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -7,6 +7,7 @@ import ( "net/netip" "slices" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -191,23 +192,30 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { // every private-service request) don't contend against each other. // Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { - mux sync.RWMutex - muxRelays sync.RWMutex - peers map[string]State - ipToKey map[string]string - changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription - signalState bool - signalError error - managementState bool - managementError error - relayStates []relay.ProbeResult - localPeer LocalPeerState - offlinePeers []State - mgmAddress string - signalAddress string - notifier *notifier - rosenpassEnabled bool - rosenpassPermissive bool + mux sync.RWMutex + muxRelays sync.RWMutex + peers map[string]State + ipToKey map[string]string + changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + signalState bool + signalError error + managementState bool + managementError error + relayStates []relay.ProbeResult + localPeer LocalPeerState + offlinePeers []State + mgmAddress string + signalAddress string + notifier *notifier + rosenpassEnabled bool + rosenpassPermissive bool + // sessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. Zero when the peer is not SSO-tracked or login + // expiration is disabled. Populated from management LoginResponse / + // SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC + // so the UI can show remaining time without itself talking to mgm. + sessionExpiresAt time.Time + nsGroupStates []NSGroupState resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo lazyConnectionEnabled bool @@ -223,6 +231,21 @@ type Status struct { eventStreams map[string]chan *proto.SystemEvent eventQueue *EventQueue + // stateChangeStreams fan-out connection-state changes (connected / + // disconnected / connecting / address change / peers list change) to + // every active SubscribeStatus gRPC stream. Each subscriber gets a + // buffered chan; the notifier non-blockingly pings them so a slow + // consumer can never stall the daemon. + stateChangeMux sync.Mutex + stateChangeStreams map[string]chan struct{} + + // networksRevision bumps whenever the routed-networks set or their + // selected state changes (driven by the route manager). Surfaced in the + // status snapshot so the UI can fingerprint on it and re-fetch + // ListNetworks only on a real change. Atomic so the snapshot builder can + // read it without taking mux. + networksRevision atomic.Uint64 + ingressGwMgr *ingressgw.Manager routeIDLookup routeIDLookup @@ -237,6 +260,7 @@ func NewRecorder(mgmAddress string) *Status { changeNotify: make(map[string]map[string]*StatusChangeSubscription), eventStreams: make(map[string]chan *proto.SystemEvent), eventQueue: NewEventQueue(eventQueueSize), + stateChangeStreams: make(map[string]chan struct{}), offlinePeers: make([]State, 0), notifier: newNotifier(), mgmAddress: mgmAddress, @@ -401,6 +425,7 @@ func (d *Status) UpdatePeerState(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -426,6 +451,7 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R // todo: consider to make sense of this notification or not d.notifier.peerListChanged(numPeers) + d.notifyStateChange() return nil } @@ -451,6 +477,7 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error { // todo: consider to make sense of this notification or not d.notifier.peerListChanged(numPeers) + d.notifyStateChange() return nil } @@ -500,6 +527,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -536,6 +564,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -571,6 +600,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -609,6 +639,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -702,6 +733,7 @@ func (d *Status) FinishPeerListModifications() { for _, rd := range dispatches { d.dispatchRouterPeers(rd.peerID, rd.snapshot) } + d.notifyStateChange() } func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription { @@ -760,6 +792,41 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) { d.mux.Unlock() d.notifier.localAddressChanged(fqdn, ip) + d.notifyStateChange() +} + +// SetSessionExpiresAt records the absolute UTC instant at which the peer's +// SSO session is set to expire. Pass the zero value to clear (e.g. when the +// management server stops publishing a deadline because login expiration was +// disabled or the peer is not SSO-tracked). Same-value updates are no-ops; +// real changes fan out via notifyStateChange so SubscribeStatus consumers +// pick up the new deadline on their next read. +func (d *Status) SetSessionExpiresAt(deadline time.Time) { + d.mux.Lock() + if d.sessionExpiresAt.Equal(deadline) { + d.mux.Unlock() + return + } + d.sessionExpiresAt = deadline + d.mux.Unlock() + d.notifyStateChange() +} + +// GetSessionExpiresAt returns the most recently recorded SSO session deadline, +// or the zero value when no deadline is tracked. A deadline that has already +// slipped into the past reports as "none": once the session has expired it is +// no longer a meaningful countdown, and the sessionwatch.Watcher does not +// arm a timer at the deadline itself to clear it (only the two pre-expiry +// warnings). Without this guard the UI would keep painting a stale +// "expires in …" against a moment that has passed until the next login, +// extend, or teardown rewrote the value. +func (d *Status) GetSessionExpiresAt() time.Time { + d.mux.Lock() + defer d.mux.Unlock() + if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) { + return time.Time{} + } + return d.sessionExpiresAt } // AddLocalPeerStateRoute adds a route to the local peer state @@ -828,11 +895,19 @@ func (d *Status) CleanLocalPeerState() { d.mux.Unlock() d.notifier.localAddressChanged(fqdn, ip) + d.notifyStateChange() } // MarkManagementDisconnected sets ManagementState to disconnected func (d *Status) MarkManagementDisconnected(err error) { d.mux.Lock() + // Health checks re-mark the same state on every probe; skip the fan-out + // when nothing actually changed so we don't flood SubscribeStatus + // consumers with identical snapshots. + if !d.managementState && errors.Is(d.managementError, err) { + d.mux.Unlock() + return + } d.managementState = false d.managementError = err mgm := d.managementState @@ -840,11 +915,16 @@ func (d *Status) MarkManagementDisconnected(err error) { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // MarkManagementConnected sets ManagementState to connected func (d *Status) MarkManagementConnected() { d.mux.Lock() + if d.managementState && d.managementError == nil { + d.mux.Unlock() + return + } d.managementState = true d.managementError = nil mgm := d.managementState @@ -852,6 +932,7 @@ func (d *Status) MarkManagementConnected() { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // UpdateSignalAddress update the address of the signal server @@ -885,6 +966,10 @@ func (d *Status) UpdateLazyConnection(enabled bool) { // MarkSignalDisconnected sets SignalState to disconnected func (d *Status) MarkSignalDisconnected(err error) { d.mux.Lock() + if !d.signalState && errors.Is(d.signalError, err) { + d.mux.Unlock() + return + } d.signalState = false d.signalError = err mgm := d.managementState @@ -892,11 +977,16 @@ func (d *Status) MarkSignalDisconnected(err error) { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // MarkSignalConnected sets SignalState to connected func (d *Status) MarkSignalConnected() { d.mux.Lock() + if d.signalState && d.signalError == nil { + d.mux.Unlock() + return + } d.signalState = true d.signalError = nil mgm := d.managementState @@ -904,6 +994,7 @@ func (d *Status) MarkSignalConnected() { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) { @@ -1110,16 +1201,19 @@ func (d *Status) GetFullStatus() FullStatus { // ClientStart will notify all listeners about the new service state func (d *Status) ClientStart() { d.notifier.clientStart() + d.notifyStateChange() } // ClientStop will notify all listeners about the new service state func (d *Status) ClientStop() { d.notifier.clientStop() + d.notifyStateChange() } // ClientTeardown will notify all listeners about the service is under teardown func (d *Status) ClientTeardown() { d.notifier.clientTearDown() + d.notifyStateChange() } // SetConnectionListener set a listener to the notifier @@ -1261,6 +1355,79 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent { return d.eventQueue.GetAll() } +// SubscribeToStateChanges hands back a channel that receives a tick on +// every connection-state change (connected / disconnected / connecting / +// address change / peers-list change). The channel is buffered to one +// pending tick so a coalesced burst still wakes the consumer exactly +// once. Pass the returned id to UnsubscribeFromStateChanges to detach. +func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + id := uuid.New().String() + ch := make(chan struct{}, 1) + d.stateChangeStreams[id] = ch + return id, ch +} + +// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel +// and closes it so any consumer goroutine selecting on the channel +// unblocks cleanly. +func (d *Status) UnsubscribeFromStateChanges(id string) { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + if ch, ok := d.stateChangeStreams[id]; ok { + close(ch) + delete(d.stateChangeStreams, id) + } +} + +// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops +// the tick if a subscriber's buffer is full — by definition the consumer +// is already going to fetch the latest snapshot, so multiple pending ticks +// would be redundant. +func (d *Status) notifyStateChange() { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + for _, ch := range d.stateChangeStreams { + select { + case ch <- struct{}{}: + default: + } + } +} + +// NotifyStateChange is the public wake-the-subscribers entry point used by +// callers that mutate state outside the peer recorder — most importantly +// the connect-state machine, which writes StatusNeedsLogin into the +// shared contextState (client/internal/state.go) without touching any +// recorder field. Without this push the SubscribeStatus stream stays on +// the previous snapshot until an unrelated peer/management/signal +// change happens to fire notifyStateChange, leaving the UI's status +// out of sync with the daemon. +func (d *Status) NotifyStateChange() { + d.notifyStateChange() +} + +// BumpNetworksRevision increments the routed-networks revision and wakes every +// SubscribeStatus subscriber. The route manager calls it when a network map +// changes the available routes or when a selection is applied — the peer +// status itself only records actively-routed (chosen) networks, so without +// this bump a candidate route appearing/disappearing would never reach the UI. +func (d *Status) BumpNetworksRevision() { + d.networksRevision.Add(1) + d.notifyStateChange() +} + +// GetNetworksRevision returns the current routed-networks revision, surfaced in +// the status snapshot so the UI can detect route/selection changes (see +// BumpNetworksRevision). +func (d *Status) GetNetworksRevision() uint64 { + return d.networksRevision.Load() +} + func (d *Status) SetWgIface(wgInterface WGIfaceStatus) { d.mux.Lock() defer d.mux.Unlock() diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 17ed47cd3..29404d413 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -314,3 +314,39 @@ func TestGetFullStatus(t *testing.T) { assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal") assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match") } + +// notified reports whether a state-change tick is pending on ch, draining it. +func notified(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) { + status := NewRecorder("https://mgm") + _, ch := status.SubscribeToStateChanges() + + // First transition is a real change and must notify. + status.MarkManagementConnected() + require.True(t, notified(ch), "first connect should notify") + + // Re-marking the same state must not notify again. + status.MarkManagementConnected() + assert.False(t, notified(ch), "redundant connect should not notify") + + // Same for signal. + status.MarkSignalConnected() + require.True(t, notified(ch), "first signal connect should notify") + status.MarkSignalConnected() + assert.False(t, notified(ch), "redundant signal connect should not notify") + + // A genuine change (disconnect with an error) notifies again. + err := errors.New("boom") + status.MarkManagementDisconnected(err) + require.True(t, notified(ch), "disconnect should notify") + status.MarkManagementDisconnected(err) + assert.False(t, notified(ch), "redundant disconnect should not notify") +} diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index 1bf3318af..9e9577796 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "github.com/netbirdio/netbird/util" @@ -71,3 +72,22 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return nil } + +// RemoveProfileState deletes the per-profile state file (which holds the +// account email used for the SSO login hint and the UI display). Called after +// a successful logout so a logged-out profile no longer shows a stale account +// email. The state file only stores the email, so deleting it is equivalent to +// clearing it; the next SSO login recreates it. A missing file is not an error. +func (pm *ProfileManager) RemoveProfileState(profileName string) error { + configDir, err := getConfigDir() + if err != nil { + return fmt.Errorf("get config directory: %w", err) + } + + stateFile := filepath.Join(configDir, profileName+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove profile state: %w", err) + } + + return nil +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 32379e837..16f8d48fa 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -442,6 +442,11 @@ func (m *DefaultManager) UpdateRoutes( m.updateClientNetworks(updateSerial, filteredClientRoutes) m.notifier.OnNewRoutes(filteredClientRoutes) + // A new network map can add or drop route/exit-node candidates without + // touching any peer's chosen-route state, so the peer status alone + // wouldn't notify SubscribeStatus subscribers. Bump the revision so the + // UI re-fetches ListNetworks. + m.statusRecorder.BumpNetworksRevision() } m.clientRoutes = clientRoutes @@ -582,6 +587,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) { if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil { log.Errorf("failed to update state: %v", err) } + + // A selection change flips Network.selected without altering the candidate + // set, so bump the revision to push the new state to the UI. + m.statusRecorder.BumpNetworksRevision() } // stopObsoleteClients stops the client network watcher for the networks that are not in the new list diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index c9d6acb4d..2b1ba3fb9 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -859,3 +859,31 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) { }) } } + +// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the +// tray exit-node toggle disabling every non-exit routed network. The tray used +// to Select an exit node with append=false, which the RouteSelector treats as +// "drop the whole current selection" (default-on semantics) — so enabling an +// exit node also turned off every LAN/route the user had on. The fix sends +// append=true and lets the daemon's SelectNetworks handler deselect only the +// sibling exit nodes. This test models that handler sequence against the +// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other +// exit nodes) must leave non-exit routes untouched. +func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { + rs := routeselector.NewRouteSelector() + all := []route.NetID{"exitA", "exitB", "lan1", "lan2"} + + // User has two LAN routes on (default-on: nothing deselected => all selected). + require.True(t, rs.IsSelected("lan1")) + require.True(t, rs.IsSelected("lan2")) + + // Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true) + // then deselects sibling exit nodes (exitB), never the LAN routes. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all)) + + assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on") + assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected") + assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") + assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") +} diff --git a/client/internal/state.go b/client/internal/state.go index 041cb73f8..0adfa26e4 100644 --- a/client/internal/state.go +++ b/client/internal/state.go @@ -33,17 +33,34 @@ func CtxGetState(ctx context.Context) *contextState { } type contextState struct { - err error - status StatusType - mutex sync.Mutex + err error + status StatusType + mutex sync.Mutex + onChange func() +} + +// SetOnChange installs a callback fired after every successful Set. Used by +// the daemon to wire the status recorder's notifyStateChange so any +// state.Set in the connect/login paths pushes a fresh snapshot to +// SubscribeStatus subscribers without each callsite having to opt in. +// The callback runs outside the contextState mutex to avoid a lock-order +// dependency with the recorder's stateChangeMux. +func (c *contextState) SetOnChange(fn func()) { + c.mutex.Lock() + c.onChange = fn + c.mutex.Unlock() } func (c *contextState) Set(update StatusType) { c.mutex.Lock() - defer c.mutex.Unlock() - c.status = update c.err = nil + cb := c.onChange + c.mutex.Unlock() + + if cb != nil { + cb() + } } func (c *contextState) Status() (StatusType, error) { @@ -57,6 +74,17 @@ func (c *contextState) Status() (StatusType, error) { return c.status, nil } +// CurrentStatus returns the last status set via Set, ignoring any wrapped +// error. Use when the status is needed for reporting purposes (e.g. the +// status snapshot stream) and a transient wrapped error from a retry loop +// shouldn't blank out the underlying status. +func (c *contextState) CurrentStatus() StatusType { + c.mutex.Lock() + defer c.mutex.Unlock() + + return c.status +} + func (c *contextState) Wrap(err error) error { c.mutex.Lock() defer c.mutex.Unlock() diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index b20a823fb..cb9af9ccb 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -15,6 +15,7 @@ var allKeys = []string{ KeyDisableUpdateSettings, KeyDisableProfiles, KeyDisableNetworks, + KeyDisableAdvancedView, KeyDisableClientRoutes, KeyDisableServerRoutes, KeyBlockInbound, diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 67b126101..b76c70a75 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -24,6 +24,13 @@ const ( KeyDisableUpdateSettings = "disableUpdateSettings" KeyDisableProfiles = "disableProfiles" KeyDisableNetworks = "disableNetworks" + // KeyDisableAdvancedView gates the advanced-view section in the + // upcoming UI revision. UI-only: NOT stored on Config, not + // applied by applyMDMPolicy, not rejectable via SetConfig. The + // daemon surfaces it through GetFeatures (tristate: present + // true / present false / absent) and the same key appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAdvancedView = "disableAdvancedView" KeyDisableClientRoutes = "disableClientRoutes" KeyDisableServerRoutes = "disableServerRoutes" KeyBlockInbound = "blockInbound" diff --git a/client/netbird.wxs b/client/netbird.wxs index 6f18b63b5..f30a7aa7e 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -13,9 +13,6 @@ - - - @@ -32,9 +29,6 @@ - - - + + - - - - - - - - - + + + + + + + + + + + + + + + - + diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 488b0186c..6fbb09958 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -192,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Severity.Descriptor instead. func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51, 0} + return file_daemon_proto_rawDescGZIP(), []int{53, 0} } type SystemEvent_Category int32 @@ -247,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Category.Descriptor instead. func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51, 1} + return file_daemon_proto_rawDescGZIP(), []int{53, 1} } type EmptyRequest struct { @@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string { } type UpRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` - Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` + Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string { return "" } +func (x *UpRequest) GetAsync() bool { + if x != nil { + return x.Async + } + return false +} + type UpResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -978,8 +991,12 @@ type StatusResponse struct { FullStatus *FullStatus `protobuf:"bytes,2,opt,name=fullStatus,proto3" json:"fullStatus,omitempty"` // NetBird daemon version DaemonVersion string `protobuf:"bytes,3,opt,name=daemonVersion,proto3" json:"daemonVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // The UI derives "warning active" from this value and its own clock. + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StatusResponse) Reset() { @@ -1033,6 +1050,13 @@ func (x *StatusResponse) GetDaemonVersion() string { return "" } +func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.SessionExpiresAt + } + return nil +} + type DownRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -2129,8 +2153,13 @@ type FullStatus struct { Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + NetworksRevision uint64 `protobuf:"varint,11,opt,name=networksRevision,proto3" json:"networksRevision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FullStatus) Reset() { @@ -2233,6 +2262,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState { return nil } +func (x *FullStatus) GetNetworksRevision() uint64 { + if x != nil { + return x.NetworksRevision + } + return 0 +} + // Networks type ListNetworksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3030,6 +3066,86 @@ func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{36} } +type RegisterUILogRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterUILogRequest) Reset() { + *x = RegisterUILogRequest{} + mi := &file_daemon_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterUILogRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterUILogRequest) ProtoMessage() {} + +func (x *RegisterUILogRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterUILogRequest.ProtoReflect.Descriptor instead. +func (*RegisterUILogRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{37} +} + +func (x *RegisterUILogRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RegisterUILogResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterUILogResponse) Reset() { + *x = RegisterUILogResponse{} + mi := &file_daemon_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterUILogResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterUILogResponse) ProtoMessage() {} + +func (x *RegisterUILogResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterUILogResponse.ProtoReflect.Descriptor instead. +func (*RegisterUILogResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{38} +} + // State represents a daemon state entry type State struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3040,7 +3156,7 @@ type State struct { func (x *State) Reset() { *x = State{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3052,7 +3168,7 @@ func (x *State) String() string { func (*State) ProtoMessage() {} func (x *State) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3065,7 +3181,7 @@ func (x *State) ProtoReflect() protoreflect.Message { // Deprecated: Use State.ProtoReflect.Descriptor instead. func (*State) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{39} } func (x *State) GetName() string { @@ -3084,7 +3200,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3096,7 +3212,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3109,7 +3225,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{38} + return file_daemon_proto_rawDescGZIP(), []int{40} } // ListStatesResponse contains a list of states @@ -3122,7 +3238,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3134,7 +3250,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3147,7 +3263,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{39} + return file_daemon_proto_rawDescGZIP(), []int{41} } func (x *ListStatesResponse) GetStates() []*State { @@ -3168,7 +3284,7 @@ type CleanStateRequest struct { func (x *CleanStateRequest) Reset() { *x = CleanStateRequest{} - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3180,7 +3296,7 @@ func (x *CleanStateRequest) String() string { func (*CleanStateRequest) ProtoMessage() {} func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3193,7 +3309,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead. func (*CleanStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{40} + return file_daemon_proto_rawDescGZIP(), []int{42} } func (x *CleanStateRequest) GetStateName() string { @@ -3220,7 +3336,7 @@ type CleanStateResponse struct { func (x *CleanStateResponse) Reset() { *x = CleanStateResponse{} - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3232,7 +3348,7 @@ func (x *CleanStateResponse) String() string { func (*CleanStateResponse) ProtoMessage() {} func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3245,7 +3361,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead. func (*CleanStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{41} + return file_daemon_proto_rawDescGZIP(), []int{43} } func (x *CleanStateResponse) GetCleanedStates() int32 { @@ -3266,7 +3382,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3278,7 +3394,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3291,7 +3407,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{42} + return file_daemon_proto_rawDescGZIP(), []int{44} } func (x *DeleteStateRequest) GetStateName() string { @@ -3318,7 +3434,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3330,7 +3446,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3343,7 +3459,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{43} + return file_daemon_proto_rawDescGZIP(), []int{45} } func (x *DeleteStateResponse) GetDeletedStates() int32 { @@ -3362,7 +3478,7 @@ type SetSyncResponsePersistenceRequest struct { func (x *SetSyncResponsePersistenceRequest) Reset() { *x = SetSyncResponsePersistenceRequest{} - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3374,7 +3490,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string { func (*SetSyncResponsePersistenceRequest) ProtoMessage() {} func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3387,7 +3503,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{44} + return file_daemon_proto_rawDescGZIP(), []int{46} } func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool { @@ -3405,7 +3521,7 @@ type SetSyncResponsePersistenceResponse struct { func (x *SetSyncResponsePersistenceResponse) Reset() { *x = SetSyncResponsePersistenceResponse{} - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3417,7 +3533,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string { func (*SetSyncResponsePersistenceResponse) ProtoMessage() {} func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3430,7 +3546,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{45} + return file_daemon_proto_rawDescGZIP(), []int{47} } type TCPFlags struct { @@ -3447,7 +3563,7 @@ type TCPFlags struct { func (x *TCPFlags) Reset() { *x = TCPFlags{} - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3459,7 +3575,7 @@ func (x *TCPFlags) String() string { func (*TCPFlags) ProtoMessage() {} func (x *TCPFlags) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3472,7 +3588,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. func (*TCPFlags) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{46} + return file_daemon_proto_rawDescGZIP(), []int{48} } func (x *TCPFlags) GetSyn() bool { @@ -3534,7 +3650,7 @@ type TracePacketRequest struct { func (x *TracePacketRequest) Reset() { *x = TracePacketRequest{} - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3546,7 +3662,7 @@ func (x *TracePacketRequest) String() string { func (*TracePacketRequest) ProtoMessage() {} func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3559,7 +3675,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. func (*TracePacketRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{47} + return file_daemon_proto_rawDescGZIP(), []int{49} } func (x *TracePacketRequest) GetSourceIp() string { @@ -3637,7 +3753,7 @@ type TraceStage struct { func (x *TraceStage) Reset() { *x = TraceStage{} - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3649,7 +3765,7 @@ func (x *TraceStage) String() string { func (*TraceStage) ProtoMessage() {} func (x *TraceStage) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3662,7 +3778,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message { // Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. func (*TraceStage) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{48} + return file_daemon_proto_rawDescGZIP(), []int{50} } func (x *TraceStage) GetName() string { @@ -3703,7 +3819,7 @@ type TracePacketResponse struct { func (x *TracePacketResponse) Reset() { *x = TracePacketResponse{} - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3715,7 +3831,7 @@ func (x *TracePacketResponse) String() string { func (*TracePacketResponse) ProtoMessage() {} func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3728,7 +3844,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. func (*TracePacketResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49} + return file_daemon_proto_rawDescGZIP(), []int{51} } func (x *TracePacketResponse) GetStages() []*TraceStage { @@ -3753,7 +3869,7 @@ type SubscribeRequest struct { func (x *SubscribeRequest) Reset() { *x = SubscribeRequest{} - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3765,7 +3881,7 @@ func (x *SubscribeRequest) String() string { func (*SubscribeRequest) ProtoMessage() {} func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3778,7 +3894,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{50} + return file_daemon_proto_rawDescGZIP(), []int{52} } type SystemEvent struct { @@ -3796,7 +3912,7 @@ type SystemEvent struct { func (x *SystemEvent) Reset() { *x = SystemEvent{} - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3808,7 +3924,7 @@ func (x *SystemEvent) String() string { func (*SystemEvent) ProtoMessage() {} func (x *SystemEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3821,7 +3937,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead. func (*SystemEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51} + return file_daemon_proto_rawDescGZIP(), []int{53} } func (x *SystemEvent) GetId() string { @@ -3881,7 +3997,7 @@ type GetEventsRequest struct { func (x *GetEventsRequest) Reset() { *x = GetEventsRequest{} - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3893,7 +4009,7 @@ func (x *GetEventsRequest) String() string { func (*GetEventsRequest) ProtoMessage() {} func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3906,7 +4022,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead. func (*GetEventsRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52} + return file_daemon_proto_rawDescGZIP(), []int{54} } type GetEventsResponse struct { @@ -3918,7 +4034,7 @@ type GetEventsResponse struct { func (x *GetEventsResponse) Reset() { *x = GetEventsResponse{} - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3930,7 +4046,7 @@ func (x *GetEventsResponse) String() string { func (*GetEventsResponse) ProtoMessage() {} func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3943,7 +4059,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead. func (*GetEventsResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53} + return file_daemon_proto_rawDescGZIP(), []int{55} } func (x *GetEventsResponse) GetEvents() []*SystemEvent { @@ -3965,7 +4081,7 @@ type SwitchProfileRequest struct { func (x *SwitchProfileRequest) Reset() { *x = SwitchProfileRequest{} - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3977,7 +4093,7 @@ func (x *SwitchProfileRequest) String() string { func (*SwitchProfileRequest) ProtoMessage() {} func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3990,7 +4106,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead. func (*SwitchProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{54} + return file_daemon_proto_rawDescGZIP(), []int{56} } func (x *SwitchProfileRequest) GetProfileName() string { @@ -4019,7 +4135,7 @@ type SwitchProfileResponse struct { func (x *SwitchProfileResponse) Reset() { *x = SwitchProfileResponse{} - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4031,7 +4147,7 @@ func (x *SwitchProfileResponse) String() string { func (*SwitchProfileResponse) ProtoMessage() {} func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4044,7 +4160,7 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead. func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{55} + return file_daemon_proto_rawDescGZIP(), []int{57} } func (x *SwitchProfileResponse) GetId() string { @@ -4100,7 +4216,7 @@ type SetConfigRequest struct { func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4112,7 +4228,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4125,7 +4241,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{56} + return file_daemon_proto_rawDescGZIP(), []int{58} } func (x *SetConfigRequest) GetUsername() string { @@ -4381,7 +4497,7 @@ type SetConfigResponse struct { func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4393,7 +4509,7 @@ func (x *SetConfigResponse) String() string { func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4406,7 +4522,7 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{57} + return file_daemon_proto_rawDescGZIP(), []int{59} } type AddProfileRequest struct { @@ -4421,7 +4537,7 @@ type AddProfileRequest struct { func (x *AddProfileRequest) Reset() { *x = AddProfileRequest{} - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4433,7 +4549,7 @@ func (x *AddProfileRequest) String() string { func (*AddProfileRequest) ProtoMessage() {} func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4446,7 +4562,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead. func (*AddProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{58} + return file_daemon_proto_rawDescGZIP(), []int{60} } func (x *AddProfileRequest) GetUsername() string { @@ -4474,7 +4590,7 @@ type AddProfileResponse struct { func (x *AddProfileResponse) Reset() { *x = AddProfileResponse{} - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4486,7 +4602,7 @@ func (x *AddProfileResponse) String() string { func (*AddProfileResponse) ProtoMessage() {} func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4499,7 +4615,7 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead. func (*AddProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{59} + return file_daemon_proto_rawDescGZIP(), []int{61} } func (x *AddProfileResponse) GetId() string { @@ -4522,7 +4638,7 @@ type RenameProfileRequest struct { func (x *RenameProfileRequest) Reset() { *x = RenameProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4534,7 +4650,7 @@ func (x *RenameProfileRequest) String() string { func (*RenameProfileRequest) ProtoMessage() {} func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4547,7 +4663,7 @@ func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead. func (*RenameProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *RenameProfileRequest) GetUsername() string { @@ -4581,7 +4697,7 @@ type RenameProfileResponse struct { func (x *RenameProfileResponse) Reset() { *x = RenameProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +4709,7 @@ func (x *RenameProfileResponse) String() string { func (*RenameProfileResponse) ProtoMessage() {} func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4606,7 +4722,7 @@ func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead. func (*RenameProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{63} } func (x *RenameProfileResponse) GetOldProfileName() string { @@ -4628,7 +4744,7 @@ type RemoveProfileRequest struct { func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4640,7 +4756,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4653,7 +4769,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4681,7 +4797,7 @@ type RemoveProfileResponse struct { func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4693,7 +4809,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4706,7 +4822,7 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *RemoveProfileResponse) GetId() string { @@ -4725,7 +4841,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4737,7 +4853,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4750,7 +4866,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *ListProfilesRequest) GetUsername() string { @@ -4769,7 +4885,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4781,7 +4897,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4794,7 +4910,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{67} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4815,7 +4931,7 @@ type Profile struct { func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4827,7 +4943,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4840,7 +4956,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *Profile) GetName() string { @@ -4872,7 +4988,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4884,7 +5000,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4897,7 +5013,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } type GetActiveProfileResponse struct { @@ -4911,7 +5027,7 @@ type GetActiveProfileResponse struct { func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4923,7 +5039,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4936,7 +5052,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4970,7 +5086,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4982,7 +5098,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4995,7 +5111,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } func (x *LogoutRequest) GetProfileName() string { @@ -5020,7 +5136,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5032,7 +5148,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5045,7 +5161,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} +} + +type WailsUIReadyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WailsUIReadyRequest) Reset() { + *x = WailsUIReadyRequest{} + mi := &file_daemon_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WailsUIReadyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WailsUIReadyRequest) ProtoMessage() {} + +func (x *WailsUIReadyRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WailsUIReadyRequest.ProtoReflect.Descriptor instead. +func (*WailsUIReadyRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{73} +} + +type WailsUIReadyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WailsUIReadyResponse) Reset() { + *x = WailsUIReadyResponse{} + mi := &file_daemon_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WailsUIReadyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WailsUIReadyResponse) ProtoMessage() {} + +func (x *WailsUIReadyResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[74] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WailsUIReadyResponse.ProtoReflect.Descriptor instead. +func (*WailsUIReadyResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{74} } type GetFeaturesRequest struct { @@ -5056,7 +5244,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5068,7 +5256,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5081,7 +5269,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{75} } type GetFeaturesResponse struct { @@ -5089,13 +5277,19 @@ type GetFeaturesResponse struct { DisableProfiles bool `protobuf:"varint,1,opt,name=disable_profiles,json=disableProfiles,proto3" json:"disable_profiles,omitempty"` DisableUpdateSettings bool `protobuf:"varint,2,opt,name=disable_update_settings,json=disableUpdateSettings,proto3" json:"disable_update_settings,omitempty"` DisableNetworks bool `protobuf:"varint,3,opt,name=disable_networks,json=disableNetworks,proto3" json:"disable_networks,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // disableAdvancedView gates the upcoming UI revision's advanced + // section. Tristate: unset = no MDM directive, the UI applies its + // own default; true = MDM enforces disable; false = MDM enforces + // enable. Sourced exclusively from the MDM policy — no CLI / + // config flag backs this value. + DisableAdvancedView *bool `protobuf:"varint,4,opt,name=disable_advanced_view,json=disableAdvancedView,proto3,oneof" json:"disable_advanced_view,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5107,7 +5301,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5120,7 +5314,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -5144,6 +5338,13 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool { return false } +func (x *GetFeaturesResponse) GetDisableAdvancedView() bool { + if x != nil && x.DisableAdvancedView != nil { + return *x.DisableAdvancedView + } + return false +} + // MDMManagedFieldsViolation is attached as a gRPC error detail on a // FailedPrecondition status returned from SetConfig (and similar mutating // RPCs) when the caller tries to modify one or more MDM-enforced fields. @@ -5158,7 +5359,7 @@ type MDMManagedFieldsViolation struct { func (x *MDMManagedFieldsViolation) Reset() { *x = MDMManagedFieldsViolation{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5170,7 +5371,7 @@ func (x *MDMManagedFieldsViolation) String() string { func (*MDMManagedFieldsViolation) ProtoMessage() {} func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5183,7 +5384,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { // Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *MDMManagedFieldsViolation) GetFields() []string { @@ -5201,7 +5402,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5213,7 +5414,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5226,7 +5427,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{78} } type TriggerUpdateResponse struct { @@ -5239,7 +5440,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5251,7 +5452,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5264,7 +5465,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5292,7 +5493,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5304,7 +5505,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5317,7 +5518,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5344,7 +5545,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5356,7 +5557,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5369,7 +5570,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5411,7 +5612,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5423,7 +5624,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5436,7 +5637,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{82} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5469,7 +5670,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5481,7 +5682,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5494,7 +5695,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{83} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5559,7 +5760,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5571,7 +5772,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5584,7 +5785,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{84} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5616,7 +5817,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5628,7 +5829,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5641,7 +5842,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5665,6 +5866,318 @@ func (x *WaitJWTTokenResponse) GetExpiresIn() int64 { return 0 } +// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow. +type RequestExtendAuthSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional OIDC login_hint (typically the user's email) to pre-fill the + // IdP login form. + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestExtendAuthSessionRequest) Reset() { + *x = RequestExtendAuthSessionRequest{} + mi := &file_daemon_proto_msgTypes[86] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestExtendAuthSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestExtendAuthSessionRequest) ProtoMessage() {} + +func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[86] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestExtendAuthSessionRequest.ProtoReflect.Descriptor instead. +func (*RequestExtendAuthSessionRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{86} +} + +func (x *RequestExtendAuthSessionRequest) GetHint() string { + if x != nil && x.Hint != nil { + return *x.Hint + } + return "" +} + +// RequestExtendAuthSessionResponse carries the verification URI the UI +// should open in a browser. The daemon retains the flow state and resolves +// it via WaitExtendAuthSession. +type RequestExtendAuthSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // verification URI for the user to open in the browser + VerificationURI string `protobuf:"bytes,1,opt,name=verificationURI,proto3" json:"verificationURI,omitempty"` + // complete verification URI (with embedded user code) + VerificationURIComplete string `protobuf:"bytes,2,opt,name=verificationURIComplete,proto3" json:"verificationURIComplete,omitempty"` + // user code to enter on verification URI (for device-code flows) + UserCode string `protobuf:"bytes,3,opt,name=userCode,proto3" json:"userCode,omitempty"` + // device code for matching the WaitExtendAuthSession call to this flow + DeviceCode string `protobuf:"bytes,4,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // expiration time in seconds for the device code / PKCE flow + ExpiresIn int64 `protobuf:"varint,5,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestExtendAuthSessionResponse) Reset() { + *x = RequestExtendAuthSessionResponse{} + mi := &file_daemon_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestExtendAuthSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestExtendAuthSessionResponse) ProtoMessage() {} + +func (x *RequestExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[87] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestExtendAuthSessionResponse.ProtoReflect.Descriptor instead. +func (*RequestExtendAuthSessionResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{87} +} + +func (x *RequestExtendAuthSessionResponse) GetVerificationURI() string { + if x != nil { + return x.VerificationURI + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetVerificationURIComplete() string { + if x != nil { + return x.VerificationURIComplete + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +// WaitExtendAuthSessionRequest is sent by the UI after it opens the +// verification URI. The daemon blocks on this call until the user +// completes (or aborts) the SSO step. +type WaitExtendAuthSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // device code returned by RequestExtendAuthSession + DeviceCode string `protobuf:"bytes,1,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // user code for verification + UserCode string `protobuf:"bytes,2,opt,name=userCode,proto3" json:"userCode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitExtendAuthSessionRequest) Reset() { + *x = WaitExtendAuthSessionRequest{} + mi := &file_daemon_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitExtendAuthSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitExtendAuthSessionRequest) ProtoMessage() {} + +func (x *WaitExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[88] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WaitExtendAuthSessionRequest.ProtoReflect.Descriptor instead. +func (*WaitExtendAuthSessionRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{88} +} + +func (x *WaitExtendAuthSessionRequest) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *WaitExtendAuthSessionRequest) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +// WaitExtendAuthSessionResponse carries the refreshed deadline returned +// by the management server. Unset when the management server reports the +// peer is not eligible for session extension. +type WaitExtendAuthSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitExtendAuthSessionResponse) Reset() { + *x = WaitExtendAuthSessionResponse{} + mi := &file_daemon_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitExtendAuthSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitExtendAuthSessionResponse) ProtoMessage() {} + +func (x *WaitExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[89] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WaitExtendAuthSessionResponse.ProtoReflect.Descriptor instead. +func (*WaitExtendAuthSessionResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{89} +} + +func (x *WaitExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.SessionExpiresAt + } + return nil +} + +// DismissSessionWarningRequest is sent by the UI when the user clicks +// "Dismiss" on the T-WarningLead notification. +type DismissSessionWarningRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DismissSessionWarningRequest) Reset() { + *x = DismissSessionWarningRequest{} + mi := &file_daemon_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DismissSessionWarningRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DismissSessionWarningRequest) ProtoMessage() {} + +func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[90] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DismissSessionWarningRequest.ProtoReflect.Descriptor instead. +func (*DismissSessionWarningRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{90} +} + +// DismissSessionWarningResponse acknowledges the dismissal. Carries no +// payload — the daemon's only obligation is to silence the upcoming +// T-FinalWarningLead fallback for the current deadline. +type DismissSessionWarningResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DismissSessionWarningResponse) Reset() { + *x = DismissSessionWarningResponse{} + mi := &file_daemon_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DismissSessionWarningResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DismissSessionWarningResponse) ProtoMessage() {} + +func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[91] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DismissSessionWarningResponse.ProtoReflect.Descriptor instead. +func (*DismissSessionWarningResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{91} +} + // StartCPUProfileRequest for starting CPU profiling type StartCPUProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5674,7 +6187,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5686,7 +6199,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5699,7 +6212,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{92} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5711,7 +6224,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5723,7 +6236,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5736,7 +6249,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{93} } // StopCPUProfileRequest for stopping CPU profiling @@ -5748,7 +6261,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5760,7 +6273,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5773,7 +6286,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{94} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5785,7 +6298,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5797,7 +6310,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5810,7 +6323,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{95} } type InstallerResultRequest struct { @@ -5821,7 +6334,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5833,7 +6346,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5846,7 +6359,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{96} } type InstallerResultResponse struct { @@ -5859,7 +6372,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5871,7 +6384,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5884,7 +6397,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{97} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5917,7 +6430,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5929,7 +6442,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5942,7 +6455,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{98} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -6013,7 +6526,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6025,7 +6538,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6038,7 +6551,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{99} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -6079,7 +6592,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6091,7 +6604,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6104,7 +6617,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{100} } func (x *ExposeServiceReady) GetServiceName() string { @@ -6149,7 +6662,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6161,7 +6674,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6174,7 +6687,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{101} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6228,7 +6741,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6240,7 +6753,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6253,7 +6766,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{102} } func (x *CapturePacket) GetData() []byte { @@ -6274,7 +6787,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6286,7 +6799,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6299,7 +6812,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{103} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6317,7 +6830,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6329,7 +6842,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6342,7 +6855,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{104} } type StopBundleCaptureRequest struct { @@ -6353,7 +6866,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6365,7 +6878,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6378,7 +6891,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{95} + return file_daemon_proto_rawDescGZIP(), []int{105} } type StopBundleCaptureResponse struct { @@ -6389,7 +6902,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6401,7 +6914,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6414,7 +6927,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{96} + return file_daemon_proto_rawDescGZIP(), []int{106} } type PortInfo_Range struct { @@ -6427,7 +6940,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[98] + mi := &file_daemon_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6439,7 +6952,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[98] + mi := &file_daemon_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6557,10 +7070,11 @@ const file_daemon_proto_rawDesc = "" + "\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\",\n" + "\x14WaitSSOLoginResponse\x12\x14\n" + - "\x05email\x18\x01 \x01(\tR\x05email\"v\n" + + "\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\n" + "\tUpRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + - "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" + + "\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" + "\f_profileNameB\v\n" + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + @@ -6569,13 +7083,14 @@ const file_daemon_proto_rawDesc = "" + "\x11getFullPeerStatus\x18\x01 \x01(\bR\x11getFullPeerStatus\x12(\n" + "\x0fshouldRunProbes\x18\x02 \x01(\bR\x0fshouldRunProbes\x12'\n" + "\fwaitForReady\x18\x03 \x01(\bH\x00R\fwaitForReady\x88\x01\x01B\x0f\n" + - "\r_waitForReady\"\x82\x01\n" + + "\r_waitForReady\"\xca\x01\n" + "\x0eStatusResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\x122\n" + "\n" + "fullStatus\x18\x02 \x01(\v2\x12.daemon.FullStatusR\n" + "fullStatus\x12$\n" + - "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\"\r\n" + + "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\x12F\n" + + "\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\r\n" + "\vDownRequest\"\x0e\n" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + @@ -6676,7 +7191,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -6690,7 +7205,8 @@ const file_daemon_proto_rawDesc = "" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + - " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" + + " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" + + "\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" + "\x13ListNetworksRequest\"?\n" + "\x14ListNetworksResponse\x12'\n" + "\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" + @@ -6746,7 +7262,10 @@ const file_daemon_proto_rawDesc = "" + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"<\n" + "\x12SetLogLevelRequest\x12&\n" + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\x15\n" + - "\x13SetLogLevelResponse\"\x1b\n" + + "\x13SetLogLevelResponse\"*\n" + + "\x14RegisterUILogRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x17\n" + + "\x15RegisterUILogResponse\"\x1b\n" + "\x05State\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x13\n" + "\x11ListStatesRequest\";\n" + @@ -6935,12 +7454,16 @@ const file_daemon_proto_rawDesc = "" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + "\t_username\"\x10\n" + - "\x0eLogoutResponse\"\x14\n" + - "\x12GetFeaturesRequest\"\xa3\x01\n" + + "\x0eLogoutResponse\"\x15\n" + + "\x13WailsUIReadyRequest\"\x16\n" + + "\x14WailsUIReadyResponse\"\x14\n" + + "\x12GetFeaturesRequest\"\xf6\x01\n" + "\x13GetFeaturesResponse\x12)\n" + "\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" + "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" + - "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" + + "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\x127\n" + + "\x15disable_advanced_view\x18\x04 \x01(\bH\x00R\x13disableAdvancedView\x88\x01\x01B\x18\n" + + "\x16_disable_advanced_view\"3\n" + "\x19MDMManagedFieldsViolation\x12\x16\n" + "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" + "\x14TriggerUpdateRequest\"M\n" + @@ -6977,7 +7500,27 @@ const file_daemon_proto_rawDesc = "" + "\x14WaitJWTTokenResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + "\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" + - "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"\x18\n" + + "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"C\n" + + "\x1fRequestExtendAuthSessionRequest\x12\x17\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x05_hint\"\xe0\x01\n" + + " RequestExtendAuthSessionResponse\x12(\n" + + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + + "\x17verificationURIComplete\x18\x02 \x01(\tR\x17verificationURIComplete\x12\x1a\n" + + "\buserCode\x18\x03 \x01(\tR\buserCode\x12\x1e\n" + + "\n" + + "deviceCode\x18\x04 \x01(\tR\n" + + "deviceCode\x12\x1c\n" + + "\texpiresIn\x18\x05 \x01(\x03R\texpiresIn\"Z\n" + + "\x1cWaitExtendAuthSessionRequest\x12\x1e\n" + + "\n" + + "deviceCode\x18\x01 \x01(\tR\n" + + "deviceCode\x12\x1a\n" + + "\buserCode\x18\x02 \x01(\tR\buserCode\"g\n" + + "\x1dWaitExtendAuthSessionResponse\x12F\n" + + "\x10sessionExpiresAt\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\x1e\n" + + "\x1cDismissSessionWarningRequest\"\x1f\n" + + "\x1dDismissSessionWarningResponse\"\x18\n" + "\x16StartCPUProfileRequest\"\x19\n" + "\x17StartCPUProfileResponse\"\x17\n" + "\x15StopCPUProfileRequest\"\x18\n" + @@ -7040,12 +7583,13 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xff\x17\n" + + "EXPOSE_TLS\x10\x042\xa3\x1c\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + "\x02Up\x12\x11.daemon.UpRequest\x1a\x12.daemon.UpResponse\"\x00\x129\n" + - "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x123\n" + + "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x12D\n" + + "\x0fSubscribeStatus\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x000\x01\x123\n" + "\x04Down\x12\x13.daemon.DownRequest\x1a\x14.daemon.DownResponse\"\x00\x12B\n" + "\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" + "\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" + @@ -7067,6 +7611,7 @@ const file_daemon_proto_rawDesc = "" + "\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" + "\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" + "\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" + + "\rRegisterUILog\x12\x1c.daemon.RegisterUILogRequest\x1a\x1d.daemon.RegisterUILogResponse\"\x00\x12N\n" + "\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" + "\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" + "\n" + @@ -7080,11 +7625,15 @@ const file_daemon_proto_rawDesc = "" + "\rTriggerUpdate\x12\x1c.daemon.TriggerUpdateRequest\x1a\x1d.daemon.TriggerUpdateResponse\"\x00\x12Z\n" + "\x11GetPeerSSHHostKey\x12 .daemon.GetPeerSSHHostKeyRequest\x1a!.daemon.GetPeerSSHHostKeyResponse\"\x00\x12Q\n" + "\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" + - "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12T\n" + + "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12o\n" + + "\x18RequestExtendAuthSession\x12'.daemon.RequestExtendAuthSessionRequest\x1a(.daemon.RequestExtendAuthSessionResponse\"\x00\x12f\n" + + "\x15WaitExtendAuthSession\x12$.daemon.WaitExtendAuthSessionRequest\x1a%.daemon.WaitExtendAuthSessionResponse\"\x00\x12f\n" + + "\x15DismissSessionWarning\x12$.daemon.DismissSessionWarningRequest\x1a%.daemon.DismissSessionWarningResponse\"\x00\x12T\n" + "\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + - "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3" + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" + + "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -7099,7 +7648,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -7142,195 +7691,219 @@ var file_daemon_proto_goTypes = []any{ (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse - (*State)(nil), // 41: daemon.State - (*ListStatesRequest)(nil), // 42: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 43: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 44: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 45: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 46: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 47: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 48: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 49: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 50: daemon.TCPFlags - (*TracePacketRequest)(nil), // 51: daemon.TracePacketRequest - (*TraceStage)(nil), // 52: daemon.TraceStage - (*TracePacketResponse)(nil), // 53: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 54: daemon.SubscribeRequest - (*SystemEvent)(nil), // 55: daemon.SystemEvent - (*GetEventsRequest)(nil), // 56: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 57: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 58: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 59: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 60: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse - (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest - (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse - (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse - (*Profile)(nil), // 70: daemon.Profile - (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 73: daemon.LogoutRequest - (*LogoutResponse)(nil), // 74: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse - (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation - (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 96: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse - nil, // 101: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range - nil, // 103: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 104: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp + (*RegisterUILogRequest)(nil), // 41: daemon.RegisterUILogRequest + (*RegisterUILogResponse)(nil), // 42: daemon.RegisterUILogResponse + (*State)(nil), // 43: daemon.State + (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 52: daemon.TCPFlags + (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest + (*TraceStage)(nil), // 54: daemon.TraceStage + (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest + (*SystemEvent)(nil), // 57: daemon.SystemEvent + (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse + (*RenameProfileRequest)(nil), // 66: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse + (*Profile)(nil), // 72: daemon.Profile + (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 75: daemon.LogoutRequest + (*LogoutResponse)(nil), // 76: daemon.LogoutResponse + (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest + (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse + (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse + (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest + (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse + (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest + (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse + (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest + (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse + (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 106: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse + nil, // 111: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range + nil, // 113: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 114: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration - 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo - 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState - 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState - 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule - 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State - 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration - 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 13, // 41: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 42: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 26, // 43: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 28, // 44: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 28, // 45: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 46: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 35, // 47: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 37, // 48: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 39, // 49: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 42, // 50: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 44, // 51: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest - 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest - 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse - 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 77, // [77:117] is the sub-list for method output_type - 37, // [37:77] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState + 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState + 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel + 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel + 43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State + 52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest + 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest + 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest + 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest + 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest + 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest + 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse + 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse + 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse + 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse + 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse + 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse + 85, // [85:131] is the sub-list for method output_type + 39, // [39:85] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -7345,13 +7918,15 @@ func file_daemon_proto_init() { (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } - file_daemon_proto_msgTypes[47].OneofWrappers = []any{} - file_daemon_proto_msgTypes[48].OneofWrappers = []any{} - file_daemon_proto_msgTypes[54].OneofWrappers = []any{} + file_daemon_proto_msgTypes[49].OneofWrappers = []any{} + file_daemon_proto_msgTypes[50].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[69].OneofWrappers = []any{} - file_daemon_proto_msgTypes[78].OneofWrappers = []any{} - file_daemon_proto_msgTypes[89].OneofWrappers = []any{ + file_daemon_proto_msgTypes[58].OneofWrappers = []any{} + file_daemon_proto_msgTypes[71].OneofWrappers = []any{} + file_daemon_proto_msgTypes[76].OneofWrappers = []any{} + file_daemon_proto_msgTypes[82].OneofWrappers = []any{} + file_daemon_proto_msgTypes[86].OneofWrappers = []any{} + file_daemon_proto_msgTypes[99].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7360,7 +7935,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 100, + NumMessages: 110, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index c1e3fe513..8d5294eb7 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -24,6 +24,12 @@ service DaemonService { // Status of the service. rpc Status(StatusRequest) returns (StatusResponse) {} + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {} + // Down stops engine work in the daemon. rpc Down(DownRequest) returns (DownResponse) {} @@ -79,6 +85,11 @@ service DaemonService { rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {} + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + rpc RegisterUILog(RegisterUILogRequest) returns (RegisterUILogResponse) {} + rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse) {} rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {} @@ -111,6 +122,25 @@ service DaemonService { // WaitJWTToken waits for JWT authentication completion rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {} + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + rpc RequestExtendAuthSession(RequestExtendAuthSessionRequest) returns (RequestExtendAuthSessionResponse) {} + + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + rpc WaitExtendAuthSession(WaitExtendAuthSessionRequest) returns (WaitExtendAuthSessionResponse) {} + + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + rpc DismissSessionWarning(DismissSessionWarningRequest) returns (DismissSessionWarningResponse) {} + // StartCPUProfile starts CPU profiling in the daemon rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {} @@ -121,6 +151,11 @@ service DaemonService { // ExposeService exposes a local port via the NetBird reverse proxy rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {} + + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {} } @@ -229,6 +264,12 @@ message UpRequest { optional string profileName = 1; optional string username = 2; reserved 3; + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + bool async = 4; } message UpResponse {} @@ -246,6 +287,10 @@ message StatusResponse{ FullStatus fullStatus = 2; // NetBird daemon version string daemonVersion = 3; + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // The UI derives "warning active" from this value and its own clock. + google.protobuf.Timestamp sessionExpiresAt = 4; } message DownRequest {} @@ -421,6 +466,12 @@ message FullStatus { bool lazyConnectionEnabled = 9; SSHServerState sshServerState = 10; + + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + uint64 networksRevision = 11; } // Networks @@ -518,6 +569,13 @@ message SetLogLevelRequest { message SetLogLevelResponse { } +message RegisterUILogRequest { + string path = 1; +} + +message RegisterUILogResponse { +} + // State represents a daemon state entry message State { string name = 1; @@ -771,12 +829,22 @@ message LogoutRequest { message LogoutResponse {} +message WailsUIReadyRequest {} + +message WailsUIReadyResponse {} + message GetFeaturesRequest{} message GetFeaturesResponse{ bool disable_profiles = 1; bool disable_update_settings = 2; bool disable_networks = 3; + // disableAdvancedView gates the upcoming UI revision's advanced + // section. Tristate: unset = no MDM directive, the UI applies its + // own default; true = MDM enforces disable; false = MDM enforces + // enable. Sourced exclusively from the MDM policy — no CLI / + // config flag backs this value. + optional bool disable_advanced_view = 4; } // MDMManagedFieldsViolation is attached as a gRPC error detail on a @@ -855,6 +923,55 @@ message WaitJWTTokenResponse { int64 expiresIn = 3; } +// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow. +message RequestExtendAuthSessionRequest { + // Optional OIDC login_hint (typically the user's email) to pre-fill the + // IdP login form. + optional string hint = 1; +} + +// RequestExtendAuthSessionResponse carries the verification URI the UI +// should open in a browser. The daemon retains the flow state and resolves +// it via WaitExtendAuthSession. +message RequestExtendAuthSessionResponse { + // verification URI for the user to open in the browser + string verificationURI = 1; + // complete verification URI (with embedded user code) + string verificationURIComplete = 2; + // user code to enter on verification URI (for device-code flows) + string userCode = 3; + // device code for matching the WaitExtendAuthSession call to this flow + string deviceCode = 4; + // expiration time in seconds for the device code / PKCE flow + int64 expiresIn = 5; +} + +// WaitExtendAuthSessionRequest is sent by the UI after it opens the +// verification URI. The daemon blocks on this call until the user +// completes (or aborts) the SSO step. +message WaitExtendAuthSessionRequest { + // device code returned by RequestExtendAuthSession + string deviceCode = 1; + // user code for verification + string userCode = 2; +} + +// WaitExtendAuthSessionResponse carries the refreshed deadline returned +// by the management server. Unset when the management server reports the +// peer is not eligible for session extension. +message WaitExtendAuthSessionResponse { + google.protobuf.Timestamp sessionExpiresAt = 1; +} + +// DismissSessionWarningRequest is sent by the UI when the user clicks +// "Dismiss" on the T-WarningLead notification. +message DismissSessionWarningRequest {} + +// DismissSessionWarningResponse acknowledges the dismissal. Carries no +// payload — the daemon's only obligation is to silence the upcoming +// T-FinalWarningLead fallback for the current deadline. +message DismissSessionWarningResponse {} + // StartCPUProfileRequest for starting CPU profiling message StartCPUProfileRequest {} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 5f585aafc..2d01d474d 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -23,6 +23,7 @@ const ( DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin" DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up" DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status" + DaemonService_SubscribeStatus_FullMethodName = "/daemon.DaemonService/SubscribeStatus" DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down" DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig" DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks" @@ -42,6 +43,7 @@ const ( DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture" DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents" DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents" + DaemonService_RegisterUILog_FullMethodName = "/daemon.DaemonService/RegisterUILog" DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" @@ -55,10 +57,14 @@ const ( DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey" DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth" DaemonService_WaitJWTToken_FullMethodName = "/daemon.DaemonService/WaitJWTToken" + DaemonService_RequestExtendAuthSession_FullMethodName = "/daemon.DaemonService/RequestExtendAuthSession" + DaemonService_WaitExtendAuthSession_FullMethodName = "/daemon.DaemonService/WaitExtendAuthSession" + DaemonService_DismissSessionWarning_FullMethodName = "/daemon.DaemonService/DismissSessionWarning" DaemonService_StartCPUProfile_FullMethodName = "/daemon.DaemonService/StartCPUProfile" DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile" DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult" DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService" + DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady" ) // DaemonServiceClient is the client API for DaemonService service. @@ -74,6 +80,11 @@ type DaemonServiceClient interface { Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error) // Status of the service. Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) // Down stops engine work in the daemon. Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) // GetConfig of the daemon. @@ -110,6 +121,10 @@ type DaemonServiceClient interface { StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) @@ -129,6 +144,22 @@ type DaemonServiceClient interface { RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error) // WaitJWTToken waits for JWT authentication completion WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error) + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) // StartCPUProfile starts CPU profiling in the daemon StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon @@ -136,6 +167,10 @@ type DaemonServiceClient interface { GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) } type daemonServiceClient struct { @@ -186,6 +221,25 @@ func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opt return out, nil } +func (c *daemonServiceClient) SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StatusRequest, StatusResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeStatusClient = grpc.ServerStreamingClient[StatusResponse] + func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DownResponse) @@ -328,7 +382,7 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_StartCapture_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_StartCapture_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -367,7 +421,7 @@ func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBun func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_SubscribeEvents_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_SubscribeEvents_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -394,6 +448,16 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques return out, nil } +func (c *daemonServiceClient) RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterUILogResponse) + err := c.cc.Invoke(ctx, DaemonService_RegisterUILog_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SwitchProfileResponse) @@ -524,6 +588,36 @@ func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTToken return out, nil } +func (c *daemonServiceClient) RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RequestExtendAuthSessionResponse) + err := c.cc.Invoke(ctx, DaemonService_RequestExtendAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WaitExtendAuthSessionResponse) + err := c.cc.Invoke(ctx, DaemonService_WaitExtendAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DismissSessionWarningResponse) + err := c.cc.Invoke(ctx, DaemonService_DismissSessionWarning_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StartCPUProfileResponse) @@ -556,7 +650,7 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_ExposeService_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[3], DaemonService_ExposeService_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -573,6 +667,16 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent] +func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WailsUIReadyResponse) + err := c.cc.Invoke(ctx, DaemonService_WailsUIReady_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility. @@ -586,6 +690,11 @@ type DaemonServiceServer interface { Up(context.Context, *UpRequest) (*UpResponse, error) // Status of the service. Status(context.Context, *StatusRequest) (*StatusResponse, error) + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error // Down stops engine work in the daemon. Down(context.Context, *DownRequest) (*DownResponse, error) // GetConfig of the daemon. @@ -622,6 +731,10 @@ type DaemonServiceServer interface { StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) @@ -641,6 +754,22 @@ type DaemonServiceServer interface { RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error) // WaitJWTToken waits for JWT authentication completion WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) // StartCPUProfile starts CPU profiling in the daemon StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon @@ -648,6 +777,10 @@ type DaemonServiceServer interface { GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -670,6 +803,9 @@ func (UnimplementedDaemonServiceServer) Up(context.Context, *UpRequest) (*UpResp func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) { return nil, status.Error(codes.Unimplemented, "method Status not implemented") } +func (UnimplementedDaemonServiceServer) SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error { + return status.Error(codes.Unimplemented, "method SubscribeStatus not implemented") +} func (UnimplementedDaemonServiceServer) Down(context.Context, *DownRequest) (*DownResponse, error) { return nil, status.Error(codes.Unimplemented, "method Down not implemented") } @@ -727,6 +863,9 @@ func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc. func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetEvents not implemented") } +func (UnimplementedDaemonServiceServer) RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterUILog not implemented") +} func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented") } @@ -766,6 +905,15 @@ func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *Request func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) { return nil, status.Error(codes.Unimplemented, "method WaitJWTToken not implemented") } +func (UnimplementedDaemonServiceServer) RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestExtendAuthSession not implemented") +} +func (UnimplementedDaemonServiceServer) WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WaitExtendAuthSession not implemented") +} +func (UnimplementedDaemonServiceServer) DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DismissSessionWarning not implemented") +} func (UnimplementedDaemonServiceServer) StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented") } @@ -778,6 +926,9 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error { return status.Error(codes.Unimplemented, "method ExposeService not implemented") } +func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} @@ -871,6 +1022,17 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _DaemonService_SubscribeStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StatusRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).SubscribeStatus(m, &grpc.GenericServerStream[StatusRequest, StatusResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeStatusServer = grpc.ServerStreamingServer[StatusResponse] + func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DownRequest) if err := dec(in); err != nil { @@ -1199,6 +1361,24 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RegisterUILog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterUILogRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RegisterUILog(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RegisterUILog_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RegisterUILog(ctx, req.(*RegisterUILogRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SwitchProfileRequest) if err := dec(in); err != nil { @@ -1433,6 +1613,60 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _DaemonService_RequestExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestExtendAuthSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RequestExtendAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, req.(*RequestExtendAuthSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_WaitExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WaitExtendAuthSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WaitExtendAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, req.(*WaitExtendAuthSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_DismissSessionWarning_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DismissSessionWarningRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).DismissSessionWarning(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_DismissSessionWarning_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).DismissSessionWarning(ctx, req.(*DismissSessionWarningRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(StartCPUProfileRequest) if err := dec(in); err != nil { @@ -1498,6 +1732,24 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent] +func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WailsUIReadyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WailsUIReady(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WailsUIReady_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WailsUIReady(ctx, req.(*WailsUIReadyRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1589,6 +1841,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetEvents", Handler: _DaemonService_GetEvents_Handler, }, + { + MethodName: "RegisterUILog", + Handler: _DaemonService_RegisterUILog_Handler, + }, { MethodName: "SwitchProfile", Handler: _DaemonService_SwitchProfile_Handler, @@ -1641,6 +1897,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "WaitJWTToken", Handler: _DaemonService_WaitJWTToken_Handler, }, + { + MethodName: "RequestExtendAuthSession", + Handler: _DaemonService_RequestExtendAuthSession_Handler, + }, + { + MethodName: "WaitExtendAuthSession", + Handler: _DaemonService_WaitExtendAuthSession_Handler, + }, + { + MethodName: "DismissSessionWarning", + Handler: _DaemonService_DismissSessionWarning_Handler, + }, { MethodName: "StartCPUProfile", Handler: _DaemonService_StartCPUProfile_Handler, @@ -1653,8 +1921,17 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetInstallerResult", Handler: _DaemonService_GetInstallerResult_Handler, }, + { + MethodName: "WailsUIReady", + Handler: _DaemonService_WailsUIReady_Handler, + }, }, Streams: []grpc.StreamDesc{ + { + StreamName: "SubscribeStatus", + Handler: _DaemonService_SubscribeStatus_Handler, + ServerStreams: true, + }, { StreamName: "StartCapture", Handler: _DaemonService_StartCapture_Handler, diff --git a/client/proto/metadata.go b/client/proto/metadata.go new file mode 100644 index 000000000..9b1dbd16e --- /dev/null +++ b/client/proto/metadata.go @@ -0,0 +1,61 @@ +package proto + +// SystemEvent metadata markers. The daemon stamps these on internal control +// events it publishes over SubscribeEvents (profile-list refresh, log-level +// change); the desktop UI recognises them and acts on them instead of +// surfacing them as user-facing notifications. +// +// These live in the proto package — the shared contract both the daemon +// (client/server) and the UI (client/ui/services) already import — so producer +// and consumer reference the same constant rather than duplicating literals. +// This file is hand-written and not touched by protoc. +const ( + // MetadataKindKey is the SystemEvent.metadata key carrying the event-kind + // marker (one of the MetadataKind* values below). + MetadataKindKey = "kind" + + // MetadataKindProfileListChanged marks a CLI-driven profile add/remove that + // should nudge the UI's profile views to refresh. + MetadataKindProfileListChanged = "profile-list-changed" + // MetadataKindLogLevelChanged marks a daemon log-level change (or the + // per-subscription snapshot) that drives the GUI's file logging on/off. + MetadataKindLogLevelChanged = "log-level-changed" + + // MetadataProfileKey carries the profile name for + // MetadataKindProfileListChanged. + MetadataProfileKey = "profile" + // MetadataLevelKey carries the lowercase logrus level name for + // MetadataKindLogLevelChanged. + MetadataLevelKey = "level" +) + +// SystemEvent metadata markers for daemon config-change events. The daemon +// publishes a SYSTEM-category event whenever its effective Config is +// replaced (engine spawn, Up RPC, MDM policy diff); the UI re-fetches its +// cached config/features in response and, for the MDM source, shows a +// localised toast. Producer (client/server) and consumer (client/ui) share +// these so neither duplicates the wire literals. +const ( + // MetadataTypeKey is the SystemEvent.metadata key carrying the + // config-change event type (one of the MetadataType* values below). + MetadataTypeKey = "type" + // MetadataTypeConfigChanged marks a config replacement that should nudge + // UIs to re-fetch their cached config + features. UserMessage is empty so + // the change is silent; the source is carried in MetadataSourceKey. + MetadataTypeConfigChanged = "config_changed" + // MetadataTypePolicyApplied marks an MDM-policy-driven config change. The + // daemon stamps it with a (non-localised) UserMessage; the UI suppresses + // that and builds its own localised toast off the paired config_changed + // event instead. + MetadataTypePolicyApplied = "policy_applied" + + // MetadataSourceKey is the SystemEvent.metadata key carrying what + // triggered a config_changed event (one of the MetadataSource* values). + MetadataSourceKey = "source" + // MetadataSourceStartup marks a config_changed from the daemon Start path. + MetadataSourceStartup = "startup" + // MetadataSourceUpRPC marks a config_changed from the Up RPC. + MetadataSourceUpRPC = "up_rpc" + // MetadataSourceMDM marks a config_changed driven by an MDM policy diff. + MetadataSourceMDM = "mdm" +) diff --git a/client/server/debug.go b/client/server/debug.go index 14dcaba33..0b6ac4b53 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -53,7 +53,10 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( if engine != nil { refreshStatus = func() { log.Debug("refreshing system health status for debug bundle") - engine.RunHealthProbes(true) + // Background ctx: the bundle wants a full, fresh probe regardless + // of the DebugBundle RPC client's lifetime. The engine's own ctx + // still aborts it on shutdown. + engine.RunHealthProbes(context.Background(), true) } } } @@ -64,6 +67,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( StatusRecorder: s.statusRecorder, SyncResponse: syncResponse, LogPath: s.logFile, + UILogPath: s.uiLogPath, CPUProfile: cpuProfileData, CapturePath: capturePath, RefreshStatus: refreshStatus, @@ -124,9 +128,26 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( log.Infof("Log level set to %s", level.String()) + // Signal the desktop UI so it can attach/detach its gui-client.log. Rides + // the SubscribeEvents stream as a marked event (see publishLogLevelChanged). + s.publishLogLevelChanged(level.String()) + return &proto.SetLogLevelResponse{}, nil } +// RegisterUILog records the desktop UI's absolute log path so DebugBundle can +// collect the GUI log. The daemon runs as root and can't resolve the user's +// config dir, so the UI reports it. Last-writer-wins (one UI per socket). +func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.uiLogPath = req.GetPath() + log.Infof("registered UI log path: %s", s.uiLogPath) + + return &proto.RegisterUILogResponse{}, nil +} + // SetSyncResponsePersistence sets the sync response persistence for the server. func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) { s.mutex.Lock() diff --git a/client/server/event.go b/client/server/event.go index d93151c96..753a051e7 100644 --- a/client/server/event.go +++ b/client/server/event.go @@ -1,7 +1,9 @@ package server import ( + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/proto" ) @@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo log.Debug("client subscribed to events") s.startUpdateManagerForGUI() + // Replay the current log level to this subscriber so a freshly-connected UI + // learns it even when the daemon was already started with --log-level debug + // (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent + // directly on this stream rather than via PublishEvent so it reaches only + // the new subscriber, not every connected client. + if err := s.sendCurrentLogLevel(stream); err != nil { + return err + } + for { select { case event := <-subscription.Events(): @@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo } } } + +// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the +// daemon's current level directly to one subscriber. Mirrors the shape +// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the +// same way. +func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error { + level := log.GetLevel().String() + event := &proto.SystemEvent{ + Id: uuid.New().String(), + Severity: proto.SystemEvent_INFO, + Category: proto.SystemEvent_SYSTEM, + Message: "Log level changed", + Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, + Timestamp: timestamppb.Now(), + } + if err := stream.Send(event); err != nil { + log.Warnf("error sending initial log level event: %v", err) + return err + } + return nil +} diff --git a/client/server/extend_authsession_test.go b/client/server/extend_authsession_test.go new file mode 100644 index 000000000..a1a048a7c --- /dev/null +++ b/client/server/extend_authsession_test.go @@ -0,0 +1,42 @@ +package server + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +func TestInnermostStatus(t *testing.T) { + t.Run("wrapped gRPC status", func(t *testing.T) { + inner := gstatus.Error(codes.PermissionDenied, "peer is already registered by a different User or a Setup Key") + // Mirror the daemon wrap chain: engine wraps with %w, mgm error is the inner status. + wrapped := fmt.Errorf("extend auth session on management: %w", inner) + + st := innermostStatus(wrapped) + require.NotNil(t, st) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "peer is already registered by a different User or a Setup Key", st.Message()) + }) + + t.Run("deepest status wins over an outer one", func(t *testing.T) { + inner := gstatus.Error(codes.PermissionDenied, "deepest") + chain := fmt.Errorf("outer: %w", fmt.Errorf("mid: %w", inner)) + + st := innermostStatus(chain) + require.NotNil(t, st) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "deepest", st.Message()) + }) + + t.Run("no status in chain", func(t *testing.T) { + require.Nil(t, innermostStatus(errors.New("plain error"))) + }) + + t.Run("nil error", func(t *testing.T) { + require.Nil(t, innermostStatus(nil)) + }) +} diff --git a/client/server/mdm.go b/client/server/mdm.go index 1e1005e05..9836c6bea 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -100,7 +100,10 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error { proto.SystemEvent_SYSTEM, "MDM policy applied", "NetBird configuration was updated by your IT policy.", - map[string]string{"source": "mdm", "type": "policy_applied"}, + map[string]string{ + proto.MetadataSourceKey: proto.MetadataSourceMDM, + proto.MetadataTypeKey: proto.MetadataTypePolicyApplied, + }, ) return nil } @@ -125,8 +128,8 @@ func (s *Server) publishConfigChangedEvent(source string) { fmt.Sprintf("daemon config changed (source=%s)", source), "", map[string]string{ - "source": source, - "type": "config_changed", + proto.MetadataSourceKey: source, + proto.MetadataTypeKey: proto.MetadataTypeConfigChanged, }, ) } @@ -161,7 +164,7 @@ func (s *Server) restartEngineForMDMLocked() error { s.clientGiveUpChan = make(chan struct{}) log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config") go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) - s.publishConfigChangedEvent("mdm") + s.publishConfigChangedEvent(proto.MetadataSourceMDM) return nil } diff --git a/client/server/network.go b/client/server/network.go index 7a3c08f2e..c38715256 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -172,6 +172,17 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { return nil, fmt.Errorf("select routes: %w", err) } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil { + return nil, fmt.Errorf("deselect sibling exit nodes: %w", err) + } + } + } } routeManager.TriggerSelection(routeManager.GetClientRoutes()) @@ -249,3 +260,38 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/server/network_exitnode_test.go b/client/server/network_exitnode_test.go new file mode 100644 index 000000000..1c0ba0ecb --- /dev/null +++ b/client/server/network_exitnode_test.go @@ -0,0 +1,26 @@ +package server + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/route" +) + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/server/probe_throttle.go b/client/server/probe_throttle.go new file mode 100644 index 000000000..ec6137e15 --- /dev/null +++ b/client/server/probe_throttle.go @@ -0,0 +1,88 @@ +package server + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// healthProbeRunner runs the full, expensive probe (network round-trips to +// management, signal and the relays) and reports whether every component was +// healthy. ctx cancels the probe when the caller gives up. Satisfied by +// *internal.Engine. +type healthProbeRunner interface { + RunHealthProbes(ctx context.Context, waitForResult bool) bool +} + +// statsRefresher does the cheap WireGuard-stats refresh callers fall back to +// when a fresh probe isn't warranted. Satisfied by *peer.Status. +type statsRefresher interface { + RefreshWireGuardStats() error +} + +// probeThrottle rate-limits and single-flights the daemon's health probes. +// +// Health probes are expensive (network round-trips to management, signal and +// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently +// and concurrently — the desktop UI alone issues one per connect/disconnect. +// probeThrottle keeps that load bounded with two rules: +// +// - Single-flight: only one probe runs at a time. Callers that pile up while +// a probe is in flight share its result instead of each launching another, +// even when that probe failed. A failed probe therefore does not make every +// waiter re-probe in turn; the next, non-overlapping caller can try again. +// - Throttle: after a fully successful probe the result is cached for +// interval. While any component is unhealthy the cache is not advanced, so +// later callers keep probing frequently and notice recovery quickly — the +// intentional "probe often while unhealthy" behaviour from the original +// design. +type probeThrottle struct { + interval time.Duration + + mu sync.Mutex + lastOK time.Time // last fully-successful probe; drives the throttle window + completedAt time.Time // when the most recent probe finished; drives single-flight sharing +} + +func newProbeThrottle(interval time.Duration) *probeThrottle { + return &probeThrottle{interval: interval} +} + +// Run decides whether to run a fresh health probe or serve the most recent +// result. It serialises concurrent callers: at most one runner.RunHealthProbes +// executes at a time and the rest call refresher.RefreshWireGuardStats and read +// the snapshot it produced. +// +// Both calls run while the throttle's lock is held, so a slow probe blocks +// other callers until it completes — that blocking is the single-flight +// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up +// cancels the in-flight probe (and any caller still queued on the lock falls +// through quickly once it acquires it, since the probe ctx is already done). +func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) { + entered := time.Now() + + t.mu.Lock() + defer t.mu.Unlock() + + // A probe that finished after we entered ran while we were waiting on the + // lock — i.e. a peer in the same burst already probed for us, so share its + // result rather than launch another. This holds even when that probe + // failed, so a failed probe doesn't make every waiter re-probe in turn. + sharedRecentProbe := t.completedAt.After(entered) + throttled := time.Since(t.lastOK) <= t.interval + + if sharedRecentProbe || throttled { + if err := refresher.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + return + } + + healthy := runner.RunHealthProbes(ctx, waitForResult) + t.completedAt = time.Now() + if healthy { + t.lastOK = t.completedAt + } +} diff --git a/client/server/probe_throttle_test.go b/client/server/probe_throttle_test.go new file mode 100644 index 000000000..cae776fa4 --- /dev/null +++ b/client/server/probe_throttle_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeProber implements both healthProbeRunner and statsRefresher with +// caller-supplied behaviour. +type fakeProber struct { + onProbe func() bool + onRefresh func() +} + +func (f fakeProber) RunHealthProbes(context.Context, bool) bool { + return f.onProbe() +} + +func (f fakeProber) RefreshWireGuardStats() error { + if f.onRefresh != nil { + f.onRefresh() + } + return nil +} + +func TestProbeThrottle_CachesAfterSuccess(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes, refreshes int + prober := fakeProber{ + onProbe: func() bool { probes++; return true }, + onRefresh: func() { refreshes++ }, + } + + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 1 { + t.Fatalf("expected 1 probe within the throttle window, got %d", probes) + } + if refreshes != 1 { + t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes) + } +} + +func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int + prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy + + // Sequential, non-overlapping callers must each re-probe while unhealthy: + // a failed probe does not advance the throttle window. + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 3 { + t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes) + } +} + +func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int32 + release := make(chan struct{}) + started := make(chan struct{}) + + // First caller blocks inside the probe until released, holding the lock so + // the others pile up behind it. + prober := fakeProber{onProbe: func() bool { + if atomic.AddInt32(&probes, 1) == 1 { + close(started) + <-release + } + return false // unhealthy — the share must happen regardless of result + }} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + + <-started // ensure the first probe is in flight before the burst arrives + + const waiters = 9 + wg.Add(waiters) + for i := 0; i < waiters; i++ { + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + } + + // Give the waiters time to block on the lock, then let the first finish. + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&probes); got != 1 { + t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got) + } +} diff --git a/client/server/server.go b/client/server/server.go index e8ef2f96e..363f716a9 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" gstatus "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" @@ -67,7 +68,19 @@ type Server struct { logFile string + // uiLogPath is the desktop UI's absolute log path, reported via + // RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle + // can collect the GUI log even though the daemon runs as root and can't + // resolve the user's config dir. Last-writer-wins (one UI per socket). + uiLogPath string + oauthAuthFlow oauthAuthFlow + // extendAuthSessionFlow holds the pending PKCE flow created by + // RequestExtendAuthSession until WaitExtendAuthSession resolves it. + // Kept separate from oauthAuthFlow (which is reserved for the SSH + // JWT path) so a concurrent SSH auth doesn't clobber the session + // extend flow or vice versa. + extendAuthSessionFlow *auth.PendingFlow mutex sync.Mutex config *profilemanager.Config @@ -87,7 +100,7 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher - lastProbe time.Time + probeThrottle *probeThrottle persistSyncResponse bool isSessionActive atomic.Bool @@ -135,6 +148,8 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable captureEnabled: captureEnabled, networksDisabled: networksDisabled, jwtCache: newJWTCache(), + extendAuthSessionFlow: auth.NewPendingFlow(), + probeThrottle: newProbeThrottle(probeThreshold), } agent := &serverAgent{s} s.sleepHandler = sleephandler.New(agent) @@ -152,6 +167,15 @@ func (s *Server) Start() error { } state := internal.CtxGetState(s.rootCtx) + // Every contextState.Set in the connect/login/server paths must push a + // SubscribeStatus snapshot, otherwise transitions that don't happen to + // be accompanied by a Mark{Management,Signal,...} call (e.g. plain + // StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed + // after OAuth init failure, StatusIdle in the Login defer) leave the + // UI stuck on the previous status until the next unrelated peer event. + // Binding the recorder here means new state.Set callsites don't have + // to opt in individually. + state.SetOnChange(s.statusRecorder.NotifyStateChange) if err := handlePanicLog(); err != nil { log.Warnf("failed to redirect stderr: %v", err) @@ -235,7 +259,7 @@ func (s *Server) Start() error { s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) - s.publishConfigChangedEvent("startup") + s.publishConfigChangedEvent(proto.MetadataSourceStartup) return nil } @@ -252,6 +276,10 @@ func (s *Server) Start() error { // "intent" (clientRunning) is maintained by the RPC handlers, not by this // goroutine. func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) { + // close(giveUpChan) MUST run on every exit path (DisableAutoConnect + // return, backoff.Retry return, panic) — Down() blocks for up to 5s + // waiting on this signal before flipping the state to Idle, and a + // missed close leaves Down() always hitting the timeout. defer func() { if giveUpChan != nil { close(giveUpChan) @@ -290,6 +318,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil runOperation := func() error { err := s.connect(ctx, profileConfig, statusRecorder, runningChan) if err != nil { + // PermissionDenied means the daemon transitioned to NeedsLogin + // inside connect(). Without backoff.Permanent the outer retry + // re-enters connect(), which resets the state to Connecting and + // makes the tray flicker between NeedsLogin and Connecting until + // the user logs in. Stop retrying and let the state stick. + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + log.Debugf("run client connection exited with PermissionDenied, waiting for login") + return backoff.Permanent(err) + } log.Debugf("run client connection exited with error: %v. Will retry in the background", err) return err } @@ -424,7 +461,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile wgPort := int(*msg.WireguardPort) config.WireguardPort = &wgPort } - if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" { + if msg.OptionalPreSharedKey != nil { config.PreSharedKey = msg.OptionalPreSharedKey } @@ -575,8 +612,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return &proto.LoginResponse{}, nil } - state.Set(internal.StatusConnecting) - if msg.SetupKey == "" { hint := "" if msg.Hint != nil { @@ -591,6 +626,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) { if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { log.Debugf("using previous oauth flow info") + state.Set(internal.StatusNeedsLogin) return &proto.LoginResponse{ NeedsSSOLogin: true, VerificationURI: s.oauthAuthFlow.info.VerificationURI, @@ -627,6 +663,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro }, nil } + // Setup-key path: we are about to dial Management with the key, so the + // Connecting paint is meaningful here — unlike the SSO branch above, + // which returns NeedsLogin and parks on the browser leg. + state.Set(internal.StatusConnecting) + if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil { state.Set(loginStatus) return nil, err @@ -635,8 +676,43 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return &proto.LoginResponse{}, nil } -// WaitSSOLogin uses the userCode to validate the TokenInfo and -// waits for the user to continue with the login on a browser +// WaitSSOLogin validates the supplied userCode against the in-flight OAuth +// device/PKCE flow and blocks until the user finishes the browser leg. +// +// The daemon holds StatusNeedsLogin for the whole browser wait (set on +// entry): the login is not done until the token returns, so a client that +// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads +// "login required" and offers the affordance, instead of a Connecting that +// never resolves. The wait is also tied to the caller's context (see the +// goroutine below), so a client that goes away cancels the wait instead of +// orphaning it on rootCtx until the device-code window expires. +// +// State transitions on exit: +// +// ┌──────────────────────────────────────────┬──────────────────────────────────┐ +// │ Outcome │ contextState │ +// ├──────────────────────────────────────────┼──────────────────────────────────┤ +// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │ +// │ │ drives Connecting → Connected │ +// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │ +// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │ +// │ UserCode mismatch │ StatusLoginFailed │ +// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │ +// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │ +// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │ +// │ switch / app quit / another │ internal aborts are │ +// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │ +// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │ +// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │ +// │ while waiting on the browser leg) │ re-enters the Login flow) │ +// │ WaitToken: any other error │ StatusLoginFailed │ +// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │ +// │ failure, token validation rejection) │ surfaced verbatim to caller) │ +// └──────────────────────────────────────────┴──────────────────────────────────┘ +// +// The defer still applies a StatusIdle fallback for the early +// oauth-flow-not-initialized return (before the entry Set), so a half state +// doesn't leak when there is nothing to wait on. func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) { s.mutex.Lock() if s.actCancel != nil { @@ -644,6 +720,21 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin } ctx, cancel := context.WithCancel(s.rootCtx) + // Tie the in-flight browser wait to the caller. ctx stays rooted in + // rootCtx so CtxGetState resolves the daemon's contextState, but if the + // UI window or CLI that drove the login goes away mid-flow (restart, + // Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of + // orphaning it on rootCtx until the OAuth device-code window expires. + // The goroutine exits as soon as either context completes, so it can't + // outlive the RPC. + go func() { + select { + case <-callerCtx.Done(): + cancel() + case <-ctx.Done(): + } + }() + md, ok := metadata.FromIncomingContext(callerCtx) if ok { ctx = metadata.NewOutgoingContext(ctx, md) @@ -669,7 +760,11 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin } }() - state.Set(internal.StatusConnecting) + // Hold NeedsLogin for the whole browser wait — the login is not done + // until the token returns, so a client that (re)attaches mid-wait + // (restarted UI, second `netbird up`) reads "login required" and offers + // the affordance instead of a Connecting that never resolves. + state.Set(internal.StatusNeedsLogin) s.mutex.Lock() flowInfo := s.oauthAuthFlow.info @@ -696,7 +791,30 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.mutex.Lock() s.oauthAuthFlow.expiresAt = time.Now() s.mutex.Unlock() - state.Set(internal.StatusLoginFailed) + switch { + case errors.Is(err, context.Canceled): + // External abort. If our caller cancelled (the client closed + // the browser-login popup, or the UI went away — callerCtx is + // done), clear the abandoned OAuth flow so a fresh Login starts + // a new device code instead of reusing this one. The entry + // NeedsLogin stays in place, so a reattaching client shows the + // login affordance. An internal abort (actCancel from a new + // Login/WaitSSOLogin, callerCtx still live) leaves the flow for + // the new owner — don't clobber it. + if callerCtx.Err() != nil { + s.mutex.Lock() + s.oauthAuthFlow = oauthAuthFlow{} + s.mutex.Unlock() + } + case errors.Is(err, context.DeadlineExceeded): + // OAuth device-code window expired with no user action. + // Retryable — leave the daemon in NeedsLogin so the UI + // keeps the Login affordance instead of reading as a + // hard failure. + state.Set(internal.StatusNeedsLogin) + default: + state.Set(internal.StatusLoginFailed) + } log.Errorf("waiting for browser login failed: %v", err) return nil, err } @@ -753,6 +871,22 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, err } + // StatusNeedsLogin is a legitimate fresh-start entry state: a successful + // WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is + // done, the token is in hand, but the engine hasn't been brought up yet — + // see WaitSSOLogin's state-transition table). The same holds after a + // mid-session expiry tore the engine down (clientRunning == false) and the + // user re-authenticated. In both cases the caller's Up is expected to drive + // the connection; treat NeedsLogin like Idle and reset to Idle so the + // engine's own StatusConnecting → StatusConnected progression starts from a + // clean slate. Without this, the first Up after an SSO login fails with + // "up already in progress" and the user has to trigger Up a second time + // (CLI: re-run `netbird up`; GUI: click Connect again). + if status == internal.StatusNeedsLogin { + status = internal.StatusIdle + state.Set(internal.StatusIdle) + } + if status != internal.StatusIdle { s.mutex.Unlock() return nil, fmt.Errorf("up already in progress: current status %s", status) @@ -815,9 +949,12 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) - s.publishConfigChangedEvent("up_rpc") + s.publishConfigChangedEvent(proto.MetadataSourceUpRPC) s.mutex.Unlock() + if msg.GetAsync() { + return &proto.UpResponse{}, nil + } return s.waitForUp(callerCtx) } @@ -927,6 +1064,10 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config + if msg != nil && msg.ProfileName != nil { + s.publishProfileListChanged(*msg.ProfileName) + } + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } @@ -943,23 +1084,37 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes return nil, err } - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusIdle) - s.mutex.Unlock() // Wait for the connectWithRetryRuns goroutine to finish with a short timeout. // This prevents the goroutine from setting ErrResetConnection after Down() returns. - // The giveUpChan is closed at the end of connectWithRetryRuns. + // The giveUpChan is closed by the goroutine's deferred cleanup (see + // connectWithRetryRuns) on every exit path. A timeout here typically + // means the goroutine is still wedged inside a slow teardown step. if giveUpChan != nil { select { case <-giveUpChan: - log.Debugf("client goroutine finished successfully") + log.Debugf("client goroutine finished, giveUpChan closed") case <-time.After(5 * time.Second): log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway") } } + // Set Idle only after the retry goroutine has exited (or timed out). + // Setting it earlier races with the goroutine's own Set(StatusConnecting) + // at the top of each retry attempt, which would leave the snapshot + // stuck at Connecting long after the user asked to disconnect. + internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle) + + // Clear stale management/signal errors so the next Up() (typically for a + // different profile) starts with a clean status snapshot. Without this, + // a managementError left over from a LoginFailed cycle persists in the + // statusRecorder and appears in the new profile's initial + // SubscribeStatus snapshot, making the new profile look like it also + // failed to log in. + s.statusRecorder.MarkManagementDisconnected(nil) + s.statusRecorder.MarkSignalDisconnected(nil) + return &proto.DownResponse{}, nil } @@ -1174,7 +1329,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil } }() - return mgmClient.Logout() + if err := mgmClient.Logout(); err != nil { + // The peer is already gone from the management server (e.g. deleted + // from the dashboard). The logout's goal — deregistering this peer — + // is therefore already satisfied, so treat NotFound as success rather + // than blocking the logout/profile-removal flow. + if logoutPeerGone(err) { + log.Infof("peer already removed from management server, treating logout as successful") + return nil + } + return err + } + + return nil } // Status returns the daemon status @@ -1227,9 +1394,24 @@ func (s *Server) Status( } } - status, err := internal.CtxGetState(s.rootCtx).Status() + return s.buildStatusResponse(ctx, msg) +} + +// buildStatusResponse composes a StatusResponse from the current daemon +// state. Shared between the unary Status RPC and the SubscribeStatus +// stream so both paths return identical snapshots. ctx scopes the health +// probe runProbes may trigger — a caller that disconnects cancels it. +func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) { + state := internal.CtxGetState(s.rootCtx) + status, err := state.Status() if err != nil { - return nil, err + // state.Status() blanks the status when err is set (e.g. management + // retry loop wrapped a connection error). The underlying status is + // still meaningful and the failure is already surfaced via + // FullStatus.ManagementState.Error, so don't propagate err — that + // would tear down the SubscribeStatus stream and cause the UI to + // mark the daemon as unreachable on every retry. + status = state.CurrentStatus() } if status == internal.StatusNeedsLogin && s.isSessionActive.Load() { @@ -1240,15 +1422,20 @@ func (s *Server) Status( statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()} + if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() { + statusResponse.SessionExpiresAt = timestamppb.New(deadline) + } + s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) if msg.GetFullPeerStatus { - s.runProbes(msg.ShouldRunProbes) + s.runProbes(ctx, msg.ShouldRunProbes) fullStatus := s.statusRecorder.GetFullStatus() pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() pbFullStatus.SshServerState = s.getSSHServerState() + pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision() statusResponse.FullStatus = pbFullStatus } @@ -1469,6 +1656,151 @@ func (s *Server) WaitJWTToken( }, nil } +// RequestExtendAuthSession initiates the SSO session-extension flow and +// returns the verification URI the UI should open. The flow state is held +// in s.extendAuthSessionFlow until WaitExtendAuthSession resolves it. +func (s *Server) RequestExtendAuthSession( + ctx context.Context, + msg *proto.RequestExtendAuthSessionRequest, +) (*proto.RequestExtendAuthSessionResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.mutex.Lock() + config := s.config + connectClient := s.connectClient + s.mutex.Unlock() + + if config == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured") + } + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") + } + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + if hint == "" { + hint = profilemanager.GetLoginHint() + } + + isDesktop := isUnixRunningDesktop() + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) + } + + authInfo, err := oAuthFlow.RequestAuthInfo(ctx) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err) + } + + s.extendAuthSessionFlow.Set(oAuthFlow, authInfo) + + return &proto.RequestExtendAuthSessionResponse{ + VerificationURI: authInfo.VerificationURI, + VerificationURIComplete: authInfo.VerificationURIComplete, + UserCode: authInfo.UserCode, + DeviceCode: authInfo.DeviceCode, + ExpiresIn: int64(authInfo.ExpiresIn), + }, nil +} + +// WaitExtendAuthSession blocks until the user completes the SSO step +// initiated by RequestExtendAuthSession, then forwards the resulting JWT +// to the management server's ExtendAuthSession RPC. The returned deadline +// is also applied locally via the engine so SubscribeStatus consumers see +// the refreshed state. +func (s *Server) WaitExtendAuthSession( + ctx context.Context, + req *proto.WaitExtendAuthSessionRequest, +) (*proto.WaitExtendAuthSessionResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get() + + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if !ok || authInfo.DeviceCode != req.DeviceCode { + return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow") + } + + // Preempt a previous WaitExtendAuthSession (e.g. when the tray + // notification and the about-to-expire dialog both start a flow on + // the same deadline). The older waiter exits via context.Canceled; + // the new one takes over the IdP poll. + s.extendAuthSessionFlow.CancelWait() + + waitCtx, cancel := context.WithCancel(ctx) + defer cancel() + s.extendAuthSessionFlow.SetWaitCancel(cancel) + + tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted") + } + return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err) + } + + // Clear pending flow before talking to mgm so a retry can re-initiate. + s.extendAuthSessionFlow.Clear() + + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") + } + engine := connectClient.Engine() + if engine == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised") + } + + deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()) + if err != nil { + // Log the full wrapped chain, but return only the innermost gRPC + // status (code + clean desc) so the UI shows the root cause, not + // the daemon's wrapping layers. + log.Errorf("management ExtendAuthSession failed: %v", err) + if st := innermostStatus(err); st != nil { + return nil, gstatus.Error(st.Code(), st.Message()) + } + return nil, gstatus.Errorf(codes.Internal, "%v", err) + } + + resp := &proto.WaitExtendAuthSessionResponse{} + if !deadline.IsZero() { + resp.SessionExpiresAt = timestamppb.New(deadline) + } + return resp, nil +} + +// DismissSessionWarning forwards the user's "Dismiss" click on the +// T-WarningLead notification down to the engine's sessionWatcher so the +// T-FinalWarningLead fallback is suppressed for the current deadline. +// Best-effort: when the client/engine is not yet running the call is a +// successful no-op (the watcher has no deadline to dismiss anyway). +func (s *Server) DismissSessionWarning( + _ context.Context, + _ *proto.DismissSessionWarningRequest, +) (*proto.DismissSessionWarningResponse, error) { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + if connectClient == nil { + return &proto.DismissSessionWarningResponse{}, nil + } + if engine := connectClient.Engine(); engine != nil { + engine.DismissSessionWarning() + } + return &proto.DismissSessionWarningResponse{}, nil +} + // ExposeService exposes a local port via the NetBird reverse proxy. func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error { s.mutex.Lock() @@ -1535,7 +1867,7 @@ func isUnixRunningDesktop() bool { return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" } -func (s *Server) runProbes(waitForProbeResult bool) { +func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return } @@ -1545,15 +1877,7 @@ func (s *Server) runProbes(waitForProbeResult bool) { return } - if time.Since(s.lastProbe) > probeThreshold { - if engine.RunHealthProbes(waitForProbeResult) { - s.lastProbe = time.Now() - } - } else { - if err := s.statusRecorder.RefreshWireGuardStats(); err != nil { - log.Debugf("failed to refresh WireGuard stats: %v", err) - } - } + s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult) } // GetConfig of the daemon. @@ -1682,6 +2006,8 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, fmt.Errorf("failed to create profile: %w", err) } + s.publishProfileListChanged(msg.ProfileName) + return &proto.AddProfileResponse{Id: created.ID.String()}, nil } @@ -1708,6 +2034,8 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ return nil, fmt.Errorf("failed to rename profile: %w", err) } + s.publishProfileListChanged(msg.NewProfileName) + return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil } @@ -1738,9 +2066,51 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ return nil, fmt.Errorf("failed to remove profile: %w", err) } + s.publishProfileListChanged(msg.ProfileName) + return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil } +// publishProfileListChanged nudges the desktop UI to refresh its profile list +// after a CLI-driven add/remove. The daemon exposes no dedicated +// profile-changed RPC event, and a profile add/remove doesn't move the +// connection status, so the UI's SubscribeStatus path never fires for it (and +// the tray's status-string guard would swallow it anyway). Instead we publish +// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent +// recognises the metadata "kind" marker and translates it into its internal +// profile-changed signal that both the tray menu and the React profile views +// already subscribe to (see proto.MetadataKindProfileListChanged, recognised in +// client/ui/services/daemon_feed.go). userMessage is intentionally empty so this +// stays a silent refresh signal rather than a user-facing notification. +func (s *Server) publishProfileListChanged(profileName string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Profile list changed", + "", + map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName}, + ) +} + +// publishLogLevelChanged signals the desktop UI that the daemon log level +// changed, so it can attach/detach its rotated gui-client.log. Like +// publishProfileListChanged, this rides the SubscribeEvents stream as a marked +// INFO/SYSTEM event (kind "log-level-changed", level the lowercase logrus +// name); the UI's dispatchSystemEvent recognises the marker and routes it to +// the logging toggle instead of an OS toast (userMessage is empty so it stays +// a silent control signal). The "level" value matches log.Level.String() +// (e.g. "debug", "info") so the UI can parse it directly. See +// proto.MetadataKindLogLevelChanged, recognised in client/ui/services/daemon_feed.go. +func (s *Server) publishLogLevelChanged(level string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Log level changed", + "", + map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, + ) +} + // ListProfiles lists all profiles in the daemon. func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) { s.mutex.Lock() @@ -1812,11 +2182,33 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) DisableProfiles: s.checkProfilesDisabled(), DisableUpdateSettings: s.checkUpdateSettingsDisabled(), DisableNetworks: s.checkNetworksDisabled(), + DisableAdvancedView: s.checkDisableAdvancedView(), } return features, nil } +// WailsUIReady is a no-op the Wails UI probes at startup; merely answering it +// (rather than returning Unimplemented) tells the UI this daemon is new enough. +func (s *Server) WailsUIReady(context.Context, *proto.WailsUIReadyRequest) (*proto.WailsUIReadyResponse, error) { + return &proto.WailsUIReadyResponse{}, nil +} + +// checkDisableAdvancedView reports the MDM-policy directive for the +// upcoming UI's advanced-view section. Tristate: returns nil when no +// MDM directive is set so the UI applies its own default; returns +// &true / &false when MDM explicitly enforces. No CLI flag backs +// this feature — MDM is the sole source. +func (s *Server) checkDisableAdvancedView() *bool { + if s.config == nil { + return nil + } + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableAdvancedView); ok { + return &v + } + return nil +} + func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error { log.Tracef("running client connection") client := internal.NewConnectClient(ctx, config, statusRecorder) @@ -1986,3 +2378,28 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage } return nil } + +// logoutPeerGone reports whether a management Logout failed because the peer +// no longer exists server-side (gRPC NotFound), walking the wrap chain since +// the client wraps the gRPC status with fmt.Errorf. +func logoutPeerGone(err error) bool { + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound { + return true + } + } + return false +} + +// innermostStatus walks the wrap chain and returns the deepest gRPC status, +// or nil when none is present. gstatus.FromError does not unwrap, so a status +// wrapped with fmt.Errorf %w would otherwise be missed. +func innermostStatus(err error) *gstatus.Status { + var found *gstatus.Status + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := gstatus.FromError(e); ok { + found = s + } + } + return found +} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index 225cf6494..8b6f78f04 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -6,6 +6,7 @@ import ( "context" "net" "os/user" + "path/filepath" "testing" "time" @@ -59,9 +60,25 @@ var ( } ) -// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables -// we will use a management server started via to simulate the server and capture the number of retries -func TestConnectWithRetryRuns(t *testing.T) { +// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login +// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several. +func TestConnectStopsRetryOnPermissionDenied(t *testing.T) { + // Redirect profile paths to a temp dir so the test does not need root. + tempDir := t.TempDir() + origDefaultProfileDir := profilemanager.DefaultConfigPathDir + origActiveProfileStatePath := profilemanager.ActiveProfileStatePath + origDefaultConfigPath := profilemanager.DefaultConfigPath + profilemanager.ConfigDirOverride = tempDir + profilemanager.DefaultConfigPathDir = tempDir + profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json") + profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json") + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDefaultProfileDir + profilemanager.ActiveProfileStatePath = origActiveProfileStatePath + profilemanager.DefaultConfigPath = origDefaultConfigPath + profilemanager.ConfigDirOverride = "" + }) + // start the signal server _, signalAddr, err := startSignal(t) if err != nil { @@ -113,8 +130,8 @@ func TestConnectWithRetryRuns(t *testing.T) { t.Setenv(retryMultiplierVar, "1") s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) - if counter < 3 { - t.Fatalf("expected counter > 2, got %d", counter) + if counter != 1 { + t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter) } } diff --git a/client/server/status_stream.go b/client/server/status_stream.go new file mode 100644 index 000000000..c6ba547eb --- /dev/null +++ b/client/server/status_stream.go @@ -0,0 +1,57 @@ +package server + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// SubscribeStatus pushes a fresh StatusResponse on every connection state +// change. The first message is the current snapshot, so a re-subscribing +// client doesn't need to also call Status. Subsequent messages fire when +// the peer recorder reports any of: connected/disconnected/connecting, +// management or signal flip, address change, or peers list change. +// +// The change channel coalesces bursts to a single tick. If the consumer +// is slow the daemon drops extras (not blocks), and the next snapshot +// the consumer pulls already reflects everything. +func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { + subID, ch := s.statusRecorder.SubscribeToStateChanges() + defer func() { + s.statusRecorder.UnsubscribeFromStateChanges(subID) + log.Debug("client unsubscribed from status updates") + }() + + log.Debug("client subscribed to status updates") + + if err := s.sendStatusSnapshot(req, stream); err != nil { + return err + } + + for { + select { + case _, ok := <-ch: + if !ok { + return nil + } + if err := s.sendStatusSnapshot(req, stream); err != nil { + return err + } + case <-stream.Context().Done(): + return nil + } + } +} + +func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { + resp, err := s.buildStatusResponse(stream.Context(), req) + if err != nil { + log.Warnf("build status snapshot for stream: %v", err) + return err + } + if err := stream.Send(resp); err != nil { + log.Warnf("send status snapshot to stream: %v", err) + return err + } + return nil +} diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go index 454d3afa3..e2be0551c 100644 --- a/client/ssh/server/test.go +++ b/client/ssh/server/test.go @@ -1,3 +1,11 @@ +// This file is intentionally named test.go (not test_test.go) so the exported +// StartTestServer helper is visible to the ssh/proxy and ssh/client external +// test packages, not just this package's own tests. The //go:build !js tag +// keeps its "testing" import — and the whole testing/flag/regexp transitive +// chain it drags in — out of the wasm client, which links ssh/server through +// the engine but never runs Go tests under GOOS=js. +//go:build !js + package server import ( diff --git a/client/status/status.go b/client/status/status.go index 5b815aaa3..a53585c99 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -55,6 +55,10 @@ type ConvertOptions struct { IPsFilter map[string]struct{} ConnectionTypeFilter string ProfileName string + // SessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. Zero when the peer is not SSO-tracked or login + // expiration is disabled. Sourced from StatusResponse.SessionExpiresAt. + SessionExpiresAt time.Time } type PeerStateDetailOutput struct { @@ -155,6 +159,11 @@ type OutputOverview struct { LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` ProfileName string `json:"profileName" yaml:"profileName"` SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + // SessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. nil when the peer is not SSO-tracked or login + // expiration is disabled. Pointer (rather than zero-value time.Time) so + // JSON / YAML omit the field entirely with `,omitempty`. + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty" yaml:"sessionExpiresAt,omitempty"` } // ConvertToStatusOutputOverview converts protobuf status to the output overview. @@ -201,6 +210,10 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO ProfileName: opts.ProfileName, SSHServerState: sshServerOverview, } + if !opts.SessionExpiresAt.IsZero() { + t := opts.SessionExpiresAt + overview.SessionExpiresAt = &t + } if opts.Anonymize { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) @@ -547,6 +560,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total) + var sessionExpiryString string + if o.SessionExpiresAt != nil && !o.SessionExpiresAt.IsZero() { + sessionExpiryString = fmt.Sprintf( + "Session expires: %s (in %s)\n", + o.SessionExpiresAt.Format(time.RFC3339), + FormatRemainingDuration(time.Until(*o.SessionExpiresAt)), + ) + } + var forwardingRulesString string if o.NumberOfForwardingRules > 0 { forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules) @@ -593,6 +615,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "SSH Server: %s\n"+ "Networks: %s\n"+ "%s"+ + "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), daemonVersion, @@ -612,6 +635,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS sshServerStatus, networks, forwardingRulesString, + sessionExpiryString, peersCountString, ) return summary @@ -1025,3 +1049,57 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command) } } + +// FormatRemainingDuration renders a time.Duration for the "Session expires" +// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago". +// +// Granularity drops to seconds only under a minute, otherwise minutes are +// the smallest unit shown — sub-minute precision is noise for a deadline +// that's hours or days out. +func FormatRemainingDuration(d time.Duration) string { + if d <= 0 { + return "expired " + HumaniseDuration(-d) + " ago" + } + return HumaniseDuration(d) +} + +// HumaniseDuration renders a positive duration in compact form (e.g. +// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so +// callers that don't need the "expired … ago" wording can format +// positive durations directly. +func HumaniseDuration(d time.Duration) string { + if d < time.Minute { + s := int(d.Round(time.Second).Seconds()) + if s < 1 { + s = 1 + } + return fmt.Sprintf("%ds", s) + } + + const ( + day = 24 * time.Hour + hour = time.Hour + minute = time.Minute + ) + + days := int64(d / day) + d -= time.Duration(days) * day + hours := int64(d / hour) + d -= time.Duration(hours) * hour + minutes := int64(d / minute) + + switch { + case days > 0: + if hours == 0 { + return fmt.Sprintf("%dd", days) + } + return fmt.Sprintf("%dd %dh", days, hours) + case hours > 0: + if minutes == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh %dm", hours, minutes) + default: + return fmt.Sprintf("%dm", minutes) + } +} diff --git a/client/status/status_test.go b/client/status/status_test.go index 44fc30baf..2babd9342 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -648,6 +648,53 @@ func TestTimeAgo(t *testing.T) { } } +func TestHumaniseDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {0, "1s"}, + {500 * time.Millisecond, "1s"}, + {8 * time.Second, "8s"}, + {59 * time.Second, "59s"}, + {time.Minute, "1m"}, + {47*time.Minute + 12*time.Second, "47m"}, + {time.Hour, "1h"}, + {2*time.Hour + 15*time.Minute, "2h 15m"}, + {2 * time.Hour, "2h"}, + {24 * time.Hour, "1d"}, + {2*24*time.Hour + 3*time.Hour, "2d 3h"}, + } + for _, tc := range cases { + got := HumaniseDuration(tc.in) + assert.Equal(t, tc.want, got, "input %s", tc.in) + } +} + +func TestFormatRemainingDuration_Expired(t *testing.T) { + assert.Equal(t, "expired 3m ago", FormatRemainingDuration(-3*time.Minute)) + assert.Equal(t, "expired 1s ago", FormatRemainingDuration(-500*time.Millisecond)) +} + +func TestSessionExpiresLineRendered(t *testing.T) { + in := overview // copy of the package-level fixture + deadline := time.Now().Add(2*time.Hour + 30*time.Minute).UTC() + in.SessionExpiresAt = &deadline + + out := in.GeneralSummary(false, false, false, false) + assert.Contains(t, out, "Session expires: ") + assert.Contains(t, out, deadline.Format(time.RFC3339)) + // 2h 30m drifts to "2h 29m" within 60s — match the family prefix. + assert.Contains(t, out, "(in 2h ") +} + +func TestSessionExpiresLineOmittedWhenNil(t *testing.T) { + in := overview + in.SessionExpiresAt = nil + out := in.GeneralSummary(false, false, false, false) + assert.NotContains(t, out, "Session expires") +} + func TestMapRelaysTransport(t *testing.T) { out := mapRelays([]*proto.RelayState{ {URI: "rels://relay.example:443", Available: true, Transport: "quic"}, diff --git a/client/ui/.gitignore b/client/ui/.gitignore new file mode 100644 index 000000000..9f233d8b6 --- /dev/null +++ b/client/ui/.gitignore @@ -0,0 +1,8 @@ +.task +bin +frontend/dist +frontend/node_modules +frontend/bindings +frontend/.vite +build/linux/appimage/build +build/windows/nsis/MicrosoftEdgeWebview2Setup.exe diff --git a/client/ui/Netbird.icns b/client/ui/Netbird.icns deleted file mode 100644 index 20af72825abc8eabdaa289fe0d2cd96b81217e41..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80549 zcmeGFbx<5n6!#0yF1EN!aCevB4#C|axVuB}g2;!Civ}mjHpF!6mrc<#+3; z`@Hhht-5vZKX1L+so9y{?Vg#Q>FGY_dp@&kYwqL@fCP$d%{e#$0FnHYsIVo|?51?ax`$*yeyy}YF z8^!t1Z-fK*__!f)U*V;u$<8*}&UDB@LFuAym}>r%QSDhCq*?S)YScFo3q+@3W#sRq z#0Rp-ZV0KIthAL-2v6Vqp~`Z&<`ABpHQe@II|uXSbB( zYk8u|Qxt2(2YvAFP6_fASXrbxDe^{RG@W)I(P_(1v+viwFZ%vmgG;;NpmiQA`k-uD z%`Ak)$&`<2InUjzx1ydjX0>d%EtfQl1pQSzIw4;__eIrH@*Q>|d=)BJpF?xTmxjTN zU*E{s_OaJ|D^-Yqou>C)0t4!7W`jEOgsQ|U?~{YiwKPZ>Da)SZY`^-6benZD)r37|M1&fFZk1sz?me=H`D zlN1>QAv%}@D7w`W)(3q}qO>SNrtfw&(-?2cD{7o&v+L>j{O22N3-uk5^*KWC9Pv(} zr9h#A7&#M-w}(b!Ks3^|58=DVN4 zBiLcP&nAm^%`Ctci~GkVLtw*FU&b{_2!z@x?q+1lgNPBk5k_e$g;7R?qR80$ZeqE0 z@-(UQi`V1z*}Lla7le+B+(j{{M@5u1VTKpgm(K@VDrIpHFw06f%NjFU+S-*y?!oe> z=_8qfAK&VTPC;jZ%%dP>Wu?T_aSDAdO<`n=K-vcV%Jvl1MJ^WpN?WrA13_m)9iVK( zE#RerfwP&M`?-b=LzZKVRS#O@GufP?i`C^Qykx=g(6rfJ7nh9g6V+}N0Lov5iSy^Q z<#HsEFz4rl=w8H17!I6iVnI%E?a(ZHdgJJiOut{{SVq%5F8Ln9fW%1Xv+1L&cv!A1 z(F_JuLw92Ta7{BoIh9s*!RLIQPN^PaMHtsbn+6D%t`6TnYS1g4`))6{Synm9-YMY~ z&|;|76I!_=WvG5BAiD}9(O)gu0hU=EUBFM>LSP$HfKV zFCjC*TR!i@n8@uVdMk`72R{1EQuO_HiHZnWx&TD}w|L{V?WrDOILFP#R#Fv22?+=n zD6WmhFG{lTd{iW|WjNAP@605tI=b>g&UF6Hsk;E`JXbTUGXVOy`SY7X^-9N;7G381 zqIIWMO%rf2uU*D8`tW=e@}O`DAoAzp>2n0WmR8()`>Bj&FxXnZ9qCZ?{HPrT5jdyv z$XtIz`|;OM>D)wi;CkdTx5E-Pz^&`r@QC-=>o~Pm#0918RpYa7)|)a9x9UcEzL1=n zsmwUHmntgt+xX1|aDOl@dW`y|>OBq0Fo$4psxKavAbt?YD!W^uFsn9@>{aKY| zY{(2ukq;=P1o0I4LX{Igkd&NYw>`qzm(zvyril~|pJm`Zum_Du*EBXD>tPT9+)k!N z=gF3prmi8Q187V|?~k*VbwQ91z>>vp(w#$N898ShCV(1=`z8BNM++P5#iShFk+3nr z-O(Su!AM-F@s)x?$fsj7Rm9zNrmcnSJc%uf7(L=F=K7}lMpS(=CV++|0HNdD^47>? zQxUd|%Z7_g6X(UNVY|_)f1)qPc*@p{8HWf6@4EMwf+8phNaoSG;)xMNB7~x(6Ez9_ zX4hb87X14`ZDtXO?;{~(;AP@#5zovWXr0eTg zf4{GLh)@*ko6XOslx-)-QgbLj1XBU_M==+)_k7E0A51rG$e%F@rQz4-<_n@-(Y)hY z7tihxK^Qirdr+|#l#3gdmff<2s zcz~RIQw>uapJ)T*f(*yw$iAl>ia?$d4%;1C za~-`;35OTkPqX?qOZG9WM2J%+2_0T6i+YK<6@g@ZRMB8!V-ohu@U>8Ao=E|yEb0>8 zKhBtFc}))j>yT!GQw~xMyn2x?C{vWZA zp#LxS(SLgYWMg5yUylIzANG-hL{$D!}SNPj2 z&pTI1?BiOS{o01gXC{9!Wt0Q5 zlg_xTsAI-j1+3F#8M-m&8m-W%m4dy((VzVjWLwyUt@MZMiI)A0$7GW0fIC$N7pEZq z-lO~iw#jdo=ieq&)@?Rz73z{1klt?`@4JYZk9@s#r`W*uln4EYpQdA)_&g>+?a6vqGIU`<##fB94&dCOhqoykV$Ewb!+AV6GVA+s z{+XN?$+#!i529pQ7!+uG;o^6GbS?(|OcQuB|5@WyA6z~3WHs=N2{V-++P;6l_#q$F z#zQ!F!YEy?4i$o+4&akzBBE5IAKuS!-+Oc3T_xLy{op&X%dp{3L!$ZGiiAx{3SalqL;(C7zI-F8y_?=6pfBI#_23W5T4* zGH=o-81w)t8=>B?*l z%b;qb&-dnW!S|zkDsz>mpPbzuuo%w7zSawE%<|ysL4ytOcn4qwCEWNUGR|Rpw_VH3 zSvv}-C>r!`b3d(d*c5;iLY*K#BeH2DCZbc&xyf#S5{yYR&bfQ}N z4k5ue^Sp1iMc~kIO!UruBNs!R2M=AV9!gd5pMB4Dx)j#b$hXwApX63cfPNQh5C7*v zADkHoUTC~~U#9`w3dvsZK}D9QK0wF}TKFdW7mx4}KU*m7X3M_~zAY6$@K6}D=W-%K z8?=GaQm=i~FgW`adK9EEzDUq!Ivc^qniaK?5hHhg|{^-SkD zhRqJ`je~~jpCRk1pL6DvATuv@Gx_^RU4s*ilyi1zP#L(@AtP7+n_~dqaFF_>RYE#f zKIAf((_$*7BUC$ZZAx|XmtJ#cvuLZY8Xfcdj$EGxr66q~BHKe@^c9l<{cIhO15M;4 za>?cTlWK^i zX61szg)+vU4NMm~31cDJt??5ilOrH0($04GMmc`Sy*FnP2QNH8wcH*sb{dUcs=hP)9t|_^d&(4S@-W8>d{YvEV8gDC zKJxY{{YN!Dru`g^|JxlJ%`k0gZ)SbI(D}IV=IYvj3qfgQ?RY&Ng@mRq9ZrcrbS9XG zp;TnX7Dv=*iAXnsyzDHU{VF-ce7?;RWh%54_~|uDvXoy{ANc=I|k}@Bp4&FHa54t9=6o{#gT8fB?0uqE6KAMJEkyUewFeMzP(Xz>{@kwR#>GZrzo3^*xUb*Be?+yT;ldf0jE@z6FgnIOe>H#KJ5F1Wt} ztpVvLFJSbpuZ8z^33i1*zpUhA6$#=9D-^3kiM8;{h&c9p9z$wBDMFC9CnjZ?jC(;MrWH<_w$+1pMBAo@BgJ{;F{;qTgrUR|?x%TK1(fA`-3VkXT(n0;4 z^SdnTGb5Rdz!^qY?MZ;51KWUkKpsDqs>I<{quYZ#B84>^?GSNl3tF1mWbmy=RSadV z0**I#`wo)W9IwaarQS>%hFVp7DXuE`;Fz6FG|bVIv~Xq-#;|Hy$7tCY zHXM0dq2p6caj_={=}*G1jQOGZ2F0dEmH_85rPv%PLh^ph=e7VhDP4WRTJGZq9o=AS zBr%V-O*h@~JXK_y>}oMsvgdbCx4QTx;g;ZrjT%it&xii|@Y|hdb<36E8%{3mgt1Rs|#WC!c2 z!K8Q@{fQ&!PR1ORa^gw1I(-7u8lxd2S}|wOIRh2$T$A`nVkm=454`JdZtRDVaJ0ax zN4n{0S;OkQ@gZ0^sy1#PIri&0mblO9ct_~9nw@c=5J#JnPGmvPhD7Ul-u%#>A{*j^ zho{_Od0bs`%7fa*n6SuC^JeX7b0N~k3;@UH7&NDebT?Eu31v(!<>V_56_$7=Pjt%M zwqPM~hHJY#8ZzYOo5wGjJWjh0%Am*%7e8lvM*FHP3{^1mak;jy%WRsa3Ks6PLhzR@ zv`xM=li#Nvam3sn(+PsR+s$kR)JK%Y{H}2K!w-W)Sc^`^xp><&Q0bEx@%*vRNg_zI zv=b#X^V6SgrtR&M_Z8sCSMQ{QYWoBuYC^LvZKy+K>gmTSmrDKYV0V(2ry3?&)^P}z zsq5zvrlBqF@_KQ~x7r{GdY6-dFFSK;Y0^0ZjNsaWA*IH8Cwn(Jo`xmW3kMa6|DuBu`T6t(}Jq}Y(h|$9XW+{OW4O}Xq23}g`OX+FS zJfK!#C-AbQptEgpCP}b?Rw?y=;OZV-g3xKZjy0S6QrkV`rRBvKce>kanbFuIwnm znTAaH5XeJ0f*tAnD_||2IqZFAKO?;T%P<^0^rlM_`Yrd_hc7=*(*wa-J%oS(gcgaM z#~@CiB3lak)|35Kl616X?|6tfrO?tYlmC8|^ca;Hp=ruqSAo_=j4QMuAlev9E#&w> zNdw!O$H`^4HL&!G%5B|u?MT2+fqJp_-wmtvJ3 zWF-7OQKBQu7ShsIF8T9zt91(B9)#sgkDd99(NU7I{rTbilw$+O2f<56%pQS?DS1>u*%V`*#tWB_epSUAyC)HF!s9l5PSrKkqsD2D0rOQK|y2yfyV)J2^ z0AI+oE)9_Ae?H7aguoxK3Dp4BlT52!nNung%(+lTG z7O3UEAq4Q0zJ(QPjp&#*R1kB@D`t*J4(dhV{*^DJZuRc&reTy zyA1QwPgMNOK-{A(r04;}VM9H3$QUbV!PLsI$*S>mtBZGYpHWwWDp{~6R;zZ9;`aqC zDGL3ozkTn>-Y)J~l0|e=lh%Sy?m7Ryi~s#Sukf$A0-<-KOj;Un(2-r*w8~ScRKt8s z5SX|K7DrX0z;X^z+a~fIUBDt4A@5~3f5EE2qnZYstOtt#x}Zs3v~Xx=1d_yVf*O8p zA`}bhLSBE<+~cL$Z?gZAp)3PDyo}#}`Ys{G#xZwNgcKT+oDYhYX)P;+c@u_iql2!1 z%|vu^_)Dur^J!~N5tPXAWW5388FP<~BLAjO1-QbtVUwq#*{AM}9fjULK=F&(TqiRd z2Ky_L;x@+i2K8UQ*7bG#UT@IMu|aD1$<(PNWL#KJ(2=P(F=5-${E&C|WA0CDrY^wn z`E5(UGp>kSEOYNzABcu~EiZL*`lH-!f72mS{GPtcyB?FYR)IB@Y`z3Hd1&$0S#GaQ zIzH*ilus#k^O*)oH6zsDBO{h1Pn#$cF9cba4AH|)3P;UCO&Rfsoo#Ubt>a=xl}I{$ zo_r>fD@*a}imxkueqM@rI=(QKnu?r^1I4%&!AC;jmHI}C;e~nZr|p@8Oz@EbR}k&> zP%9=|`ryaf?ZiJ{+*EaA>CKOnC~xzRwr(6qFEKr16E-u!MJTx(po^S0Q7qQ-|EzV zfjAKGzpGR4-O8-~OZ`6(y#oRNQ=R&64*+0&KqvRAp-!hPMyi5R5ZusH%McI!o31yBxhgOts*w$?SB@Uax2k z$?mzy(x1&&EPA)8+SQgc+?6L+Tf;7~+4X7KB$7Bt%(Q7UWW6{qbQua}(}wy1%5t@$ z#)xHttn79wLhj)+UvC(dI*1ZFNg5xL73D|wZ8mZBKR{r{6Ms~nv{>*)02sCskx}2~ z{J6egqfh$GT_?hc83;X2semgQxXnvuoRcITH0BOP)#RT7MI5*2m1os6Kj}~4uIQm{ zP2Z;-p6_f9_m}2$a{P&o09K~Vx^pqgstv9m+EN9&E4G5fp^|!%_;YnxHru6#Lg^=X zZb;)nKQ)&@t^o@tUJu|0Yp@ffLsMUkZ&md~L&B&mnU6PR<{YdPM8YDhtN1<@$f_#-`=8Ms& z>NfATlo4Sw@m0?TIv#)~UfcW^a>T?U%$hafFOhT*WTCOCG&fLF(74O+@RzUYLJ9i= z0Xu_XIi}juYh=0kCl?-2HGCSN1Dkt)Wc!OZoBfu3(CS zRV{*?#{`%YrOt1FO;nh38wGo(*InygLbcMHrFNFEY6g|P*518668-D%st2l!<}vFt zS0AF_pj5w)%1MG@iY@7iH0~L{E*M)t5~`;>Ly)e@5 zO0B_(J1E8hp~sC%X_Sin&Cw%`*l1u%rmwi(fkNkCKR90}1To+xWo9PW=bbO=;C-6d z%kTBSfiFnlaNYDr4fBsw8a0=MOvq3pMB39QnBw}iOr=UxTgjjs#mYP}2q=J4ThqpA zyP)q;a_yWFks0X7PtRvZt1`L<(VscUzPZP8x_ZbuYdDHX(R`eZ7@$$n>5RCq>ATdTH z#AO$GCX+N<%rT^?_%TGK$^88vVq82`$dm7!WK==3wDDG|?t_D2~5XNwE)S?$%9-NKI{;@0f`H7Rvk7ud&cG+&}R993hLU zHQ90R&+}%}+|8pb;z+70pPlQq==Gw%Wn|c!1OZIHY&`QeTSTm8DV?QHWj)a@oe)GL zK+pxIEFb395USV0U0v{rk^38Ez3g~*>A>;#T!TwzYZNVV8r|SvVF3p-T-<&);9bS_ zpA})*1?B#361DC~566#rX<-G$bF&8>~m#=9zQmRY-E48>r}=p`H5 z;16YkGzoG(^u$?sRN&<=smeEqqi7R%c(oTWwsC>f_+>79ot zX21+W>(3jv<>ZMxe3`*bHALNKL#s%pFTzL5%gBPbF-22k^7u3{ZC2DSgmFi*BRz1T z?@;%DMLZOhUv*$@+Fpv-obSjf5l&jM99uRo^B&9kKd$xiNRo|~TrQuY!cSg5K$gU( zxU9OqI^)UEithKcGqJgkR#yUg1NjsGzoirgTJ zt*}6RjR;%UX@4-eXsyuO%h=?p6KTSoSvRuwbx4mqA)#O+k=5*Kmx;xJcVKamH7H-6 z2~X+pN~0hL_F{aK8TM9tZk@Nl@qOak2I2q+f5q0G+EP$?9bMxGOf)Q{ezE-Xx6`Nbh2hC*KYcI4aKeDF6IFZX^|M^>Xhfz;5pnHP>#Cf5` z`_tSqBVggIIKrlSgg4^+=W5E(YM`5bI$0Id6|3g0<%bQ6GU}yDgt>~@d1t$Umg?r+ z#bb>FihRs(y99)G%`dUW-F--d-CNwQc?6_EUCZnSccKkecLS{ri#;qsOcGC>Du>Ui z?R?$rLV=}g9NstpLU)5lt(w9Mdm7iOc8!_%X9KV9i!GiF4ccE|5Zp&!t@Ioj6)~ZZ z+=Vz7v%xrbDh-v69hwN<>ng)QU+qX^@m1zc*KSE05#KMZwYIbOX?=5B4+HN|;eS4Q zlE4AdLEjoYTz?Gz&QXfS?SL6uLvh6tV61$%BBdV;4ZIbHd^DEid;}^{z0JpNJCfL~ zo{06^A7dr-yP+4Jsj@CX492y6+=k8leoIhw8d=OIVMlT zgx0QOkdGO-xO)fP6-=&d0*GrX&OZaaBy5>NCGB{Yu3j9vH$9jEdhwK*sW<86Z3tWgrRN`NjrF#KNU`8`zYleMu3|gmqqXXaw)}ar z^Nu!G;4O}Tt+znq>1^)m|ST=A7r35=0Z`kKN2l$BqLtw+c`4!NTq*+;#75&*c>F zb0WY^F>U!rebUg>zvWCS_we*8)>i6fJma`?^DHkB`#&Xvbo-iCf8nIX1myJ)vgkbP zk%L}}KZ(v&v0c}n&(gJ53~blBuV!GKvwqew;&oq??78H-PldJegInAZL*}_L?e6z< z6U$PyJbuREX5Yt9_UgqVuUe=mc3ibTIN=}J29Y^+_oM*%!)RX(uVwUBTCguvk;?{5 z@twfQ(|edEK8OmCG5w^$Y5s>Q{~FLNii7==vSz5RMZ+=q&VaY@$R zg^|CZhdSC)jxuqLoPRzmb4?A{lz{*u&e27jSGqis$x72+|~ zENAJ*`}3L>(N^O@2xZvLgE zAn!Nn)?n!7c%1UrA?Azwub}Y;R_wmj0BY#?pqt7UPQg#`Mz-`Olju{qQrK*+;Q|!9 z!{{sz;#t?dAz#=2?!l1>`!E9ZIjtL?OdYIRa?vH=BiDm+hH-jx}p)5aAOxf{weOLx=%0n7Y)d{#-c;b?%7vW@~oG@HZ+)duXE78o< z?lfM`>$pf5Yad0Aeq-jZ1nORNQ+2zY@BWoIy=F1CE2Cer-ti*7<&I^4rqkcGz{db- zYBv%++Dg>mFO;1k2_v?mCWYqgVEKMx8ay$xP@K=Z)|X%k+vW(WqQJ+Z5z(={lIP5E@a>yr_QNT@eoYVo?Rq|;U$8VAM#G&L&MQ7ubq_mca%HIN#ur6HNVZtxxmyU zEa)8KbI-k2# zrqr}0CZX5ZWqQZraiT00y$K?pZEc$)YR|D}yO4k=7Z(pVCY;d;gydrft;om^du3-U zF{dFm!E%tPEpkWtzHj-?o#^OiJ|sBDF~Ngt%AJgZ9casyr}wM?{l>t?nM}UHImKeh zqGbJ-BO!8Vc2R1cFA%IH@_GJliN)a;BZ^$Sl7+Ud7o8I|e4)-qNc5l(>s)p-6v=Ep;6=!xIo?E91XK&*wUwS> z-|j!dr{Ny3*1F39d$QKuf1E_qdfNk(%%~ECi6ITitxBB_Hx6$iRb(JkQ#a1`nJWRz zs~vAX%;UVHeR-o%lqd02b79jXU7~LL#Zol$3SEddPVVK%1zG_Vb5X?7KHGVEs`O^I zFdgL)M^SRI3wR207d@9&Bae^-|Be1!b+-FJp}tZ&T7B7s0nHVIJE0SUsNM}&<#FCy z*gW~IhHZd-y=y`x0M*23sz1CsES~&CIQx_^YKwCekN7*qycL(a*J^BE%BEAd(vd*| z>2P?9-5@5tKZaWtCPtD}KxEIW(V6Btzml2ZegZbf%88tAt1ukyBTs#5E079_YQjmv zfS9WkXF1#cNh4%)2=tTNAN=Xq5q6+f1`8wvZ@q`3a6v6s8SJRtt0Gq|?rRa{G_;pz z)1#ox0&{p$1LV;Q_&QF${ET@70wA&V^ZBh^CeOy;Dx^k+cXL%G887twSm2^cvC)(Q zW}m;NiB^XV47{E92h|2E5i>8mk{PJnKIdPx+F@79oiEV(Xv4r)5%Z7b1|+RcSj8mT z5n7+M;ZNlVlb4t!lfKivJiWbiFZ?6H06^AeX6BdvnmtpZAaqSim?#Gq&V;}5^s85~ zad%es%`!|ZoI;}(p-l?8uFGQxiYLjqm#d#dz35cMFGykwa1qF2ebynsX-x|g`$C|$ zozb{Vwv+gi)sebi*4=e}&_uACA}AcjZQmmghO;mPGG<05QGHmxTA6afM_Nnb=ztDosMUKj`in=v&INPk+?buxrUMB}62> zjBH2$EQ){VV)z*?$4PJId^NBX#7u$+nn8^mR)rD+qM33#g%t59-}n?}DjVXbF@Nd+@;?(0kiM{@k=&v9 zTH`%OM@UXA)<3%%TpuP24vRjtOP0|J)gREw4;gmZ8kbH+{{(6iUys7py) zP)#UzNH1vchppe-)cBYBNklk^;@7b(Dw5SxrNoG7t*iN0HyUj)6v;Bs!p!jJTs5t9 zjO-79dP!u3Za>04z7s|r4Jr6b9c*XPGSU|f^e}S%Tv`e*Fm!J#VyLEPr+}z@884P{ zPC8edX5Q)PPuI}`BH|Tz&m{rs2Wn^wW!V=D9sYWlyhSe(R}6^V7;jg><$bK!aZIRV z4E>$Xzk(pm;FdTVu^DFs6~=+Y!=cg@({^bR1m41tb+Mn4n==5B75@##J0y<=vL`VK zi{i3j+1JAn8yq^Cp z&qYN9`&p$itsDIJ+}E(j7bz~3avToUWrAE6lrT7^h-ts z!=X6__$I80POO-vd^8a4d?6Qk@8b8v=M1*vzelIH=p@t}bw*}v0KGNd`&vttiDa_A z>>lSnM%XPU3u)M!{s*IT>o9XN72y&~QTo)BX&8cnJLvsWmX!+K*serbnr-LFGox?t9PPtwqvMZCeTfNUT+xAn-D*fF>gYhlxDIT@f$=dfT7d5r(l9A{U+*BP|c=YnQ?_18&hf4`wH850coXs(CDVbI= z+Lq1YqeyJ}K|r$d&32(Nf|m`{)Ly%`*^&8it5q{cg7 zY zwVbDNI8}CQC{e27JC@yl4>9B`S71&T`4~L`HTdgNjlqh)*to~MSg%*;0bWPR;cLcH zsVbV{_mhDBx%;GJ!5NnCJngKA@l{Y;=taDt`LC1vqm(St6HcCln+b4!LKsXXZCBU@ zyomK)=B1}R)T%zoVrMjh(F|E7uCH<2KjZvYNXkovjQBf70zAOIu9A<_)qFfrXT~4O zMgp>X8kvHlt?zK?-SL;8WIZ;8%2a|-O}pPEllt>up2ptE;sHai(Gx`@!^W*8<~A_t zHK#SB5ZeQZ7WM z=beU*Fv#foP;7DAT)o82HIrQ{5al?~AmJC$`$6K$1zmt|SXltE#EOut_g#XqCk?C^ zZ$jwSrMF7W&^$ZK5~(?}QaNErXmxs@Nes7UqAM$(|8 zlWQy*#jDUrfAmKKWg_H{h#|z{1RkVT$STG3AA67?X8{~{;Tm`H8A}tLRe9J>!Y1o2-S;WS76Wj@Xnfk z>OHwtqOqNwDlAJ<)yeBU)V1?*<*G(vDa2cl*L+985x-4;ZNeBUuNH90I2@QBafN^g zaHhRx!xO*9>IW_DipB`KJCOxW;ICqm&vT=TEq`48R`RGOZ)ix6>p$H-{09>Y8A^Iy z%Pw#RjL8H^F*v@Mex#)T?BAw>uG$`e>&~pY=Xj?LZ`GAQ#*>gX$igB_a|?Pc!bP?m z`O;WDH-tInfy9EE_ji_R%E`FYR6F{U7#1?d#9ooSsOwr)7$b^KA2gn0X0M44GTCXP3b4w0;AHg5V#pYlFhAa4ebQOQ{9X@YVu#15Trz=U14?KSFV4j8-Q0?6d8UAn+!`px!C9WR8U3*echqS#Q+$0x`8hQS9O z*k#g3>hI2&4ftw|k4OBNI>|9e`2i0Qw)!$NAb*6+hp8W2gD58ok}wfL4qkws-NRFa zOT-Hk#%IzLi7>PJv<+DoNn@C=04eXS=k2+)e~DEdE*%j@MTucE^M8| z!q?0;R`2btq+A5jKY$+4GW=faKJKEZxTYkxlu9OL)dmtwT&)M;>)V;b9=}&v*ivpB z?HJh?JaP^PkO6#}0y9m~tkFm=dw1(+!7#}bQahb{xjhaG*Kvt_Y>uJH`UV85;_5do zwufFrY_&3305X&nSz^1Qe)aSIj)H5bZkltSpw>ON01P!OXUy74M4$5*MZEZ-G2Z-q z@4|vpwtE&E3D&Eak1_X^ca-}b$FhHR*1dCtxCD@5Yz(Fli(34Roi2U&m(3%vA(5!>QORu=3kE= zQ*&>{4ZyFR-zNXLs-td#wt8%FsH}OM&q-{kSl2Xm4=lwEY@1|Mt|%IO-!wU#xo+}@ z;6_1T8UK2Kudnb}%QE6h=U2mXZmz*gYi<00mqbj(NJvKNp@y73bGom$vLJQT7#PsCL5Z%J{*^%2|ien9YbE@ zOfm?Q750;N=xwYbFo&EZ{@1tZp}~KB=Ik@Dr?1j3-F2ZQpe@@j$iw`-5aj?2T1L%Xx)l^=7 z`<-|;?ebvSP@F8JCmr$zcO$oF+QD|SrrO>c?RUrB>X*Dzw&wo|keABu?u4=qPW|D& zU!{@>6@Ue<;B_Hm7OJlUH#AkNGXK-B8)jQIMarNoII(DE=F;mx&b*m5Z5l3P5I-T( zH}Qlww?&s!8t37?S#Mw7<{w2`HoT6E7KgnvGXsFTB^Q&ZQpbbu;cG^bSPB0pa{S<= zgvq*+Ksx;58rL&{?wQv{3$=3je7UWA?H54sD#}j2rRR){4n+cSKWC1W@my5jkN>s$ zHE{DA+XUo-(!SKTJ?h#rBz-*QPjV6UBATFZZ)R(cO6z#3fE|Qe5XetI(ENmu(?OsM zjE%T!&3G{1)C|-a_u^M|^SgR_%FSXliDN=QM`W^582Of>#I4#oZybwTEU|N7#wPwd z={o=m(dY~DXkgI%f@L@VS#u{f#S}Zegdk!PIV?M3(k5fFaEeF-3 zCOH*tFH`@cwhS!q$r=UOE+n{x2q!p!Z115`XO`dI6M^xu1>tqugs1SAmuovSgL;M; zcL$)3LmfHDm_BabTFW(T9l-;#q+wFG>4A5(-dQvW;_Cyzh5Iv6ynSUyYhhjIlU8(y zphesUrRagF{7A0!Y^baW_wGog!l-kKvWlx6wvo3E|k%mIG2aDnteo=PO8m4!{ zkq-93YH-rql{aT9pQO&QSzdLpIJ5==h5f>(eG$_M`MIYSU0lk5m$ni{U;QczF5@Q# zk572OY%Fd=n7T$kFU7kG_csJXT0>KqoPKbMv8*MiBvR4U}#xP?1gb%oGlQp4hj zL74lb`?tj4kzW{Tx^+8DAB)MSXm}>1kf1-bcC*7TwnTQ@Giu1?aS>k!a0)-L8YWoJ zaN&o%5)Q(7Gi)&Ph%2o+AgfXRqvSFyfa3yJYM+td#$?e zjr5N!7rKTBO+pd0(Vr+7UpjR!R0@3iS4VWF8dswo&}@~Hx?UP+yG2jCdGjJ{q0|;~ zWk22*`T`XVI7!cDC`sd=${samY}Dgv5x(m-dd%HsCL%O`7Wt7PO3)fl4^n;08H5_s zEee?#)4}-VzgVFaem;~ zf&yNu8o$g8iQX%dUxV}O{vO>Fqdc)m*zQ!G{EC6dL3-jN1F^g?EeX3H($QW6&f$4M zgdLW%lMe(eGCxDazQRH858BVwe{fs^9*j3fgs^~Q{y7odMq!+G7|P5|nwzsgX$l`( zpE&tE*yLXIP9ptIfX;R_Fg6NLuAf;mJZ-&DMLVpn;iz=f-ktZ=Q&S`Sm2?7Y#sti@ z;VN)bRI{5`o*at*%Fh*hH4Hdd%c$d3Pe*>0KG5GK^Q$~$s;IeqV} z;^@8z>v@T2%_6)sp)iO{0p`sEG+|N;xh7|EIS>7hZZ^g#$7qh!NnGAi8}6=2@qVA3 zXx!EHuEsq!;}tACIjr(!DY96&NJZwo-kE5gmrRgIq`9j+`K!mTd{b^PPk?qiOc(fT z(w2cdh#Gdsj}5jAnvwaH5YDyub~HwtSI(^a89OouD0?Frl%N!Sb5@rZ-6#1S#7Q4w zKGKE<^HgD&|xrvREfs|1k&cy-t9oqPcCM&Fl_mNXo*fj911Su#Sa0V z8zm9Or?jTTQ46qUR6xnlgow1wCTH~{Deh}`N{FQD+%xX-TASdmm(-8QUu*o5DbWTI zz_pk_`YDn4VK;rkazFq*fmeLJ1d#NRFdSal(EPF3;$1BB^N%@-(v#Z%YB-?VD%A9d z&c6ECTC7R?)~f5l7%y{3mL~MzW%OH#3m{6IoGz65^dqyw?{t1VXd*Q*(;Rj56~PAf zJjfcJ4_(^Obpl1@aT!Ime3$bf9&iGv2(lXpSph$M-=Vs1-%v?*pmLpq^9xVzTtsLl zs?rTF)jBN+UZ!Lr`9+Ho`5(nG)$hv>UloGzvyt16pPupG=>uhz6Z&PL^tF8|WJr{+ z_l7uT!Vu4s$l!mDTGKvEV`ZFAzo?`<`3yM!{p<_=<&#Rx&wUXJ7J9F`vKjMT@?TgO zN+?lN;ba+!hwxq3d$H_6@TJF);NjN}>%M>sMe@CR^nGux%sC~Y4k`tCEC+p}^fnvQ z;fCSWU9*@;Y!2cjHgKb#oD`XMVZ|C17~ubqvsgz)QmhDirAX%2)Z%I^JmA z=McwE3WE@$1^&X^9FG53D!8$p9YjB-VW@ryH2q7=nnGIfcUfcUIm&0~(b?%)bvJEM zCLI#bl0MAe@V47p?l?;e`NbNa9z=ru@p?pZHj3^UJdPNf{r^|^m=5f-Xw3LwIg*he zGw}NQpI{+7-#@VMA6WQ*iKqMr7XAYZ|AB@7z`}oE;XknOA6WPgEc^!+{sRmDfrbCT z!hc}lKd|s0SojYt{0A2P0}KCwh5x|9e_-K1u<##P_zx`n2NwPV3;%(I|G>h3VBtTo z@E=(C4=nr#7XAYZ|AB@7z`}oE;s5XdeBJ*ESjY_kV0`h{zY?qb-`IQ4sHUE%eKZLH zf`SwaO{DkUn?L}iH|ZUuE4@n(T?M2|Z_+ym3WTaC(m|?pq)P9hge3R;-n-tl?ppWj z{dCv=1B*#==FFZwd-i_jnFibv76T%KxwQ24{R#);8AfMj$C@&ih2x-TNf6jB0tY$) z{!+q0V8`2N9C(u^0tE7o#(`X;K;STR1P(lW2S|Yj!*O7YS~Li(cnPHHz`%E~UqI@V zCmIBD1K#j11_V|xLKuP#$cjNgskj&rHSX&A@&bE-!=ccZSC`jUC>-_{b8`y9{r3*c zc{mD;LxqFxUZJm0sEf066!3He2oHGp9B~G@z@nl+kc*rD(l@BBXb{2q)!Etk)kQRj z@DhD-c6NJ;ivkg@pfITO3)C4P8VZNNfN`fWAcD(V6zcl==zjz;hyl?eqM{-rVk1BX z5iyZbQPJ0S`t9L4*Z_f$9nhhYNuOqCp5jJRuh=-`U_w!@c+GLrIRq1RoS;@IFt8MI#mA6YSmcZAUp<^ff=Y58>+4 z^T(RG=T5sca^Bz(#T~myUV#@7T7fOH@bHmy*Ra zTK(O2kEgY(cY@sgrz?;2{qyRoya&%z$PgeX17Nyv5aj+i)&ev@i{di|xC0K-6b)kSGyRJ|FwXOt)0 z*q&j(5tv0CApf6}Ky0_gX-oUf2Wp??`llSfNfA8E8a>Om@@~(6^5<5FwpDQZy0|oV z;JT|LwhiP$mYD-zzH}(-r8TNMUlzY4QpbHHN0c_LtHIz5>(E7NM`(~1_OGy4A0~=u zzbW@ZODx~SHu%hLtZn+bM?V5C{bsCbbU;or7sGC~>qr6JVkWzqu&lXk$T+jomGU3T zJIiaCI-&*}b4k`kf}ldmJr-VrgQk-m@R-8_cK$>Dd{6(Hp`c|w$jW2Ae*Bw;(2n8^ z#d>PVdPbZInem0cT^wEIwV--Gwr_3r1(G+D7SA#A5w zXYrRCYTw32{801VW&!C1uf~5netJP%k%CVxnN4(nQsdjb9>=*n6aYD>9#j6ODzXZL z*{8p*m*ZqsZr9>3LM^wB3JizQ;ps*B9*ve9)!*w&$bd9#rf{shJB`bQ!NuPO`34Vl+|3`tNm6;kI6xh zT@j~wIsM)&)K;$g2UAbz1gkqxi;qzA>9cb0^3HL8)aGXz+-&fAK0E0WIrUb?J*$;- z>e0l~H6eyHspz*SoRQjFlw7o5gWC%X9PShr_zciO$lg8-zbO-CSI)oQq+ZA1*53G= z3exL0H<8*u3o9FF2(3v=fAR9Y;mu&*K$jo(JtZDPaYFoSMFwuDUv8&cz^h@b zuJz(kz-X@JXx2kQsSxUU_tUb1bibsiz%9GKR=hr!0WZ!bhQ-#lrhyDAif9DG+JxY;Y zt^wAN=rKS8A)_J=M_2E0ao=tYVHn;tU1?)#o@#g}9QWM8ep7*Sk{L^Z15Pfz`l5v3 z7t*B(saEEJT6q=&Ft}rf#DA(H4_}&2HQJ20J-X`aVifomC*o73v~24pr=+Hk_ME7B z5le$oe<*!X`ds_)FA)_;CQ8+i4AEl^+i&$*I#m-6E_J+Z*}^+w7wzhbS(=`{Wr4B0 zL-1&x&=f6kA6!9%`8>-AG6g^cu$)f}pzuT3)2k19${+dfTK-i)Z(rwzRU!9g?JEkP z;UyFtgAM6Xw;1bJn5n74Jp@?5X)TQq4C8s%gB>aFH1}JQHbX65ONOkMuUk=&bHPkYXNJ?W2bUDwxtWKxVj zyb`+MALTmyoP6?*Fou!sf3r1kgezB-=ZUN>w4n1bsS!sR&O*P&%y6InNH8)&e;7(}Yb!NBu0hu9r@ow!9NScfu_Pmq zn0G@~y;A<+l6z%YFQ?^|8UF{n*vJ=l?WF11$(~Oz2y~Z0b_PQ!pc6`kxwA{y{qjND zGE3gRo^cvUX3#5VlPN+75zDd1#m4VO^S@)c-j0-B*gGM6^z(Xx;kRUxl2Fq|E`@gH z_ruY>`?u*-1aPl;k`x7obSTP5JLF~3<%+@a$dHd>7fmmwa!NQ|3SP+YsK-Y*@W|MA zw>xG8if8?YrZ5<0m)3*Y9f)?kx)xVwNINS%vy%T+@H-}{==C;fD+bd;fc5pP5F>y; zZblH3^;3ms?ar6C=lc5!3UpWU-+sn_-3QZIsv#f8_m|q>AHl3Op0UxtQ^W{@)(Y|M z5bzk-6u%|fOz2x!DRH!H6Pqp82ScqJ8dF_$g8i9rvB&)4OwaQhf)Z{n68@M>MG|Zz z)2Tmn@O1VKHsF+Jc&AK;1!fnH;FqaA$}-&uz%_NeJY8AWbyNbaeInXGz~2)M>(9#g zvbCUPN_%i52UoPGEG+UscRoJ|Lz0Jh*#$ehNQK!af|Jr8smL%2ut0*u@@^?sTIfIa zJC51*hpDI){wX5YX^}Hoz0tA$6c@|(<0-x~hYbaQG{d{d{RbvAZpkmn@q6wZJTT_kaYevv->L-uA_Dc}l!__7>c zsb6FZiNqfW0EhFCdr(Z*e;n$TKFX21Q+u`{M*+kHxb&>~ zXkzxrKY#9ZGxxX5AfU1p5A7o03P2=8+P)6;3E|wvNu6Pc447;#qY!+zshTxix&g~G zV;3yFvUU;Ral#5aqHrFHKQCmiE5G!FcBEZeRh?k~Ri*>BDas_BC;oY1CCkz!#8LqC zd0wGX92%%n79*_T9fnXTc_!G8Z1_QnUjD1vFk8WGnSxQdnJR@= zFa_DHQ9b#5SVg=$dIA)8P{pAHLVlm^j{BJ+vK%|J`2_Q~YeF#n_os}zalRScq87%d zwcIpv1(zKhl+^t0HMeIPd@v2Ih`RpE6!WyRTzVD0+F76mmZ6tsSdJOz!BKtft5?^r zztC__m}-Z5cR3G6lZ>^F&zVo+k2uurh~{Qg&zXzdzxg?D%$$AlReP$FR;D_}9jB7y zoeR^jYTZ&56A@4I%ICRKJn)<#`AnRMP(r&#xoMA{i4Um2ZQO`S44>`Z2m8lRzS)~e z7zAvl>s{X`4yv;Rx~4vQ;l2UKmO-kp=;jRj(M-Xehz!*s6E-#K)UcsvH7UV{%m*i7 zry++u{z{)}r0Bl=4x{jx5bmmxHNfdzYh&N577{k?jIS%^ls{IUxNx3$>uNXxIv6(# zn+h`^!7rz68Em}EI$M@YqsC|>^0sj`V3G-O&;KBK-NNgdFZpG;@G>8#m27)w*U69U_5ud4X_(}6&*=>ki!`y$w`T=8y1wbhIn-i2Hm+~m>sVUhjK z^pnaaQjF~3uUNmko*AN#PTQ)skLvP1cZld3KWuYK_jz6IvTCTx{-(yKgk7Z^_~-KL zZNo3MSk5ra``hUc9UKjDr)KgBB{TuFays{vls9Nb!lrGy)9LWu zU)oX|w{xkFu7Zr2B{q5)V<%RI$|J3YLsxEsj-4xw_8wyv)Ch4>g7mw|E|B#OWUw4_ z%Cuc`5u3vh-}M2xB<^M9BHrmjNU|gS1EIwC&rd4~yc5%U(-X6H#a|N&ZQy!Y82*Ob z6>ht|*z5^QezyUH+Ds2Tz9LJqAe(KugTkD!AvuBxezb=@{?bjW5WU$f`68yW7^PfHGxHql*& zcQ+MW9EC5pU4+T-)2f(#d20Rmu>hIF0Su!R%NPInUt?%}Uqt*zrz0U=9E$#G?@5Yq z7|(4ePXW%1FHl8E zuz$te{M@+SvLM@6!Mlp8)hUAol_l;%LN=l1P5%o15O=Zqo;Dlv+n3V7KHCmmefg%reeA)nPy@pRuxlD`FC4p@rsQn8zDlaO zgZMH3F&XM!f7`6rd}6y(;6_Pz3+F=`&G0Ysw!1HOb|6EPyX}TeyU=3wA&%cJ`Q!wi zr+t~%vw{t4!*lF=_q! zyp9@4=U0l-0d!wk>~j!er-~$z@LD|8&ncaZVI^nc{{8bGa`Jr5B6Hhq=UeG(BrXlJ zEc@NOGIBqEenfy{NglC`Z@2PW0E-snz!~@-2{C=`C5^~|{8FBf{?v`VzjA*?Q+py%C&Bu#je@u@Xx0dX7 z>{cJGnP>V$pL5_@cFRTx&eX(Zjz(483{yc0MKUt9TLvcdFFjHEhBG$QM4kWbAqJ5! zO$<-xQ&QvbPj|6Q0X1DJPbzkGTTG?UpSYf_?D|$E7opv<*=JEHJ^NGPF|Ok;Ag;v| zf``poGZa*pzUv+C&)MW&%z1e4`(t0n4*B?w#6{*_zvrnkFNzT-f{?w5K=@_0!4*nO zk9;>tQp3^V74)JMry{_7R#h{B^BNT51PfKci?JG7z{$zRKN)jVgSF2_mk z>19nT zmR~&=4pb{9u_MkaMg!=u?;T+j&C&6ZYz!@JG&tepv#`>RmuoqZeBJb$zOJAt@$Tbm zitiWq!v)6%@t1`{q$k=GueE1R1Y`e@mL@v}6a7mIU%XrU8=S7oL_3%dbNe2E&G630 zY|*`x@b&U-52XLWbI;F6a3!oSi`m*OWmsLuNxPiv+V+po{drJkZ5uIt zUB+~B4t~lvg3QwIRt)d2giIs`Ie<9c?r1vmac1?w%v+3We6zd}M&}tgCcAsSq|tE3{nPyTt`O7jrr-e>y;JPpCdsyj{0Kso3VQVt)oLM;vQx!3ZBb;iiU>2;;J z7@YCTHkGj3{eQaa^p(0CMv<=^WCQF}CjUdjz3Jx1vGKzr%I}Vvvp)RS?=a>Ircj|8-)!YEO=1eTS@;u^Yhrxin|8aqaoq$5Ys)wS#+o-u^7-^yD+m1hmWi9z_r3%8%P1KDmP zIvSs5Bq&WRmQ*Y>z=i|KD&!d&@v&xWo?*>y2g*kJ?wI7ksnhWj{}A~tu`m+FU}Muy zixT%G{-rF$GhLdMCg@pxXsYQZ~OW_Gy zk^f^JM2?@d;{H47P`k5rVQ8Q_ypvEQWzJ{h&`UaDfcNs_!uN06rn~g+o%X73J8TS1 zPBf&p{!YaiA3m%EIwPHf3jK69zp~8q_kr?;z zy|92YxAnPLyQVc{T4f7;i#2XF)%z3dpA^m9019KWb88@pCk|>JKfT)M9#G<_H*i*u z%9lnHRiANQOZdg!R zaiY0RT!=xnW%1D56&Iv`^@@-ZJnW(NAtzfQU2DyGSa38>w~8yndEy1sf}>&&>hpK? zlO9oR^%XsIO&ea8SE`!htUg^U_qMwklPq9JfRJq&Y|-p&`t3u{RYg8tAeG7uolu9n zf66|eehoTbBbazvvTmde4yvbuXxuGnIk2m6xJfY~J-Fn0vEE5tg?eF-kPa@K$fE76 zXtr4~b1p(ZQ$^YS*9YjxlAZ_EPT$^qT^d-IUX8Z%EK-CA9iMf zPL*4&INlr}%jvrKgo0I+@SY@*@=%yR6TE#)3E8!01BW?m7`F8UJ1P8X6w0$%YN~5J zpUEo~2&|<7(!96P-NnwUIF!bqm|R2hW^vt`%>J^|>p_ws z5GK3(F$bSdD&0%Je8`*Po*_UO{?Koc*?*p}Qf(&w!iJ^r{*srQAI>{7{;iKb2IZ#$RL|6R5$LbWo4cs?&awb|=Fbap zZSrz|Z=U75FpeuiK2IP0J;4TLUVbv!;pN2n9E5)$Xk8+#Up2INK*vt}0nsHVHD>YU zvy|`bs#=1->N^3@gO`=aO|7iw-#C7gZT{CX`wY7i)tO9qBVw1 zXhV>G8kRKO<{v`QXBZy#cLD6EkBcna_lcy~d%UXXGrbp6Ka+$=7G~WCreBeX`VgQQ$m- zDH3$3uR8=dOg(7Hz@9*RT+nvC+6$AP7CPn7r)5x~X^mJ56JNBm=~1r_*4vq>5omSC zxxUJ=A;S-0Otgd}|M(&RqAvm==1XUJWYB?=hfi{XO&(Cm6w=-$`2-*AmkRITyLCpk zCx{x^<^2r4&~647rgOPx-aKPIA5D2L0zfN0;g#|P#1f++qNY__U$^(U1K19425*6Q z=z&sAIUfZ+<;z5t!lS4)l>fDg?`y_0EB-MGR7EMZf+4h<4OL}rhdxCv|S8tdw0HM~HXR3G%g@Tv9hjN;# z)x~>|Cm(-?C-%pdB)XKSiWAku)#0UX;|@9cU9}3Y@F*pH2@oCE?Sm)ZtcIh0E302m z8Mv>KL_us7;Akb2i~4WA6R+oXzbjQUM%iaG*ZRh*I;+)E(L5eCyg#Sj@*9Mt;7u!11Y{j=HrUOjYq$BPn4=pt6wX`*}i$d%Y z@XYR(Tm0^t0 zey3d9Sf8GZIQmyc28i(<=;k}$yK8tqq;lt{ zx-yv+;7|IWB^u8)T*&fock1%|a4pT1H34ED2>hzb5VF-uE!l6=G%Xdk9XWmc?Z+Jz zcqHA2w>K#McFdB1A9kjmR5@bfV>S^Ud|FS|f0u6s-*M2VhwgbiV&m%{9#uZ~9yjHW zTu+rPreWh2q|>lJ+`mjjqPBXz8xg`P7FIMSWx4ind{L^~P$63oNA^UYnOh>$k;a_# z>Jwi|MtO#>(2{`pywmQo+o3?c>6<-;e!`!yKg_>Ow#gTiq~ClywE?M2wj!H7cRzf~ z$iJl!wg*)pm8$jhMx*OtwLvguhHDaubkU@HqXTQ=ONSYwhh!wxC9lL(2DULtGg<5A|~ zg7eB+O-3J-g7;yqwD9|)z1NNtAf4Bn7iQhd#7lb{9xaD-@CY~$OhwNJZD;J={dk;b z+i;+UyeAvcc5S!1F!Q~8Jk)op?#6|nYN3V80ZT3%@;BQEpeUF}1DJ()$Hm9(h{M0a z`pvpKm5C8j1U;WRpvhqo7sHvk1GViObz2dlib6SU;IZRs>|v|Hxk@CWEB)1N2xX}P8DxMc6y$!q`m@nKrf zd!4Tdw{L&IB+O8x@eN?4yZ%N46!JP~P)W{@y~R%X+~>!$PQ0>BuKmjo@2KWeia;7>wwT-z|J7CZe?daZp#;}%d+ zg2($(wiyjgqM6la`|X3<8jE=Xzim1B2znS_bGm0DTdCXPe7WHJAPOjfB78o(dO+^o z&9CVK!MlvYZxKEZ@RLb6{hJMije z`eH~tppRuyv4}o&n1(5>TbTMb_f>8@=uhkh{5zrd86TbfHCq?wJ-(Bk0 z%pb6-swud!2`*9VVxaVh#5scW-Z9a zDW@&2ZvB6H)WuB$SB<2*W!%1{6QC@57E;dKIFn8KA>KVtBW;z4>=B+dn3XQ*x&`&9 zcZ+`TUW8RI%oy}Q&Q}XA&6d^VX9F5n_5I-fn;TjkqQ8juj=X`RN}`~!Xx_P~b7=FH zd!5Ho*S;5I{LH%ypnUCaXVsW*wew<`#luQ+Pxn>M+N@eNTFm{Ky@i; z_9fMxXZv&V#eLHAwh9dg?Zlaw`7b9UQ6omG4TZc^U{}z3S~O9=o+jRGYo}MCwuEjN zjKeH}yf6-u<3+rO^*Ge4BV5A-i66kc>G$-=v&J6b6^^hqR3R~Lasu@g;>Aal-cMEM zfAqcb{%=k(1nnp}Y3>t<&RedcFrAZy94$v2xVzEB{xqFEPN>b-&l{IWERiMa0sRBL z-dW`JznRHnJmm0yl?&LF6k8!CspGo3oyNU{ZqjTXziF60b*RlXqtBzqXh+3)5;>L* zP)FNf%Dl=o0g#lweG~cy{xFyWtA_q064f6(UOsIm-**2 z?PoqOR(ntTZO0JHf(MFPH#VEO0oo_~yeoT(D7yd{m>8jG?c-&s6Qxxe(?wgx*79FoiZ4XB$>ywdil4e+rGv zd+a#(rR>=u)4C9(CMyx-j3P;C!t^S3VWs0O!D-}T84~+_ClI7t1A81CA=UPrPYj4D zwyfs=lV7*dmbT^AqIXVwXhvQ%uboMLl>;)rv!4-$-axQsE%vZU`M~!In`aJ2r_y8h+lNx_;`*YKhz!BVjjvGOQL`hitlycFp8Q6SD zSY+WmKE+2`FE{L3th~daR+E}}05AY_$lTAozOXzSzZkew6A_MhBCn|^c53Y{badpf z0mm}`j6&=xJhG*rXLxes)QOQsS2msugvG@^n@fV=6~+eE0g&YrbwG|U7Xru}q50Zy zexRSWY$0pvPKmkOZ*6Wiyc_}&gWt%|s+nd|Ld(NXMR!uZeHR1;)|@Aw44ma8ck0Rj zm?@B0JbdoUA~t(svO*C4o)wJFd`jTVL?cD>KJ*@6VHXgB)?yXO8G55_KT-8gnd^%kegt4u0~^NQ-OliWFVapE=IE7v^BeZHgp@Db zwuZifQh|+WTE5nsn&i#1Cfxdd8OH>f@L1~J5dB#?MQLvrJbkWSUQ#UPmiYDqc-#mB zXqoBqg%qyK#!|#Pu;e+pZ`gNZ--TX2t2e&9%`FXVg+cj^Bl6N@L@(FuW+ipKh?+Yf zA`eKE;8tmWywGViM~*$D65~MvkYPwniQxF&n@og4?L=JX}!*0-q!k%_n#+;GR)Ynt607% zj-k(=d)px`2th_sw+xl8drY3XCqDKt`l%_MH^6-3j1RM^M~t?9@$`GKgjI02DU&!$ z7Sq~JlfA$CV%o7s*`a&C!>rrg)x&|==dy~Fhm#d5vfN$A5<*-kvhUFp02F?)xBZ;4~V!iH4*mHm1E+HN1C5K$D0qCRMg1821XV)1Pc9Z zDzE6?SB2m#uUA6bJ}fjTz9H0_CD-x{$9lH?>3FS5?eSs-W(_(gaYa)bjTYRdwGv{Z zIf}^}qOH-ZF(GAq4<_H>gz({Ng5f4IO+DVKYJlr#8ntl1dZ8oL2N7{MN+7&KcPHtg zH+Jh&R?~UX3zwa5!x5u@NH$$Ug!NmxL%ez{L>@dOuF;vkhzB&947yI7k_C7n_PaQ2 zlaTJUG1|y;s}OelkLJOS*dXlP(2fuWPo-H8lJ%7EEN;%?WLLe5Df zl{*$TRoj#yPM_LrOHLonDP2vvFh9P}y0g%Y#0U~Uz8_7jOk91#E-~J5d^T}Cn~g$o zShcz>r(Q8W`mf$aF>TRr-t**gQkJaFpq=Cxcg7KnukXP(bjlBpHzSMc>pg% zP~m5qaTCt+w)JPmeayRD`n4q+=f180!eije&Q!j~oa2y&xux${>FKtC4&%Kdv)Wte zhDN?>!@OdL4~l@R+hv9Bc<9q|sQu`6aKia+7~>|DG5wT{%m^@ZigJ>iL)5KjcTYtNc zIqQJxuT^D9Zg3Sy`x^Dm3GWrPcfKVTAS{v%E~V@07;0YZzDbfXVM{}Ny*mSWTK6=; zjL9vGRMgdHVBN}#EC>h{jYhx!LiiS14^aL1j#G+9hY+QnzaBT|l@G!9R*qzUYx;ey zeE}k9@64^a&A2Kxs{PM}**S}tP1u51bXJPj@3(Bl%P_}x5KgUpoMMN(H~fToH6<5= zMgNc(n0t*Le}RZUBG&VWLO*9t9ca6enlL$w)k--ks3uW71KLY|P)c{|w6D@#MDY?m zp?xqTDWOYB#4*}=>epg*55Az-z&WJ0prrWd?O|#+wF^6;KN?+rIx{d>dgXD2r=^=rehAD&F9d<`BK(J}7zmRf@kPE#0FH z_E;B^`?bX&YN7|F#R+R4f)go~jO;8gi3kwjBry}v7esVGG}SN&lXfNn9;uUa@PC1J?`0F;-nc=<*#ZHwhh{P1%my?7JY zn<*Qo9)d3T7~6`(csdS`y|db4V{itU@`Zcjva{VqVObrR6?49}ZY|AHAye`v-kkck z{Z|`GZSwUqRN(pY$#$Rj4-WAX3}U{qjw4>Us{pe6Yp@s*Z@IL&7EH?S-Z z6$h*>;-@zV>r#0P#G4hLSe1;w|&?&&_XQVfTJ%{gwe}8ENovN7f1#!;Fd(H5D$Zh}8VY~Lb?vICO z4Q!nDhZ-H`zQcaXfU=8|C9hJCznTkkFvw?5o|kkl#7h93wpVbcUs4_FK#5mYIEp;3 zr*3nP=UKrGJkXEjdJH(XRWhnqa*r#umigV0^)6A%0Gs{EHQ^GMxd`ELR12DMz0+ zZM;o^PYi!v%%pr_MJmHnlrzq$e|w-NH_zB`uErdkEn$Fry7?vcRWd`&tzE`zE57XF zaK>6bOWo8ytH4)U*!XgoGwAKZ8S=8u0asfZO`oz?^dr&O=ZYmNzk$oL!Q;xUo}+<2 z!Cuv(*sSEUC;gwut-qXU4=~kAv@~f9l{Kj&sRWAdYMj}CB-T6I^_RRH%Pun425mtl z#%*y1wO-3V{A(Poc#CA{^~4J_SSrhIoZ6SXl9IU`73w`}OMZMD*NjM+nK>_oo5!{c z1r}ZYe8}tNs|`f-pqO`~MIE7gvpF-^F7M?hOKZXxuVxPTqm$=#>)Tljbr!N>GPHCi zG=FRaSm!lS*41bos3rY-JI@H1>b>yNlk?;Rv+f5mD3a5ULaDEr;q)%(4XgLIU&EuWwpV#!XU0UBk9bcb7BvoB z5PX9ZZ*WBZBWf1w>Os*y-zo#4{b8QkyC`;9++>&NG!Q=(j0Tc)H30YcbMT<~H8U-{ z+{D{`lhcyVl{q9yvGvmdVSs;feU!Vx3(g`KPcCO+@kQUwLpzlsHy2csD5=|lvd8JV zrz~W|x-Misvsrg`5ta18#>M$9;-eR=cITa$xJn|mkoOq2CP7jd0R1ip^1Yra!@0E(*uWy7Nrd(+RrFNC~XlNvWjDg)l^WFaGV_B`z z>Qw_?7J{J9H5mL|OHaM^B3~m)I~9T7A!V?7H)Acl&n!r$yBu)^z|>VaxNM!fY_>{|a^f#5pjgFJnfW<0#H_`Byi^pDc~AK13rh)b|&YytOfOifsB7 zf{FnDbsNSctI%T^e@nZC-R{fxh6h=7UV@;B(y{!RKK&MPwe(XnM<#Tomu$G^b9y9l zlHM&$#+B0UD-<$8Y?jP!+bWKrxgy!?()aAXAXTrR&S1$zh^bRRAF-_R1sxi9nqVtj z?%pg2X|%4kHD13k`#RjNbou@-MQ^N|iv(l;D(o$~(&XSNjw(S(wAF@^OO+B$GnvhR zzW#nwSs6XL*>X;8}`(+q|eXySHmRTwF52Lu0 z#F~Ni!nN0Ad0}PQf7jE0g5EsFC%j8;Y`N6MOq^3+Eqc&m)6E9I31O#`JHb*;aCDR8 zB0i|Sa-#E8P3Vo7%6)cA(bA1Wy>Ig;$;HR-ivS!n&r}jIqf+$(y$@Tk0q%{9B}L9x}yHpWWSarVAi!lW!Z8AwCQWkBsN?M0Tb`o0|<(lq(&Y zzul9?r#Ni+-Q51E$Gie{y2@JkLxOzu6*apni_XW>tFJhxD3s_a$6RUSkJzkSB<81G z^o4+=j^8km(^&N$C}#CuMX_)4b{(y>`)Rk07SRa-9E4{LN}9C%@$cHKTla3K9K~!I zMo|!Q9?qp$ReKP+nF$RyzCSKj)vi~iz6x5X_QQS|#}-md)xQ&^kD{dl{7iU}mcP91 zwsvS8xsv-w)0s%dW-fH*1Dalmy`34UFJ?ku*Xo3C7g*%6cG+$RhX`fXSr_h4%I4O0 zeR8Vr#Zm^h6~l~BrC%)}8cg`znh|k>57rsw&d+oPU;RYS@@fzmILPfOE@v?ow(H;H zdiaxzwDz)|*>Zg{k}K6~4RDhTSShu%D8SLItauzgUJr6(P=|;#ygx+!@+y1w<$hE; zTQxYGVBAe~Im~V;-t&cl_*H}q)PcwvG@P>PJ_d_-%M3IUKII#K_)F^iPYX-Hg3PJh zMfIVhWJooL+VLaetI@%G!iIZrJU`KzOI3@89Ft}JU@f5Cylwstxe@7$X}G?QIkZ3? zSMY~4IdG9`w>0s$`J08*#rCX0?al|MOEIWMqPO48&c<}gm@~D$9GE$Y!d;m7q^PsA1}g{ z-PV&98xt;JYlI!ns=V3!%Bh)s&7^>42ll-UiV;0FTI#AdKen=Rp6B;>h4!E6E@5-s zUVb9gp@61nz-PhdFD<(Ma}wxsuhzAr)Of-Z!RaF~C~u(d~n!D_)NPw)iZ-7raSRfxR~? zIIBru1H)^zrtct?N}F}l+T`60OkY!dMXX!9Y{k4*U{#;?uY-D5yWC7-2(M8O>%F&h zCW;mp{CcjJ>BSS9%Gf?f*ke@y)2J{7A4-Rx_PNS^>?v^+Q8v)SncD=E=o89BTDhk5lXU9mkpfEG^~@%J{;M&Y zdEJcNKK8Rw|K0J(>*#op$>Zl8IEmL}S%J?B&9p(j=(rk6DMx;t`W(xF=dwFMz)Et% z^mX}B2wlnLkQ|AQBI&$oz^B&EKheJq_)*AbJ$$f#3O#%sAdz_osyTlBPqkKu3U~NM z{_F4YoKHVY)75H))>2=0ALT9tG5GArbNcG)#Sj`_Q7YFm>hTk`#*BLN^8SKb;#fCO z`3x|zEUv$eAC&}ij9!B5^o|;|u#EA;v_H6!Y=Rn3wEZ%kQhB0-atR4JSqfw7 z>hO%$6ySMicT;ru+70i5u1YM|MRE^g;=Jrfg&8uZNVt9Q>149w3e!-gq9`QS=T?gZ z6ar6SNOFMKfSSKU{Ee0K#HyoFzbwftrvH}a-UH9*PZF#>FYE~2?WOp!kvFjyn-9z` zMkYhUo9`$6#c2VnFTQtwblvYzSDIZx>CBVA=w7~W4K2LX7-&?70Hc8F-p*yh_oA=z zJ23vLsYCbW;R|htMC0G2KuZVpFAv1kf7f+uKBFTS^81euD5roTx2pY z2*j$<(dHd#bj8BeU`{Lb!191Wb!EIqs?R;q&M}95zk$7bW*hpUF+CkXnOo0$NqZR@ zx&!A`yS7Ig32lpWZ1WsH+CqsHDbpmaA@f_)Fk_ZityU&iEyB&;p2y`5j*%HBfe3mc z-duT(L*w07EC^$H!P; zCD8~Zc=Y9;M?gs0kY$wr+?D>ZN;mN@z9~c^0u;)KRrTX1G7lde~ z?SKRc05(qfSC$}qVLnK?6|#FTOIQ=#;J*J;FyqMy1;7C^yu72H(>ghmuQ!-=pA=XJ zQvKmIG;Io mfI(gSmfqhbGp_l3SwDXN#9IZ2z~5-lFV-c7*I-Sy}qh+ z$lpr2)VsH{BkjlTjq|&`x{sg)uc6SJaA)V$trpmOE;`%qHyIV{;^kN5>n30U=Ro2c(p`C?Ww?gk} zfh!gYG!jDEZgaOoLt7y)0!@il2{H{w`dTdymz<46a`Ia-S?lvAS1WD?@jS#LlG|+< zWAK5{ddC}z{@S!9YzeCw|83;=g5dS8H)`+nRoQ!B@CngO__Z^7sUY1Od$!<(-YW@} zqG&Kzg4p3PQa~wG;jTv1{ak}Oh8v5(g6u^x&$t#d(2$ z$8NU;^fk2q(RZxi>4Ts5oHA&(f4+rlvEdvY7G*wv-8`jw7tO&53@VcpO8eT>D7Ni7 zhcWb%;&^cG6(PuvVPjrGDzK;9y?!ym@RXWc|Da()Zbw)9e_{7zgQukd#L#K0suf3; zIoE+Y?>MoX{>QO9#+1c(+(eGoV--!_0AdP#PaO_l!OEbG1$IN&96m8>-2s43iO&Dk z-kHZk_5F|k-Z2`#5*4E)gp@?GFO>*cv(K0r``&^GDH4jbS+Z0VDNB+h zOZLdlj4@{Bcjo5qul0vRj)CMDv<}=+x7@)j77~b9^3$_*s!0uLI|6;?ccBMrAok*{QYI5r~CxMH3CjgfYbxa$X9a3s3~d{tRRE+Ih4^Z1m@@4x7b?W&XO0tr+3MibN}?g zcYYR4sJQGP^}Xf}LyY>2nsUG1FBG@Z(Q^m?9c^Ca!AKfJS+r8so>Kf?=R3HZmBk(u zxVjxyS7{2ss{PKeK8)O2O|N;JZuyyeKs(=Gy33PQf#gjhhgM0Rd2+kWqW;9Z1T|K& zL`d2bw(WtJc0RiDS}Zpci>PIMo5(%z!23bSSaSfP4A-j2s_clpAD;4lfAb(q>$K$E z0Y?RFGYT!bo#&AL*6N))G9PQ~1^2}dIBG0JJoB5l6ZLn`PE``t8hK+pkd&I3DpwiR zqc|Hrym|fTx4v1ToM@oI26LO6v-DFu&ly|4mesvOQ9Z$7eC?mBcgas18ms!t<>!E= z>6hJl5D#l{g;odow>Me*^ zs^Ij;-eEW53N18|w!nm5L4kS1JwKSmE**|Nab-YDFVD$36%jFZd%1tW_tM6GaRP@S z$D>o;rJJc6YkavMP76x>`xxb;uaR>)BIeut+?i2%hIm(ez$W8r5{?tKWXTPtEDv)z z^z2+Yn5FSrOQ-slrA(I=D9?!FIroyDOyytfpnZ(>P=~gbcIp`>;Y5>=ZjTO79u*hm zE|54W+0T?-h1cutO;bYZD1hM#ZH=dgh)t`-;`MTuH~Zv*ehJ1 zT}AFvGi=&th&9RacPzb~Ns4-zOT&kKojwquh%=Sx;sgm*N8G7rt&fJ>R$d?8H+a8- z?mE*03W8HsdTnD~pEK}xzFLk8Qns5@se?Y*vD?j9Ob}EEeq7$@XY)%h!x<`7iwiYh zG{N9>*}(&d=G1}A2o6)=D`)R@;b_IaCC02at)gf`$-8ww$Bd*8;_AZ;=`JDs(O%2E zOulHJwv}sEw^|B9Li^%mGwU7C2lP&G1fMxMr@*H``?@dsjrDU^viN@M(?hpNI+h>D zg`AMmAv!y2o4-;k)TTDQf*2-_ClS0~7xMRUFJ}f0@{$7f;m?{M+-y4`^jbRyUrg?vG$6MVP7Gv~I37XM-^GM<6 z_Yk%m)bM88qf5daR;Sgdd1YJ;iVuIzRoMU45tcT@ddgv4-^^YSgZZ*) zNGIrT0_AcJ^5tm+y!(Q2*I9nIU%}mcDFV4s^$R(-FW9aR9}KnUv+&IuP*Xwx55S!l z@0x#mSzs8=Fluhpx~)nlztZeYu6=kDl7;?!^X&ATsQYz6xN~!RHfV&1g(=9StCiB* zCEqF)Z1a1p{aVf&b4GXL>yL!TYQ@E8&&lk@F{eUpKO2IWn@h`LUtu$^YTt|`jbc+EW`K)TW&4pygVFIrPS@ZlSi?lTXT z?5~^vX5+Y27uS<-xrN?QK>k?6ioClfW<@1NGpwf4p=HpjvH|$+PH`}`ETtA251Yn1 zJ~3=E!644J!-{Jxt9pVG@}NfPtmB3KS^L3F`1tH)AEmLUhXUpNBCQOy`~%!2tE^DH5qYA18qdb1y)1q2>S;}qc2WNp?RYM4|66R;q#f`H=<(_DABUHp0 z$ui->l_p!!2r=mPul$5jvbDgkF9H=qRIRoroEevIL*BRxy_$U^ zj#pHFwcFh3_=`QWvod4fk9zPVoSE13f8!;4BtfWaTk-J@xik_c&^5Tu#Imo|_W}O} zDUn7^prcb`cYc=r+V0Th6)GM#;7H13?k=Sk2#w@R8#bW~u>x&d_m)K)Y(C!`JXodV zLuXhZ3oO8N31D*sq>B|3KRkX!55ioIn?BczX{DX-Knb_VMO zv@cr=JjC+i!SoUEGM&du`1#=VKeZG0r#i&@Ip)fYBBTqPTCSD5BX6M*8`$cK3p4NZ z9P{ov_j}t0wFOJfdOEvp#p|-1Pc%m4+#OMK`M3NTe7>~V=ik4fTBS4*Zik&OrO`gu z#rBK4PdBFfGhex{jVpv@sa()^+K2Qt*}+R*7p?VN;mfnqtnq1^o?IesN~J}P&-({< zKBvS>4&73(*~yKJ^k^vwT@I=6KKVuMc18C3AI~3*BCF4lG@Z}pYfN%A*pe*i3?h1Ctd>?23T&L)-+Y_fA&F^?BcWWztNC@ai zvfW_v1yhDr8a|XeBt6~fyY|SiDNX_g&wEX_xZg9QabNkW(8ZG{nO1sF7aL?FM`e-1 zW;jZ+L0QG&-xRye(Ozw}jS>U;;TU>kt!sZ-S)ds%eGT&7p;TaPTGw{+N+Cw-^FGNd| z_-j4d=?8u0jKNCx`{Ah7yPzIb#LLSy*5^1bhKH;$d$@F?mU^;wz)GMez$NRH^=Zjn zRi6KE0Q%Cw)*aaF9N@HT4{lK~F#qc61A5|;-_G&5C{cRU#a6DUjQt+B|L&}`-CD`P z)#+%W-1bh7XJh4ey3%V?{u2-{37ZcL$?A6BUC%2VUyw81G;V76(h?s8ZA~}UiF8bc zsIfx6s7!d*WV1ou(i{i1sy-ZQIfF#TfEQ&>`+me)Pan0WdtDgXP)iFm3;D%}c@%4I z^8GP%Pq+y+O|%W3njD`SR;xL+e_<%pF6 z2VR@f$C^=TEqHLJ9N)b0VTRndIA=i3P7PmQQq#s^|9v=EvGQP^m^Hr@cj@xsjdZGD z3ngsnq~~_nbzeok$=TO>;)iCFtCJ_o!!B=J5DPi(^aE%n<_R9Y<6{?ar~1Ir2W>x? z&|^x;zCVZA_qdl9`a zJx}!eG|&0y9^+Ye01o_v6TlpRmc5{F$F|ot{kOX36_aYNYoGfGo|#!iSlSy_;P-Tf z+OUu;j)+Ri(&MpS2VDUk*@DcU;N5=7$dL6LzvafzY_$-5>3{tac7Hl>&77JX-mz&Y z`G~putfb3%&@%@Oot!q%X8fMzn&x31j4_O^;OEYDyG9m%-Se(_`qwCl2|4}ZB zO0B9^e3ZyEQj|e^B3lD+)@eHT2lgtFv2UU^I>}D4UE9xqWx~KG*o9tj$1#h@JFf2<(sHG2OVPQEV0%x1~tA{60)4v*3 z`hzo7>!~OI8EG=w3v7-^>C5zimBZ9rX8W3jBcE~ho`Q%*BH#DQw-2d2ZZqE zA@rl-j`cO_UuV5V7_1iq#sU&V9O}kNop)mCj@na(sEwO@T7u4qu5Ino8v1So5nvTD z`GqexWXT6e?YrMp@V&6(#W#atLg}Ub;f07ZJx?=lOnwwzqVUM?iif2iKn+P;XyK6% z_rW=}DaNJoyN`1`b_%w#2GDQ4z}4sU<*9bQ8g7|W+siw5{lMpE!m>^$UL}JO+Z;^y z@wZi*dyjrN6(ndR2jUx)&iURS+@c_!w5Hn1S&SPP{_V9MkcQ)DmR-h$qTB-Iq$~KC z36oWyCWdP=gTU%ztr{?@O`(!W9xXe?x$>3EJF3n4QsdPEJ{WjkOTZU25xlI6HYXoc^@RGcedCs)6oyy2>f7f_f>kr+v^nO-+ku2;& zrd1_ZDqeO4Et7YYb4TR;<-8|M35{#gTahs)!1R@@*C~ZBfK}Q~Rc2~w*^q}NTg&aA z7~;HkjL%N$w*>5jK|jxPBPjoI)^+V~cW;9ErUYP#gnx=OcMiASElzI9ZH#8<-8b1) z{NtzSr27@SWL)&FM=v~;+suST|I$uY^jBTWs#e^IxDI+Vf+wH6kg=q{6K8bVjWF|V zuM8Z@v6M!0!+%!I&Ll&##=g1Vw!_*X6Zr?b>xVuqe$u|$X)V}#1e68x;6DqjeeW-Q z^J_eQJrm3M!PxBpB48N8AOdWq&17Nidz1TezB~{IBgwgGvV6$V|2TL}V^by~S81eN zst0%rj{ER!9}ujojhQYHsJZU z=~#dZd4Q4lz24&2U53Q##wt8xHk}ACNh%e2q_7Du_4~oY}P3Cl>CyFAQ!FT9iI*7i>KU zk`bq)K9p0xgm=(}19KVM3ZD@Z9~(C9#~|F4em_(`*mkOVc)RPui;uk(W``o0W1FzR zYVjlegGtrx4(gLh+KyscoYnNej~Qi$rG2|l7RROM4V&;cm@Y3wt_6k!(o(iwZF?-( z%6i*^EQu_xVI~*7X)J#GBMR~2wysd%{-*Mz*FpurHs;bcTK`z)o9An0V{m*<197Ukqa)Avt z-Pxq#W0NURZmoiJi0@`*?N{`4c`PJ-KbpR+86^=P%aW_=ez)jEyZn5|U7Zp&WG%~| zU*)Z?ebn;J2O0_5D~W3s`L*Ebp4bE$(vWIHtiC+cv!VXU<|?X z&#*jivhVwrHd%i+I|pN5!stowKfFR5L+tDpM-OjmVGbBDno>#+WIpcL^Php(A*8-jML zNVEZm9RLOJ)9^snHjd$PwTUm7RVLxLnWDWgxXBEmZz#0-9yCg}V1jI2DYdJ{d)6)U ztG_l<-3;Q8Hk`??x$>pm4^J@x&H@ib3@+q?YpfD$iVMbt@~rltTHBPEFD@@=6^$gc zaE|e#uuiNIQoio(BR=ygovO91jnY#&2=6Ek)%lQ))?&6S>pzGY>PwTxs?c?Rg=MotTHPQ2nv0LS1lrI9AqB6uuf6_#kg}U)zjr9_9Ws?n>5Z# z`dit#Pmc%kgU4EFuOeSshhzvKtIUB_ZdGv_F19pZN)EFmxHFG`N48LIu~ZXQ;Jx?P zwPp9;jy*pHy+5CGmn)$0*NMi>%A^Sc%^W}Xuq?E}$8QW6f=$)uUFQhlQZM9wiY(!UIsJ{uSoe2ML*%R8Mv)#`*n z-f=;efX5hrJwJtwzSWE7%dyz+EMs6iCg5;=Chpk~Rk&Mos`kd=9U1E)eE1X;n49qY znChR(C)`!0{i20t?WNmNgU(4S9ET8z!12RI?2Di*$a+4~-CprYdJFQaKF|m%G`HdJqkLu6sD(;ljiUGBg)&B` z(gmks_}a7zsA-4~Vp@pRw<6p70^EHFArJcg;*oBkLEZ%tkD#PEs5TY#&$Bd6^IYGM zLV5GX^8h0`9{&Izt?ATt?0bcB(%N0^g|YUh1ItpF^xYWgG##KBj`M#QW}Z5o0DWR7*XY~abPAL|ZcyVyTHiI+PVH`2Yrm#Ds-SxeLL6mzupQ4zbf54( zbbNQj5S7#<6+d}nQ6=bT<8f(Gl9Xate*$Ri{Xa4q8nFM^HXKE>XLU! z%5QsB8SmcCE8d=jBK#7v5B5LeY$NG|$?;>3%9 zUa&-(dazxs`FFj6>x!yPb3WKEMfoA*H`Kn?pG`O2E0`jo7x7ZA1PC z8}k1_OT%Ujuvr6a)&QF|z-A4wSp#g=0GlD~@4X{}QY}NpqHNa*K zuvr6a)&QF|z-A4wSp#g=0GlD~@4X{}QY}NpqHNa*Kuvr6a)&QF| zu!qeWV6z6;tN}J_fXy0Uvj*6#0XA!Z%^F~{2H30tHfw;*8UX9Tuvr6a)&QF|z-A4w zSp#g=0GlD~@4X{}QY}NpqHNa*Kuvr6a)&QF|z-A4wSp#g=0GlD~@4X{}QY}NpqHNa*Kuvr6a)&QF|z-A4wSp#g=0GlG$HpSh2*%FGGU2a$I0%9MNhL9nf9riPzd#VwNVpCbqm6mXLX zuc1j0gv($;sj(20l*WX|C1N2Mk(tVbJmMjk^FcBb{)9?^0BQ%XKu$~hQX!}cWJ>~9Wyt`i8W91nT!1k-2h=6+v5QCg$cz$EZIp+c$S$4A(>R_IwhKnppsWoA>{G~gGnQ7 zz)UibpUDJ=)+v;ADs2r&O`$PJWW?qg69;juZmiRyncR$Z$_9CZLXRQCv^6})oJ?CM zk~!At%(YlDg0Y4pKnOC85<^C;uB;Nth;`~J2|}#VDMT`~ieDj+!HHr)6ehR=Xf1jL z1dB9TnvE(ClG*!OXCOxBI}P7LjoEm#Ke*ah!{d5 z4&ot^h@`l<`1rV39Fc<%ON@;nz%fLYj-x?#1TJE1yss}F>>=U^EH(cd8sTJjDUujX zKoE&I5(I;U1a9J0dk1@$NFts9$3=rGNQjFjz{DGP2Za5lXdD4t7!D{LM~uT05Yg`T zcn2=K>)@*52=NeiHrNnI{`Le1PG=k;mViruxULi92vN=;2x%8UiXkLH$aq4$w;j=e z!`>sF091ktb&hdBxJCnc5fL8tBnQ|n7>G`ci*f_05KaykQXsB)aK(`>_R$Wovtug6 z4e}%q!=3GM4hUyQD^?~%5)NE@n3KJOgQFABE)iTdCo#my-o*jr8$%)yh{%`_X9q_R z`4S-pXpVG)70-$ki-QwN|93G!SfU23fK7uSZ5)P(hSe~s5C;aMu1>_j8WE;I&%RzqWA&@cua4T1DAXby}zh83WWCPFY8 z6N|=yRNyesI2uDjLm0d|fprupTMg_%AR9c$6|EEp!5H;uG*KPG;6MZnsMQ*1bsUI6 zQb(e}39+D7YopcC=ooblaC$XRs}<1dm>6{gMjZ_VRl;b*s>2u!ASXs6Mjh5f16_a% zB&j1b)HOh*RsflR{9#QXCO8)!9Sx3Plt5A-M>HC*4r_oU;0O`Tp{}l}4w3^|zz@tn z=|NnUU>d9wSZTy5K~^k1dbnaRz*>Hcw=h4sg?9%p;J0isHZnL1w$iLGiVOVr4-H)f z8`vLMSLkWSu0P<1fxC_IB{MTaI6P}WJLkr9pDQ=90f4M0k$wJtN`)fqtXQ& z|7V{Su{_3Q1zdrmv4Q?M8=krIrxNW>i0Do=Y>?a21iN{R48)Jw{fMtd>9-1`A%(=B zM(0$=#qphq%kIj?95d3CbCc+_eFNiB{(27Ej5h>37Fve50;Xh7O@RYs4Cx zyEJ90#m|i>JKFUX7CcZrpUS8z_*jb^|1^8>%NMIcn;=xtX`U5iqSv-j;XT4)w(f~y z%EcGcfA~)b?0g;8^fusHqic^=e@U<=a%;#8C5cI zQ@|bJjv1^XAH&AQ?K2TA4Hw6Mk>4|{lM)jk1;F`9s<>YwR@hM(cCW!|ZBFg{q3PJ_nyypMP~mXXwbb zBEMr5R#MN)yKU_UPxw8&0_h_PSJlq#d9h_1U&;v!vdd6S%#3Wo;ZHZegesiLztYBU zW6xcMm|CycdF8!C!etp3lHK>g*S=hi;^p=BMVZ(sg^DSvsO^$?u<-W1!TI=bx!uAWrFz<7Pqf@ZQ{@U2 z7P%W$PI_z_{>YH}<(`$A10&8iiv*50xDI?YGk+d2Hd6cQ;*6H~Z8M6pl-YaTPQ*+o zyrEF$ci;ELl4qpy@KBfgH^vo;XWsD&1F?gV{2hmgF)D+eW?d{Sh5 zWzMhtOjDkK)rRX!$%wpQ&GKv2&zKrpW?nw(A3gQMyy)*>lLaoZT5Z(>r5dWe*mK+9 zS?Ke{iR9tBFI$cn?l0Jt*X&#+Idwx%$g%Dx_l+gO z-%Pom8W$w1{~7pfQyOYLs`^n9mzusel}haPHh7ed+ZEZptx`+}Ua zpX(J5@avphfRC?@oR7;@ch>-aVF->Xa1<0Uw>;$@;_jyZZ=`M%&pAIIp8y>Eh>u@f zV*8HWR&w5fUS2(0wh9Pt6SFqARP}TZ@wWxre=|BS=j!F`?_Ve+j6>i!3PnV5NE|1Q z>zudqHFrlrF>uH|z|}dx*~{m$il6%>cRzP;SNC%su0GzZJa_IAD=5HmeU*}y*<<^k zoLC8M{aeFVzsE%RJDYvRVjknRTQD-YmcTzZDPgNSXDDMVy{v!wPHlgH;Bew z66fpt8CY6t|$7&y)@Qt+wZAlPtP}dv+`dhTR^SwO<-mS>;QYv3RXO0Yl>g(Jd5tY^9yO7fm6hv* zc$X0R{hpY_q0ITgR7?G;nGP->=g%l2`Oo|aeIf~Xc1G;Xi)=?-%>8z6LFXZwgf7hH zPn|~dvN_t+53P0@X~cUIekvl`Hw!mwq_hTW=TD1#Rzs8xrYDurpb|kmI zyRUaJQ-_f=>qB2Y*}7iMMrI?=y#XZ3D#;mJ(!2%NspeGWZ2JP~Lp}n3ww5G7 z4vH=u0)$(rgXP(K^ZV=3Ic~=T%JjE7jQ0<7b;nOYKlpr_u9$& zBOb{3J$Rb)J=l5#x+GcE1uT7LTB@$0IAV|>23E?i16{y(L#An=Ny1Ig(4fEN@aCgf zc@Dn}uV=dHa2tP_P_dRKDW6rXv6BJiofB$!F~T}nY{yGK!`nszV<^}Cr39*f0Kp8x z2>Lq&Mf1E|x+)fhLdI@++ zeGA%}RfmHxt;Jykc`_@FTyn2?=H0jP* z8<%2=*sRug*Yve)JzxmY)WZnEBp|+j(gl0pD*C6f(a++W$sZ}Sj1iYX=5ho-&#Q`b z{t5mgOMtPfogoreE>Z?$Zh&IY0pgM@(DZs)=L$&c zTuc-y@r8B|*wy#3Hmq#?kV9v0j1dYx@|&47sc8Y^;oYRx0<92D5_LwiAxeP^em>F8 zbY}h+g>P|~2i#?NHBGz(M4VqC0C%P2wJ%Jp3R-_v)!_ zqag5JZUb6|D2k!k>==}!sY|~55GlztArNZcT5$`?L&pNVqhh`>l5=F%7L;Xo&ck_9V`Us=j!tcUL<5G{tU29`EET2QW2NKamcnnKP}um`91yx6 zh#r|VbS#cZZtg_ZVROP=LAI}w@Y6IUGJepBt?wSuKE7{e=R1s7v`Sl#YHp8mi@b|t zg-BU5=2@gKlu&rW5O(RQ~6pStHHor*Za&PNCrFKCbTaxEoLs$GovK{Kf zaHxVTKpb2J_i8WqMS8J%w8-I-EJcWj3tO6~E)!dx#X@0{1`X&bb#+_uIU0LA%({>L zxulevNcZxhMsC4)sW;B>5B6>to~2}OO@YH$D@}MJO8};1@6k%B!<86^+Ulz54y46A z9w43Ivxa(n!f!cOWM~hLJ)JLf)Zkni5bHKM|7Yn@U>7+&MPe{WvqhNIO(#vpmVb4- zWN81=dMk5Y@$8=GZt#Y*vl%Pkp+WZ3Djw$a9p<@O?w^}b4C&8ax1?usk{2HR?;B%z zQBk%Jv5fSHL;uf=^kUL%Hy*yOKuMORmoMon$8tEd*?|KfqKx`Kk(TYQ?eYqh>>rT1 z2J>#U$fCb?3)Z8%_cD3#HZTN40QC0P>md15jaTy# zL5*t4%)8-VrT5h53-(r+@+@JAb%UpV-n`7aS?^u`lI9)PwoqRezm&eSZKYjxoXW#m zGQAOIlc!d=`Fg+IUV!|wmIk$(%%k_cQh{~qcpoI=Em6M60ZUhqa~2>hX_ZvJ1Ou`2 z%KiPZ%FBOqMTK^6$()svSBKW`GxejjzulSQwrGL(ff~?p-7vXKuDqng7(xm+uJ#n0 zJ50Ok7tb!X=BRohBf2_}pjyk3%mAXl_syAh zpL@-)Uc)SO*79=!n0evb&UnqVVo%G0?>JD^G?uB00s2+S-gI*y8(XR8fk(%6q0`Nt zKzPmL8M3f2FJ%i8!D=z#L!1H#EFB}wy-Y0tKS;V3mg&6cufWGc=AnHDMne*##lle9!3b+y@|MiHJc0# z{}B|Wo%R&$dDNx4EUWtOWW}zUyxDhKiDJtD<=dG2e}229Wp_h|i(Pl@Kl>c)V+Zxb zLEzAGhGR1WDwf&@JFDM*Q;@E#QeYG3S8Y?{9mpSgBp*4Os+cYMzaBu{>Y?*aZZ;~u zg6__4v6rTzc*rNyg~D*!spN{rd6;KTfz5mANA-_QpQ=r#GT;T6@IFxyBT+%mF->Zh z8O~@Q!GmjsD#5JU{#~t}gtQWB5RAqSJauQayX(pmPw&Ua!J*Myt40$|ylO#X8TWvS z%E=_EBHi!5cWm2tQvv1a%z>+1Di0*Ylcb`<3)Z#MJcN{|(Y$B*i-v{kaGUQ-51brS zL_BwD*1P)5+)Pq@`P(hg==7z_n2xFnB2VWs>mJR!XoCYJE~|MD*-MIgI8tmmbx-st zz`{Y0!&O5}xbXgV+=Tup50R5D>NT-=z;?<Aje?*B`mM9Wl#e)nlmZx)LGS>E+eK64w{p@a#zR+i^ZbQSG5ZTr*?iu zRht5~_`eR?Yh(x5_r80WcL}N*ORgoF(1|;6gRONw*p!VWuS(@USM!yHP9SF%JcA4J z;Wmf4QtQkAeMu{tD0JNNv5In}9Lw~lKaGx#Dh&dCe5j@?$6{;y$^O}y!UEc@9y$`8U#Am4V3lHQ|4R*s%F%-`Z5B_Limg+a%>t6`$7V6;H0lF?7@9VUYJ^Nt6KT z!EU9`>iv_XGs8=|e3`HGMC2YCWf5M9aOEZ~+;tBDReJe;D!^6@Q@)p$mxavEkN-vn zJ=^ajMGI$d+Sm^VO+dWcAQ)b^AQdw_T1VnuXzrK&;Y+^rBBBF(OdWI^Z3hD_D3EnA zcSR}~)B%{ap}Rap7TFk>e&e783p)j`$TI1LoMYnqts6l5!aQvI&MK>IDD|Z>5$t5p zQwjWf?Uij=!l|@NriN+_nXe^HPUbx5xQI7p^@7!c#XJcM^@7(`LRbVARvinTA%cbk zN*FUAF7-b$qgXUW4yY;jX8*yd_0%cmaJ=EKLf6z=B-YFgr&~RQRVXgG;@o!3Fu-2+vg_V$N`9zuZr|BpA zLmpSMe9-oCudRX+6l1|+^@6wbOCWpMhosSI+%%_y!3g*X z3UIwkq#Wi8LKLHeh=@I;2LQu@A=vN6z;<;iAK5fdsvT&I`B%&VMCjsJ<*JZQL-qXH zFdUT#un%=PfiM`Q0X*TYM&$EvrH>ABmbJ9*j*x+zr zQJ%Pw^_(0b1+#w&Ppt-8B-fg@1juq~5BBnKb&gz{F1woFCK2<;B=@?>+))>omq-}B zn(hVeOL{3k${pFTJcXL%w;%e&#p9dNfIdpTJ%ViAGPgtzBSOObkc)6)pcE~lWib%!$ zMcXeg_dmV)d=AMkwOP=jPGKR#T@nw2ugmIB#*8ygn<5aNk&L&P-Z=PM?YYfrzChva z>Ld}!-+B<&8|>_+TVeVv0^oYp*+QyLZyjI>-^jn;Uvnmz?*~IZ!VgWyR(b7~Y`#U>erWEmPXcxu&LhM+ zJhoTTDHQR}bJg2+#K8ZP$7u+~Vg9AC+J@awXZ}`!u-IJ5u6F)_eAt10IPY`AVV;)w zQaf$--eUd!%<@NF5K_G}vRQJckEWXKh`!h3dNP4_DWxj9(``U!dMYg%kR~Cu_H6wo z^gI+`fO8)&iiqnn8cb70b4tNpi&IQHg%A%twx9;}3Nj)Cq-#LoRVM*@rY_Tx011n( zu|W=~JnkrP)^;YR;52ZfEd2cRG2w#AEAf1uESr&&hX4zhW)|BZybV+LW8_v^=rqcnQF@iHrNi%3C|!8k)D(G z`HE2Yv9IKK^0DOnMCirwSE3nrg0Z$eE;s!VIdCXlxXB{%#wv)4P#1KMLi_+ZmS|=y zr!2Q?v0b+o-=B`1LZF=PvgTEcLfi3D#-Sk|0f*)f+8gl*)6}7w;n1Q|D(R_ytK?9T s^lVYL-u_s(rGlM5fBhf-UMRZGdDvsbuOptA$+3yZ7oC&9ytgCfkZ4X zU%Una@d<-K;9q|T04=ic;bjm=nQnQ}^hUJX^5lUw7oWJjkjRUNbe=U|{p-!0km*BF zF0Y%L?v_gEG#$a2U%GyLehM})F`=-}?D3Vy5j9Td93DB%XN+E}I$!B@nIBmfhS%)M8vcE`t;z?abOYuV?Ga27(Q}2l1wHW z*jZaoRQO>m!u5;d#+QGwooYs{*lbek@ z72gr{jnm05Hc`u+-Us^BnIt0f)^KAcF{s3*bC>mDugvf{L8JY&9j)Ckf6ukJ$E2vl zZh%=-JO|S&emX!JT&R$HDMY@clTF|%>AAV+Z0s`jFgv>@YAE+~Ds#^>eSvmMUj;RZ z3p3PT4%QM% zT|xM8U+wI)$+|liyRbjt)#BO$gjtL|;_5cyMSD0D%BKa}+$&JCGwG|dBdvZ2zY)*H zYv-We>foGe2t7I!45zSc zA4bW8^3X(a7r)&tj&q$eF0h;R0j>1G2zxc!Azgfv)6FW|s9}}qQKrs)&87~1{}vv< zh2BBhK*hEf1{Y_??o1kW8{|ZJ6ZH-!iafO02MTjWTu`Y?W0&C^oiIurUMz)Z+xU*w z_^e@OV10h8W)UX6C&`m=v&vz4bfsjW;nMDcD9EeMpL|IM!ZLc6@M_!;+Bq^h|PYNC8^BG z7|qfh9BcVb7bJzT7Tjw8)KlwrFbunUck!6o+&+)_M$50?KbwiQTR{)l^*kMBgh7;4 zax-Lz?PA26!W3b|1M;cYvEEN=iG);UMI&r`J3P3KqQW#>Dt`N|&p-^+_aKx$=TPNP zbB3v4SUpZqdhc1p?f-{pBuJZhna*wtebk#zPu*N+4Xh>Y>})iVW{Lz01VO&Go6VlR zS&HMeuK2fw`Fk^K3ka58Tfm<~KF|Ym?%bm}{bnus!+7ji%-Hbo@Kn1Sv)Br)HaT_R zDQMyhH3hI1hjfmuF)9UetcQG8LI%AXkvXagC1NDeB)F{67P{T*j4v$ZJH}S zz;1lkZRr@f?Oq)bOV$o5EG&dfe?Sy$HGo0XqmPabO(DMbu+0~~?(8JWqF^TP?rw*v)P0n#2EI&B7Fc7Ny@E7?SC$vk3xw1plNY7X2hmZlcHoFf|$ zKRur$$lqvzh$lR{{pH0a=USH*R=N$@P{EF`j`;L_1*`1mUmcaI}+U=LB1X{mhA4tUb`u!xct zZNXgx!Iv3$b&|X8;8I*Nc)6;e9ASOT%V0Re6!hs9qCmH6n=3sZWZfOH1#gRSG}Us$ zp|tA4$g7(OL`$++3)*5gZ=Xk6HtrEx-Q8{AdbAA^6~8-O*>%YSu!b+u!*<-;-sBmDq1yYI8{1mncF)ke-m0MNUH4xmmP#~=6=|*^*4lFp zq=D2a{M_s>ux2bhY*F5L^=FaT5g!We>;{$<-6!x1AucW+)3lAlL@Ms@RL-^OZbC(-@4j)eA}Y2|`GKVFvkqTGdL#a(tVWQJFo`vvNR$^$_YQM> zJcPeKB^$rwQ~Hx4-Ra&_KD%K2;B%hiz`f?`!oTuIX=h{99j}1`PEhBP-}OGGn(Hwy zS5&wQOAid)%I$w&H>9_@SrX=wJ!flN4$3q2d6hf+O}l?C)Mc8E+P`{}b3T`Lo@!WN zh4tL1FsyYSVQ`2Co{SG?AV7i7LnAkdIVw?us|mtmBGPXi70_09ZPDHAa|pvdM<=(H zKl7*Y_jbPu&iFRZ3YD@BE)`RAZV50_dT$=WI8x8L2Ew=7S4 zwrwFTlIUWKrYilV#1`G|@@|6)@C*$<>ubFY?2$RimE0NTcfbU>77g^xadYUR!0|L6 zppM)`IKLga+x{Z9qX3Nb>+a8MI#2ZCVhp*1q&ZaR$m^TzkVw%ueLBj!B%P#o|4~42=va9SZ(kFNo2n> z5W>U%h(BGhe_H$WWLrme1a|$~8-H9C<*&}R>$btx>nm#}1*!#)LU$2HQi_w!q0VdS zZ=GtCIORlsxzRYXrqnSWaSw6!`#i2U-2j-mr+d9gL&aY<{AhMBPJ@K@^2g54}uwZ&w zlOJAzvD`ixxNbL50nQUAj=unoU#w?liy_>AO%jG$7CftNkz3ffFIzJF>(R|lT~g+^ z+q}4ALvAxpWRN)LpEmUx7}WHZ?|M>;I*^a~(k3D(t~f-?wu{?jn;}2rp=_PBW}m{n ztpRJ5#`~zBJjhf{In&vb-G3p8EL!8hXpz3r?SuVUC2wXu1j#uU?c1V%nh{EAG-jL= z()g2F`k%Em;n!Z8Wwq7G=rS0SLVr62=shl6 z5{D_@JfH43VrDrM?J(eXS*cu+s`&$XkH7C#N=k~6X0WK0NtIw$Swmd_%kIkdu)mDQ zCt>4%bRk`|rgW7PWi4Oput}&V#>PfQ?P2Yoz=3ZecPm;Xj*i5??y1IM%j#k+to5JoE@&*yH_G#MS}dSq{~o?t1q? zKoe_>Ha|bluX)SZUhIe^yIkFS?x#0X6C_hJF{7ARHW@FUO`@ov1=6=F)5R@>@ zc$O&I>YCKcr4OeEJ~MPCQ^grYt+O962bfc}t;xa9{v~vK!^PGV(#eCmEEwY5h7)(Vov67rJjA z9=fo&7$DX0^vzz@y91fE7*LW5`lc!ma!AX{4a{tO$-IBUFMd6BE#HTfyX<0&sfO?C z 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 V>cxt`kmog7Ub4Mdd%@$u{{j=u(k1`^ diff --git a/client/ui/assets/netbird-disconnected.ico b/client/ui/assets/netbird-disconnected.ico deleted file mode 100644 index 812e9d283d096d823824fd66e6533359fb375b4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5056 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 M);HIyz`Mr%AJn`~GXMYp diff --git a/client/ui/assets/netbird-disconnected.png b/client/ui/assets/netbird-disconnected.png deleted file mode 100644 index 79d4775eab038831d38abcc2845f671adc7a1b4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7537 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 Qf&c&j07*qoM6N<$f}PFco&W#< literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-24.png b/client/ui/assets/netbird-menu-24.png new file mode 100644 index 0000000000000000000000000000000000000000..087c1c2aeef6cca5bf3793eee16d9b28acf758ed GIT binary patch literal 739 zcmV<90v!E`P)=$=#01N`yEPK_TG}%M%Uh+ocbdHf3UM#MBP_#-U1h8N-&mD!@!NAz~(@A zi*mxFqD_;sC1zn~v>{W7SqlgICNf*NT)OiLs3)qj`?b*KE`ajWU1vpP3}`L`7J(Th z^3rnL_eO_fR9&s~da0=)03{;i(p`51(v^E4_|l41o5yZl1yv^AA~W7$=LFX$0{EGt zcxrMGcuQkuU$R=Z<7aO;~vvGojL$Jq$ z5?&?leH`C;w_^;ExSqTTJS&-gIf(?j*DmV`^lJUit3WApYj5vvpZ#LIj`qvIiy~3M z!K^ey8N+;Q*x^DR*QBOrD+>VhjA~COFQMcOfL?4ujEWSHd<^kp04sT|B101Wr~2RK zywJIzzbEF+T5Tkx=wc&*Kq}ckx%TS<0DQh-=Z+wS-AS^O*=*g@@rpZ)bl_Ws z{w;C<<%T%zOWNS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5l&DCJ@J#ddWzYh$IT%If%U}(=|V1cUf1JWSi0mL9Z5Sn=bBUCj|YylHowa@}) z1RJDnrCM7Okm4-xh%9Dc;1&j9Muu5)Bp4W&EIeHtLo5W(2HR$cIEvKer+wR0-FY%I zNP<<+MZkKakZ1O4j*Y^XZrofVsArgXQEh5(>)NS|x;bk@ws2+q@nm9jc2H`n-+83s z-TgB*_ustLlfIlWy?ncQ{=Mq^=J)rRvv0W1^WXi#`EM%|>o-)&@Ko$#U^~?{qN%PR?;v zZYzDV@sB%ai^bOYOsdaf9Ar4|*ZWS{H^b!_>!v9#!bfg79a-+)$-1#q_g7;=tawB~ zrdLtV12ez9Kc6N2+`0B!kb82O>a{OgAF6_4+O`S!99jLjT;fEFU+DZK)0wdux8Brw z#w=i9TQ+^aK+1IC-0&K;#Zy>IBtpYPj=Xquho8l;^&!{$s<3HKcRxG(ly}0TptQ`; zGK;BGQ;(j?{S?gExwXG(=F#O}-{>h#|8ToI`}DyEV?8S#n=M zedo=+r=L$_YLK~ElRKM_h}%XV|4{ieeyzdp b>pz)Ul%Kp@p#5<>D1mvp`njxgN@xNArAI%^ literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-connected-16.png b/client/ui/assets/netbird-menu-dot-connected-16.png new file mode 100644 index 0000000000000000000000000000000000000000..3a7fa31a4031def909d9e51280e930d2cfa501d3 GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`3dtTpz6=aiY77hwEes65fINBEI2;2C9rtu`46!)dH7!`Uw`D_l zW=8gu6$?3zZ)>}^lw;Fg#TT4RTiFg9uCEUkk?N>U|6}nyn(ssZ57y()eV>->FAJ{Y zVQ^UZYFlu=T=?6En*$7X>sG_x`?vobW*HZZUZhlH;S|t_@1`rL7Tk?SF88qNFlw{_n k7MCRE79jK(h8UVy8G($4=#f;Ez5~+a>FVdQ&MBb@0E!Z*0{{R3 literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-connected-22.png b/client/ui/assets/netbird-menu-dot-connected-22.png new file mode 100644 index 0000000000000000000000000000000000000000..78b068748396091b655aa0bdceaa1cd23fd612e7 GIT binary patch literal 615 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H3?x5i&EW)63dtTpz6=aiY77hwEes65fI$sfT_}}{C)aurVgbUVw ziV`WR_Cl4W#5tg8YIRm^k^k6+O!)FsqbZ z`t|Q0i~a6DOvhDra{X1h2$Z@n$9n^)lrhQM-G!lpRn`N@;VkfoEC%{l6@(c*gH%2M z1=&kHeO=jKv5N9>aUJQH#tsyk;_2cTB5}F&VkBRSf&j~fGG-CR!uyQx>pdjXJB?oc zs!?$$XRHxA>M7*@!Dp7>DGAlcs>6F8ES=!x8Td)*f7a61$0S}S`vkv!wRY{hJFV9n zUzb!D+V430uV}}@`j;ovlk*LQWkr6DvalAcE-e$*gq-Y4UXSb6Mw<&;$URm(~pc literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png new file mode 100644 index 0000000000000000000000000000000000000000..fc8ce4d857b68466807de884b622b0d22ec2c6b2 GIT binary patch literal 452 zcmV;#0XzPQP)m1pa;b#dq-Q`>4`S0@rt4 zyL%0%4?r$&e0F;~!{2{I#SI2vX8KbvrRs_76L=~Dg*hl#$Tl1V{{CYDnE^5j!vau* z5*z0D0LToGSuj34h>Y)6Jf?;P4A4kFtK1>W0P_JTom0zj2B3+ literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-connecting-16.png b/client/ui/assets/netbird-menu-dot-connecting-16.png new file mode 100644 index 0000000000000000000000000000000000000000..f874706b5a357263a61b176ff373d688e8b164a0 GIT binary patch literal 520 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`3dtTpz6=aiY77hwEes65fINBEI2;2CUG{Ww46!(!oFKv4bA#pXx)mGP_W#Vt{rY{sF*7ss zVZ*dzd$V1C^uN2b^YiQljCZb^M)d8in|$>2pXE^>{?{9_v9Yy%@99}@_dhjrtB~l- zZ=s)$$)|kvdBNsW@$*{S^q>AqU;O`{Pxjk4FDj&iU|ZE$^->9%h|?|H_5_ z{EvSmKk0@&(42$+|Eq$;lrk7rG8}FYKGXC2p0Uxs1gGFH^Co^g%9(bomW5YCK-})r zPkry-*>1OofGORp23_C4D_>K!P%m;c;wZt`|BqgyV z)hf9t6-Y4{85o)A8kp)D8H5;`Ss9sG8JcPv7+4t?BwhR|jiMnpKP5A*5{m`{hz7?k vc|i3H8gLs*GILXlOA>Pn5PA$l3{9+zK*mG#NGeL-0crAd^>bP0l+XkKnYp#4 literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-connecting-22.png b/client/ui/assets/netbird-menu-dot-connecting-22.png new file mode 100644 index 0000000000000000000000000000000000000000..d8e5970f5605bfbd8a1080f7fbdfc78bce8ef0e6 GIT binary patch literal 637 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H3?x5i&EW)63dtTpz6=aiY77hwEes65fI=ur6IsHAsMsIXOi_67NS zizNI0=P+DX*tbr+?3CcK6a1eK^M5$Zf0x1c-Wt`0DkY6wZ z6DJ?HqG#CzW|gu_zyAGWvETiN>A1>HuCl*M7nwXc4`pz^1i3TG+uensgH_f8$l)yT zh%5$rUKNBHJ%dy}00r4gJbhi+U$Khvad92#n8pqin(OJ}7$R}GbfUXZivbS{FI!F% zuUfjy&;P>WW}pK+vnarJN<6i+sxmICC{3q+y7m-tn)QA+tQyyS3CGw zThi+$vqN8Fv;E9kGV|(AXttRcutadma2#bk;FD$dAa-wUHBb-Y?4s9INRpm zzi&5R5Yo$tOF}f%p68DJ{Q^19Pw9`I|Chn0MoyCP$)oLjGgS^MPI$fg;1{AT=zeJ7 z((>oVd|U=&v#5qeBe7Ke#2$hc+zZtW5ifeQ{Lfzo7GeyC0TyP4KQH^_B9&HOUW3yD zkjt~4UEfZs;mDRulhX7=_6a-{fx;XVEaVyv0wBF0GeBlxSOAJpV#6FC0GRh*@0jS6Y*-WY7ATvN_ zVORhv&p|~lsK6rEaFAY*8OU`5&5LV{d;n4aDso8)E|6M~9(2R8`Tz!KT4kaGP{9Q% uTVUBAl!-v;{P*jP7l_eMQve760RR7Xh`~Y9fk(0c0000NBEI2;2C-S>2H46!(!oFKv4bAx5CxNZ{f?Oj)Iz4>3C&c?>p z7TnWQ9bQ>2e^*NU`|JgbYj&4!o)fh}q5J#W+gS(q*B_Mtf_uis`+w=^+{v0Y!Qk!o z)vx7KzMgr(cIN-r(|%9?{^nd%|F7e4&V!%d`+uK5KfkT*`ntLI3ckK#PWkHdf^Ej% z|L;|Pd|!XWUS(rF4-ZdM{eLfzSj#%O!wf-;ZH&t$B){)XPpdoFvh0Pe@#k)!gMM>J zO_(&P=*i{{1;4Y!riK5Pkd*vqXk@e}EwS=zWW)|zRn?~)R~HwG7yyIH>PTd3cQy+* z(5tE?t`Q|Ei6yC4$wjF^iowXh$V}J3RM*HL#L&#j$jr*nRNKJ7%D^D$;!kN54Y~O# znQ4_+G#EfMIBv-Us%OxE+fb63n_66wm|K9*V;Ev+Vr2v}9->E5QTh%@lc%en%Q~lo FCID9Cy}JMa literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-error-22.png b/client/ui/assets/netbird-menu-dot-error-22.png new file mode 100644 index 0000000000000000000000000000000000000000..d9bd013d66ec215d5926a805a2bed2c3bee2b026 GIT binary patch literal 629 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H3?x5i&EW)63dtTpz6=aiY77hwEes65fI1Tq2zdAd=cXkHqdhg=$*~#gnvop}TiSJae11b5EAirP+CQd$X zMbEMc%qnGIh+L^k;On? ztAa42XOPMVpdfpRr>`sfD^^iHF0LaT)7XJRb39!fLnJPjUcAZItRTP|z@n?c^lta2 zfB((H*7|Nad*`?_)0vo-buKdmWEQ4Bdg3a%c)iNL#91F>JeDpwQ8}${tH$am>DO~i zcda}1XtD3@pBo+vO4nRBQ~%z)|AWm3=lT~XmOsej6qYZaC!YI0=c|$Omb^nd+ zwXEFYzim>I%ZwuXz#a#l7$zIe8UZPG3ArfI4G}BYR&4p-b$^NoHS@Z{5Ey z+66*-8F5LxTD6LY`O%{Sg#!oDAN}|tgH4Sb2gj2K4h}Q_%E?U-3k&-~v<1!o|GRYg z`p(B?FgAWn|n#(1IK1&xc5tl`a4C|36W|2LmjOjDMbXbwz3z8m_@<0m$VA zZf@I2H5}QJ3A=WABKrhx0VvFs7cII@Y_Q-0py0Y27pDn}6L>NOMJT0)g8;}3kXbN3 z+yYQ)q113}W`QjLrE_Yy9tJ>Wfy{(k0LnzvG8_hAW`Zq%n1JdYyog4?yKP$Q7gn7f3Bg4|-j|Fkn?CI-sP|r#S!w b00960)gi!0=-XYW00000NkvXXu0mjfUTLV+ literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-idle-16.png b/client/ui/assets/netbird-menu-dot-idle-16.png new file mode 100644 index 0000000000000000000000000000000000000000..354b5b860bfce07d9de3d22f59ba27cf77cd10e5 GIT binary patch literal 490 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`3dtTpz6=aiY77hwEes65fINBEI2;2CZS{0<46!(!oFKv4bAu(idfLWMA2&z-|MmIy%tj#S zZ7qGjynlaR*mRe_Y8ejS_w3X;G-poa|9=Hx#s&r*j;-;A8*B1wfB*fx<@^HVa@DsgLgQm@Ai)Sv;kp(HamwYVfPw*XC#5y*ImC6bELcYt~rJYD@<);T3K F0RRG?rY!&f literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-idle-22.png b/client/ui/assets/netbird-menu-dot-idle-22.png new file mode 100644 index 0000000000000000000000000000000000000000..675cf1ffecc5849c26f3f03d5a46500b8fa646f2 GIT binary patch literal 602 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H3?x5i&EW)63dtTpz6=aiY77hwEes65fIP zpymV=oH~C4P%(q(r0U7Lfa>QgUO#8?2A~z$v5Uollw3)WUoZm`Cm*+>XW0a1m9k5} z{;}Ba{=;N{TxBQMU!{vooHvxj4**p%CV9KNFm$lWdH^|`1s;*bK#!_|Fr#OX$_Jnz zdx@v7EBh-}Q9drNBOTM&fkJ(rE{-7*mrE~3^0g=kuv`ce31Imid-3=Dpp-Nty_LUe zR2<3$YlT~SS{d&OAC2+QtdNN{@C;qn+kGx%)*lo7Q>Sj7J$h@E&ZquszPqcxuvg!{ zGH>_4^~=BCUD+(9|EeaW`ElmHm7gn>qIT46dd9u>P)@(ZMxn|1(|8Pbu-I_gbsS?n zyg0P+=pw0r%=caVw=9$$kAJRR26UNfiEBhjN@7W>RdP`(kYX@0Ff!9MFx53O3^6pZ zGBCC>HqtgQure^1sx)moiiX_$l+3hB+!~(L>v01$XuxeK$;?eHE=kNSK+|JrU}b0k VvBW2{))}aW!PC{xWt~$(695+S(e3~M literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-idle.png b/client/ui/assets/netbird-menu-dot-idle.png new file mode 100644 index 0000000000000000000000000000000000000000..79e7bbbf8cf94aaea503fc2ce2162a6b774ddbcf GIT binary patch literal 483 zcmV<90UZ8`P)QaLM8!3|&OXkUd_z^KlPc|HRcIM#h z=nI7OGUAf>KXodP^X@AJx#=ogKDhlsG~~#(xy%3l{YzBv z!ClJC{Kv*CIZ{>N^%|T$0J)s!PyBjP4F`e$|Nk>Rxf>AlN67LxvQOYX0EPJmC4=j@ z!kbtCikENBEI2;2Co%VEb46!)jC6&7I;J!10AXg!i=6lDj$G?>?NMQuI#T^ zMfte6j&w|82MSH_ba4!kxLkTMlCMQUfaO9Nv&hXm+r#lGhMP0%F`A6mE?~cb8I^v)~X}6 zno;}h{zt-EQ7Th~?llGg-KJXN8c~vxSdwa$T$Bo=7>o>z%ybP*b&U)|3{9*IjIE4K zwG9lc3=9HVS6HHG$jwj5OsmALLGgw_D^P<5+=i0O+|=Td#M}ZjJ%$EWh6WHzd@^gD QfqED`UHx3vIVCg!096^#6#xJL literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-menu-dot-offline.png b/client/ui/assets/netbird-menu-dot-offline.png new file mode 100644 index 0000000000000000000000000000000000000000..7aec5d01da5b9697291400ae9b341a6f3292102e GIT binary patch literal 456 zcmV;(0XP1MP)$j9x}u60ho4 z@qEgOE0{C2BmMKI4>H))@bU>fNy)C5$v0)u1hK}jFGO1~Y3)OoXP4K{$7L`!i==b| z6Z3-Q_YkzeExIj4UGmTJfB*im5MwwDFflRxx%r$Wa@W$TH7MffE+@xu5CG``=>?gA z)d!$3?_+#+o!DT(1wg@7^j}I77AJ5YfFhJq!$AOK2FNTJpBcvB;_g}c>*o(XN-Y3^ ztCxJ#jvUz0&j9lQC{0tt^)LW33zkp77F<6v*MwSz!vM@oumwjB?4X+KVE|?(eaa#m z2ahMzHW+3m*aA(PZTX94#_(OL&0gr!!rYF)LcAnsZe zYu#1iz)`DU9jK^8v7$u;1VUux|NXw?VG@#%@rVJF4`1%xy}SF)yLaRJ?h(Qwmc-7E zKn~=t4OXDpGSy-PCcXvs=b#FqNbtil%qI8+5Gl5_|NfqF02mH-vk;@_I|cGXt{Xi=mzE0zT~fuO1mc8IVCK!OY$ zWQPi^Wy*@-8bN6t?2tSHWiJAVrO*`#fc*Y+IV?+GE29I-xdIeZ<$1znSFaog$d9yR z9T?JlsG&o+ogE|;yH3p&yL#p%12`ZZ7$PXt;g9!Ze}LPGGb6Vc@?v?qj;?T>%<;*M zXNTn@Po*@;m(8<;@<%!I^XRg2A=@txWiwW`41EpqGlP2v0L1KIpnp;+en}k=r|yLa zQz5jb%VEe4req%>y-(%iES9H%vxve?*AYUOXY=w=Z);@B($}2nlnlk3d2(g3jy!;7 z6z&jV0G+=~=%06uvm~#uOuEEI4oJ(lfcrxLW&$h#m;*2zfYO^ZF0qOCwng*Ni{?XJ z2z8)s?*X_0AZz21>dG17o;ZOJ@d`rt=7c62OW>D{fwl7%JCoCVKeAQC_6MX#fTf=F@m9Kkikv&$y(DOZTI2O z9wOC3s1Qx8^rr;3?lgo5rvq-fEh`IsOA*6`8r(Y3AnkK+sh~j<+?4)-E*d|ETIm9C zyX%M_aia~=cBl^xF=r)YY%-&N_+EE6cC!HD_U&F?ne@sC$Si z8l=y>ohtcOkcX>u4j_lyS1oR&BN@O_kp}6QW0R)1seITU$;ch;Lc{V4%HR(XuF$Zo z91hMLXpfU~#rRCnqI@>wZAjAq*Ne3%OXIK`Ev6TRsu^5s|#YXgfHf?pTauBcd*P5SV9Q52tY)LV-|pb5L-SW zRtP*oV08k(oUp`V9snQKCb9tJe>jO`19$_Ju2Z!5IMykEWz$bX3BcT;FRZ8Z)iAqJ zWdqv#1LPq95B-gbxN?Y*(w+k$y^fGmS$F`N`=ZQcXs3BVJuqfV@QlT0zpoCUk_Moe zwi%jc#gH*u0=gXlG69s0QBe?W$~srdcs$ z#Fk+Gh4W6FAE}Zzovw~{bv8rwKT6sH^23NN0qq{;&|eHT(_V$m)CM|Ush|&S3H5@Y z%C7XbCFvaaOgTKLlfN?CLAjTR?ZJj&XGUe7q7eM33)nG34q#pxpXb>7OW*J z<>ANI?Z6{I{ZGmRtt(}EP|3ggnyK|a+;^q>9J!EM_jtedqLPR5aNvF5p+am)+Mkx$ z!{PWUT|+IG2h@`)U6o@?uVAcEt}d0y3gq7(pmdMBoV8DtJW#ZI=G=nyGL?B$*b>^7 z&NhTR0sV*3jyT`Ruh+@0-N{^+p-+Z(YQ2v$GxsbzM1D=5mYsqvDeb%HyqbnUdkfC@ zdHFzx+OT$&+m+C=n-=(UovUI>T2-d?3)zGU*KgN-V_Eh-3 zom2VP{uKR>VPB=pIxdZ0lfH}2t1blE(f;!(znzI1%b(^))>lW8CciJ?rw+Hi_kiwh z0Jzpm^_{49Ep<>PKlL<<(vGK8yuajf-%ctz-RV=9{uS!Co=?!*12F?lLri@2-5`3H>~0f!e;N zOdSfeB*56nLY8LwnjMmhwBsIXd39)hAAAi;{c@&&%q8P9&C4sNrHs!CJR|Vgs@kVl zc|97@0rJkIWG?SBqET5Dvc$dXdbHdzg!mkQ(z%q1a@yd;{buwN84Y~__Q9ooIt%F=ngP8k=Nq(~ARm^~R3cSIDjE!je$_v@B z!wU4HKSd6JI=ZyEr_$%AJbHA!545)eI0L{$dwT%72d{U-{@@vjy66Rf&q?{YkoLM+ zXjtmyv7!8c2i&8=_Jw_jwtH3H@->=bCJC4%V3L60Bp~q`!grhSCkeTdu*6Xk7THQd zkrjj`#5~Jf5(>b1iHOlHi3)9bBoN{}NAO(2(3Z!COEKT-yucPhfq7~cyk8|2u<}LV z!GtbLMJy@|>;>$KMWmM_#*2u9Z3q=sx4lAz#43}zPY^$pBZyxtkcNElZ^AeaE1~|V zf8av}o>YW@9~BjXFBL@>6sJM~hK}&Q7luF|Pc{TPsUH=>KcF{@1oFU<3gyI?h62n3 zajI$vDU=Yxk4o1lL_aNe03JZ8pB5y14DXWx%ch@60#!}|u-CL0_9hosImR?#2zdvf z_RK#v8OZYfvB^Lg?;nc{)bVae8A#V~-C-We1oVPEM7ekTlr?`%csHmFq<#?~18vrF z6!W4#-9%;l%BE?~UziR?dVBN_nTB z6Gk9|T^iZ|J_GfUK^fkSK?bnj&=~mAX5U8_8K~slIAkF7)4~SC>;Pdg?0su8{-OFg zn#c0O57idH25_GyMI#vml=|=#gAYczGANH{#rq#l2Gl;V;y716jy@0a{!3L&25~BF z0C@*L4_O~4>2;Nn0r1`tpzQPT{rP>$_{TJ@WPt5PleVK_TPU}0RcvGG;=n)pX}q_4 z^uJYd=7z3zK=F?9(okmQ$beDaQ7$kyK%Y#>JBh$Mod2Ur=gQL6+JOx3blGBdKwb}Z z`oMcCWWYG@G#$V{?$wKR^!ZgP10}pu{$qgWhKB1XvSaHC;~izd4$iTJca74O*f_1X z@v70NrsKb%Vg=VX@0V|n)}8K~qPePrPr8!7*(Fagt zKWL_91-QKd$~^n=OfNn90o7%vfPcH}b5?SFglWDssSloc=mV+Te-Xz%s?Xn3&7TUJ zr)8Ub<@%h;@}TQA;6gt` zjn=|djQw@fKfKSSf_FN7j;=guS`P4|Ut;Nda^=uNowY{mPTx?2cSF$cQ_fFXQ@%9G z2kjkwpSAWa)p0y1ZzGcPF=;)k3pZ)Pi?(@ycPHQ-&z4h%Tlqa5-yxH=7d%5&L)`c* zQhH{Ewmj4Mp*}osB}GI0O6k=09W+_qaXh6Z?l_uueM8u<`~+~{)e?VMdhvOv{X4;u zxsGP;J8&zujM@JF|P)Fo+96LJO$dU&VKmt?vu(h zUG=er^4_77m5&x@gwi}KhC2C5q1=GS6Z-I}sw@ZJ!P7+VWzWP_lYP76V4fr0E}P<=TdW3 zxW;!<4JCsWiZ%e_JDkhnS$r`7j{%sJ1+Iu^0l7HA*(0%K=kG0m%~K9dAY5->@?Bmt8IOcJO#2~fuh zsQQ;Cy>U?*21!HdsSYJ15Iz+{%!>#YPcN_%5l0MdvxqHxng+iwV}&7?4<{?Y`3iW@ zfh24Q6^J0c2UJi5`2wJVA}A+@3QCC8J2>P4LMX(C3b2p>u6a->3$Ecfh%^y|e8Rn0 zgn5W#Pzi;|mWV0DmP8zhh~a<+G2WMiJP5fEhX6zBR0T&d;DsL)6G=nrAP1^CNPv)! z=@fcNh*2PkDh|gnP{kpniUT65ID`T!AK^+vN4_NP$diO{G6bIXfG0#yU8I;Bljk3Bo*#JXA ztt8bb)4j`dt1WCKQ~R1L{tQukHK2H-tQe7_dolQA|}2tL+2zcVds78RlU zDrEyqe9u(%`z@;c{ne%Cv%`b~WObBLa?DwXc5jt%5t z|8FQAtHM($bzc>1KspZ4?MxwN?50xdzDn2t^*!bf(g#!t-B%GC!1w1>_0JWNUZr$j z*=+#MfM6R?|5Z}=mEQ&oa`sS_)_rBR0XPq*A@l)EzuzzW>@a5C&jH;bl=+U6u4$Fg zpAhs?J-yEKvD<)jK8R;4$+lZ2u9?(7lkK$3dCeca4TR+3xm!%=T(J&g zvwzjN z_aD0rz?u+#Pb{qp*?`u%H`xGnHm~A1j>)*8`T?DF|1sMDnZR z^w#}HZ3B`sE){jgbUdFkGnXmfAKCQ*#&rL2+d$sIitGbi;d_ekO^$r{W*?I_z_{)| zRvUmb*_}ZD6*3>h@9D{WkA*QCpmkMI;m5X42>M^abRVGNn_MOvs3@b=qh9IW0AusP zGT)z(|0a{k2K3NXMHPkT0DdDv@3EhT-z7EKKt(B1k9tA>dd&y%JCa&_o7rRodg!Vm zit6JlMtz%I`|om_Y@i|(sYjjA2jKUZw0aKcsC#;DP@y(}^G=56o=o`>RoEO>5A_qN zub9U3LC#VY-&>R)^U=Ci6;_B1P`byp2j5S+|iSB877^xp+q@xWO zz3!1W{J~gIwXayk=$;WBsnPxW`h9ZP5-#=oucZxC8r{=Uf%boZg!#C@9A)lsSSqXsd!=%qjF}23a>|CGLt2QBJ1G?%S%8AjIX`V{MuzW`5 zgJmUHc~e05L8bDM?gzNe{NQ~6+KOa7R~K7^K1`7(X*{9wYKnQvK_&~7$|(Sz1KRKL zY16(dG_S(gPu>(cg&pbTe`!n0p~pEHfg`C#SK zJT=zCHQz#Y`lrT%u!d3@ZGa7Hmuh(n5r$Vry)i*E$YlV)`?Ws#wjn1s4Sl?f%SK?` zvK-yxec(UOi3xdDraFM{zMATumZ1^M2eq;<^hc&?FDE=)nRKTwt?ZE89v}~`&j#Ya zJi0PH133O_pZ)T-5t`puU%6I>@}T?HpnIMDzFlV-#eA@QI|M(4a@xG&{e-GM7R2Wq z$V9*MVR0|G63hqjJtQsl5Bc+ESB5sM3yo6Ur)V#evg2pYJmY^3uwmUo2VT>d-W$+G zS4@{dolt+PB-%n5N_#aPFJ zoP}D9`^xeM=ND9sy#a|&XI_P!1^oehL&~=~?ZX?tJHBD6eU#RNa{u7^$LGE>KM(!*XWUn; zBA*AK`!fC;2C)U44_5s90cveb&oO_gEh~{%L`CY1sk%Ry?xpREItSoG?f1X;?^dTr z-R~Lsd=PBbSBc%~&aaMr<_GZ=L;pVA*7~%O&vmBSDbw{pyB}=~xtzn4t;%hN_mjC=?al0wok^M z^D^^Fj_(K5JuSCTp}Q{n)29rK^U-%F#gIDG7Z>BcVlcMUmnMeF8}!|w%BXuv&P={L zWIVd3ZE(6i?OD^ZQs12?z~>=SI$dKAfA_ zUK4$4lgALgJBF64R%b}TUZMfk^qI0PM&BKh`tDTJ_d1#4S?hWzUon|^oT~nSdS9FQ zeSEFy)v|ps<|_txwLXhkOIj;h7Aos^Rp%=P{^TEMO;07gTFCV_6TV_F&s(DfPA2F! zY#qwp9l|jluIW_1HZLzbJ$=PM_aDZ0N4IgWJnvf6A?GUwXUkNg=X`lQ=ji(G5VU#! zD&6j7?E~k7vvM=FdN)_Lym4P|(Po^Fc|q?+;k#o1`@_An2zFQ=8$vvDq`dZ2Iqz%X zD+Y7N5g-Sn^53Wcz2MsALOfGcwrxOKTm|4V2AjDk%crVq;2r%ps;chc9rL`k;Gfi0 zHUHQT7_Se5Ubd-$uNcVY8^93>P-)lpWn_eQd>wcSQKxf7TL2pgkg3BM+AdQxXm!ps zJG9Ua~ znm-WQ$sCaJS_pAn@HPO-pD9~aS5GG3L0iQ4$*`>s$MtTcO&ea+_PH6wf<3fH?Tn}2+jKWEy|HVAV@2UxfC1Y3v% zxD8<3eS@C9VuqG)Wy6QQnC#G8++T)uacWH*_b!YFV6DBf$<#!zNdhJbm?U75fJp)- z378~cl7LA9CJC4%V3L4I0wxKVBw&(&NdhJbm?U75fJp)-378~cl7LA9CJC4%V3L4I z0wxKVBw&(&yadDs`;`RBqblOc$3>3v@hn^UxWGz2&Nr8j^H}n6LgeBlgapba6cNIe zjf)6zq~Zb;kfS(@5D5WrfmsEFNC=?ffnq)(5(1zgqT&%EApqlWB}s)E0TJW`^il%w z%8E*r5P)%8F;$C%0EkoQX#yZlp{EJpVMTnx;z-HJ0%4Q{ARqFBEKw3bQpMsRWSNoxG8ibnC`OvFJTk-; zr%{OLxFcTB1i*c|VlJjboT`{6fF~6#O#q)FfXYbagSbSuQUU}J4-|ua@g)RdoGS+V z5=iPrjX8=%ln|g6qyq>5`vM_g92v3_3n(FAT%s|)6$*ilv&1}LR)Q88Bw}P1gaAJ( z9)NKW0v+#xG^V|eHnUftD}*N$RI#QU)pMNYc2NK`~xR zj+8;vhVyJb>Cc`9AJn?r4uVj zz@o|FO5$L})UPyOpn!_o(&Yjr8A{odFa&H%@|Bt>O_!rs`jd*WG%n?fHc?s*Qj>&2 zJPPsqIG>73O%m=)^zc3|sZeT?h?L4NHA##Ms06%{{3ME}W2R6dfge1Ed|Au7u{8** z)>i}i4u`H5E?K0yB?RN=@1IIY{W@Ru{bI!8hZ&C_&vE$D&r)#0ul3S4HLY9Dy*FUu z>mQF?U0?grsbG&$vBFxT@6@{g#G<FB!6u^dO`B<_3-*;1EF9XqZ0@7QocwG>)kf^^J>4HY8ILQ$)icc!)|%xp2rvK+}mK0*?XP!y5FxJ z9M*2}wD>%yl`qzNo<1HaI6EXbuz~ltp3xDliEGJs0rN-hs<-eP?zI9N@^*RdKgWve z&u$USwF%0z;H^v_)^EVhwYBG%5p&+QU)uOJ@K4=%`NrK@V;lv8Ryj19voAe5%c{=o ztnp8phm|~-^`QGZm;aeN9bBIL>NM$MF?vN--K(8jp6vE+mL(Z8FFNa5jXkkP{W>gV zzgucUX4b2j^68f~12;4zcUDet$@s7KIXgkmq)E>1yqKertL;l#wLE0iKYPi!?E(9{ zFWx`8D8S=oWY0_XIVCII$gek^WON$b>CMaRk$%r>?3wp<-tXrhalh-?b=Ae3jNCr8 z{9pMwbeb|Vd2ZurWW=@DEerRAbpIvX-*b4=@t(P9Hl%IF%ZWR>+b#HZZP?U*lL}o5 z8h11s-X`J1ZQE)m6Q1R7AR$YV18z4YlV3mhrl49v{@MJyzYh)Mh4Uu6`@MR2w7&mN zizbJz^sd={j40yL=cH|4NR16C9Juauvzi_D=Ds~3&Q3brto=I|mjGUT=2?sUn*qO^ z^(wmlLj&*Z75!R@c3n%dT4L>Vc0tCw^wgzSyEmS-qTp`v^A(l{{~0)89SJE)+HMhY z(IUjFskiULO<((5d}jOQdY`zhne%$SOr03;!<(H^;TE$7`Z^C2bs$$yMkPhXzSy07 zu(x9{%QrW&x0l74=8NwPd%5Tno13c>*K*jxxqB9!6xde}JNKX9_eCr~?6bh~vHgkx z>%C*l|7;h0?Eb?pHQ65Sj_!`u%W4(v`SfbQ_bc09@@+V0UsAPr`wdq+v^;s*uV*r+ z&ea*=7-a5SD!zg^7=$$zj@sU=k!i+ zyu?3ve&BVId+b8s*}YkUV?&SroyYs}P)6W$KZg<9NM_uTt&iK~oZV;1&)#?1yLkG; z9l_yWtodu2wOP?3vqxT!dptOIV8%tyhnwnqK56pPuFhVg{x2}Fg!}bS&oNfvk5Bcm z`{P2S&);?@U)nc27QASXeaR4ian?e=Z!Jb`Ens=PteNFh=TXekg|R0#&1qr1XuMvhBR zE8j7#I(OWr(bUK(Q;)M&jC-}*m(#kV z^|F|GN58YQZ8fdhcu~!vcR|ld5&01|L0y|3uD`(dySxp*iC=$Hu(D?0gafBDy5#un zD?Wd8+^Z|=-r3fT%6jE>$2_mg=uz$bn=NYn-+f`k@vPy3;*8wAW{1x7^8Izo>Uy~+ ztP^?NZ1;DmW@DCc_heR=858=f>(cpX6SLW;dM*5QOOHnOTynKzlw+HhS86=plXK?c zt9q?`&aL)5an*iaD?8gpZC;kV3e33HFUW7hQP$zslcyb?BEEC!|LkYh5MG|Tu#dUd zsQ}0Q-HIPS`#Wi9z1oE}CT}^)^4dRS88jj5e2X3@wl3Qpbokr2Mk98-ZtFclQ1eS( zz;97;X)}UmO?l|Ql|9qiZ;sm;Gr^A3{Lq1RF;S5}*FXAtZIab<`{lg4*2T?-<{!9l zvR&4B=R<#;Twg8hd7D+s3KHh|kL5q$`FM4H_sVwT>>9#9wj4Tp@BFg(9jq@#AJ3W= z93Hy4(;pdKV(xs$>X`fc7xP!$-lP}XAIO~?b=ULWD3^I#-B^LIA{!U%zL&b)tfO7$sHb6Z zUu|-%b=fX!)Ki~0>)g84iaQLg@n3Y!UiZ#)Jl3O$@3Ad;$1W7UZYi*Bv~_ObrjjvM zyb`Z?yZr-qri?kV)ZEj@)@n+hI&oJwyxar28p}|K-hIan|DX(PLSoLTyO#&(5(M z(Ta-Dh7F7~d- zKMGf!-<#~e)p!1IVUb>+#B)tn_FdKU*nywbA~c3n|V2``&5Exb6k5e_s|G(7tH!{DR|0 zx419){`kB3eh#3QJ+jXu54*Fthq?QX%XOyj8|XVX&+mAu zU&Esp-;zH3)wQQ5AI{?R{9$QNcj(8{Ie(}2BLw)`xTdq$km>HfO!xec6?wdP?~=NN zoU!gO>FA9u8&bQw`=78|dOV}cjWdf1AMG2nIqK93XGJ{IFbn$0O3oFnWob=1xcm^|6yDB-q=#V+i%srpq{%y>GSF}JDkqicx(B+i7^j_<65zPN~v*Y zRa(p?Gw6*P+_7<51kL-Cr7o-`e?Q~yt<$#&#|xT$byq^1UMz18_Sz?DS62Az3+WMI z{^yuWv2zoK5VSE{Zt3 zw`)f8*GnQ?>JGCcb{>7!?-T3`Z1iwOdaTPNXbjEAS!_#t5R*G;W#po1^PV1Ve9SL* zg!4ger`OOLZ`~yGv44G&b(dudW^s0r!XeD^9vzAHd*EWub;ys;lW!$+vDAvX62QH|IwBZ z!K10d_16#eKEi!`rP_nJBCFQ9-P;9pY%%KYX+kd7>Nh@&B>UeT+W1Yx%fj2sew{vr z5dKR$#|Lv-H~+L_(dW_EW|`G1IMShL@Jt`_yjH*2%iR-~z3G{BX9j=Os>1(mim65T zb4EEo7MAW{Th<5|9jo`k`K4A?V?)WT%Gtt7;%~Wyz9`R-fztlVrL&beLK3_yt##gCy{N@ zGm$RnV!pi9>+Ns;FE9V|;<|;ocTn4J!%k+TU1j|hW8U`;mGr~i^ar&!J{ZJH4w$p) zRQu5uUMDhcI|@2`Pbf@&HMrV%FX8vIbN=0N+G#+5Pu3$|1E0ddsWwqj(Y5;Bf015) zPvWSKJMQH^dYu_Hm>Nb}ZVux)_$IsTt5@*p9+$Nn&&}>`F{pvpq}e%Bf?3|vhmCT+ z;lm||SBWnSxfd6^AunbHuk%Tls4!}D-12V_S!b_F8{#9yJ2u$0jg{UAl>*g4_;$`E2=&Z1c@i&1yCrC8J7bDYUG6Q2 zf6l+fUHUTZxYyHX33nTstuJx&+3b0KO}}O4chiHPXV#k2zeeA>P-k>b#Zt^`;xD|pIc{P$*`qoGDe*ieKzt@ z^XE4Fb#DaR@PL?ZE(4xe2e$G)C<-c=X7=_IkEY}2_&wS?vrRzM+d({*xA)w3k3HOH zzU}$npYCTH&8dFBeaZOk&Q5`Y|D2UGavWft{LapsH0*jLW7^wg_7`nf_VJMiKegD{ zJFrutr%&Ns&!SU5IrB;D>2Hol1||09PsvXEHI!$Y8hLMYcG|#DcZYT(YPpROjqA@H zmbxRTSx(I2LH3r*nsB{$iiVzJh1pyF&%Cw!;f)am2^+@O+n;l2?aKwd8mzrGh<_z} zT!#Su^j3oWFV|ZM=j@viogMoo>F(OPnGaXI8{K2W<{K9N4sm(UxbK#?-(JJlGPZB? z_!GseJ@Y%%$~&FDYiazIXIqa$@l?_F23(Knt@va#^146gtMj*iI#FDmF!eU=qEct8-AN#7LO2(Z``%0^Spb*u3mZ8ra8AMAt{$L2WJoM_U{6o8}I7@!D}14 z1b9C>b@`UhrJW79Suv&+4j=2!(K1rZGD?$ z{d~-aYySP`^(JH|xh#KX)z!S~@!xK2srh{;DrGaK5Rc5q0fW~C1(#eaI=m|0EAHo2 zjshF+-$J?{S`oW)tXpPFK2$e5-j+>pu2Mbrji1BypCuJserDB>yg5-k=<--6i_t}S z;m1Tlo2}gMcJ}Jn!oJ#n7mDn>0r87xR%8FH-u3mKZy%rUgDV36*XVYwe*W!Cie7Bk z+swyjN>LqqZsM~>JTQBYaL2AQThG5U_V46p_NS7c?}~|8oigh4dfdc+8u2ozp;X}E zf?ej>N5QE5gV*F_7w+B*^>;I0hqIXFV;3Z5E--H$5OpLid0dk?mznJYqQY2-3nE-* z)hqZl3q7b^-i%gQ7AZ zWwK>#-1c+(68|>u_1nrLqM;6v#al^u;BWQa$eygQbk0Uc|L5_xqa$m?ioAa4MS`CQ zXZ6WWYad3!#k=_mFc%&7*M23)htnp(84*_Og*R?TgAf`|S`L|fc!Seci(-}MNn$F& z{1rw~m!G*D@~*zPdr4|));>|tRWqmiKHSLZ+4(7#egX|ggimdu#$dat;Or5v;yvU=HEN%5UB#z$uE{PafE1qxZ$Uu7C3Fd_RkZ z9M&k%&(}D6drgkFwfKa<3Qm?|`eTdbyq1%smg z2Q5MH&IX?D%e&UyKME{maWhhHg;SrXYS;j;{uI?aswH(5?bJJ}A9dw=xKGp+>Wbyk zCn}7(s&lw^)E?@}*`;?>Jaxs{=bZQ!8|-t|XU_a*R(#?#baX9N12M;vEV($adaP)u zH(58TF(%nS(!TYe+k|-H&=Uw8_IJ3VoXC2^&}f&ee)62wdDeda@r2P7^nXzUnum@1fq_!T%2=xPHU{ diff --git a/client/ui/assets/netbird-systemtray-connected-macos.png b/client/ui/assets/netbird-systemtray-connected-macos.png index ead210250d7a4b25461aa0311fcb6c13cec75057..d29a7ade8821d87b12982d8b051ab5294a7a329d 100644 GIT binary patch literal 3690 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 OIuXWJMzz=7AN~`y+wr{s literal 3858 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 diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1f7d40121c11e9886d395344ee8ec2ce96a6238d GIT binary patch literal 1548 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

6A!aevjJ&cU%ypida~ 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 diff --git a/client/ui/assets/netbird-systemtray-connecting-dark.ico b/client/ui/assets/netbird-systemtray-connecting-dark.ico deleted file mode 100644 index 615d40f075b41f3b3c9241611cc4b249d0851899..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105128 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 OF~=dJ2Ok^g7W{u+(t)V} diff --git a/client/ui/assets/netbird-systemtray-connecting-macos.png b/client/ui/assets/netbird-systemtray-connecting-macos.png index 0fe7fa0dbe50bcf8c08e45c7cf13b7e6e2348335..306c6ddf555e6049c57ce8bcf98eee80bd2db730 100644 GIT binary patch literal 3725 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 literal 3843 zcmb7Hi9ZwWAKxsKV`S@a4LQ1zww8BQ9L&hqTVnMm(VHeJ{&1|y{dKffC8 zlC}S(ws`iw%h`)dEHU_pdi6w_!N!#5PadL=QO=_N`v+iSQXXKHruP4hlr2#ImWi<% zoXzW?5qCCq9zWzAKF>etFjCUW=XJz{cY;N>4EbkxDG*Z)mIC{22W_B*_&~D~F>|Qs zfuCgL?asi2(Og$66y)<{L5gjPW2J8>|Lb96mYj=*R~wyiLKm`Q>^?GkDZx=zBsMz< zbs%^$vu6X95kUemyud|bem^J2p;jpI9KDUwfk+KV4!sD8{!p`Q*v~9*wYF@!=pY_~ zAr2Ez;#o{1kUxY<_qITrzOmuUr5U`{M+KMgcyPtro-FD#kME!@KROYJThh7^Y3l~^ z>Y1mAMg0b{{{u_&^{Gm&9vpb`dM`a12q+-u)pAxi}#xJhT=C5oEYp&EtDXkU@1YgYf8?DRRDT3IPSqFv} zdNn#Q60x4J)0xqYTf5n6r{%S~S1=tnVRzn|>o8qESav)wmjB4E=S#Yq_SnbS$%<}F zcBO=8wQ%YtPu|(K&xi4;=gmCeJwKI!*yJG=`@h7gnSl{XB*vJw;xUR-t?&a+iGRO3{#qA>%JU2A!L zJcly<{`*rRWE=KE8E2*PP_bVBFYlT9_|uSDbV}J;jjqiQmGnM}m7|RBO*X2Ngr1uW zB`57iAB>tDcg%oubmKB3k2{7iHY{@pXqG5=O@HDdO793TXK0W;|fg17hgDb z5xg)sz$6g(=MG&-GE(oT*l?tM44khezIS;X$xhQ`{%|nQavaP;k5K?MtN+FfL)=nb!f4e5ae%ZaR;@ zsofv|=}9vlZk&B*dIEy^PFSRyPGNh~5lfYI=nrG|Jh{AjeeGFRA2chIQcr(-a%%CF zDox}5klXZ*2Tz_JLv7*ACsI0{rwI@3lH)fIIw`{SRj$M8&#DH4*+V&rnJ&vAIw3cq zdL-+r!+x|Zy-B>FgkT5l$r+hQo2sAEpaS?(Iz!wf6qwm_^S|ibf)q9}&y;xC`{CpYm*VlNw`a&1Tt*Ix9CKJ5YPME!DqK3y zk;u2&b>Vcl(G9gu&&3Au7!{}P4v^8BG2FkWEiX>}9pD1)+(z^T%+U{1Aj^rzNKTEe zPN>?#)|O<|P?T?28*XK)>VES-rO##MvPlN2`^&ifO)5Fdv*3RNFI^ z!8B}8brLTHBi*+#Gt7MW1Wff~yU)r4gMk;OOIYD3LZ!y-zQL!vSt>yFgz7*(Tjb>^ z*_oi@uGnI;Y&?x(dOy zp^!xA)o4swSsBtpxh76U7ukpL zD7e`W&q-c`{ZN`q*J&6EdQT%9k&0`vQ&Rn6AX`0ivG!@xty zO_^q9wS$!a_(vA)7v7D0F4b8zS z)%E}%xGK(}@Dgk;aRtG}`)kO5Tf{Q=an_G$C{yZ0CEB`NvE{N^%foMI74cu2{x*<) zqH1^N`OyX2*0Bs4?Zb(gTS66NO3;#kzV<%6Cxe7QXC&9hhwO%>m@U8R5oObY+F9(m zN=nX(uTr%X`v7(aeKMS9U9vB~Q@x?ufw{ zi4Q)`-S}MnD>5~@leqVzV|V)k8>14|K4#{t@4KzD!K&dC|-Iacb_{NbFk^QM>K3R8lS2S)AN> zVEfw+FeCk^La5}j6j!J5v;NZ}=VJ-;?~^tbF|A;G+k|CGw{M)L59rvexjDem3gZwF0d2TF+M>YM$^;==&;#R#mcmEPT7P|n^; zo**r6dJe9cT6`x#^9HDM_Po8XsMg-bM2IqtX6RY?PnF)BpWfY=l7Y*D$WFzK^bf;+ zfXF2&u5_-cm7+-fU5GLRp+`zHIS>J?*mLS=aLi~-@6Y_NDi9->17sCBgm zk9#Tl=wGlsli<{HB$f6Gh^+#q>+6yj1$j`TzaZCS-I9kykEPu2Woz9!K0s|D>=FxO!|G?UYHQa} z!k79sCQ>`7wO|j8-ERm=;ZXg2ak`gfj9oC5AyJd zs_x6};}7SXgm0@;iFuoZ(@RT-iL()7Z}ROXLIuM#ghw24%kZnnyQbPjyNJ4CF&Qpf z?$*{UIf7hO0j1U6Dgke#eO`WO5KzeAZpu=0rBK3zm^)?)zICGC4Fbl}8?QD@f0j^@ zb25K-DtKHukAErIEIlz$Xkc>Qb)CD1Jnfo-Zys?JSQiO_doia&5okNTs~o8brk*9aoEAK5)IA?J-XFk2lM?Wc@u5gXt;i_MaGY0vhUaPG&)EPWL`oog+udaT zB6)Klpg5-`QRH2bEBJxSG2;8L!w1~EAd?@doK8=b@fu`2p~icXr)7#QQEl@ zGG=+o_W|V$q~(b6)o;lmae{Q|jgjJi)u)!P)QO`0mT~dU1I;qr8911$K@~?e!Nd|$BiRY+4A1%H%bRk!}QBOn^N0-s23Il4>-A!F@%~$XZEWn zO_^<#{O+;$);BHRg%5QSW;hb{7-fcZwHA)2vw+z?Xo}oMA~t3?ux_*x3@4*>Zqaf!z^Od7GSp$rMsaeFT ztAI0JQ3ht@RD|+>@qzliDtg|6k$YG5<(Yy2!bvT#?S?l5(?@m2okc;)Q5VA z%!;xRO6#E;l`jQ_nuQ5sL6%8bgHChioL>)nt@+mZ=A1d_J4RD53pU^R_Fj9f^S9rf#(zBAjl=Qo#ilkwCHfuwYkhF1B z3h-~Vk~IMO-zF5q_HknxWcl<)kzOlB7$zha_2ZH%XYN@ydw7RRa$6n+Sl^NN5uz>b0- zYNq2aU?Isd)+qiXFKFq`CgcS6Gu^5y9Q-YEdWSD*qc2}+*J@jaXa z@q7+^p1gl8O}w86pn!53@Nt_UT+t>nM=T=6ZIvu{03I`*DW>bwCLsL%E3!f`#`}!` zI0yl=fG=8r@MS&$oD=50n=_;qWZV^iUIq-BjQC~tdDk+I_nQHT6V3y^ZQ`Za_h6WN zLlgd*^$h`w$vpaaj^CdHt&ATJK%96n@IwQm+-u*Eg+vc#)>_o(%GDSjrWzheL*C6R#Tz)4f!--$hB zc{2|<&e(X)(};~9Agx>pINxMfNxRLaWwR3i5yUm-r3zzXxm8(=}Fu4y3%AX)+c7 zSLO7yY@M73%1T>nr|Tu{R<>tvmuelJvg4_Zr_R3#mvR_anK!a&A1I4`tOCiqRX8b^&-2c1!wN(gM}r z{hX4tQqpHRrH!gjyy&Vm7JGD``N|66@;<#xIq$P15U< z)>xl=+GY1kdQ;K{r8_CC-wnM&($<`eL~SN+nRMbhLF4_7Z;@Ard?=C5$b&iF2Lg}C zZbF>C2j-I9r!EHm$nmUq`-&v)=A5xk4rUm*l&mTV*k)s=$xp16x7-K+Y<^$@9rrzfW`@hoLq4^QY`NmK0i4AtkC+cPKF5M}sP zXs-{p0)S0=!3=OE9-n^aUy?Lc(j_*}6Lq`s_9)Anx0UW!yCofzv{%_3YLC)?1UrKb zQHJNn8<~ZzLua|g<$lunw^7>%dxZwB@F;0xWJErnz*2XM8RMbx53Qxbl>)&s&eX{zPIna?PNf>KOAoN*{bS4r|Q%> zP@_hT8a4hW7y+8@|JlH!z^A|;z%0<#Z(f-Odcb4zr1O4^XaTqYcn?TanX)C(@-uK$ zmVgGr&;nPQ86|FSIOPT@-vpkFlxz#C5KP6>r$LwmsI z1mZyd02~J#2223Q0?U9SfW^Qe+Ajh20e=E}f!)COz_-8;2$L}yTK@vDPRAcyiIz0( zR%4o34OJNNr44)qJP%w697$LOW9u3<)U#QY{Wo>aKIIJ$X~%y^dwwS>zO9=f@O^?m zc`N~5sc1x^GG>6230txcChS=Pt5u>ymk{DEck`}6wkL#Dd62YImT}xiB0c-yWAw`v z1jNhyuAA?0;BR0LaIw}Oq~8Zh$mnX>gZO7rv`dB3w<;K6D=-VJbV44F`rQ>7@kC^N z9x3a!e=hqTX5~U)dr2cq&{K|x70ABrk`9~qX`hzL`Y^```~Mjs5Da8LHUeNA_@uxH zTjt+@rEcuoks^tk<9cW4Nx&5ShnbB#Py3c)-@^tR4SY>DOTO-QH};~O{)yI21CsG0~)hxkCV zIy;=^*h$ED=JdcSg0ZxX5J|4kx=|$8-+|;h69eWM1I~B<=fwb1{u0t#W!5Rv z01ic7GC+*-rA0Up4R#x}H>a$?(jfg#RT-8QwvZPH%Qo%godV9x5SqoTf9T3%K7Lyo z)Lsf^^_^s=)ji->$|f&Gmus5@O{$}p*_S(!`)^kH0{8~F&M9YXZbW=%8h8~+pZ!DHgc8(79*Mo9+Z^{GL1G5E k_>7k^phk@vHEI;%U;0C8@{CnPx&QzG07*qoM6N<$g4?le1ONa4 literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-connecting.ico b/client/ui/assets/netbird-systemtray-connecting.ico deleted file mode 100644 index 4e4c3a9b114af61a28150b9fe2deaea75acdaa06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105091 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 OY{ZbUgZ~;x5BxvcE4?TH diff --git a/client/ui/assets/netbird-systemtray-disconnected-macos.png b/client/ui/assets/netbird-systemtray-disconnected-macos.png index 36b9a488f16673b1fcb1c87037d3d1606dfb1e5b..48cfa7c609bbf6e7cff2b3cec89a7eea90d31c47 100644 GIT binary patch literal 3474 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 F{|k6OYw7?1 literal 3491 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 VFbHp#EYMSdj14UGKVEZ=`9J&Jr*QxP diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..035e71ba7f80e8a04caa63fc13b77bf2ed147381 GIT binary patch literal 1235 zcmV;^1T6cBP)yJ_qL}CyOhD9#t2|Ip2C{LXh-gq;1UGS!=pvv*R1ic^M8$<}ghV2U z?p*i+-S_~(MTigyVn8K9Fp94j-)5ZdzMqRzHMi!bANTgm*a%&4xOdLEr>g#{bL#x3 zCj<0aINc}FdSK_E#T`C#qxM;y(E*AOL|n&W1+v(!)`ZeOPY{$K+>TF z#mjgS`|`-O7)-m;2g=`u;{C0!zEt@WKPX;LX# zbCP~D1t%mOmvl_h*EZe_R7cX&=KP$bDb?S5x#@aB(huf$o}i_*^w1FN(Pa)e3LF4# z2iAo3J^?g=?e+~oyQcn*t^Ln|Dbt<#9*7(5Ih$}AXoc1etyOBBE(Q+=Ls9>^z)Jx{ zUJ-x;-fnuDQLjp!ZlG0BIp#=*lnH=66qV;{;L?(fhiiav=ilq*d<2l$U)hYV z`7!_pkpZiLgB3xzp*Mn#i-BKAORF8ed6LDMc)O&-#h2wNV1^>N0|6*}xEA=dhk>%8 z)4)cG>hMBoo}Zuq_XRsMz>lUQ76hMnKSed`ddl}Y`aNCif#ZEZc;7P=dua>ZD^wo( zXM?TXC0%{XW>o+Y#75w|pq%iY+t~ROnR~wi;G@C0Ch(c*ip%ZRk{`7J#MEsit;Yz$ z4ebu!Z|~#0i|?s>!@Ql==LD~3*{lm7g4hQ9MX?khmCYt8lGDH!6#Jr=c;-$3=TH=l zZ*6RzK4o(l01?DZr09C2V%dD46ybj28QK}IL8;`^F!urH_b!_x9ag%K zG&J~P;?gmSyhGBfDyVm+{+FXOoT)3$&6`TRd|%L0)p@CKayHCusD|^>@R#}+sUyP} zRsnXCJ##J2Y$X{MK&Es@m83nAUQxX2%T+Ifl7($%x0yLAJM~cSQlXk*yS@D<{#5+3?L85;=>GMybU%R|3EX!8zwVCn@+#Mmm&w(QyC! zmXh8T1>i<*>*AEZe*;_|=3EJUZ)0T!`|ff$UloA=t~m{SNq4sCjvWKmQ7nlqz^~z( z4g)UfD*?dykF(8QT;DhC5#WO0+%^*D@$r4j<$M(Yv1~fq?8o)p$&Y|l!O5Gg|4&lc ztjso-3cwwB(HyAqvtV53)1)&(z%GhQ`m#hH`m<4Iotq_nY^P*Zw%L-jO47@cc3GdF x(6!j+AX(0z*GD4b|B;Pa#sEewV*sO;e*xEJ!J}<>$UXo7002ovPDHLkV1lEZT7dun literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png new file mode 100644 index 0000000000000000000000000000000000000000..d68c4dc5bbc9b83346dafb3f5c886e07dbb461a9 GIT binary patch literal 1088 zcmV-G1i$-^^SeR8zY_(t05mPIUcukB>xVsjm4*n$WEA`{3!rI%7hU-#@E0)Q zY%*-uM}ZZ<6Tl?!7%*;qOMwwH&;b4c{s1lmKLK;VFZMi-R1-J^?6LWyK7d5D%+>+m zcZ<8_2fS5qMSN=jmw=Cf7lBnSK&9EVjZxYZv9A>S-jUBu+4pV2ZSH;Z7hH?0LNmwV z1@c$flLK|ifnJy*`Ps1FubHhH@HFsvxQ0qI9P~Tx2UjsIfU!hvAf6DpV z9ob4Qo2ew$0#}{!xISM69wOVha|whmx!~%+cV^3{+m0xXQqb0IB;=zfAxFF2_~|ae ztLdHE>*mc^p9EgdvRQ`2X5c1SDyVEq(2W5*Wbw`+^&Bc-2^b8Y)c0s+Z|l;+FnNMv^)n6&FK z5o09W=hjhsV;o#Y6{Ug8<(q*_;n~yl#eTP;_WwQZnvA$u#iTX9YNoe(FBfEWt zoNOc$T`hvVm~vxQ5yV+1O|)ogvn+x0E;wnP>ty<+MmUr@$#DJN9@*_HM?d0H4r(_4Cr{3?c1w%I+fj zNFUhhL&(>8Le1~>4+yne1QI96{uD0^R4!DgP@zJFuJ|9-pl)IY;7^MH0000>!^2 literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-disconnected.ico b/client/ui/assets/netbird-systemtray-disconnected.ico deleted file mode 100644 index dcb9f4bf83dcfcb18d4858d5cf553895b092eca0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104575 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 Uw;PvXstfzveZt6u;jD=N1N46qGXMYp diff --git a/client/ui/assets/netbird-systemtray-error-dark.ico b/client/ui/assets/netbird-systemtray-error-dark.ico deleted file mode 100644 index 083816188d695c0a083ba7dcd78c1f31e9f06080..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105062 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 OdBMWYyyBea{eJ@L!Le-s literal 3837 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 VpKP%8!^Qvr002ovPDHLkV1iNR^nw5Y literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png new file mode 100644 index 0000000000000000000000000000000000000000..164d65a4f20c892e9c852a5e2f978dfa1e7e7881 GIT binary patch literal 1449 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 OdBMWYyyBea{eJ@L!Le-s literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6bcdacd447ee52d82d3a511abd5435b9a81297bc GIT binary patch literal 1571 zcmV+;2Hg3HP)lCl;3IO|$Nluj%-P?$v%h=y-s{>D?7%R;`}@tD^ZlMVbLPz4 z0-*nMDGoM(6D>mo2D=OqXt0#r^9)HVC4D35kfbh2ow2P}J7W7~uN2_Fs>Rq|3493r zL*u0kbOKkI+?L3rkW^oMoXz`&q;fx&FUCI0Bt5BY=6h=BO-r|=7D;bN+S&_f0QZ3w zWn{ci1OJMo``!0aDxgL&LmiVeP11oVr*{UID|B(+Hz76%-cbVSmh zlJ-g38|!Zh7D`J=!OBlX@Ewv`)%e;gM_Erx+8=R;S&zQ-SQGwewPXa@>W zZcFt2JAfZ!|Lz9m_e9_S2^bmWnfzV?1>p5K;5g7#XD#O_+y=CgEn2H*0Pi$G!usAG z<&?bKG(wnyt_Bp830CTezn731WjZi6(XpZd64vLdJn$)jA}|d2qzMu>&ASP1Q;4$m z0KWn^Mm?>uU0$b-?kM|;{s zewt4QzDor42mVth<4oXS1l~okB7|Te!t4mxR7HomZ_-fm}k(dJf5rvr*eKAF^I#5#y3px)} zIewc+X3>WdSvKw$;1rUuSuX+4`f}h%4hggVAz+LH^icxH_bmf+35&TD$Fve+y!Ekh zcT>an>mlGt%p&X$WoK1Ww8Ztlp$2#l%5QW67RK+E;BAS0YJ#tafLC&hvyi#N9zs+p zMrGR*0v`N4Di^R3DFCAhi{94BOzN3XwFU`@T? z8zJD8+jMV=zl*>q;CCv0-N4^V)FCHsG_U$KDm(^9d91ARY&>wEAE z67oKlr*VCxlb;7Tmqeok@)S=3{*IRSQRKb^yn&ku+?l*fffn4@#ZG4*63q~>X>W<` z$7CCofnS(_HrucPqvgl0-NlI1cYq}S7^bS3-t zB9+HYE9Of3f$JH788C%+RRN!p_#j#>RqDaN6#IJc-Bhj#fMaBluBkO({@VuZrSC#Z zi>28s;`rh8wS3-fMBd`H_9$>oH4^n&e`yrfN#?U@wVBxoOeTw3K6iO82fU42O}4Pp zec}jV{aG}ui-2{!koxcPcLC=ndtx7({awI$=?v5hs8naIcJwoa^PNjP>%jYQMq+|< zr=5f}rH_G5#tc-emd88-yy?K^kv0;OfiDO*c>aBF9mv0!2Bi%BPdwOVh`?Z%e*sEd VpKP%8!^Qvr002ovPDHLkV1iNR^nw5Y literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png new file mode 100644 index 0000000000000000000000000000000000000000..164d65a4f20c892e9c852a5e2f978dfa1e7e7881 GIT binary patch literal 1449 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 Z(%>Cos#cWtK%X^)%YJvqTKk}k{{iS8*lPd) literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-update-connected-dark.ico b/client/ui/assets/netbird-systemtray-update-connected-dark.ico deleted file mode 100644 index b11bb54927fe232adf7924216fbbce9569d5156d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104704 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 diff --git a/client/ui/assets/netbird-systemtray-update-connected-macos.png b/client/ui/assets/netbird-systemtray-update-connected-macos.png index 8a6b2f2db85a6b6e312015ce634ec49096c45059..8b7b9f131f7581a4fc9d86c7d248735b2e6bce39 100644 GIT binary patch literal 3328 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 literal 3570 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 LNkvXXu0mjfn;@Q* literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-update-connected.ico b/client/ui/assets/netbird-systemtray-update-connected.ico deleted file mode 100644 index d3ce2f0f322f6df5c47adb9b46c883fe0eae86bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104698 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 MdJg!tzfZ*f0jyzlfdBvi diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico b/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico deleted file mode 100644 index 123237f665722d0194d825033775bc90616a6a3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105086 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 WuRrb?z6NGGiIKzHh8`Q@9rAy92WzMR diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png index 8b190034eac6588e58a36365880d83d9ea8d8db0..b6afa3937ae8e93102d69d1669c16f40844e8343 100644 GIT binary patch literal 3747 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 OSX)cSE7j({5B>{<{Ua~{ literal 3816 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 literal 0 HcmV?d00001 diff --git a/client/ui/assets/netbird-systemtray-update-disconnected.ico b/client/ui/assets/netbird-systemtray-update-disconnected.ico deleted file mode 100644 index 968dc4105c8fcc22249cc187ff1b2fc5786616af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105115 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 diff --git a/client/ui/assets/netbird.ico b/client/ui/assets/netbird.ico deleted file mode 100644 index 2bab8a503d92aad4dd9bb5eccc02f044762a7f6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106176 zcmeGl30zFi|80k;2q8xYa!0l0mm@0oEr0jXr6MG9tkSs&xz8U-BKMKD9Fa=4$W@fW zCQ2cG6gJzf&71k2Z{ED`d}jv3NLULD{gJTl*vu{%wiJF- zshoVjmLx0`t{E9|^P@2=y$1=?*5>3d^v1B3)=;xMH{TY+g13{fwveLsmPujQ{G%jn z`;6%mTFZ8og{E6inmBeQDn-x|p*C520dRKdGHL9n*^Vh+Q#T${wsHNG6F+OD`m}jB zt&8?$dbXY4Ub)R8JF|3ppMlobjt$$seG=7L@qm%uD~ng@vt}jwt6BwX-ylauOSD-d z5heAm^VK&;)T3GqHdVJ$*6#e)+d6fY`toyGkJMgR^>W@5dE(NS??3atcnA7?`(mEA z-u>srtODvbQT!=KhiV#~QJH0R-szmh3DPl%lAcUCx~_*J^*m`pD2*&xHjUKXr|ldK zW0^7;X3zA&>DK-q!f5ior0J?_O}9LBIkh6QUwTKnE!CS8Z*My5sY`-FwAuiy*FUFn z9w!Ayv>B>WILALd?A)=Gt&0_@5=O^%+&3Py_-hxs@0AE;rU_#(Hoo+ky;48y-u?>< zH@0C8ng4VA^}Dna(aA3jM-(VXQ*(E#czzz8nMs%K^|~OXRjPZ|<8G^h;!DSnx{tIO zGez^u5jK(L{jf8(z}-fA9$n6q z(bH>Kyh8NFBDa&+zCB;V|0}w`)9Y!A`yTBWc@Gyyj+}L(=)o4Zi*~x7AFdC%dO!N@ zLFJbR*KhYw`Z=mFb;~#ZcGT;GF3oj&5#5)n2#v9dlP%TU5|(=>o&&DCF+1jF7zf<8 z&@}lny(?+rl>EK7={x0emZq;{ggiWz_Ejdt%RB6#gwfGXb2MVD-gTog!fE3= zw?NlnHa2nAsV^z*?G>3>G~4~XZzq|X>nFsibi}TnDSZ)=*_pMPJ_T@2DV}^qwQR~Y zhnLfh7Ce7G&bG8OYgleZ{H6sKeqC(KNIu<2*@1Iq#{@_BIq!40>kzem60y`wy8Zj| z)|2LSkxafHIDM_l`WDFw$}3BTNaT}d(G9v?3%^c2w&r%E=D7E^UfZ9XpX8J?so3N zts-*h8^sx3nyv@OzX;KYDrs-uo@sKx;A{MfH|xiB&c@uZmC}(4&#AAK z!i?^QfPN&IYi4SX{`cIQjF{Qu^|oFMGzdOx$Xp+yW0^6m*T`3&R>k)y+WOr0>_3!r zLyyV$83;%+aglh<+eqrUD2}g4P(c@KcsVb5dE6zowkYBeM`ERq;60fA!R(+ z)};^gt%o^j`rVX+lk(3@DOV;%gsnNf%4f%=LqQVeEm$${mVGsJ4%3QS`%f{`<`F5~ z&u<`o?2E}A3l$F(J@1w3CjUm>rH!KdUA5GdkY}T22bLHxCoweo4Z3wtA(}OO0d@O* zjriyv(+-TWKcJ#djh8q<-+3oHXtkmIxqw42I(_RtEO)>wX3HZ7OZ;Cx%dnAz?jc@g z6=~0g+jA2StI%zz-7Ht9mhdz0dSso#V+o?Wg%+`=G}l)EG}8&zv0?AzxVD z>->w!H)7~YR9iX66N^-*j>u%5QgK#I+LEzoMts@8EA%O)-Af8q4b1NU^WW1eGDCXZ zouoo~xYwDgurPbVf!c@i@z)o2gRYK}Dfzn^$MQjQ%+C!t-SHk~M<|#`)f+ zdqqXQbzJJZ`E6>Rk!oKNo5lL8s6 zwK$#ikN3A7H+`*@PD+PAm~n@pp0@NrfMTz}r+Gu3p9{a8&|6=k!(leX_5cCtmL%z@W%rr3;7ONvGF8>55;r#J$+@jv*gix z>-Es+-ET>>6aQ`_U)Hr+>FF3nHh|l zV(Kwv)KE{d4Q9Ts`0B*icYb|7cX>U)cV(x01e`RnW0x1#)&T@u$X7|TFiy%`EGXxB!)RUdv9f~ zJMAG8kw0EBamTgNyo@L_!$}j?7?_$mAB%DEJk#pJnSHzEb38{DzSN{TT#orSWSzf^ z!FQPq+v1$DDo^BR%lAe&9h9@J69KDH`FW2pC%XnpsnGlyPKyZD;#_O<&kqI40CI1 zCbNFdGfqoFNbGg(_Xl)T?o0{RPh@>{?-y?DD=Fug z5FMi__uobg>#%pwuF>DMR(7^FzFsuBfB3u}+Iv}G51Ch~ky6x+uRDFoE1a7C-GsK% z?3LuB{0%9uM)sNWv!8M}<%U_I3}akwUQj!`;b*BiWvzy$=I!`2sf6nF)hYa8TF#y; zN7QAU&Qqn$vSl(HOq3G6+tHQX@4gI+irjDgc)(|gaEA4ly9$p!KAzR}V4K5tjPH7> z6)R&{jKvTCyzK9!x9e~J@L_}+818&Gq4frLJ=^aI2A;R*ly>WiRWa;r-ogDbC7rZ0 zilrZ3b)4X97nw-!bH26Bjk5gP;hmqunC6~xUlKM$c11WjLblK&OR2}frTVK`nI~LsNV;jcPGN!dR*o`+cvdW$9?~NKlZLT@X6q% z-@BbBVd*j%p7e5wnYsff>+4UWZ8!y@Q1l|}=f1FXeR1AYDaZP$BnCZ4_@ppdI|tJ# zodymMHy$7JT0S>I`kXEWUe4h$6cywBM;#8RZ8pTrT4%2@opSWm$lE~GSos|FTd8XH zerlU9X8ZdcWou53>1!mbxoq0Wq2XSoM;9yP2JK#;_Vr$H6s4Wqq&XN?II3{k0bOa9 ze)pBL-oADjX5r8^dp&KmMVwk|H3!psqh~51`*2frRJ`RLe$!&B$MTSlhDZ0RE7KJ$ zBWz8St{j=&0-4weiLzr65gmtjSRMPs%W}Eytydj}1}INGb9v0&jG5ioHl}dwb66Y3 zO6wN1*R<3nSJM{eFEwm==fALRXU`1BFnIjhw=`j7Onsc|K~sqL#mCX8ve<*ZwVa*Xb-; zDuG$_YH6kL^Nxo*#dJ#0E6F`e3nujl*UAU^zrcU#R=;-ag*!pmsG-kB??guvoH<{2EltH&G3c9TPw|Iz)`Kyr^ z-*zcB24Tih+66wg#;{#d83w^Y(tS)BiF3NFV!g2KuPQy3ev7tq*@UIY3i>!m1WCAk z2s^y$ihk0NaN~u^t`}9LP2S05l%jjiu`Ol7B&SwyK7}3L^JMg~JU#P~hojXc3SP;d zIDMdq-9H!{ppD)eN;|8V^WI1QW2(c3)nCR>{B(Ge_BHRIPd(XkkL}|tsSiZ#kw205 z(xHFX-mU>d+q)-H+W9@4hGGA1ve#gCwFt^uxbV;eJ@ea^1!vw#UFDaY@k{HWA zenUe%yIx)`XT5NxkT(QHEj^dgA^2kFL9Q^Uj*GY5AI_%3mfq!zo$VTfVXIj$G_Jh< z*w2}oM>F?a^>7I?LPuTZgEKBS7e?snp1*Z2cIEqE|86htKRX{6Ysa?HR_R)kY_hhferj4ZXEGPEHfntk>~7uO8B)fc?n* zocQXW6g?eHui#tHKKXf=_uI2|NVwUjRb97XSij@BJG)U~s860S?*MM(o>yb zKd-f(iXG8iWqreJs9fQ;tcBU%jy?VMK3cwX_-3aNwapG;W~r;X9f2>zVK-v#zl`V@ z`fNeFHJ5@`#GWyj`fr#@T)0V)jh6gukl&E!U+y0rB6-3gr(^}|g{y0__thwc6+R4AG(-qS?#Pj}6W0}Y_Mqh&qph2?lRG&2UMdbX|ra_iL@?a<(7^`KAO!(_Wir;8JG`?{tmM4J>GSN@OP3FuE6 zv;RbH_N3<@0}7V@m%sl|DSa4iUI3$o0n)!q=WGqCjPn(jaE*|_%RkPBpQgsb)A%}X zK;BUa|0sn%?kf)Lr{ADy4s}*sWIb1D$Q4zIw69LC#iK~}+bq|fdJsURnJzsLbumyU z_S^YIL3z?{ZNII%ZBXiuIkq=!X-i)%G0RUgR(0aI_dB8%hwI3O4x%Z%O?R48JdUKj z_r8?E`J@c_JP?h$%5en?58YU+w<<@-zG)}r;Poa)S4;dTeSX7<&zX<&bn$?|S}ODDd4C3)TXQl3HZr*PVxw;ix4P}_?BdeO#Rb9}YV zuXH|5-C1h!qX#3Tz*oY*6}L-aHi$}ZYO6ok`WOG_Ia1m-(CexF$| z%QvV)^4qo830fWbAm*sq7Wy39TQt z(Bf&?jhMJHbBnb1_l9SCwqFyn2fNLerRsGU@^UFTf>hYv|CQ^^3){8Rq@`$C>!*&)DiN)HbiVyNVB>W}+7xH6r`pteu8TUGGHJ#VX>#EEr6(84^3Zwg~ zoJ^WXN;p^gVwM**_IP2`40S14n#RGtBl7gix+g=ItTC+vX1C*tyYz^HV6`@$1%tO3 zElfUtT#iXHJW{ehD=cv=DPijcr!Qps{USG;!yXQ!FVE9!-R7;7V~4oVtOJSTS0~*_ zo<{$lT<=g>MPEQ8jR63;T=lGnWviv+Ye>C_ZT9o{6i1+8f zD@U%5_P5zuIBD_jTW=^`7WiZH?|r-SsY|-7ll|bg`cEw<%X*k;s~?;5X7I_PBfhBE zesSS6`cCJo_L6Bioo21SeYnVSYthW2FWY@h8T&iRQl;Ueu8X6w@rirA7_E=AW7zsC zZVk)%>O+ft*j}0*t%*&~F1qHpyY2jMPLy>PUDO7eE(lKo)Tgi`6J`gFP#hY*+Iapl zlTjD8_WY{B40Sl;>Yb%?bv#M?&J!bdnUCvQkK1Yz61Vh z3~G|!h@#*y*^njMUdugrWozoaf1Fc*isg!=luhK1y7aC#<2uk;b~-Uf_O#c0Ld_}i zO#GN)Z0dbrTvw8EbZ3?ly+19>Vwhv!g{73EtNle{t9#$qwYx=!k6!E@;gdF*9zU$f8zTeiEPB`2jqq5yc z3|iKaz5N3M5+YLNfvL*+mF!+H_m6c(gG4DmW`qBO1?HPqVI_Gussov%Dnu?CCy5* z!L%r*6Bv$04;A_E8;lQ|2r?;yc%5*3TVTIxR-gBrU?6zD!Q<$)JQ8zFzOVK5y!hR^sYUAr{b5^Ry%g+os!7yw`p0Od)<;p1}4pXP0O07zV6=` zWuNG_o!{x7eLt_v52%^;GG9S!d6?7VeKEdL$Fsr?58kP!o1EIeh}BIy(sfeN(Ud^i zlrDpBbtBD>DEBldnet;#$dQhpJGVO0V^xCJ+ubJ8pnv7oyTC9tBL3+#Ypt?`>a26t z@s!eq@|R4cvEWuiA5+@-kQp=1{HHCo^;T%EN6KBA`HoSCd@*o$9rU;vMpIumb?xKe zE^C-?q`&3u`7o2A71cd<9F|PRG}R52qVv7IO-4NbP8+@S$h9fnQ?ngz7kds=B59jm zDA5GZVb;Uzx3=~y9lp+TThO8M4eyd@^PCfwz%#P*rKU{Ur?5RH{cfsbd zK{u12Q@wOlFbk~~b3A%U0tlowPAHCcku4(Y9gXQ<}KT_W5kBiejl4(&uIWDpEUgXBiC5zm$o%}x7 z=nRvXueZoik=dR>k(ZtIWi(J_uqcw2KaP>r(<{~ln>vSam|74@vr0J9vD5cty)krc ztMQjpT+Yt;C_%S)Dl>h(>4WY%6Xr))rL^?up{ZeRnv`I;MW-XOWBGJHFa+z56L$`~ zw52W6F>cXfnJ}{L(Io>g>J#eD*ts9trakL)?b0o z=NI&m@VAINA!!S>jWbnl8qGT?aiX93qMr_oyhrb5s$tI@mvo##G4&cqA3WOMF*&}c zZhKoByB22*u;7(abHg>qbjta#wda_Kn=X_>|E#Ol%P+P;Pjc_{AZT$K^T5`g=N^`B zQO{n=$P~qN!nSSKGwxu!75Zg<){QWRxe{#Bxaniz+j3Js_)SF@U?K(0v~d1N*q(tH z8@LyUgpdFs0YU zBtS@jkN_b8LIQ*Y2ni4pAS6IYfRI3i1hhT#WdYm(sDBh}%BPZ-BQ}j=^1KpdkIIx)INT|7P34rdM{sjG# zS69(JvH^3}51pmCu-^so$4>&F`<4J{zq1XH*W^_`_xSUVQVW(pKi-Kljs!sWvwm0o zlh^UkJ;x5pbgWshB#`*yCxJm;ObqlN`McQwnZj51oIb&V^+3;=iMA*r{`g1$bgv0e z^1Ijo*+Br^=kb z0Pw9j+W^@~NZsRhv6!`)Klel_k^ty_b+gsKGw9wzi02;HJxa}41v*PvJqRuHmjLKq z8sKHKvjJDo{o;D+9=8Jv)@Ab%=dZx2JSF&yi{M~ne?)v}~HK`3yLHE`o)IHt@ShAM=-P#q5d(i#P zCeyzM=zd8JpFXa?hNkAMEb?+zJHe#z_ud7%R{;1=>^6XOZ_`M+$8BdZYv13iS;096 z-CK)Q|C{p5A%3Iw`5at#qDhOPf9Wr{B>v7jwKf+>fc~F|#Rib>SBORT_}sv)zf-Hi zaSgg32~gJ9Ho%VE5zVtLc>iIY3!AzUe|1R!bbp~S^&FdlWPNPEs4k&r{u0pI zlt%*n-)o2suw!?GiQmAV(Z)_KSogH4a7F_0$5#TNdtHFi`q%(@BRu!bQTMoQS?4a~ z%REuYmH_D9PiXx&Bz^&TF=B z$@1XCI#I-x0O+0~kp5j^KBu9vJ2iFGn0a$nK70?V5sKz70nmL5fE2zqAj;UC#?*Pu zwimN5^5>o?MG^qrPv@zBQOEAo)KMen!8bL8&@z7s%(+yKY~a=}Ho%VEX=1-_1MNfG zBR^VeYa#JH$lnBz{s#d3tgrzTzp?c9);$`paQT^62m3@ZNC0GYfGsbW!*BH1od!PF z0?ju-{1I3G8CstCtq3LamjKABz4oU3Z1VEo{JH1a7MhRd>K~=<{JAGewKwNW!gwiC zpl3@Q&u;^9pmpU{^pEDfRR}HDE&;Mf0h$vy2G4)v#q2gvpTgQDT4z{A|LFI*+PVL` zuMXS89{=ybWySXIi&Q<2ZVeDU1|0w>A*jKEExr0ooxbE>(tTCL8VQbda-u+eW zAWmm|83x{`02J3DAGZ7i+Mh|xy5*(witDiEPypW0y4YTSn+*WZMgaK&$frVvzndQz zy|)D9@5#{&wQyr*#D-#-SFI_w?5BlBh}FczePQGMg5}UpJoHVQzwArddQ=88KSuXQSRd-Y8$AW@2#DF z{{7#fKS}?f^R@u-BGWzI2MDugtElx`M|*YneCo0Z6#FvhPp|=P&pbKc<-VwOkGIz> zV?)4Ch2}1C8ta)xXzncktxW*l>s&8i`uWJ{4V3+M=MKS|PPE6*89sc~S01(3L$Sfl$|qp|92P%fF7Fk>@W5?4z<0L#c&PnG1!Q9kJiRF>vM;IzLky0 zzreC>l6Y~5O{%A_Phh@4w)b^BbDQPbA)w6+fM@KESnUF>4Hk2(SdG|jVRS0&z0L02 zA<*qifXcO6{Qa83@>{R_e>uCc5&Qo}?)Pi*{{dv(Y|b45S|$J#0@S3lY4a#nOdR_I zpz(g)v0b8;g=cdZ3xdu^0OU41-Q(YbjlCBfdguSTrbE=auC2WSka_o}v;n}c1Hf#q zy2t5iwEh0!eS%^UaXd|s1~iraf!F>3Uz&ht;pL6ac58%pe?8+mgr}eXd(}wXHUK*B z0f5FJqUNR1+7orG72kM{xf*&iC_l+m0*Gv^Q^pDYR+ zfNu&Ezj6GJ(>?wkSji_w0OHAO*{4O+dTOlplC?MIHR#--Ayjt!#{NH5_iVmuTRQ~b zhisqD@Au5tw57ET=>tGMoBn9s<9$GtGlGzR=r?-L*VK2-Jo3qRs*epoz4d>C?%6zU z$}c0Y%>5zc+T5D5Zm#*G!s;JnvJ{|s#BVg-_z39>&7GlhCNuze?=#)FG&Hu|pnX)x zg4zJwi_Tv{@nwxoS5vl+)*Yg=1v=nz(cp}A8h33<)5$c7ytx2pm1hd_mdmXgSUf_ME;of!tqXAGqc^Uxb7uoO2=E^4v=tI6~wB7*qWA&N` zLHi6t-8%rymCuII0f=nw8`?s96NqGA3TQ`AzQl1~F3_VawBiM<0ByGh=mRhk0L1}Y1EBt6GXU}< z`2w5)xC9Uca0MV3fCg|8;1s|SfL#D?0CoUqJURwo0D#!`Mdaf93aL2sB2FA<1ytu> ze;ept4?tY~nz(ELjq8U0^~x1c03a)L7AcCW5SM<$fhU%{C7;-5T5uca1^Q?FVK%T> zK=z5-Kmwq91%SXm$Oh1!d#wrO3N8WUHv*Y00YGc;#nDeUHjZtAb5GofO8|7Q0&w{c zumQAI@2}fu8rNe(Q;?kjz}?^64uTshhrhZ$XWj_e0rF=wt98#p*yY&2T8AQH9^~B( z0QtL6d_4NjZtVBBsM=2gzxN-#&$NhCEO0ZR`>p^M0LKAd0HFCOQR%BO+F2BtS@jkN_b8LIQ*Y2ni4pAS6IYfRF$o0YUos(C?%WLNA$zV;5Rpe_j#3Nt9 z5Le?Bd9HYk8v2PK+nxrpvO22RiOVh&t@pSvVAr~ z^cs0;WuDDoWtPog6>``NRw0L-uR;!n7_34Lh8Rk((jLmwtL6>MapUD&g?!awV6ZBB z#9&qQ7-*~-deB%k^uOdKtCIr^RwD-(qH^%#dqU#msBy`{^Z0%4>Fff9iah6r3N|?m z0o$DWDohloQ>~o)$K?YzNu1A$JXa2E4YI3n2&&A}*%vCFIWB_AJO@E#ugT>E$B$E3 znde}v%oD$m1kk3@MgUX*64o95hd(0>+cS^@>^$)wApt@Hgail)5E39HKuCax1Ym6o z+AGWuz^Kt-Q@#?O&Xdq54@8A9*9B)==`NR&+oHfIX5N0wc!Td z(Oz;5S`$v*C;;!A^G#VgmaJ~I(b1IGfPepb+Wg1mayumJwUc&-W379v1NdGU*1T(bL9Z%IvI$IH$m|L@2e31qCS`W zm%anW4}j+h?BU+5_I(AKq``mjq?6WLu|K^;?k5*Ba0pAk=knL5~L$%*z3ViGG>Bs6*rtMno*{_Q4CD0-*YCdag z5AdKfG4lBFj+cpa>pY>e}cPHTwNuadF0<73aW- zl5f-&@Qlta$r6}%{Cx=Lkk-f6tJSe)kNIljx#qP-nL7fWdjez%%RBpiZK+t0TEyrB)mjnhy)MgD(2JmY0({44_e zXnvxrFfxGqr2rcBYz#P`nXe4UYZ|QgsYO{$_GJ6955m2|wqLLNe~EWMV_W!cZY2B z?L49PA(|6`Z$uRTAO_rKyq*KVy+I84Yx0;$cFH@d4d2OP!i~nR@-<}u*OmeN;=gDl zn$Q^?R{&-KNORArZk+c_?a%R+TKG2rTATn-pZ^A+6hKruL1XbmfWrXj49Q>S?%-N? zfbRgJ!pW~46u;K9rVOg)p&X5Sv;YvjXm4+He&uO^8vro?@c?K%oD6{Wf`1AS4S@9O z3*Zi53ZMrdSG8?kzu`XQOZj#FtZ`%#$cv7qybk=&1t5LTA)ZnpZwS{RK`Ck<_UYhkU;zjbJqR&-$rT_> zM|nu;C0A!extu{6n?r;P7+}-DPT>Z2+JOJiJ`vgg7629dMD!x|i2!jBe}n`G3H$~UfbU=( z0DDmZ#G0&txf!vysziLd!e(qcmUc_ z3{^r+9;)o|QN6F^O?Tye<}5#P(a+VvIH12`KKz%M6+vjOe$P+nPyZ1Y4{#1VdX{NO z2R!%Ks7_y{ta)6AXZ{YfC$@EGfJ+_L5fnBwei(nA(V7HkbGF!Y0Pkdo<@<@_Z)mCw zdnW+>X#n+(?G%)Ez=7;PAI0d@D|WZ04WU?GcyBXYVx{Y;>=EwJ@e}#N2qe3xj(?CYP93yAGGgB zO`8xj59Y*QGC&Gp$O!2Gqi=~R?}(B$Y->GZ|*C{78<0IBOi-2ad#^9H+0xw=re6TQfgmlKME$(%+rxos# zt&`rWH=ud|_qpxVg@;p)`qRqqT>iYEwcgd&d-B4uwJKeL{05p^0^AmgcCOyhdK^J? zAjG;Iw4M$;&w~BIz36-WYJF(O>yY_Ybvs1U%MN z@mVLz%YM*x7e`CwMa%|R69?-r7)z)D`9{FcQNjr*{ zy#+8IpfvykprLeEtzXS|2W<}lSPgIjAQk|P&jgKEMc=P5{VXH5y6W zXp8T<4PLa==Q`}Cg1(pM0HAeK4WS#ai|k#jzB|M>;Oql{o()kvZ?B*ovH9+By@Brn z_0fANxOc9G5$WvE#TPg=PUEhCy{egkkC2l#WvH5Ky@( zN-%sx#7^NqSW&~o+<`M_8byq8(sXnyv8|<4!WC3oYy71RJM`6ygMjN4#)lva)cL&I?Nkg6c zz?-vn)Y4qNE&*=OYWUIqR6KlWRqUV$eCECDg|J2?z%2)W{LSUar!KHB-kf!{MpO09 z0&Wwuk6MM_st(^jTMlUcWmEpxdLyk;5%BkdJ&lU1$hYbmmX0_6127hz5IQD}cW*fE|E0 zz==A*v$7`f>u;0*$FETNKNGwqh2SA61b0jcKpA*N5R|jsFR-kP3a$w%+vVa8jtN8X zgBXGjL=Bu33h4lJ@N!T&0NxGoa7dzxs1hB3iToPX{*a#n+!AbGMTMUNMr_UX++ld0 z&|V7hzkq9gJjA@&0V{m}pdR|Zz6(&2`RI1St}3VKK4?N_Au zSg4lx5A^K{UO~3J-D|7MzUGqm5Y7QWW7LW^tNgymt=|i-83Uj=ZwdhFK2TqZ + + + + + + + + + diff --git a/client/ui/assets/svg/netbird-menu.svg b/client/ui/assets/svg/netbird-menu.svg new file mode 100644 index 000000000..bd4e9d65d --- /dev/null +++ b/client/ui/assets/svg/netbird-menu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go new file mode 100644 index 000000000..28efe7cfd --- /dev/null +++ b/client/ui/authsession/service.go @@ -0,0 +1,116 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "context" + "time" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +type ExtendStartParams struct { + // Hint is the OIDC login_hint, typically the user's email. + Hint string `json:"hint"` +} + +type ExtendStartResult struct { + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + UserCode string `json:"userCode"` + DeviceCode string `json:"deviceCode"` + ExpiresIn int64 `json:"expiresIn"` +} + +type ExtendWaitParams struct { + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` +} + +// ExtendResult: ExpiresAt is nil when the peer is ineligible for extension. +// Preempted means a newer WaitExtend took over the IdP poll — a no-op, not a failure. +type ExtendResult struct { + ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` + Preempted bool `json:"preempted,omitempty"` +} + +// DaemonConn duplicates services.DaemonConn to avoid an import cycle. +type DaemonConn interface { + Client() (proto.DaemonServiceClient, error) +} + +// Session bundles the session-auth daemon RPCs the UI drives. +type Session struct { + conn DaemonConn +} + +func NewSession(conn DaemonConn) *Session { + return &Session{conn: conn} +} + +// RequestExtend starts the SSO session-extension flow on the daemon. +func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { + cli, err := s.conn.Client() + if err != nil { + return ExtendStartResult{}, err + } + + req := &proto.RequestExtendAuthSessionRequest{} + if p.Hint != "" { + h := p.Hint + req.Hint = &h + } + + resp, err := cli.RequestExtendAuthSession(ctx, req) + if err != nil { + return ExtendStartResult{}, err + } + + return ExtendStartResult{ + VerificationURI: resp.GetVerificationURI(), + VerificationURIComplete: resp.GetVerificationURIComplete(), + UserCode: resp.GetUserCode(), + DeviceCode: resp.GetDeviceCode(), + ExpiresIn: resp.GetExpiresIn(), + }, nil +} + +// WaitExtend blocks until the user completes the SSO flow started by RequestExtend. +func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { + cli, err := s.conn.Client() + if err != nil { + return ExtendResult{}, err + } + + resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{ + DeviceCode: p.DeviceCode, + UserCode: p.UserCode, + }) + if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled { + return ExtendResult{Preempted: true}, nil + } + return ExtendResult{}, err + } + + out := ExtendResult{} + if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + t := ts.AsTime().UTC() + out.ExpiresAt = &t + } + return out, nil +} + +// DismissWarning suppresses the daemon's T-FinalWarningLead fallback dialog for +// the current deadline. Best-effort: a stale call is silently swallowed daemon-side. +func (s *Session) DismissWarning(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{}) + return err +} diff --git a/client/ui/authsession/warning.go b/client/ui/authsession/warning.go new file mode 100644 index 000000000..91ae7f101 --- /dev/null +++ b/client/ui/authsession/warning.go @@ -0,0 +1,64 @@ +//go:build !android && !ios && !freebsd && !js + +// Package authsession holds the UI-side domain logic for the SSO +// session-extend feature. The Wails facades in client/ui/services/session*.go +// are thin adapters over these types. +package authsession + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" +) + +// Re-exported from sessionwatch so UI-side consumers don't import the +// daemon-internal package directly. +const ( + MetaWarning = sessionwatch.MetaSessionWarning + MetaFinal = sessionwatch.MetaSessionFinal + MetaExpiresAt = sessionwatch.MetaSessionExpiresAt + MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes + MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected +) + +// Warning is the typed payload emitted on the session-warning Wails events. +type Warning struct { + // Absolute UTC deadline; best-effort, stays zero when metadata is + // missing or malformed (e.g. an older daemon) and the UI falls back + // to the Status snapshot. + ExpiresAt time.Time `json:"sessionExpiresAt"` + // Configured lead time, so the UI need not hardcode the constant. + LeadMinutes int `json:"leadMinutes"` + // True on the final-warning fallback event. + Final bool `json:"final"` +} + +// WarningFromMetadata parses SystemEvent metadata into a Warning, or returns +// (nil, false) when the event is not a session-warning. A field that fails to +// parse stays zero; the event is still surfaced. +func WarningFromMetadata(meta map[string]string) (*Warning, bool) { + if meta == nil || meta[MetaWarning] != "true" { + return nil, false + } + + out := &Warning{ + Final: meta[MetaFinal] == "true", + } + if raw := meta[MetaExpiresAt]; raw != "" { + if t, err := sessionwatch.ParseExpiresAt(raw); err == nil { + out.ExpiresAt = t + } + } + if raw := meta[MetaLeadMinutes]; raw != "" { + if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil { + out.LeadMinutes = n + } + } + return out, true +} + +// ParseExpiresAt re-exports sessionwatch.ParseExpiresAt so UI-side call sites +// don't import the daemon-internal package. +func ParseExpiresAt(s string) (time.Time, error) { + return sessionwatch.ParseExpiresAt(s) +} diff --git a/client/ui/authsession/warning_test.go b/client/ui/authsession/warning_test.go new file mode 100644 index 000000000..297073ded --- /dev/null +++ b/client/ui/authsession/warning_test.go @@ -0,0 +1,82 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "testing" + "time" +) + +func TestWarningFromMetadata_NotASessionWarning(t *testing.T) { + cases := []struct { + name string + meta map[string]string + }{ + {"nil metadata", nil}, + {"empty map", map[string]string{}}, + {"unrelated event", map[string]string{"new_version_available": "0.65.0"}}, + {"flag not 'true'", map[string]string{"session_warning": "1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if w, ok := WarningFromMetadata(tc.meta); ok { + t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok) + } + }) + } +} + +func TestWarningFromMetadata_FullPayload(t *testing.T) { + ts := "2026-05-18T13:30:00Z" + meta := map[string]string{ + "session_warning": "true", + "session_expires_at": ts, + "lead_minutes": "10", + } + + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("expected the warning to be recognised, got ok=false") + } + want, _ := time.Parse(time.RFC3339, ts) + if !got.ExpiresAt.Equal(want.UTC()) { + t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC()) + } + if got.LeadMinutes != 10 { + t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes) + } +} + +func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) { + // Older or buggy daemon: the flag is set but the timestamp/lead are + // missing or malformed. The UI should still get a warning so it can + // at least surface "session expires soon"; field zero-values are fine. + meta := map[string]string{ + "session_warning": "true", + "session_expires_at": "not-a-timestamp", + "lead_minutes": "abc", + } + + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("warning should still be recognised even with malformed fields") + } + if !got.ExpiresAt.IsZero() { + t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt) + } + if got.LeadMinutes != 0 { + t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes) + } +} + +func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) { + // Only the flag is present (e.g. future-trimmed event). Still emit. + meta := map[string]string{"session_warning": "true"} + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("warning should still be recognised when only flag is present") + } + if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 { + t.Errorf("missing fields should be zero-valued, got %+v", got) + } +} diff --git a/client/ui/build/Taskfile.yml b/client/ui/build/Taskfile.yml new file mode 100644 index 000000000..590d4791b --- /dev/null +++ b/client/ui/build/Taskfile.yml @@ -0,0 +1,295 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate" + cmds: + - pnpm install + + build:frontend: + label: build:frontend (DEV={{.DEV}}) + summary: Build the frontend project + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - pnpm run {{.BUILD_COMMAND}} + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' + vars: + BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}' + + + frontend:vendor:puppertino: + summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling + sources: + - frontend/public/puppertino/puppertino.css + generates: + - frontend/public/puppertino/puppertino.css + cmds: + - | + set -euo pipefail + mkdir -p frontend/public/puppertino + # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error. + if [ ! -f frontend/public/puppertino/puppertino.css ]; then + echo "No bundled Puppertino found. Attempting to fetch from GitHub..." + if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then + curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true + echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css" + else + echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it." + fi + else + echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css" + fi + # Ensure index.html includes Puppertino CSS and button classes + INDEX_HTML=frontend/index.html + if [ -f "$INDEX_HTML" ]; then + if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then + # Insert Puppertino link tag after style.css link + awk ' + /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1 + ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML" + fi + # Replace default .btn with Puppertino primary button classes if present + sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true + fi + + + generate:bindings: + label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}}) + summary: Generates bindings for the frontend + deps: + - task: go:mod:tidy + sources: + - "**/*.[jt]s" + - exclude: frontend/**/* + - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output + - "**/*.go" + - go.mod + - go.sum + generates: + - frontend/bindings/**/* + cmds: + - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts + + generate:icons: + summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms). + dir: build + sources: + - "appicon.png" + - "appicon.icon" + generates: + - "darwin/icons.icns" + - "windows/icon.ico" + cmds: + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin + + generate:tray:icons: + summary: Rebuild Windows multi-res .ico files from the per-state PNGs. + desc: | + The colored tray PNGs (assets/netbird-systemtray-.png) and the + macOS template variants are committed to the repo as the canonical + source. This task only regenerates the Windows multi-resolution .ico + files from those PNGs by downscaling each to 16/24/32/48 px and + packing them with icotool, so Shell_NotifyIcon picks the frame + matching the user's DPI instead of downscaling a single large PNG. + + Run after replacing any of the colored PNGs (e.g. when copying a new + version of the icons from client/ui/assets). The SVG sources in + assets/svg/ are kept for reference but are not built by default. + dir: assets + sources: + - "netbird-systemtray-connected.png" + - "netbird-systemtray-disconnected.png" + - "netbird-systemtray-connecting.png" + - "netbird-systemtray-error.png" + - "netbird-systemtray-update-connected.png" + - "netbird-systemtray-update-disconnected.png" + generates: + - "netbird-systemtray-*.ico" + preconditions: + - sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 + msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)" + - sh: command -v icotool >/dev/null 2>&1 + msg: "icotool is required to pack tray .ico files (apt install icoutils)" + cmds: + - | + set -euo pipefail + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + resize=$(command -v magick || echo convert) + for state in connected disconnected connecting error update-connected update-disconnected; do + for sz in 16 24 32 48; do + "$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png" + done + icotool -c -o "netbird-systemtray-$state.ico" \ + "$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png" + done + + dev:frontend: + summary: Runs the frontend in development mode + dir: frontend + deps: + - task: install:frontend:deps + cmds: + - pnpm exec vite --port {{.VITE_PORT}} --strictPort + + update:build-assets: + summary: Updates the build assets + dir: build + cmds: + - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + desc: | + Builds the application with the server build tag enabled. + Server mode runs as a pure HTTP server without native GUI dependencies. + Usage: task build:server + deps: + - task: build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}} + vars: + BUILD_FLAGS: "{{.BUILD_FLAGS}}" + + run:server: + summary: Builds and runs the application in server mode + deps: + - task: build:server + cmds: + - ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}} + + build:docker: + summary: Builds a Docker image for server mode deployment + desc: | + Creates a minimal Docker image containing the server mode binary. + The image is based on distroless for security and small size. + Usage: task build:docker [TAG=myapp:latest] + cmds: + - docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server . + vars: + TAG: "{{.TAG}}" + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + - sh: test -f build/docker/Dockerfile.server + msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it." + + run:docker: + summary: Builds and runs the Docker image + desc: | + Builds the Docker image and runs it, exposing port 8080. + Usage: task run:docker [TAG=myapp:latest] [PORT=8080] + Note: The internal container port is always 8080. The PORT variable + only changes the host port mapping. Ensure your app uses port 8080 + or modify the Dockerfile to match your ServerOptions.Port setting. + deps: + - task: build:docker + vars: + TAG: + ref: .TAG + cmds: + - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}} + vars: + TAG: "{{.TAG}}" + PORT: "{{.PORT}}" + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + desc: | + Builds the Docker image needed for cross-compiling to any platform. + Run this once to enable cross-platform builds from any OS. + cmds: + - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/ + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + + ios:device:list: + summary: Lists connected iOS devices (UDIDs) + cmds: + - xcrun xcdevice list + + ios:run:device: + summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl) + vars: + PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj + SCHEME: '{{.SCHEME}}' # e.g., ios.dev + CONFIG: '{{.CONFIG | default "Debug"}}' + DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}' + UDID: '{{.UDID}}' # from `task ios:device:list` + BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev + TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing + preconditions: + - sh: xcrun -f xcodebuild + msg: "xcodebuild not found. Please install Xcode." + - sh: xcrun -f devicectl + msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)." + - sh: test -n '{{.PROJECT}}' + msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)." + - sh: test -n '{{.SCHEME}}' + msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)." + - sh: test -n '{{.UDID}}' + msg: "Set UDID to your device UDID (see: task ios:device:list)." + - sh: test -n '{{.BUNDLE_ID}}' + msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)." + cmds: + - | + set -euo pipefail + echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}" + XCB_ARGS=( + -project "{{.PROJECT}}" + -scheme "{{.SCHEME}}" + -configuration "{{.CONFIG}}" + -destination "id={{.UDID}}" + -derivedDataPath "{{.DERIVED}}" + -allowProvisioningUpdates + -allowProvisioningDeviceRegistration + ) + # Optionally inject signing identifiers if provided + if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi + if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi + xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true + # If xcpretty isn't installed, run without it + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + xcodebuild "${XCB_ARGS[@]}" build + fi + # Find built .app + APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2 + exit 1 + fi + echo "Installing: $APP_PATH" + xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH" + echo "Launching: {{.BUNDLE_ID}}" + xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}" diff --git a/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg new file mode 100644 index 000000000..83c4c22a9 --- /dev/null +++ b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/client/ui/build/appicon.icon/icon.json b/client/ui/build/appicon.icon/icon.json new file mode 100644 index 000000000..4a0371af3 --- /dev/null +++ b/client/ui/build/appicon.icon/icon.json @@ -0,0 +1,26 @@ +{ + "fill" : { + "solid" : "srgb:1.00000,1.00000,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "wails_icon_vector.svg", + "name" : "wails_icon_vector" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/client/ui/build/appicon.png b/client/ui/build/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..977d2400a04587b0ced0c7ee5218b4ee3940e021 GIT binary patch literal 90556 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 W$z{*+P_040KUF2I+xZISFa95i8Dww( literal 0 HcmV?d00001 diff --git a/client/ui/build/build-ui-linux.sh b/client/ui/build/build-ui-linux.sh deleted file mode 100644 index eab08214d..000000000 --- a/client/ui/build/build-ui-linux.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -sudo apt update -sudo apt remove gir1.2-appindicator3-0.1 -sudo apt install -y libayatana-appindicator3-dev -go build \ No newline at end of file diff --git a/client/ui/build/config.yml b/client/ui/build/config.yml new file mode 100644 index 000000000..08b95b6bd --- /dev/null +++ b/client/ui/build/config.yml @@ -0,0 +1,78 @@ +# This file contains the configuration for this project. +# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets. +# Note that this will overwrite any changes you have made to the assets. +version: '3' + +# This information is used to generate the build assets. +info: + companyName: "NetBird GmbH" # The name of the company + productName: "NetBird" # The name of the application + productIdentifier: "io.netbird.client" # The unique product identifier + description: "NetBird desktop client" # The application description + copyright: "NetBird GmbH" # Copyright text + comments: "Some Product Comments" # Comments + version: "0.0.1" # The application version + # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional) + # # Should match the name of your .icon file without the extension + # # If not set and Assets.car exists, defaults to "appicon" + +# iOS build configuration (uncomment to customise iOS project generation) +# Note: Keys under `ios` OVERRIDE values under `info` when set. +# ios: +# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier) +# bundleID: "com.mycompany.myproduct" +# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName) +# displayName: "My Product" +# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion) +# version: "0.0.1" +# # The company/organisation name for templates and project settings +# company: "My Company" +# # Additional comments to embed in Info.plist metadata +# comments: "Some Product Comments" + +# Dev mode configuration +dev_mode: + root_path: . + log_level: warn + debounce: 1000 + ignore: + dir: + - .git + - node_modules + - frontend + - bin + file: + - .DS_Store + - .gitignore + - .gitkeep + watched_extension: + - "*.go" + - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive. + - "*.ts" # The frontend directory will be excluded entirely by the setting above. + git_ignore: true + executes: + - cmd: wails3 build DEV=true + type: blocking + - cmd: wails3 task common:dev:frontend + type: background + - cmd: wails3 task run + type: primary + +# File Associations +# More information at: https://v3.wails.io/noit/done/yet +fileAssociations: +# - ext: wails +# name: Wails +# description: Wails Application File +# iconName: wailsFileIcon +# role: Editor +# - ext: jpg +# name: JPEG +# description: Image File +# iconName: jpegFileIcon +# role: Editor +# mimeType: image/jpeg # (optional) + +# Other data +other: + - name: My Other Data \ No newline at end of file diff --git a/client/ui/build/darwin/Info.dev.plist b/client/ui/build/darwin/Info.dev.plist new file mode 100644 index 000000000..78f5a7b1c --- /dev/null +++ b/client/ui/build/darwin/Info.dev.plist @@ -0,0 +1,38 @@ + + + + CFBundlePackageType + APPL + CFBundleName + NetBird + CFBundleDisplayName + NetBird + CFBundleExecutable + netbird-ui + CFBundleIdentifier + io.netbird.client + CFBundleVersion + 0.0.1 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.0.1 + CFBundleIconFile + icons + CFBundleIconName + appicon + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + LSUIElement + 1 + NSHumanReadableCopyright + NetBird GmbH + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + \ No newline at end of file diff --git a/client/ui/build/darwin/Info.plist b/client/ui/build/darwin/Info.plist new file mode 100644 index 000000000..1e12b049b --- /dev/null +++ b/client/ui/build/darwin/Info.plist @@ -0,0 +1,36 @@ + + + + CFBundlePackageType + APPL + CFBundleName + NetBird + CFBundleDisplayName + NetBird + CFBundleExecutable + netbird-ui + CFBundleIdentifier + io.netbird.client + CFBundleVersion + 0.0.1 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.0.1 + CFBundleIconFile + icons + CFBundleIconName + appicon + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + + LSUIElement + 1 + NSHumanReadableCopyright + NetBird GmbH + + \ No newline at end of file diff --git a/client/ui/build/darwin/Taskfile.yml b/client/ui/build/darwin/Taskfile.yml new file mode 100644 index 000000000..8a5c27bdc --- /dev/null +++ b/client/ui/build/darwin/Taskfile.yml @@ -0,0 +1,210 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)" + # KEYCHAIN_PROFILE: "my-notarize-profile" + # ENTITLEMENTS: "build/darwin/entitlements.plist" + + # Docker image for cross-compilation (used when building on non-macOS) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application + cmds: + - task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + + build:native: + summary: Builds the application natively on macOS + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + CGO_CFLAGS: "-mmacosx-version-min=10.15" + CGO_LDFLAGS: "-mmacosx-version-min=10.15" + MACOSX_DEPLOYMENT_TARGET: "10.15" + + build:docker: + summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for cross-compilation. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - mkdir -p {{.BIN_DIR}} + - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" + vars: + DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + # Handles both relative (=> ../) and absolute (=> /) paths + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + build:universal: + summary: Builds darwin universal binary (arm64 + amd64) + deps: + - task: build + vars: + ARCH: amd64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" + - task: build + vars: + ARCH: arm64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + cmds: + - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}' + + build:universal:lipo:native: + summary: Creates universal binary using native lipo (macOS) + internal: true + cmds: + - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + build:universal:lipo:go: + summary: Creates universal binary using wails3 tool lipo (Linux/Windows) + internal: true + cmds: + - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + package: + summary: Packages the application into a `.app` bundle + deps: + - task: build + cmds: + - task: create:app:bundle + + package:universal: + summary: Packages darwin universal binary (arm64 + amd64) + deps: + - task: build:universal + cmds: + - task: create:app:bundle + + + create:app:bundle: + summary: Creates an `.app` bundle + cmds: + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + fi + - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" + - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents" + - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}' + + codesign:adhoc: + summary: Ad-hoc signs the app bundle (macOS only) + internal: true + cmds: + - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app" + + codesign:skip: + summary: Skips codesigning when cross-compiling + internal: true + cmds: + - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."' + + run: + deps: + - task: common:generate:icons + cmds: + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + fi + - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" + - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist" + - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" + - '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}' + + sign: + summary: Signs the application bundle with Developer ID + desc: | + Signs the .app bundle for distribution. + Configure SIGN_IDENTITY in the vars section at the top of this file. + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_IDENTITY}}" ]' + msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" + + sign:notarize: + summary: Signs and notarizes the application bundle + desc: | + Signs the .app bundle and submits it for notarization. + Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file. + + Setup (one-time): + wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile" + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}} + preconditions: + - sh: '[ -n "{{.SIGN_IDENTITY}}" ]' + msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" + - sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]' + msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" diff --git a/client/ui/build/darwin/icons.icns b/client/ui/build/darwin/icons.icns new file mode 100644 index 0000000000000000000000000000000000000000..fb78a18a98a10de8864b2652083800a679808444 GIT binary patch literal 141191 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

vT@rDm(OaHzFd(h}w@UJg|bH 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 literal 0 HcmV?d00001 diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross new file mode 100644 index 000000000..a487b8db0 --- /dev/null +++ b/client/ui/build/docker/Dockerfile.cross @@ -0,0 +1,203 @@ +# Cross-compile Wails v3 apps to any platform +# +# Darwin: Zig + macOS SDK +# Linux: Native GCC when host matches target, Zig for cross-arch +# Windows: Zig + bundled mingw +# +# Usage: +# docker build -t wails-cross -f Dockerfile.cross . +# docker run --rm -v $(pwd):/app wails-cross darwin arm64 +# docker run --rm -v $(pwd):/app wails-cross darwin amd64 +# docker run --rm -v $(pwd):/app wails-cross linux amd64 +# docker run --rm -v $(pwd):/app wails-cross linux arm64 +# docker run --rm -v $(pwd):/app wails-cross windows amd64 +# docker run --rm -v $(pwd):/app wails-cross windows arm64 + +FROM golang:1.25-bookworm + +ARG TARGETARCH + +# Install base tools, GCC, and GTK/WebKit dev packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl xz-utils nodejs npm pkg-config gcc libc6-dev \ + libgtk-3-dev libwebkit2gtk-4.1-dev \ + libgtk-4-dev libwebkitgtk-6.0-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Zig - automatically selects correct binary for host architecture +ARG ZIG_VERSION=0.14.0 +RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \ + | tar -xJ -C /opt \ + && ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig + +# Download macOS SDK (required for darwin targets) +ARG MACOS_SDK_VERSION=14.5 +RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \ + | tar -xJ -C /opt \ + && mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk + +ENV MACOS_SDK_PATH=/opt/macos-sdk + +# Create Zig CC wrappers for cross-compilation targets +# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch) + +# Darwin arm64 +COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -mmacosx-version-min=*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-darwin-arm64 + +# Darwin amd64 +COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -mmacosx-version-min=*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-darwin-amd64 + +# Windows amd64 - uses Zig's bundled mingw +COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -Wl,*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -target x86_64-windows-gnu $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-windows-amd64 + +# Windows arm64 - uses Zig's bundled mingw +COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -Wl,*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -target aarch64-windows-gnu $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-windows-arm64 + +# Build script +COPY <<'SCRIPT' /usr/local/bin/build.sh +#!/bin/sh +set -e + +OS=${1:-darwin} +ARCH=${2:-arm64} + +case "${OS}-${ARCH}" in + darwin-arm64|darwin-aarch64) + export CC=zcc-darwin-arm64 + export GOARCH=arm64 + export GOOS=darwin + ;; + darwin-amd64|darwin-x86_64) + export CC=zcc-darwin-amd64 + export GOARCH=amd64 + export GOOS=darwin + ;; + linux-arm64|linux-aarch64) + export CC=gcc + export GOARCH=arm64 + export GOOS=linux + ;; + linux-amd64|linux-x86_64) + export CC=gcc + export GOARCH=amd64 + export GOOS=linux + ;; + windows-arm64|windows-aarch64) + export CC=zcc-windows-arm64 + export GOARCH=arm64 + export GOOS=windows + ;; + windows-amd64|windows-x86_64) + export CC=zcc-windows-amd64 + export GOARCH=amd64 + export GOOS=windows + ;; + *) + echo "Usage: " + echo " os: darwin, linux, windows" + echo " arch: amd64, arm64" + exit 1 + ;; +esac + +export CGO_ENABLED=1 +export CGO_CFLAGS="-w" + +# Build frontend if exists and not already built (host may have built it) +if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then + (cd frontend && npm install --silent && npm run build --silent) +fi + +# Build +APP=${APP_NAME:-$(basename $(pwd))} +mkdir -p bin + +EXT="" +LDFLAGS="-s -w" +if [ "$GOOS" = "windows" ]; then + EXT=".exe" + LDFLAGS="-s -w -H windowsgui" +fi + +TAGS="production" +if [ -n "$EXTRA_TAGS" ]; then + TAGS="${TAGS},${EXTRA_TAGS}" +fi + +go build -tags "$TAGS" -trimpath -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} . +echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}" +SCRIPT +RUN chmod +x /usr/local/bin/build.sh + +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/build.sh"] +CMD ["darwin", "arm64"] diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server new file mode 100644 index 000000000..58fb64f76 --- /dev/null +++ b/client/ui/build/docker/Dockerfile.server @@ -0,0 +1,41 @@ +# Wails Server Mode Dockerfile +# Multi-stage build for minimal image size + +# Build stage +FROM golang:alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy source code +COPY . . + +# Remove local replace directive if present (for production builds) +RUN sed -i '/^replace/d' go.mod || true + +# Download dependencies +RUN go mod tidy + +# Build the server binary +RUN go build -tags server -ldflags="-s -w" -o server . + +# Runtime stage - minimal image +FROM gcr.io/distroless/static-debian12 + +# Copy the binary +COPY --from=builder /app/server /server + +# Copy frontend assets +COPY --from=builder /app/frontend/dist /frontend/dist + +# Expose the default port +EXPOSE 8080 + +# Bind to all interfaces (required for Docker) +# Can be overridden at runtime with -e WAILS_SERVER_HOST=... +ENV WAILS_SERVER_HOST=0.0.0.0 + +# Run the server +ENTRYPOINT ["/server"] diff --git a/client/ui/build/linux/Taskfile.yml b/client/ui/build/linux/Taskfile.yml new file mode 100644 index 000000000..94d041375 --- /dev/null +++ b/client/ui/build/linux/Taskfile.yml @@ -0,0 +1,235 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # PGP_KEY: "path/to/signing-key.asc" + # SIGN_ROLE: "builder" # Options: origin, maint, archive, builder + # + # Password is stored securely in system keychain. Run: wails3 setup signing + + # Docker image for cross-compilation (used when building on non-Linux or no CC available) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application for Linux + cmds: + # Linux requires CGO - use Docker when: + # 1. Cross-compiling from non-Linux, OR + # 2. No C compiler is available, OR + # 3. Target architecture differs from host architecture (cross-arch compilation) + - task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Determine target architecture (defaults to host ARCH if not specified) + TARGET_ARCH: '{{.ARCH | default ARCH}}' + # Check if a C compiler is available (gcc or clang) + HAS_CC: + sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"' + + build:native: + summary: Builds the application natively on Linux + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + - task: generate:dotdesktop + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + + build:docker: + summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + - task: generate:dotdesktop + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for cross-compilation to Linux. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - mkdir -p {{.BIN_DIR}} + - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + package: + summary: Packages the application for Linux + deps: + - task: build + cmds: + - task: create:appimage + - task: create:deb + - task: create:rpm + - task: create:aur + + create:appimage: + summary: Creates an AppImage + dir: build/linux/appimage + deps: + - task: build + - task: generate:dotdesktop + cmds: + - cp "{{.APP_BINARY}}" "{{.APP_NAME}}" + - cp ../../appicon.png "{{.APP_NAME}}.png" + - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build + vars: + APP_NAME: '{{.APP_NAME}}' + APP_BINARY: '../../../bin/{{.APP_NAME}}' + ICON: '{{.APP_NAME}}.png' + DESKTOP_FILE: '../{{.APP_NAME}}.desktop' + OUTPUT_DIR: '../../../bin' + + create:deb: + summary: Creates a deb package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:deb + + create:rpm: + summary: Creates a rpm package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:rpm + + create:aur: + summary: Creates a arch linux packager package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:aur + + generate:deb: + summary: Creates a deb package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:rpm: + summary: Creates a rpm package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:aur: + summary: Creates a arch linux packager package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:dotdesktop: + summary: Generates a `.desktop` file + dir: build + cmds: + - mkdir -p {{.ROOT_DIR}}/build/linux/appimage + - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}" + # Wrap Exec= with `env WEBKIT_DISABLE_DMABUF_RENDERER=1 ...` so launches + # from any desktop environment use the working renderer. See build/linux/Taskfile.yml :run for the matching dev-mode env block. + - sed -i -E 's|^Exec=([^ ]+)(.*)$|Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 \1\2|' {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop + vars: + APP_NAME: '{{.APP_NAME}}' + EXEC: '{{.APP_NAME}}' + ICON: '{{.APP_NAME}}' + CATEGORIES: 'Development;' + OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop' + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}' + env: + # WebKitGTK 2.50's default DMA-BUF renderer fails on RDP, VirtualBox/QEMU, + # and some bare WMs (Fluxbox, dwm) where DRM dumb-buffer access is + # restricted. Disabling it falls back to the GLES2/cairo path which works + # everywhere. Production launchers must set this too. + WEBKIT_DISABLE_DMABUF_RENDERER: "1" + + sign:deb: + summary: Signs the DEB package + desc: | + Signs the .deb package with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:deb + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}} + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" + + sign:rpm: + summary: Signs the RPM package + desc: | + Signs the .rpm package with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:rpm + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}} + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" + + sign:packages: + summary: Signs all Linux packages (DEB and RPM) + desc: | + Signs both .deb and .rpm packages with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + cmds: + - task: sign:deb + - task: sign:rpm + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" diff --git a/client/ui/build/linux/appimage/build.sh b/client/ui/build/linux/appimage/build.sh new file mode 100644 index 000000000..85901c34e --- /dev/null +++ b/client/ui/build/linux/appimage/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Copyright (c) 2018-Present Lea Anthony +# SPDX-License-Identifier: MIT + +# Fail script on any error +set -euxo pipefail + +# Define variables +APP_DIR="${APP_NAME}.AppDir" + +# Create AppDir structure +mkdir -p "${APP_DIR}/usr/bin" +cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/" +cp "${ICON_PATH}" "${APP_DIR}/" +cp "${DESKTOP_FILE}" "${APP_DIR}/" + +if [[ $(uname -m) == *x86_64* ]]; then + # Download linuxdeploy and make it executable + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage + + # Run linuxdeploy to bundle the application + ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage +else + # Download linuxdeploy and make it executable (arm64) + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage + chmod +x linuxdeploy-aarch64.AppImage + + # Run linuxdeploy to bundle the application (arm64) + ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage +fi + +# Rename the generated AppImage +mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage" + diff --git a/client/ui/build/linux/desktop b/client/ui/build/linux/desktop new file mode 100644 index 000000000..deadfe9f4 --- /dev/null +++ b/client/ui/build/linux/desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Name=NetBird +Comment=NetBird desktop client +# The Exec line includes %u to pass the URL to the application +Exec=/usr/local/bin/netbird-ui %u +Terminal=false +Type=Application +Icon=netbird-ui +Categories=Utility; +StartupWMClass=netbird-ui + + diff --git a/client/ui/build/linux/netbird-ui.desktop b/client/ui/build/linux/netbird-ui.desktop new file mode 100755 index 000000000..6b6ed42a5 --- /dev/null +++ b/client/ui/build/linux/netbird-ui.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=netbird-ui +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui +Icon=netbird-ui +Categories=Development; +Terminal=false +Keywords=wails +Version=1.0 +StartupNotify=false diff --git a/client/ui/build/netbird.desktop b/client/ui/build/linux/netbird.desktop similarity index 54% rename from client/ui/build/netbird.desktop rename to client/ui/build/linux/netbird.desktop index b3a1b92dc..a81f3698a 100644 --- a/client/ui/build/netbird.desktop +++ b/client/ui/build/linux/netbird.desktop @@ -1,8 +1,9 @@ [Desktop Entry] Name=Netbird -Exec=/usr/bin/netbird-ui +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui Icon=netbird Type=Application Terminal=false Categories=Utility; Keywords=netbird; +StartupWMClass=org.wails.netbird \ No newline at end of file diff --git a/client/ui/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml new file mode 100644 index 000000000..a05daef62 --- /dev/null +++ b/client/ui/build/linux/nfpm/nfpm.yaml @@ -0,0 +1,70 @@ +# Feel free to remove those if you don't want/need to use them. +# Make sure to check the documentation at https://nfpm.goreleaser.com +# +# The lines below are called `modelines`. See `:help modeline` + +name: "netbird-ui" +arch: ${GOARCH} +platform: "linux" +version: "0.0.1" +section: "default" +priority: "extra" +maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> +description: "NetBird desktop client" +vendor: "NetBird" +homepage: "https://wails.io" +license: "MIT" +release: "1" + +contents: + - src: "./bin/netbird-ui" + dst: "/usr/local/bin/netbird-ui" + - src: "./build/appicon.png" + dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png" + - src: "./build/linux/netbird-ui.desktop" + dst: "/usr/share/applications/netbird-ui.desktop" + +# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+) +depends: + - libgtk-4-1 + - libwebkitgtk-6.0-4 + - xdg-utils + +# Distribution-specific overrides for different package formats +overrides: + # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux + rpm: + depends: + - gtk4 + - webkitgtk6.0 + - xdg-utils + + # Arch Linux packages + archlinux: + depends: + - gtk4 + - webkitgtk-6.0 + - xdg-utils + +# scripts section to ensure desktop database is updated after install +scripts: + postinstall: "./build/linux/nfpm/scripts/postinstall.sh" + # You can also add preremove, postremove if needed + # preremove: "./build/linux/nfpm/scripts/preremove.sh" + # postremove: "./build/linux/nfpm/scripts/postremove.sh" + +# replaces: +# - foobar +# provides: +# - bar +# depends: +# - gtk3 +# - libwebkit2gtk +# recommends: +# - whatever +# suggests: +# - something-else +# conflicts: +# - not-foo +# - not-bar +# changelog: "changelog.yaml" diff --git a/client/ui/build/linux/nfpm/scripts/postinstall.sh b/client/ui/build/linux/nfpm/scripts/postinstall.sh new file mode 100644 index 000000000..4bbb815a3 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/postinstall.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# Update desktop database for .desktop file changes +# This makes the application appear in application menus and registers its capabilities. +if command -v update-desktop-database >/dev/null 2>&1; then + echo "Updating desktop database..." + update-desktop-database -q /usr/share/applications +else + echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2 +fi + +# Update MIME database for custom URL schemes (x-scheme-handler) +# This ensures the system knows how to handle your custom protocols. +if command -v update-mime-database >/dev/null 2>&1; then + echo "Updating MIME database..." + update-mime-database -n /usr/share/mime +else + echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2 +fi + +exit 0 diff --git a/client/ui/build/linux/nfpm/scripts/postremove.sh b/client/ui/build/linux/nfpm/scripts/postremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/postremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/nfpm/scripts/preinstall.sh b/client/ui/build/linux/nfpm/scripts/preinstall.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/preinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/nfpm/scripts/preremove.sh b/client/ui/build/linux/nfpm/scripts/preremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/preremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/windows/Taskfile.yml b/client/ui/build/windows/Taskfile.yml new file mode 100644 index 000000000..f51f7fbee --- /dev/null +++ b/client/ui/build/windows/Taskfile.yml @@ -0,0 +1,243 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # SIGN_CERTIFICATE: "path/to/certificate.pfx" + # SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE + # TIMESTAMP_SERVER: "http://timestamp.digicert.com" + # + # Password is stored securely in system keychain. Run: wails3 setup signing + + # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application for Windows + cmds: + # CGO Windows builds from Linux use mingw-w64 (lighter than docker). + # Docker is only needed if mingw-w64 is unavailable. + - task: build:native + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + + build:console: + summary: Builds a console-attached Windows binary so logs go to the terminal. + desc: | + Same as `windows:build` but links against the console PE subsystem + instead of windowsgui, so stdout/stderr (logrus, panics) print to the + terminal that launched the .exe. Useful for chasing tray, event-stream, + or daemon-RPC bugs that have no other feedback channel on Windows. + + Output is bin/netbird-ui-console.exe — kept distinct so the production + binary built by `windows:build` isn't shadowed. + + Cross-compile from Linux works the same way: + CGO_ENABLED=1 task windows:build:console + + Pass DEV=true to drop the `production` build tag so the WebKit/WebView2 + DevTools inspector (right-click → Inspect, or F12) stays enabled and the + frontend JS console is reachable — same DEV handling as windows:build: + CGO_ENABLED=1 task windows:build:console DEV=true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}' + msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)" + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-console.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + # Identical to build:native's flags (including DEV handling) except no + # -H windowsgui, so the binary attaches to the launching console. With + # DEV=true the `production` tag is dropped, keeping the WebKit/WebView2 + # DevTools inspector enabled so the frontend JS console is reachable. + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}' + env: + GOOS: windows + CGO_ENABLED: '{{.CGO_ENABLED}}' + GOARCH: '{{.ARCH | default ARCH}}' + CC: '{{.CC}}' + + build:native: + summary: Builds for Windows natively, or cross-compiles from Linux/macOS via mingw-w64. + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + # When cross-compiling with CGO from a non-Windows host, the mingw-w64 + # cross-gcc must be present. Native Windows builds skip this check. + - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}' + msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)" + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s -H windowsgui"{{end}}' + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}' + env: + GOOS: windows + CGO_ENABLED: '{{.CGO_ENABLED}}' + GOARCH: '{{.ARCH | default ARCH}}' + CC: '{{.CC}}' + + build:docker: + summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for CGO cross-compilation. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - task: generate:syso + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - rm -f *.syso + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + package: + summary: Packages the application + cmds: + - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}' + vars: + FORMAT: '{{.FORMAT | default "nsis"}}' + + generate:syso: + summary: Generates Windows `.syso` file + dir: build + cmds: + - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + vars: + ARCH: '{{.ARCH | default ARCH}}' + + create:nsis:installer: + summary: Creates an NSIS installer + dir: build/windows/nsis + deps: + - task: build + cmds: + # Create the Microsoft WebView2 bootstrapper if it doesn't exist + - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis" + - | + {{if eq OS "windows"}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi + {{else}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi + {{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}' + + create:msix:package: + summary: Creates an MSIX package + deps: + - task: build + cmds: + - |- + wails3 tool msix \ + --config "{{.ROOT_DIR}}/wails.json" \ + --name "{{.APP_NAME}}" \ + --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \ + --arch "{{.ARCH}}" \ + --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \ + {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \ + {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \ + {{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + CERT_PATH: '{{.CERT_PATH | default ""}}' + PUBLISHER: '{{.PUBLISHER | default ""}}' + USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' + + install:msix:tools: + summary: Installs tools required for MSIX packaging + cmds: + - wails3 tool msix-install-tools + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}.exe' + + sign: + summary: Signs the Windows executable + desc: | + Signs the .exe with an Authenticode certificate. + Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: build + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' + msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" + + sign:installer: + summary: Signs the NSIS installer + desc: | + Creates and signs the NSIS installer. + Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:nsis:installer + cmds: + - wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' + msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" diff --git a/client/ui/build/windows/icon.ico b/client/ui/build/windows/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7abbfa5a3276e654a567589fbafc3c2907a5076e GIT binary patch literal 18126 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 literal 0 HcmV?d00001 diff --git a/client/ui/build/windows/info.json b/client/ui/build/windows/info.json new file mode 100644 index 000000000..a67c8fd81 --- /dev/null +++ b/client/ui/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "0.0.1" + }, + "info": { + "0000": { + "ProductVersion": "0.0.1", + "CompanyName": "NetBird", + "FileDescription": "NetBird desktop client", + "LegalCopyright": "NetBird GmbH", + "ProductName": "NetBird", + "Comments": "This is a comment" + } + } +} \ No newline at end of file diff --git a/client/ui/build/windows/msix/app_manifest.xml b/client/ui/build/windows/msix/app_manifest.xml new file mode 100644 index 000000000..0ae55ce77 --- /dev/null +++ b/client/ui/build/windows/msix/app_manifest.xml @@ -0,0 +1,55 @@ + + + + + + + NetBird + NetBird + NetBird desktop client + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/build/windows/msix/template.xml b/client/ui/build/windows/msix/template.xml new file mode 100644 index 000000000..437a68097 --- /dev/null +++ b/client/ui/build/windows/msix/template.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + false + NetBird + NetBird + NetBird desktop client + Assets\AppIcon.png + + + + + + + diff --git a/client/ui/build/windows/nsis/project.nsi b/client/ui/build/windows/nsis/project.nsi new file mode 100644 index 000000000..8d2530972 --- /dev/null +++ b/client/ui/build/windows/nsis/project.nsi @@ -0,0 +1,114 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the wails_tools.nsh file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "my-project" # Default "netbird-ui" +## !define INFO_COMPANYNAME "My Company" # Default "NetBird" +## !define INFO_PRODUCTNAME "My Product Name" # Default "NetBird" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.0.1" +## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/client/ui/build/windows/nsis/wails_tools.nsh b/client/ui/build/windows/nsis/wails_tools.nsh new file mode 100644 index 000000000..b63101b32 --- /dev/null +++ b/client/ui/build/windows/nsis/wails_tools.nsh @@ -0,0 +1,236 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "netbird-ui" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "NetBird" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "NetBird" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "0.0.1" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "NetBird GmbH" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + +!macroend \ No newline at end of file diff --git a/client/ui/build/windows/wails.exe.manifest b/client/ui/build/windows/wails.exe.manifest new file mode 100644 index 000000000..f8b7b8e14 --- /dev/null +++ b/client/ui/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + + + + + + + diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go deleted file mode 100644 index 2b19c2bf5..000000000 --- a/client/ui/client_ui.go +++ /dev/null @@ -1,2016 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - _ "embed" - "errors" - "flag" - "fmt" - "net/url" - "os" - "os/exec" - "os/user" - "path" - "runtime" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/mdm" - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" - "github.com/netbirdio/netbird/client/ui/event" - "github.com/netbirdio/netbird/client/ui/notifier" - "github.com/netbirdio/netbird/client/ui/process" - "github.com/netbirdio/netbird/util" - - "github.com/netbirdio/netbird/version" -) - -const ( - defaultFailTimeout = 3 * time.Second - failFastTimeout = time.Second -) - -const ( - censoredPreSharedKey = "**********" - maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds - // mdmFieldSuffix is appended to plain-text Entry widgets in the - // advanced Settings window when the underlying field is enforced - // by MDM, so the user sees the lock indicator inline next to the - // value. Stripped before any read site that feeds the value back - // into a SetConfig request (saveSettings / parseNumericSettings). - mdmFieldSuffix = " (MDM)" -) - -// main is the entry point for the UI tray/client binary. Parses CLI -// flags, initialises logging, builds the Fyne application and tray -// icons, and constructs the service client (which may open a -// requested UI window). When a window-mode flag is set the Fyne event -// loop runs and main returns; otherwise main enforces single-instance -// behaviour (signalling an existing instance to show its window when -// present), sets up signal handling + default fonts, and runs the -// system tray loop. -func main() { - flags := parseFlags() - - // Initialize file logging if needed. - var logFile string - if flags.saveLogsInFile { - file, err := initLogFile() - if err != nil { - log.Errorf("error while initializing log: %v", err) - return - } - logFile = file - } else { - _ = util.InitLog("trace", util.LogConsole) - } - - // Create the Fyne application. - a := app.NewWithID("NetBird") - a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected)) - - // Show error message window if needed. - if flags.errorMsg != "" { - showErrorMessage(flags.errorMsg) - return - } - - // Create the service client (this also builds the settings or networks UI if requested). - client := newServiceClient(&newServiceClientArgs{ - addr: flags.daemonAddr, - logFile: logFile, - app: a, - showSettings: flags.showSettings, - showNetworks: flags.showNetworks, - showLoginURL: flags.showLoginURL, - showDebug: flags.showDebug, - showProfiles: flags.showProfiles, - showQuickActions: flags.showQuickActions, - showUpdate: flags.showUpdate, - showUpdateVersion: flags.showUpdateVersion, - }) - - // Watch for theme/settings changes to update the icon. - go watchSettingsChanges(a, client) - - // Run in window mode if any UI flag was set. - if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate { - a.Run() - return - } - - // Check for another running process. - pid, running, err := process.IsAnotherProcessRunning() - if err != nil { - log.Errorf("error while checking process: %v", err) - return - } - if running { - log.Infof("another process is running with pid %d, sending signal to show window", pid) - if err := sendShowWindowSignal(pid); err != nil { - log.Errorf("send signal to running instance: %v", err) - } - return - } - - client.setupSignalHandler(client.ctx) - - client.setDefaultFonts() - systray.Run(client.onTrayReady, client.onTrayExit) -} - -type cliFlags struct { - daemonAddr string - showSettings bool - showNetworks bool - showProfiles bool - showDebug bool - showLoginURL bool - showQuickActions bool - errorMsg string - saveLogsInFile bool - showUpdate bool - showUpdateVersion string -} - -// parseFlags reads and returns all needed command-line flags. -func parseFlags() *cliFlags { - var flags cliFlags - - defaultDaemonAddr := "unix:///var/run/netbird.sock" - if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" - } - flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") - flag.BoolVar(&flags.showSettings, "settings", false, "run settings window") - flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window") - flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window") - flag.BoolVar(&flags.showDebug, "debug", false, "run debug window") - flag.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions window") - flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window") - flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir())) - flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window") - flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window") - flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to") - flag.Parse() - return &flags -} - -// initLogFile initializes logging into a file. -func initLogFile() (string, error) { - logFile := path.Join(os.TempDir(), fmt.Sprintf("netbird-ui-%d.log", os.Getpid())) - return logFile, util.InitLog("trace", logFile) -} - -// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon. -func watchSettingsChanges(a fyne.App, client *serviceClient) { - a.Settings().AddListener(func(settings fyne.Settings) { - client.updateIcon() - }) -} - -// showErrorMessage displays an error message in a simple window. -func showErrorMessage(msg string) { - a := app.New() - w := a.NewWindow("NetBird Error") - label := widget.NewLabel(msg) - label.Wrapping = fyne.TextWrapWord - w.SetContent(label) - w.Resize(fyne.NewSize(400, 100)) - w.Show() - a.Run() -} - -//go:embed assets/netbird-systemtray-connected-macos.png -var iconConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-disconnected-macos.png -var iconDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-disconnected-macos.png -var iconUpdateDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-connected-macos.png -var iconUpdateConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-connecting-macos.png -var iconConnectingMacOS []byte - -//go:embed assets/netbird-systemtray-error-macos.png -var iconErrorMacOS []byte - -//go:embed assets/connected.png -var iconConnectedDot []byte - -//go:embed assets/disconnected.png -var iconDisconnectedDot []byte - -type serviceClient struct { - ctx context.Context - cancel context.CancelFunc - addr string - conn proto.DaemonServiceClient - connLock sync.Mutex - - eventHandler *eventHandler - - profileManager *profilemanager.ProfileManager - - icAbout []byte - icConnected []byte - icConnectedDot []byte - icDisconnected []byte - icDisconnectedDot []byte - icUpdateConnected []byte - icUpdateDisconnected []byte - icConnecting []byte - icError []byte - - // systray menu items - mStatus *systray.MenuItem - mUp *systray.MenuItem - mDown *systray.MenuItem - mSettings *systray.MenuItem - mProfile *profileMenu - mAbout *systray.MenuItem - mGitHub *systray.MenuItem - mVersionUI *systray.MenuItem - mVersionDaemon *systray.MenuItem - mUpdate *systray.MenuItem - mQuit *systray.MenuItem - mNetworks *systray.MenuItem - mAllowSSH *systray.MenuItem - mAutoConnect *systray.MenuItem - mEnableRosenpass *systray.MenuItem - mBlockInbound *systray.MenuItem - mNotifications *systray.MenuItem - mAdvancedSettings *systray.MenuItem - mCreateDebugBundle *systray.MenuItem - mExitNode *systray.MenuItem - - // application with main windows. - app fyne.App - notifier notifier.Notifier - wSettings fyne.Window - showAdvancedSettings bool - sendNotification bool - - // input elements for settings form - iMngURL *widget.Entry - iLogFile *widget.Entry - iPreSharedKey *widget.Entry - iInterfaceName *widget.Entry - iInterfacePort *widget.Entry - iMTU *widget.Entry - - // switch elements for settings form - sRosenpassPermissive *widget.Check - sNetworkMonitor *widget.Check - sDisableDNS *widget.Check - sDisableClientRoutes *widget.Check - sDisableServerRoutes *widget.Check - sDisableIPv6 *widget.Check - sBlockLANAccess *widget.Check - sEnableSSHRoot *widget.Check - sEnableSSHSFTP *widget.Check - sEnableSSHLocalPortForward *widget.Check - sEnableSSHRemotePortForward *widget.Check - sDisableSSHAuth *widget.Check - iSSHJWTCacheTTL *widget.Entry - - // observable settings over corresponding iMngURL and iPreSharedKey values. - managementURL string - preSharedKey string - - RosenpassPermissive bool - interfaceName string - interfacePort int - mtu uint16 - networkMonitor bool - disableDNS bool - disableClientRoutes bool - disableServerRoutes bool - disableIPv6 bool - blockLANAccess bool - enableSSHRoot bool - enableSSHSFTP bool - enableSSHLocalPortForward bool - enableSSHRemotePortForward bool - disableSSHAuth bool - sshJWTCacheTTL int - - connected bool - daemonVersion string - updateIndicationLock sync.Mutex - isUpdateIconActive bool - isEnforcedUpdate bool - lastNotifiedVersion string - profilesEnabled bool - networksEnabled bool - // networksMenuEnabled caches the last applied enabled-state of the - // mNetworks + mExitNode submenu items. Combines features.DisableNetworks - // AND s.connected — both must be true for the menus to be active. - // Zero value (false) matches the Disable() call at AddMenuItem time. - networksMenuEnabled bool - showNetworks bool - wNetworks fyne.Window - wProfiles fyne.Window - wQuickActions fyne.Window - - eventManager *event.Manager - - exitNodeMu sync.Mutex - mExitNodeItems []menuHandler - exitNodeRetryCancel context.CancelFunc - mExitNodeSeparator *systray.MenuItem - mExitNodeDeselectAll *systray.MenuItem - logFile string - wLoginURL fyne.Window - wUpdateProgress fyne.Window - updateContextCancel context.CancelFunc - - connectCancel context.CancelFunc - - // mdmManagedFields caches the names of MDM-enforced policy keys - // surfaced by the daemon in GetConfigResponse. Each refresh of - // daemon config (loadSettings, getSrvConfig, config_changed event) - // updates this set and re-applies the lock/badge to the affected - // menu items and settings-form widgets. - mdmManagedFields map[string]bool -} - -type menuHandler struct { - *systray.MenuItem - cancel context.CancelFunc -} - -type newServiceClientArgs struct { - addr string - logFile string - app fyne.App - showSettings bool - showNetworks bool - showDebug bool - showLoginURL bool - showProfiles bool - showQuickActions bool - showUpdate bool - showUpdateVersion string -} - -// newServiceClient instance constructor -// -// This constructor also builds the UI elements for the settings window. -func newServiceClient(args *newServiceClientArgs) *serviceClient { - ctx, cancel := context.WithCancel(context.Background()) - s := &serviceClient{ - ctx: ctx, - cancel: cancel, - addr: args.addr, - app: args.app, - notifier: notifier.New(args.app), - logFile: args.logFile, - sendNotification: false, - - showAdvancedSettings: args.showSettings, - showNetworks: args.showNetworks, - networksEnabled: true, - } - - s.eventHandler = newEventHandler(s) - s.profileManager = profilemanager.NewProfileManager() - s.setNewIcons() - - switch { - case args.showSettings: - s.showSettingsUI() - case args.showNetworks: - s.showNetworksUI() - case args.showLoginURL: - s.showLoginURL() - case args.showDebug: - s.showDebugUI() - case args.showProfiles: - s.showProfilesUI() - case args.showQuickActions: - // Suppress the on-boot Quick Actions popup when the daemon - // reports DisableAutoConnect=true — that flag carries both the - // user's "Connect on Startup = off" preference AND any MDM- - // enforced override (applyMDMPolicy writes the policy value - // into the same Config field). See netbirdio/netbird#5744. - if !s.disableAutoConnectFromDaemon() { - s.showQuickActionsUI() - } - case args.showUpdate: - s.showUpdateProgress(ctx, args.showUpdateVersion) - } - - return s -} - -func (s *serviceClient) setNewIcons() { - s.icAbout = iconAbout - s.icConnectedDot = iconConnectedDot - s.icDisconnectedDot = iconDisconnectedDot - if s.app.Settings().ThemeVariant() == theme.VariantDark { - s.icConnected = iconConnectedDark - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnectedDark - s.icUpdateDisconnected = iconUpdateDisconnectedDark - s.icConnecting = iconConnectingDark - s.icError = iconErrorDark - } else { - s.icConnected = iconConnected - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnected - s.icUpdateDisconnected = iconUpdateDisconnected - s.icConnecting = iconConnecting - s.icError = iconError - } -} - -func (s *serviceClient) updateIcon() { - s.setNewIcons() - s.updateIndicationLock.Lock() - if s.connected { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - } else { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - s.updateIndicationLock.Unlock() -} - -func (s *serviceClient) showSettingsUI() { - // DisableUpdateSettings no longer gates the window from opening: - // the daemon blocks every actual mutation at SetConfig / Login, - // so the window is safe to show as a read-only view. The previous - // early-return also blocked Advanced Settings whenever update - // editing was off, which conflated two distinct kill switches - // (see comment in checkAndUpdateFeatures). - - // add settings window UI elements. - s.wSettings = s.app.NewWindow("NetBird Settings") - s.wSettings.SetOnClosed(s.cancel) - - s.iMngURL = widget.NewEntry() - - s.iLogFile = widget.NewEntry() - s.iLogFile.Disable() - s.iPreSharedKey = widget.NewPasswordEntry() - s.iInterfaceName = widget.NewEntry() - s.iInterfacePort = widget.NewEntry() - s.iMTU = widget.NewEntry() - - s.sRosenpassPermissive = widget.NewCheck("Enable Rosenpass permissive mode", nil) - - s.sNetworkMonitor = widget.NewCheck("Restarts NetBird when the network changes", nil) - s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil) - s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil) - s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil) - s.sDisableIPv6 = widget.NewCheck("Disable IPv6 overlay addressing", nil) - s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil) - s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil) - s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil) - s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil) - s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil) - s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil) - s.iSSHJWTCacheTTL = widget.NewEntry() - - s.wSettings.SetContent(s.getSettingsForm()) - s.wSettings.Resize(fyne.NewSize(600, 400)) - s.wSettings.SetFixedSize(true) - - s.getSrvConfig() - s.wSettings.Show() -} - -func (s *serviceClient) getConnectionForm() *widget.Form { - var activeProfName string - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - } else { - activeProfName = activeProf.Name - } - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Profile", Widget: widget.NewLabel(activeProfName)}, - {Text: "Management URL", Widget: s.iMngURL}, - {Text: "Pre-shared Key", Widget: s.iPreSharedKey}, - {Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive}, - {Text: "Interface Name", Widget: s.iInterfaceName}, - {Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"}, - {Text: "MTU", Widget: s.iMTU}, - {Text: "Log File", Widget: s.iLogFile}, - }, - } -} - -func (s *serviceClient) saveSettings() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Configuration updates are disabled by daemon") - dialog.ShowError(fmt.Errorf("configuration updates are disabled by daemon"), s.wSettings) - return - } - - if err := s.validateSettings(); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - port, mtu, err := s.parseNumericSettings() - if err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix)) - - if s.hasSettingsChanged(iMngURL, port, mtu) { - if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - } - - s.wSettings.Close() -} - -func (s *serviceClient) validateSettings() error { - if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil { - return fmt.Errorf("invalid pre-shared key value") - } - } - return nil -} - -func (s *serviceClient) parseNumericSettings() (int64, int64, error) { - port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64) - if err != nil { - return 0, 0, errors.New("invalid interface port") - } - if port < 0 || port > 65535 { - return 0, 0, errors.New("invalid interface port: out of range 0-65535") - } - - var mtu int64 - mtuText := strings.TrimSpace(s.iMTU.Text) - if mtuText != "" { - mtu, err = strconv.ParseInt(mtuText, 10, 64) - if err != nil { - return 0, 0, errors.New("invalid MTU value") - } - if mtu < iface.MinMTU || mtu > iface.MaxMTU { - return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU) - } - } - - return port, mtu, nil -} - -func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool { - return s.managementURL != iMngURL || - s.preSharedKey != s.iPreSharedKey.Text || - s.RosenpassPermissive != s.sRosenpassPermissive.Checked || - s.interfaceName != s.iInterfaceName.Text || - s.interfacePort != int(port) || - s.mtu != uint16(mtu) || - s.networkMonitor != s.sNetworkMonitor.Checked || - s.disableDNS != s.sDisableDNS.Checked || - s.disableClientRoutes != s.sDisableClientRoutes.Checked || - s.disableServerRoutes != s.sDisableServerRoutes.Checked || - s.disableIPv6 != s.sDisableIPv6.Checked || - s.blockLANAccess != s.sBlockLANAccess.Checked || - s.hasSSHChanges() -} - -func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error { - s.managementURL = iMngURL - s.preSharedKey = s.iPreSharedKey.Text - s.mtu = uint16(mtu) - - req, err := s.buildSetConfigRequest(iMngURL, port, mtu) - if err != nil { - return fmt.Errorf("build config request: %w", err) - } - - if err := s.sendConfigUpdate(req); err != nil { - return fmt.Errorf("set configuration: %w", err) - } - - return nil -} - -func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) { - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - req := &proto.SetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - } - - if iMngURL != "" { - req.ManagementUrl = iMngURL - } - - req.RosenpassPermissive = &s.sRosenpassPermissive.Checked - req.InterfaceName = &s.iInterfaceName.Text - req.WireguardPort = &port - if mtu > 0 { - req.Mtu = &mtu - } - - req.NetworkMonitor = &s.sNetworkMonitor.Checked - req.DisableDns = &s.sDisableDNS.Checked - req.DisableClientRoutes = &s.sDisableClientRoutes.Checked - req.DisableServerRoutes = &s.sDisableServerRoutes.Checked - req.DisableIpv6 = &s.sDisableIPv6.Checked - req.BlockLanAccess = &s.sBlockLANAccess.Checked - - req.EnableSSHRoot = &s.sEnableSSHRoot.Checked - req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked - req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked - req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked - req.DisableSSHAuth = &s.sDisableSSHAuth.Checked - - sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text) - if sshJWTCacheTTLText != "" { - sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32) - if err != nil { - return nil, errors.New("invalid SSH JWT Cache TTL value") - } - if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL { - return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL) - } - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - req.SshJWTCacheTTL = &sshJWTCacheTTL32 - } - - // Only attach the PSK when the user actually typed something: - // - "" means the field was left untouched (we deliberately render - // an empty Text + placeholder hint to avoid leaking the daemon's - // "**********" redaction through the password reveal toggle); - // sending an empty pointer would tell the daemon to clear / overwrite - // the on-disk or MDM-enforced PSK, which then trips the MDM - // conflict gate when PSK is policy-managed. - // - "**********" is the redacted echo (legacy non-MDM path); also a no-op. - if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - req.OptionalPreSharedKey = &s.iPreSharedKey.Text - } - - return req, nil -} - -func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return fmt.Errorf("get client: %w", err) - } - - _, err = conn.SetConfig(s.ctx, req) - if err != nil { - return fmt.Errorf("set config: %w", err) - } - - // Reconnect if connected to apply the new settings. - // Use a background context so the reconnect outlives the settings window. - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get service status: %v", err) - return - } - if status.Status == string(internal.StatusConnected) { - if _, err = conn.Down(ctx, &proto.DownRequest{}); err != nil { - log.Errorf("failed to stop service: %v", err) - } - // TODO: wait for the service to be idle before calling Up, or use a fresh connection - if _, err = conn.Up(ctx, &proto.UpRequest{}); err != nil { - log.Errorf("failed to start service: %v", err) - } - } - }() - - return nil -} - -func (s *serviceClient) getSettingsForm() fyne.CanvasObject { - connectionForm := s.getConnectionForm() - networkForm := s.getNetworkForm() - sshForm := s.getSSHForm() - tabs := container.NewAppTabs( - container.NewTabItem("Connection", connectionForm), - container.NewTabItem("Network", networkForm), - container.NewTabItem("SSH", sshForm), - ) - saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings) - saveButton.Importance = widget.HighImportance - cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { - s.wSettings.Close() - }) - buttonContainer := container.NewHBox( - layout.NewSpacer(), - cancelButton, - saveButton, - ) - return container.NewBorder(nil, buttonContainer, nil, nil, tabs) -} - -func (s *serviceClient) getNetworkForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Network Monitor", Widget: s.sNetworkMonitor}, - {Text: "Disable DNS", Widget: s.sDisableDNS}, - {Text: "Disable Client Routes", Widget: s.sDisableClientRoutes}, - {Text: "Disable Server Routes", Widget: s.sDisableServerRoutes}, - {Text: "Disable IPv6", Widget: s.sDisableIPv6}, - {Text: "Disable LAN Access", Widget: s.sBlockLANAccess}, - }, - } -} - -func (s *serviceClient) getSSHForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot}, - {Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP}, - {Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward}, - {Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward}, - {Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth}, - {Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL}, - }, - } -} - -func (s *serviceClient) hasSSHChanges() bool { - currentSSHJWTCacheTTL := s.sshJWTCacheTTL - if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" { - val, err := strconv.Atoi(text) - if err != nil { - return true - } - currentSSHJWTCacheTTL = val - } - - return s.enableSSHRoot != s.sEnableSSHRoot.Checked || - s.enableSSHSFTP != s.sEnableSSHSFTP.Checked || - s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked || - s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked || - s.disableSSHAuth != s.sDisableSSHAuth.Checked || - s.sshJWTCacheTTL != currentSSHJWTCacheTTL -} - -func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get daemon client: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - handle := activeProf.ID.String() - - loginReq := &proto.LoginRequest{ - IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &handle, - Username: &currUser.Username, - } - - profileState, err := s.profileManager.GetProfileState(activeProf.ID) - if err != nil { - log.Debugf("failed to get profile state for login hint: %v", err) - } else if profileState.Email != "" { - loginReq.Hint = &profileState.Email - } - - loginResp, err := conn.Login(ctx, loginReq) - if err != nil { - return nil, fmt.Errorf("login to management: %w", err) - } - - if loginResp.NeedsSSOLogin && openURL { - if err = s.handleSSOLogin(ctx, loginResp, conn); err != nil { - return nil, fmt.Errorf("SSO login: %w", err) - } - } - - return loginResp, nil -} - -func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error { - if err := openURL(loginResp.VerificationURIComplete); err != nil { - return fmt.Errorf("open browser: %w", err) - } - - resp, err := conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode}) - if err != nil { - return fmt.Errorf("wait for SSO login: %w", err) - } - - if resp.Email != "" { - if err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{ - Email: resp.Email, - }); err != nil { - log.Debugf("failed to set profile state: %v", err) - } else { - s.mProfile.refresh() - } - } - - return nil -} - -func (s *serviceClient) menuUpClick(ctx context.Context) error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - systray.SetTemplateIcon(iconErrorMacOS, s.icError) - return fmt.Errorf("get daemon client: %w", err) - } - - _, err = s.login(ctx, true) - if err != nil { - return fmt.Errorf("login: %w", err) - } - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status == string(internal.StatusConnected) { - return nil - } - - if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - return fmt.Errorf("start connection: %w", err) - } - - return nil -} - -func (s *serviceClient) menuDownClick() error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get daemon client: %w", err) - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status != string(internal.StatusConnected) && status.Status != string(internal.StatusConnecting) { - return nil - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - return fmt.Errorf("stop connection: %w", err) - } - - return nil -} - -func (s *serviceClient) updateStatus() error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return err - } - err = backoff.Retry(func() error { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - if s.connected { - s.notifier.Send("Error", "Connection to service lost") - } - s.setDisconnectedStatus() - return err - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // notify the user when the session has expired - if status.Status == string(internal.StatusSessionExpired) { - s.onSessionExpire() - } - - var systrayIconState bool - - switch { - case status.Status == string(internal.StatusConnected) && !s.connected: - s.connected = true - s.sendNotification = true - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - systray.SetTooltip("NetBird (Connected)") - s.mStatus.SetTitle("Connected") - s.mStatus.SetIcon(s.icConnectedDot) - s.mUp.Disable() - s.mDown.Enable() - if s.networksEnabled { - s.mNetworks.Enable() - s.mExitNode.Enable() - } - s.startExitNodeRefresh() - systrayIconState = true - case status.Status == string(internal.StatusConnecting): - s.setConnectingStatus() - case status.Status != string(internal.StatusConnected) && s.mUp.Disabled(): - s.setDisconnectedStatus() - systrayIconState = false - } - - // if the daemon version changed (e.g. after a successful update), reset the update indication - if s.daemonVersion != status.DaemonVersion { - if s.daemonVersion != "" { - s.mUpdate.Hide() - s.isUpdateIconActive = false - } - s.daemonVersion = status.DaemonVersion - if !s.isUpdateIconActive { - if systrayIconState { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - - daemonVersionTitle := normalizedVersion(s.daemonVersion) - s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle)) - s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle)) - s.mVersionDaemon.Show() - } - - return nil - }, &backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 300 * time.Millisecond, - MaxElapsedTime: 2 * time.Second, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }) - if err != nil { - return err - } - - return nil -} - -func (s *serviceClient) setDisconnectedStatus() { - s.connected = false - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - systray.SetTooltip("NetBird (Disconnected)") - s.mStatus.SetTitle("Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mDown.Disable() - s.mUp.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() - s.cancelExitNodeRetry() - go s.updateExitNodes() -} - -func (s *serviceClient) setConnectingStatus() { - s.connected = false - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - systray.SetTooltip("NetBird (Connecting)") - s.mStatus.SetTitle("Connecting") - s.mUp.Disable() - s.mDown.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() -} - -func (s *serviceClient) onTrayReady() { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - systray.SetTooltip("NetBird") - - // setup systray menu items - s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mStatus.Disable() - - profileMenuItem := systray.AddMenuItem("", "") - emailMenuItem := systray.AddMenuItem("", "") - - newProfileMenuArgs := &newProfileMenuArgs{ - ctx: s.ctx, - serviceClient: s, - profileManager: s.profileManager, - eventHandler: s.eventHandler, - profileMenuItem: profileMenuItem, - emailMenuItem: emailMenuItem, - downClickCallback: s.menuDownClick, - upClickCallback: s.menuUpClick, - getSrvClientCallback: s.getSrvClient, - loadSettingsCallback: s.loadSettings, - app: s.app, - } - - s.mProfile = newProfileMenu(*newProfileMenuArgs) - // Seed the transition cache to match the actual default menu - // state (visible / enabled). Without this, the first - // checkAndUpdateFeatures tick that observes DisableProfiles=true - // is a no-op (cache zero-value == desired-false) and the menu - // never gets hidden — symptom: MDM enforces the kill switch but - // the profile menu stays clickable. - s.profilesEnabled = true - - systray.AddSeparator() - s.mUp = systray.AddMenuItem("Connect", "Connect") - s.mDown = systray.AddMenuItem("Disconnect", "Disconnect") - s.mDown.Disable() - systray.AddSeparator() - - s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr) - s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false) - s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false) - s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false) - s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false) - s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false) - s.mSettings.AddSeparator() - s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", advancedSettingsMenuDescr) - s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr) - s.loadSettings() - - // Disable profile menu if profiles are disabled by daemon. - // DisableUpdateSettings is enforced at the daemon's SetConfig / - // Login gates, not by hiding the UI — so the Settings menu (and - // its Advanced Settings submenu, which has its own kill switch) - // stays visible and the user can still inspect current values. - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableProfiles { - s.mProfile.setEnabled(false) - s.profilesEnabled = false - } - - s.exitNodeMu.Lock() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - s.mExitNode.Disable() - s.exitNodeMu.Unlock() - - s.mNetworks = systray.AddMenuItem("Networks", networksMenuDescr) - s.mNetworks.Disable() - systray.AddSeparator() - - s.mAbout = systray.AddMenuItem("About", "About") - s.mAbout.SetIcon(s.icAbout) - - s.mGitHub = s.mAbout.AddSubMenuItem("GitHub", "GitHub") - - versionString := normalizedVersion(version.NetbirdVersion()) - s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString)) - s.mVersionUI.Disable() - - s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "") - s.mVersionDaemon.Disable() - s.mVersionDaemon.Hide() - - s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", latestVersionMenuDescr) - s.mUpdate.Hide() - - systray.AddSeparator() - s.mQuit = systray.AddMenuItem("Quit", quitMenuDescr) - - // update exit node menu in case service is already connected - go s.updateExitNodes() - - // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks, - // ...) only change in two ways: at service install time (CLI flag, - // static) and at MDM ticker diff time. The daemon already publishes - // a SystemEvent{type=config_changed} on every MDM-driven engine - // restart, so the UI no longer needs to poll GetFeatures every 2 s. - // A single fetch at startup covers the static CLI-flag case; the - // event handler below covers MDM transitions. updateStatus stays in - // the 2 s loop because connection / peer state genuinely change - // continuously and have no event yet. - s.checkAndUpdateFeatures() - go func() { - s.getSrvConfig() - time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon - for { - err := s.updateStatus() - if err != nil { - log.Errorf("error while updating status: %v", err) - } - - time.Sleep(2 * time.Second) - } - }() - - s.eventManager = event.NewManager(s.notifier, s.addr) - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if event.Category == proto.SystemEvent_SYSTEM { - s.updateExitNodes() - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - // todo use new Category - if windowAction, ok := event.Metadata["progress_window"]; ok { - targetVersion, ok := event.Metadata["version"] - if !ok { - targetVersion = "unknown" - } - log.Debugf("window action: %v", windowAction) - if windowAction == "show" { - if s.updateContextCancel != nil { - s.updateContextCancel() - s.updateContextCancel = nil - } - - subCtx, cancel := context.WithCancel(s.ctx) - go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion) - s.updateContextCancel = cancel - } - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if newVersion, ok := event.Metadata["new_version_available"]; ok { - _, enforced := event.Metadata["enforced"] - log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced) - s.onUpdateAvailable(newVersion, enforced) - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - // Daemon emits a config_changed event after every engine spawn - // (Server.Start, Server.Up, MDM ticker restart). Re-sync the - // tray submenu checkboxes from the fresh daemon-side config so - // the user does not have to restart the tray to see CLI- or - // MDM-driven changes. - if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" { - log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"]) - s.loadSettings() - // MDM-driven feature kill switches (DisableProfiles / - // DisableUpdateSettings / DisableNetworks) ride the same - // config_changed signal because the daemon re-applies its - // MDM policy on every engine spawn. Pull them in here so - // the UI is up to date without a periodic GetFeatures poll. - s.checkAndUpdateFeatures() - } - }) - - go s.eventManager.Start(s.ctx) - go s.eventHandler.listen(s.ctx) -} - -func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File { - if s.logFile == "" { - // attach child's streams to parent's streams - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return nil - } - - out, err := os.OpenFile(s.logFile, os.O_WRONLY|os.O_APPEND, 0) - if err != nil { - log.Errorf("Failed to open log file %s: %v", s.logFile, err) - return nil - } - cmd.Stdout = out - cmd.Stderr = out - return out -} - -func normalizedVersion(version string) string { - versionString := version - if unicode.IsDigit(rune(versionString[0])) { - versionString = fmt.Sprintf("v%s", versionString) - } - return versionString -} - -// onTrayExit is called when the tray icon is closed. -func (s *serviceClient) onTrayExit() { - s.cancel() -} - -// getSrvClient connection to the service. -func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonServiceClient, error) { - s.connLock.Lock() - defer s.connLock.Unlock() - if s.conn != nil { - return s.conn, nil - } - - ctx, cancel := context.WithTimeout(s.ctx, timeout) - defer cancel() - - conn, err := grpc.DialContext( - ctx, - strings.TrimPrefix(s.addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, fmt.Errorf("dial service: %w", err) - } - - s.conn = proto.NewDaemonServiceClient(conn) - return s.conn, nil -} - -// checkAndUpdateFeatures checks the current features and updates the UI accordingly -func (s *serviceClient) checkAndUpdateFeatures() { - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - return - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // DisableUpdateSettings is enforced server-side by the daemon gates - // on SetConfig + Login: any attempt to mutate config from UI or - // CLI is rejected at that layer. The UI deliberately keeps the - // Settings menu visible so the user can still inspect current - // values — read-only by virtue of the daemon refusing edits. - - // Update profile menu based on current features - if s.mProfile != nil { - profilesEnabled := features == nil || !features.DisableProfiles - if s.profilesEnabled != profilesEnabled { - s.profilesEnabled = profilesEnabled - s.mProfile.setEnabled(profilesEnabled) - } - } - - // Update networks and exit node menus based on current features. - // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at - // connection-status transitions). `networksMenuEnabled` is the - // transition-cached state actually applied to the menu items — - // it folds in the connection state so a Connected client with the - // kill switch off shows the menus active, and only flips on diff. - s.networksEnabled = features == nil || !features.DisableNetworks - desiredNetworksMenu := s.networksEnabled && s.connected - if desiredNetworksMenu != s.networksMenuEnabled { - s.networksMenuEnabled = desiredNetworksMenu - if desiredNetworksMenu { - s.mNetworks.Enable() - s.mExitNode.Enable() - } else { - s.mNetworks.Disable() - s.mExitNode.Disable() - } - } -} - -// getFeatures from the daemon to determine which features are enabled/disabled. -func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client for features: %w", err) - } - - features, err := conn.GetFeatures(s.ctx, &proto.GetFeaturesRequest{}) - if err != nil { - return nil, fmt.Errorf("get features from daemon: %w", err) - } - - return features, nil -} - -// disableAutoConnectFromDaemon returns true when the daemon reports -// the active profile has DisableAutoConnect=true. Used by the -// --quick-actions startup path to suppress the on-boot popup when the -// user (or an MDM admin) opted out of auto-connecting; both cases -// converge on the same Config field because applyMDMPolicy writes the -// policy value into it. Returns false on any RPC / lookup failure so a -// daemon hiccup does not silently swallow the popup. -func (s *serviceClient) disableAutoConnectFromDaemon() bool { - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Warnf("disableAutoConnectFromDaemon: get active profile: %v", err) - return false - } - currUser, err := user.Current() - if err != nil { - log.Warnf("disableAutoConnectFromDaemon: get current user: %v", err) - return false - } - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Warnf("disableAutoConnectFromDaemon: get daemon client: %v", err) - return false - } - srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - }) - if err != nil { - log.Warnf("disableAutoConnectFromDaemon: GetConfig RPC: %v", err) - return false - } - return srvCfg.GetDisableAutoConnect() -} - -// getSrvConfig from the service to show it in the settings window. -func (s *serviceClient) getSrvConfig() { - s.managementURL = profilemanager.DefaultManagementURL - - _, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - var cfg *profilemanager.Config - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - cfg = protoConfigToConfig(srvCfg) - - if cfg.ManagementURL.String() != "" { - s.managementURL = cfg.ManagementURL.String() - } - s.preSharedKey = cfg.PreSharedKey - s.RosenpassPermissive = cfg.RosenpassPermissive - s.interfaceName = cfg.WgIface - s.interfacePort = cfg.WgPort - s.mtu = cfg.MTU - - s.networkMonitor = *cfg.NetworkMonitor - s.disableDNS = cfg.DisableDNS - s.disableClientRoutes = cfg.DisableClientRoutes - s.disableServerRoutes = cfg.DisableServerRoutes - s.disableIPv6 = cfg.DisableIPv6 - s.blockLANAccess = cfg.BlockLANAccess - - if cfg.EnableSSHRoot != nil { - s.enableSSHRoot = *cfg.EnableSSHRoot - } - if cfg.EnableSSHSFTP != nil { - s.enableSSHSFTP = *cfg.EnableSSHSFTP - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding - } - if cfg.DisableSSHAuth != nil { - s.disableSSHAuth = *cfg.DisableSSHAuth - } - if cfg.SSHJWTCacheTTL != nil { - s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL - } - - if s.showAdvancedSettings { - s.iMngURL.SetText(s.managementURL) - // PSK is rendered with an empty Text and a hint via the - // placeholder so the eye toggle never reveals literal asterisks - // (the daemon returns the "**********" sentinel — writing that - // into a PasswordEntry would surface the literal sentinel when - // the user unmasks the field). The placeholder communicates the - // configured / MDM-managed state without exposing any value. - s.iPreSharedKey.SetText("") - s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg)) - s.iInterfaceName.SetText(cfg.WgIface) - s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort)) - if cfg.MTU != 0 { - s.iMTU.SetText(strconv.Itoa(int(cfg.MTU))) - } else { - s.iMTU.SetText("") - s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU))) - } - s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive) - // Re-baseline the enabled state on every refresh: when Rosenpass - // is on the checkbox is editable, when it's off the field is - // inert. Without an explicit Enable() here the control stays - // stuck disabled after a previous refresh (or an MDM unlock) had - // turned it off — applyMDMLocksToSettingsForm below adds the - // MDM lock on top of this baseline. - if cfg.RosenpassEnabled { - s.sRosenpassPermissive.Enable() - } else { - s.sRosenpassPermissive.Disable() - } - s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor) - s.sDisableDNS.SetChecked(cfg.DisableDNS) - s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes) - s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes) - s.sDisableIPv6.SetChecked(cfg.DisableIPv6) - s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess) - if cfg.EnableSSHRoot != nil { - s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot) - } - if cfg.EnableSSHSFTP != nil { - s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP) - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding) - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding) - } - if cfg.DisableSSHAuth != nil { - s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth) - } - if cfg.SSHJWTCacheTTL != nil { - s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL)) - } - } - - // MDM locks must run before the mNotifications-nil early return: - // the Settings window is rendered by a separate UI process launched - // with --settings (see handleAdvancedSettingsClick), and that child - // process does NOT run onReady — so its mNotifications is nil and - // the early return below skipped the lock pass entirely. - s.applyMDMLocks(srvCfg.MDMManagedFields) - - if s.mNotifications == nil { - return - } - if cfg.DisableNotifications != nil && *cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } -} - -func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { - - var config profilemanager.Config - - if cfg.ManagementUrl != "" { - parsed, err := url.Parse(cfg.ManagementUrl) - if err != nil { - log.Errorf("parse management URL: %v", err) - } else { - config.ManagementURL = parsed - } - } - - if cfg.PreSharedKey != "" { - if cfg.PreSharedKey != censoredPreSharedKey { - config.PreSharedKey = cfg.PreSharedKey - } else { - config.PreSharedKey = "" - } - } - if cfg.AdminURL != "" { - parsed, err := url.Parse(cfg.AdminURL) - if err != nil { - log.Errorf("parse admin URL: %v", err) - } else { - config.AdminURL = parsed - } - } - - config.WgIface = cfg.InterfaceName - if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 { - config.WgPort = int(cfg.WireguardPort) - } else { - config.WgPort = iface.DefaultWgPort - } - - if cfg.Mtu != 0 { - config.MTU = uint16(cfg.Mtu) - } else { - config.MTU = iface.DefaultMTU - } - - config.DisableAutoConnect = cfg.DisableAutoConnect - config.ServerSSHAllowed = &cfg.ServerSSHAllowed - config.RosenpassEnabled = cfg.RosenpassEnabled - config.RosenpassPermissive = cfg.RosenpassPermissive - config.DisableNotifications = &cfg.DisableNotifications - config.BlockInbound = cfg.BlockInbound - config.NetworkMonitor = &cfg.NetworkMonitor - config.DisableDNS = cfg.DisableDns - config.DisableClientRoutes = cfg.DisableClientRoutes - config.DisableServerRoutes = cfg.DisableServerRoutes - config.DisableIPv6 = cfg.DisableIpv6 - config.BlockLANAccess = cfg.BlockLanAccess - - config.EnableSSHRoot = &cfg.EnableSSHRoot - config.EnableSSHSFTP = &cfg.EnableSSHSFTP - config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding - config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding - config.DisableSSHAuth = &cfg.DisableSSHAuth - - ttl := int(cfg.SshJWTCacheTTL) - config.SSHJWTCacheTTL = &ttl - - return &config -} - -func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) { - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - s.isEnforcedUpdate = enforced - if enforced { - s.mUpdate.SetTitle("Install version " + newVersion) - } else { - s.lastNotifiedVersion = "" - s.mUpdate.SetTitle("Download latest version") - } - - s.mUpdate.Show() - s.isUpdateIconActive = true - - if s.connected { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } - - if enforced && s.lastNotifiedVersion != newVersion { - s.lastNotifiedVersion = newVersion - s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install") - } -} - -// onSessionExpire sends a notification to the user when the session expires. -func (s *serviceClient) onSessionExpire() { - s.sendNotification = true - if s.sendNotification { - go s.eventHandler.runSelfCommand(s.ctx, "login-url", "true") - s.sendNotification = false - } -} - -// loadSettings loads the settings from the config file and updates the UI elements accordingly. -func (s *serviceClient) loadSettings() { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - if cfg.ServerSSHAllowed { - s.mAllowSSH.Check() - } else { - s.mAllowSSH.Uncheck() - } - - if cfg.DisableAutoConnect { - s.mAutoConnect.Uncheck() - } else { - s.mAutoConnect.Check() - } - - if cfg.RosenpassEnabled { - s.mEnableRosenpass.Check() - } else { - s.mEnableRosenpass.Uncheck() - } - - if cfg.BlockInbound { - s.mBlockInbound.Check() - } else { - s.mBlockInbound.Uncheck() - } - - if cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } - s.applyMDMLocks(cfg.MDMManagedFields) -} - -// applyMDMLocks disables and badges any tray submenu item or settings- -// form widget whose underlying field is enforced by the active MDM -// policy. Called from loadSettings (submenu refresh) and from -// getSrvConfig (settings-window refresh). Locked items keep their value -// already set by the surrounding refresh code — this routine only -// flips the enabled state and the title suffix, never the value. -func (s *serviceClient) applyMDMLocks(managed []string) { - set := make(map[string]bool, len(managed)) - for _, k := range managed { - set[k] = true - } - s.mdmManagedFields = set - if len(managed) > 0 { - log.Infof("MDM-managed UI fields: %v", managed) - } - - type submenuTarget struct { - item *systray.MenuItem - title string - key string - } - for _, t := range []submenuTarget{ - {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH}, - {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect}, - {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled}, - {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound}, - } { - if t.item == nil { - continue - } - if set[t.key] { - t.item.SetTitle(t.title + " (MDM)") - t.item.Disable() - } else { - t.item.SetTitle(t.title) - t.item.Enable() - } - } - - s.applyMDMLocksToSettingsForm(set) -} - -// preSharedKeyPlaceholder returns the hint string shown in the PSK -// Entry's placeholder slot. The placeholder is the only signal the -// user gets that a PSK is configured, because the entry's Text is -// forced to empty to keep the password reveal toggle from leaking -// the daemon-returned "**********" redaction sentinel. Returns "" if -// no PSK is present, "MDM-managed" if the key is enforced by MDM, -// and "configured" otherwise. -func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string { - if cfg == nil || cfg.PreSharedKey == "" { - return "" - } - for _, k := range cfg.MDMManagedFields { - if k == mdm.KeyPreSharedKey { - return "MDM-managed" - } - } - return "configured" -} - -// applyMDMLocksToSettingsForm disables the per-field input widgets in -// the advanced Settings window when the corresponding MDM key is set. -// For plain-text entries (Management URL, Interface Port) the visible -// value is suffixed with " (MDM)" so the user sees the lock indicator -// inline; for the password entry the suffix is skipped (a password -// widget renders every char as a dot and the indicator would not be -// readable). The widgets are created lazily by showSettingsUI, so -// guard each ref against nil. -func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) { - type entryTarget struct { - entry *widget.Entry - key string - inlineTag bool - } - for _, t := range []entryTarget{ - {s.iMngURL, mdm.KeyManagementURL, true}, - {s.iPreSharedKey, mdm.KeyPreSharedKey, false}, - {s.iInterfacePort, mdm.KeyWireguardPort, true}, - } { - if t.entry == nil { - continue - } - if set[t.key] { - if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) { - t.entry.SetText(t.entry.Text + mdmFieldSuffix) - } - t.entry.Disable() - } else { - if t.inlineTag { - t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix)) - } - t.entry.Enable() - } - } - type checkTarget struct { - check *widget.Check - key string - } - for _, t := range []checkTarget{ - {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes}, - {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes}, - } { - if t.check == nil { - continue - } - if set[t.key] { - t.check.Disable() - } else { - t.check.Enable() - } - } - if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] { - // MDM lock layered on top of the Rosenpass-on/off baseline - // applied by getSrvConfig. No Enable() branch here: when the - // MDM key is removed, the next getSrvConfig refresh re-baselines - // the control on cfg.RosenpassEnabled and brings it back if - // Rosenpass is on. - s.sRosenpassPermissive.Disable() - } -} - -// updateConfig updates the configuration parameters -// based on the values selected in the settings window. -func (s *serviceClient) updateConfig() error { - disableAutoStart := !s.mAutoConnect.Checked() - sshAllowed := s.mAllowSSH.Checked() - rosenpassEnabled := s.mEnableRosenpass.Checked() - blockInbound := s.mBlockInbound.Checked() - notificationsDisabled := !s.mNotifications.Checked() - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return err - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return err - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return err - } - - req := proto.SetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - DisableAutoConnect: &disableAutoStart, - ServerSSHAllowed: &sshAllowed, - RosenpassEnabled: &rosenpassEnabled, - BlockInbound: &blockInbound, - DisableNotifications: ¬ificationsDisabled, - } - - if _, err := conn.SetConfig(s.ctx, &req); err != nil { - log.Errorf("set config settings on server: %v", err) - return err - } - - return nil -} - -// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL. -// It also starts a background goroutine that periodically checks if the client is already connected -// and closes the window if so. The goroutine can be cancelled by the returned CancelFunc, and it is -// also cancelled when the window is closed. -func (s *serviceClient) showLoginURL() context.CancelFunc { - - // create a cancellable context for the background check goroutine - ctx, cancel := context.WithCancel(s.ctx) - - resIcon := fyne.NewStaticResource("netbird.png", iconAbout) - - if s.wLoginURL == nil { - s.wLoginURL = s.app.NewWindow("NetBird Session Expired") - s.wLoginURL.Resize(fyne.NewSize(400, 200)) - s.wLoginURL.SetIcon(resIcon) - } - // ensure goroutine is cancelled when the window is closed - s.wLoginURL.SetOnClosed(func() { cancel() }) - // add a description label - label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.") - - btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() { - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - resp, err := s.login(ctx, false) - if err != nil { - log.Errorf("failed to fetch login URL: %v", err) - return - } - verificationURL := resp.VerificationURIComplete - if verificationURL == "" { - verificationURL = resp.VerificationURI - } - - if verificationURL == "" { - log.Error("no verification URL provided in the login response") - return - } - - if err := openURL(verificationURL); err != nil { - log.Errorf("failed to open login URL: %v", err) - return - } - - _, err = conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode}) - if err != nil { - log.Errorf("Waiting sso login failed with: %v", err) - label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.") - return - } - - label.SetText("Re-authentication successful.\nReconnecting") - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - label.SetText("Already connected.\nClosing this window.") - time.Sleep(2 * time.Second) - s.wLoginURL.Close() - return - } - - _, err = conn.Up(ctx, &proto.UpRequest{}) - if err != nil { - label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.") - log.Errorf("Reconnecting failed with: %v", err) - return - } - - label.SetText("Connection successful.\nClosing this window.") - time.Sleep(time.Second) - - s.wLoginURL.Close() - }) - - img := canvas.NewImageFromResource(resIcon) - img.FillMode = canvas.ImageFillContain - img.SetMinSize(fyne.NewSize(64, 64)) - img.Resize(fyne.NewSize(64, 64)) - - // center the content vertically - content := container.NewVBox( - layout.NewSpacer(), - img, - label, - btn, - layout.NewSpacer(), - ) - s.wLoginURL.SetContent(container.NewCenter(content)) - - // start a goroutine to check connection status and close the window if connected - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return - } - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - continue - } - if status.Status == string(internal.StatusConnected) { - if s.wLoginURL != nil { - s.wLoginURL.Close() - } - return - } - } - } - }() - - s.wLoginURL.Show() - - // return cancel func so callers can stop the background goroutine if desired - return cancel -} - -func openURL(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - - var err error - switch runtime.GOOS { - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - case "linux", "freebsd": - err = exec.Command("xdg-open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - return err -} diff --git a/client/ui/const.go b/client/ui/const.go deleted file mode 100644 index ce7a9a294..000000000 --- a/client/ui/const.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -const ( - allowSSHMenuDescr = "Allow SSH connections" - autoConnectMenuDescr = "Connect automatically when the service starts" - quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass" - blockInboundMenuDescr = "Block inbound connections to the local machine and routed networks" - notificationsMenuDescr = "Enable notifications" - advancedSettingsMenuDescr = "Advanced settings of the application" - debugBundleMenuDescr = "Create and open debug information bundle" - disabledMenuDescr = "" - networksMenuDescr = "Open the networks management window" - latestVersionMenuDescr = "Download latest version" - quitMenuDescr = "Quit the client app" -) diff --git a/client/ui/debug.go b/client/ui/debug.go deleted file mode 100644 index d3d4fa4f8..000000000 --- a/client/ui/debug.go +++ /dev/null @@ -1,730 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "path/filepath" - "strconv" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" - "google.golang.org/protobuf/types/known/durationpb" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" - uptypes "github.com/netbirdio/netbird/upload-server/types" - "github.com/netbirdio/netbird/version" -) - -// Initial state for the debug collection -type debugInitialState struct { - wasDown bool - needsRestoreUp bool - logLevel proto.LogLevel - isLevelTrace bool -} - -// Debug collection parameters -type debugCollectionParams struct { - duration time.Duration - anonymize bool - systemInfo bool - upload bool - uploadURL string - enablePersistence bool - capture bool -} - -// UI components for progress tracking -type progressUI struct { - statusLabel *widget.Label - progressBar *widget.ProgressBar - uiControls []fyne.Disableable - window fyne.Window -} - -func (s *serviceClient) showDebugUI() { - w := s.app.NewWindow("NetBird Debug") - w.SetOnClosed(s.cancel) - w.Resize(fyne.NewSize(600, 500)) - w.SetFixedSize(true) - - anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) - systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) - systemInfoCheck.SetChecked(true) - captureCheck := widget.NewCheck("Include packet capture", nil) - uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) - uploadCheck.SetChecked(true) - - uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck) - - debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection() - - statusLabel := widget.NewLabel("") - statusLabel.Hide() - progressBar := widget.NewProgressBar() - progressBar.Hide() - createButton := widget.NewButton("Create Debug Bundle", nil) - - uiControls := []fyne.Disableable{ - anonymizeCheck, systemInfoCheck, captureCheck, - uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton, - } - - createButton.OnTapped = s.getCreateHandler( - statusLabel, progressBar, uploadCheck, uploadURL, - anonymizeCheck, systemInfoCheck, captureCheck, - runForDurationCheck, durationInput, uiControls, w, - ) - - content := container.NewVBox( - widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), - widget.NewLabel(""), - anonymizeCheck, systemInfoCheck, captureCheck, - uploadCheck, uploadURLContainer, - widget.NewLabel(""), - debugModeContainer, noteLabel, - widget.NewLabel(""), - statusLabel, progressBar, createButton, - ) - - w.SetContent(container.NewPadded(content)) - w.Show() -} - -func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) { - uploadURL := widget.NewEntry() - uploadURL.SetText(uptypes.DefaultBundleURL) - uploadURL.SetPlaceHolder("Enter upload URL") - - uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL) - - uploadCheck.OnChanged = func(checked bool) { - if checked { - uploadURLContainer.Show() - } else { - uploadURLContainer.Hide() - } - } - return uploadURLContainer, uploadURL -} - -func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) { - runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) - runForDurationCheck.SetChecked(true) - - forLabel := widget.NewLabel("for") - durationInput := widget.NewEntry() - durationInput.SetText("1") - minutesLabel := widget.NewLabel("minute") - durationInput.Validator = func(s string) error { - return validateMinute(s, minutesLabel) - } - - noteLabel := widget.NewLabel("Note: NetBird will be brought up and down during collection") - - runForDurationCheck.OnChanged = func(checked bool) { - if checked { - forLabel.Show() - durationInput.Show() - minutesLabel.Show() - noteLabel.Show() - } else { - forLabel.Hide() - durationInput.Hide() - minutesLabel.Hide() - noteLabel.Hide() - } - } - - modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel) - return modeContainer, runForDurationCheck, durationInput, noteLabel -} - -func validateMinute(s string, minutesLabel *widget.Label) error { - if val, err := strconv.Atoi(s); err != nil || val < 1 { - return fmt.Errorf("must be a number ≥ 1") - } - if s == "1" { - minutesLabel.SetText("minute") - } else { - minutesLabel.SetText("minutes") - } - return nil -} - -// disableUIControls disables the provided UI controls -func disableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Disable() - } -} - -// enableUIControls enables the provided UI controls -func enableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Enable() - } -} - -func (s *serviceClient) getCreateHandler( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uploadCheck *widget.Check, - uploadURL *widget.Entry, - anonymizeCheck *widget.Check, - systemInfoCheck *widget.Check, - captureCheck *widget.Check, - runForDurationCheck *widget.Check, - duration *widget.Entry, - uiControls []fyne.Disableable, - w fyne.Window, -) func() { - return func() { - disableUIControls(uiControls) - statusLabel.Show() - - var url string - if uploadCheck.Checked { - url = uploadURL.Text - if url == "" { - statusLabel.SetText("Error: Upload URL is required when upload is enabled") - enableUIControls(uiControls) - return - } - } - - params := &debugCollectionParams{ - anonymize: anonymizeCheck.Checked, - systemInfo: systemInfoCheck.Checked, - capture: captureCheck.Checked, - upload: uploadCheck.Checked, - uploadURL: url, - enablePersistence: true, - } - - runForDuration := runForDurationCheck.Checked - if runForDuration { - minutes, err := time.ParseDuration(duration.Text + "m") - if err != nil { - statusLabel.SetText(fmt.Sprintf("Error: Invalid duration: %v", err)) - enableUIControls(uiControls) - return - } - params.duration = minutes - - statusLabel.SetText(fmt.Sprintf("Running in debug mode for %d minutes...", int(minutes.Minutes()))) - progressBar.Show() - progressBar.SetValue(0) - - go s.handleRunForDuration( - statusLabel, - progressBar, - uiControls, - w, - params, - ) - return - } - - statusLabel.SetText("Creating debug bundle...") - go s.handleDebugCreation( - params, - statusLabel, - uiControls, - w, - ) - } -} - -func (s *serviceClient) handleRunForDuration( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uiControls []fyne.Disableable, - w fyne.Window, - params *debugCollectionParams, -) { - progressUI := &progressUI{ - statusLabel: statusLabel, - progressBar: progressBar, - uiControls: uiControls, - window: w, - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - handleError(progressUI, fmt.Sprintf("Failed to get client for debug: %v", err)) - return - } - - initialState, err := s.getInitialState(conn) - if err != nil { - handleError(progressUI, err.Error()) - return - } - - defer s.restoreServiceState(conn, initialState) - - if err := s.collectDebugData(conn, initialState, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - if err := s.createDebugBundleFromCollection(conn, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - progressUI.statusLabel.SetText("Bundle created successfully") -} - -// Get initial state of the service -func (s *serviceClient) getInitialState(conn proto.DaemonServiceClient) (*debugInitialState, error) { - statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return nil, fmt.Errorf(" get status: %v", err) - } - - logLevelResp, err := conn.GetLogLevel(s.ctx, &proto.GetLogLevelRequest{}) - if err != nil { - return nil, fmt.Errorf("get log level: %v", err) - } - - wasDown := statusResp.Status != string(internal.StatusConnected) && - statusResp.Status != string(internal.StatusConnecting) - - initialLogLevel := logLevelResp.GetLevel() - initialLevelTrace := initialLogLevel >= proto.LogLevel_TRACE - - return &debugInitialState{ - wasDown: wasDown, - logLevel: initialLogLevel, - isLevelTrace: initialLevelTrace, - }, nil -} - -// Handle progress tracking during collection -func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time.Duration, progress *progressUI) { - progress.progressBar.Show() - progress.progressBar.SetValue(0) - - startTime := time.Now() - endTime := startTime.Add(duration) - wg.Add(1) - - go func() { - defer wg.Done() - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - remaining := time.Until(endTime) - if remaining <= 0 { - remaining = 0 - } - - elapsed := time.Since(startTime) - progressVal := float64(elapsed) / float64(duration) - if progressVal > 1.0 { - progressVal = 1.0 - } - - progress.progressBar.SetValue(progressVal) - progress.statusLabel.SetText(fmt.Sprintf("Running with trace logs... %s remaining", formatDuration(remaining))) - } - } - }() - -} - -func (s *serviceClient) configureServiceForDebug( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, -) { - if state.wasDown { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service up: %v", err) - } else { - log.Info("Service brought up for debug") - time.Sleep(time.Second * 10) - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil { - log.Warnf("failed to set log level to TRACE: %v", err) - } else { - log.Info("Log level set to TRACE for debug") - } - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to bring service down: %v", err) - } else { - state.needsRestoreUp = !state.wasDown - time.Sleep(time.Second) - } - - if params.enablePersistence { - if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ - Enabled: true, - }); err != nil { - log.Warnf("failed to enable sync response persistence: %v", err) - } else { - log.Info("Sync response persistence enabled for debug") - } - } - - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service back up: %v", err) - } else { - state.needsRestoreUp = false - time.Sleep(time.Second * 3) - } - - if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil { - log.Warnf("failed to start CPU profiling: %v", err) - } - - s.startBundleCaptureIfEnabled(conn, params) -} - -func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) { - if !params.capture { - return - } - - const maxCapture = 10 * time.Minute - timeout := params.duration + 30*time.Second - if timeout > maxCapture { - timeout = maxCapture - log.Warnf("packet capture clamped to %s (server maximum)", maxCapture) - } - if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ - Timeout: durationpb.New(timeout), - }); err != nil { - log.Warnf("failed to start bundle capture: %v", err) - } -} - -func (s *serviceClient) collectDebugData( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, - progress *progressUI, -) error { - ctx, cancel := context.WithTimeout(s.ctx, params.duration) - defer cancel() - var wg sync.WaitGroup - startProgressTracker(ctx, &wg, params.duration, progress) - - s.configureServiceForDebug(conn, state, params) - - wg.Wait() - progress.progressBar.Hide() - progress.statusLabel.SetText("Collecting debug data...") - - if _, err := conn.StopCPUProfile(s.ctx, &proto.StopCPUProfileRequest{}); err != nil { - log.Warnf("failed to stop CPU profiling: %v", err) - } - - if params.capture { - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { - log.Warnf("failed to stop bundle capture: %v", err) - } - } - - return nil -} - -// Create the debug bundle with collected data -func (s *serviceClient) createDebugBundleFromCollection( - conn proto.DaemonServiceClient, - params *debugCollectionParams, - progress *progressUI, -) error { - progress.statusLabel.SetText("Creating debug bundle with collected logs...") - - request := &proto.DebugBundleRequest{ - Anonymize: params.anonymize, - SystemInfo: params.systemInfo, - CliVersion: version.NetbirdVersion(), - } - - if params.upload { - request.UploadURL = params.uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return fmt.Errorf("create debug bundle: %v", err) - } - - // Show appropriate dialog based on upload status - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(progress.window, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, progress.window, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(progress.window, localPath) - } - - enableUIControls(progress.uiControls) - return nil -} - -// Restore service to original state -func (s *serviceClient) restoreServiceState(conn proto.DaemonServiceClient, state *debugInitialState) { - if state.needsRestoreUp { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to restore up state: %v", err) - } else { - log.Info("Service state restored to up") - } - } - - if state.wasDown { - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to restore down state: %v", err) - } else { - log.Info("Service state restored to down") - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: state.logLevel}); err != nil { - log.Warnf("failed to restore log level: %v", err) - } else { - log.Info("Log level restored to original setting") - } - } -} - -// Handle errors during debug collection -func handleError(progress *progressUI, errMsg string) { - log.Errorf("%s", errMsg) - progress.statusLabel.SetText(errMsg) - progress.progressBar.Hide() - enableUIControls(progress.uiControls) -} - -func (s *serviceClient) handleDebugCreation( - params *debugCollectionParams, - statusLabel *widget.Label, - uiControls []fyne.Disableable, - w fyne.Window, -) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("Failed to get client for debug: %v", err) - statusLabel.SetText(fmt.Sprintf("Error: %v", err)) - enableUIControls(uiControls) - return - } - - if params.capture { - if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ - Timeout: durationpb.New(30 * time.Second), - }); err != nil { - log.Warnf("failed to start bundle capture: %v", err) - } else { - defer func() { - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { - log.Warnf("failed to stop bundle capture: %v", err) - } - }() - time.Sleep(2 * time.Second) - } - } - - resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL) - if err != nil { - log.Errorf("Failed to create debug bundle: %v", err) - statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) - enableUIControls(uiControls) - return - } - - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(w, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, w, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(w, localPath) - } - - enableUIControls(uiControls) - statusLabel.SetText("Bundle created successfully") -} - -func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploadURL string) (*proto.DebugBundleResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - request := &proto.DebugBundleRequest{ - Anonymize: anonymize, - SystemInfo: systemInfo, - CliVersion: version.NetbirdVersion(), - } - - if uploadURL != "" { - request.UploadURL = uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return nil, fmt.Errorf("failed to create debug bundle via daemon: %v", err) - } - - return resp, nil -} - -// formatDuration formats a duration in HH:MM:SS format -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - h := d / time.Hour - d %= time.Hour - m := d / time.Minute - d %= time.Minute - s := d / time.Second - return fmt.Sprintf("%02d:%02d:%02d", h, m, s) -} - -// createButtonWithAction creates a button with the given label and action -func createButtonWithAction(label string, action func()) *widget.Button { - button := widget.NewButton(label, action) - return button -} - -// showUploadFailedDialog displays a dialog when upload fails -func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle upload failed:\n%s\n\n"+ - "A local copy was saved at:\n%s", failureReason, localPath)), - ) - - customDialog := dialog.NewCustom("Upload Failed", "Cancel", content, w) - - buttonBox := container.NewHBox( - createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }), - createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }), - ) - - content.Add(buttonBox) - customDialog.Show() -} - -// showUploadSuccessDialog displays a dialog when upload succeeds -func showUploadSuccessDialog(a fyne.App, w fyne.Window, localPath, uploadedKey string) { - log.Infof("Upload key: %s", uploadedKey) - keyEntry := widget.NewEntry() - keyEntry.SetText(uploadedKey) - keyEntry.Disable() - - content := container.NewVBox( - widget.NewLabel("Bundle uploaded successfully!"), - widget.NewLabel(""), - widget.NewLabel("Upload key:"), - keyEntry, - widget.NewLabel(""), - widget.NewLabel(fmt.Sprintf("Local copy saved at:\n%s", localPath)), - ) - - customDialog := dialog.NewCustom("Upload Successful", "OK", content, w) - - copyBtn := createButtonWithAction("Copy key", func() { - a.Clipboard().SetContent(uploadedKey) - log.Info("Upload key copied to clipboard") - }) - - buttonBox := createButtonBox(localPath, w, copyBtn) - content.Add(buttonBox) - customDialog.Show() -} - -// showBundleCreatedDialog displays a dialog when bundle is created without upload -func showBundleCreatedDialog(w fyne.Window, localPath string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle created locally at:\n%s\n\n"+ - "Administrator privileges may be required to access the file.", localPath)), - ) - - customDialog := dialog.NewCustom("Debug Bundle Created", "Cancel", content, w) - - buttonBox := createButtonBox(localPath, w, nil) - content.Add(buttonBox) - customDialog.Show() -} - -func createButtonBox(localPath string, w fyne.Window, elems ...fyne.Widget) *fyne.Container { - box := container.NewHBox() - for _, elem := range elems { - box.Add(elem) - } - - fileBtn := createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }) - - folderBtn := createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }) - - box.Add(fileBtn) - box.Add(folderBtn) - - return box -} diff --git a/client/ui/dock_darwin.go b/client/ui/dock_darwin.go new file mode 100644 index 000000000..dd8c60073 --- /dev/null +++ b/client/ui/dock_darwin.go @@ -0,0 +1,70 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa +#import + +static int lastDockState = -1; + +static void refreshDockPolicy(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + Class cls = NSClassFromString(@"WebviewWindow"); + if (cls == nil) { + return; + } + int visible = 0; + for (NSWindow *w in [NSApp windows]) { + if ([w isKindOfClass:cls] && [w isVisible]) { + visible = 1; + break; + } + } + if (visible == lastDockState) { + return; + } + lastDockState = visible; + + // Set application to "Regular" and show dock icon (when visible) or to "Accessory" (when hidden) + if (visible) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [NSApp activateIgnoringOtherApps:YES]; + } else { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + } + }); +} + +static int dockObserverInstalled = 0; + +static void initDockObserver(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (dockObserverInstalled) { + return; + } + dockObserverInstalled = 1; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + void (^trigger)(NSNotification *) = ^(NSNotification *_) { + refreshDockPolicy(); + }; + + [nc addObserverForName:NSWindowDidChangeOcclusionStateNotification + object:nil + queue:nil + usingBlock:trigger]; + [nc addObserverForName:NSWindowWillCloseNotification + object:nil + queue:nil + usingBlock:trigger]; + + refreshDockPolicy(); + }); +} +*/ +import "C" + +func initDockObserver() { + C.initDockObserver() +} diff --git a/client/ui/dock_other.go b/client/ui/dock_other.go new file mode 100644 index 000000000..0ace89552 --- /dev/null +++ b/client/ui/dock_other.go @@ -0,0 +1,7 @@ +//go:build !darwin && !android && !ios && !freebsd && !js + +package main + +func initDockObserver() { + // macOS-only; Linux and Windows taskbar entries already gate on window visibility natively. +} diff --git a/client/ui/event/event.go b/client/ui/event/event.go deleted file mode 100644 index 3b43fdc7f..000000000 --- a/client/ui/event/event.go +++ /dev/null @@ -1,184 +0,0 @@ -package event - -import ( - "context" - "fmt" - "slices" - "strings" - "sync" - "time" - - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" -) - -// Notifier sends desktop notifications. Defined here so the event package -// does not depend on fyne or the platform-specific notifier implementation. -type Notifier interface { - Send(title, body string) -} - -type Handler func(*proto.SystemEvent) - -type Manager struct { - notifier Notifier - addr string - - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - enabled bool - handlers []Handler -} - -func NewManager(notifier Notifier, addr string) *Manager { - return &Manager{ - notifier: notifier, - addr: addr, - } -} - -func (e *Manager) Start(ctx context.Context) { - e.mu.Lock() - e.ctx, e.cancel = context.WithCancel(ctx) - e.mu.Unlock() - - expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 10 * time.Second, - MaxElapsedTime: 0, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }, ctx) - - if err := backoff.Retry(e.streamEvents, expBackOff); err != nil { - log.Errorf("event stream ended: %v", err) - } -} - -func (e *Manager) streamEvents() error { - e.mu.Lock() - ctx := e.ctx - e.mu.Unlock() - - client, err := getClient(e.addr) - if err != nil { - return fmt.Errorf("create client: %w", err) - } - - stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{}) - if err != nil { - return fmt.Errorf("failed to subscribe to events: %w", err) - } - - log.Info("subscribed to daemon events") - defer func() { - log.Info("unsubscribed from daemon events") - }() - - for { - event, err := stream.Recv() - if err != nil { - return fmt.Errorf("error receiving event: %w", err) - } - e.handleEvent(event) - } -} - -func (e *Manager) Stop() { - e.mu.Lock() - defer e.mu.Unlock() - if e.cancel != nil { - e.cancel() - } -} - -func (e *Manager) SetNotificationsEnabled(enabled bool) { - e.mu.Lock() - defer e.mu.Unlock() - e.enabled = enabled -} - -func (e *Manager) handleEvent(event *proto.SystemEvent) { - e.mu.Lock() - enabled := e.enabled - handlers := slices.Clone(e.handlers) - e.mu.Unlock() - - if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) && !isV6DefaultRoutePartner(event) { - title := e.getEventTitle(event) - body := event.UserMessage - id := event.Metadata["id"] - if id != "" { - body += fmt.Sprintf(" ID: %s", id) - } - e.notifier.Send(title, body) - } - - for _, handler := range handlers { - go handler(event) - } -} - -func (e *Manager) AddHandler(handler Handler) { - e.mu.Lock() - defer e.mu.Unlock() - e.handlers = append(e.handlers, handler) -} - -// isV6DefaultRoutePartner reports whether the event is the IPv6 half of a -// paired v4/v6 default-route event. Management always pairs ::/0 with 0.0.0.0/0 -// for exit nodes, so the v4 partner already drives the user-facing toast and -// the v6 one is suppressed to avoid a duplicate notification. -func isV6DefaultRoutePartner(event *proto.SystemEvent) bool { - return event.Category == proto.SystemEvent_NETWORK && event.Metadata["network"] == "::/0" -} - -func (e *Manager) getEventTitle(event *proto.SystemEvent) string { - var prefix string - switch event.Severity { - case proto.SystemEvent_CRITICAL: - prefix = "Critical" - case proto.SystemEvent_ERROR: - prefix = "Error" - case proto.SystemEvent_WARNING: - prefix = "Warning" - default: - prefix = "Info" - } - - var category string - switch event.Category { - case proto.SystemEvent_DNS: - category = "DNS" - case proto.SystemEvent_NETWORK: - category = "Network" - case proto.SystemEvent_AUTHENTICATION: - category = "Authentication" - case proto.SystemEvent_CONNECTIVITY: - category = "Connectivity" - default: - category = "System" - } - - return fmt.Sprintf("%s: %s", prefix, category) -} - -func getClient(addr string) (proto.DaemonServiceClient, error) { - conn, err := grpc.NewClient( - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, err - } - return proto.NewDaemonServiceClient(conn), nil -} diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go deleted file mode 100644 index 902082308..000000000 --- a/client/ui/event_handler.go +++ /dev/null @@ -1,315 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - - "fyne.io/systray" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/version" -) - -type eventHandler struct { - client *serviceClient -} - -func newEventHandler(client *serviceClient) *eventHandler { - return &eventHandler{ - client: client, - } -} - -func (h *eventHandler) listen(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-h.client.mUp.ClickedCh: - h.handleConnectClick() - case <-h.client.mDown.ClickedCh: - h.handleDisconnectClick() - case <-h.client.mAllowSSH.ClickedCh: - h.handleAllowSSHClick() - case <-h.client.mAutoConnect.ClickedCh: - h.handleAutoConnectClick() - case <-h.client.mEnableRosenpass.ClickedCh: - h.handleRosenpassClick() - case <-h.client.mBlockInbound.ClickedCh: - h.handleBlockInboundClick() - case <-h.client.mAdvancedSettings.ClickedCh: - h.handleAdvancedSettingsClick() - case <-h.client.mCreateDebugBundle.ClickedCh: - h.handleCreateDebugBundleClick() - case <-h.client.mQuit.ClickedCh: - h.handleQuitClick() - return - case <-h.client.mGitHub.ClickedCh: - h.handleGitHubClick() - case <-h.client.mUpdate.ClickedCh: - h.handleUpdateClick() - case <-h.client.mNetworks.ClickedCh: - h.handleNetworksClick() - case <-h.client.mNotifications.ClickedCh: - h.handleNotificationsClick() - case <-systray.TrayOpenedCh: - h.client.updateExitNodes() - } - } -} - -func (h *eventHandler) handleConnectClick() { - h.client.mUp.Disable() - - if h.client.connectCancel != nil { - h.client.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(h.client.ctx) - h.client.connectCancel = connectCancel - - go func() { - defer connectCancel() - - if err := h.client.menuUpClick(connectCtx); err != nil { - st, ok := status.FromError(err) - if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) { - log.Debugf("connect operation cancelled by user") - } else { - h.client.notifier.Send("Error", "Failed to connect") - log.Errorf("connect failed: %v", err) - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after connect: %v", err) - } - }() -} - -func (h *eventHandler) handleDisconnectClick() { - h.client.mDown.Disable() - h.client.cancelExitNodeRetry() - - if h.client.connectCancel != nil { - log.Debugf("cancelling ongoing connect operation") - h.client.connectCancel() - h.client.connectCancel = nil - } - - go func() { - if err := h.client.menuDownClick(); err != nil { - st, ok := status.FromError(err) - if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) { - h.client.notifier.Send("Error", "Failed to disconnect") - log.Errorf("disconnect failed: %v", err) - } else { - log.Debugf("disconnect cancelled or already disconnecting") - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after disconnect: %v", err) - } - }() -} - -func (h *eventHandler) handleAllowSSHClick() { - h.toggleCheckbox(h.client.mAllowSSH) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update SSH settings") - } - -} - -func (h *eventHandler) handleAutoConnectClick() { - h.toggleCheckbox(h.client.mAutoConnect) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update auto-connect settings") - } -} - -func (h *eventHandler) handleRosenpassClick() { - h.toggleCheckbox(h.client.mEnableRosenpass) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update Rosenpass settings") - } -} - -func (h *eventHandler) handleBlockInboundClick() { - h.toggleCheckbox(h.client.mBlockInbound) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update block inbound settings") - } -} - -func (h *eventHandler) handleNotificationsClick() { - h.toggleCheckbox(h.client.mNotifications) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update notifications settings") - } else if h.client.eventManager != nil { - h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked()) - } - -} - -func (h *eventHandler) handleAdvancedSettingsClick() { - h.client.mAdvancedSettings.Disable() - go func() { - defer h.client.mAdvancedSettings.Enable() - defer h.client.getSrvConfig() - h.runSelfCommand(h.client.ctx, "settings") - }() -} - -func (h *eventHandler) handleCreateDebugBundleClick() { - h.client.mCreateDebugBundle.Disable() - go func() { - defer h.client.mCreateDebugBundle.Enable() - h.runSelfCommand(h.client.ctx, "debug") - }() -} - -func (h *eventHandler) handleQuitClick() { - systray.Quit() -} - -func (h *eventHandler) handleGitHubClick() { - if err := openURL("https://github.com/netbirdio/netbird"); err != nil { - log.Errorf("failed to open GitHub URL: %v", err) - } -} - -func (h *eventHandler) handleUpdateClick() { - h.client.updateIndicationLock.Lock() - enforced := h.client.isEnforcedUpdate - h.client.updateIndicationLock.Unlock() - - if !enforced { - if err := openURL(version.DownloadUrl()); err != nil { - log.Errorf("failed to open download URL: %v", err) - } - return - } - - // prevent blocking against a busy server - h.client.mUpdate.Disable() - go func() { - defer h.client.mUpdate.Enable() - conn, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client for update: %v", err) - _ = openURL(version.DownloadUrl()) - return - } - - resp, err := conn.TriggerUpdate(h.client.ctx, &proto.TriggerUpdateRequest{}) - if err != nil { - log.Errorf("TriggerUpdate failed: %v", err) - _ = openURL(version.DownloadUrl()) - return - } - if !resp.Success { - log.Errorf("TriggerUpdate failed: %s", resp.ErrorMsg) - _ = openURL(version.DownloadUrl()) - return - } - - log.Infof("update triggered via daemon") - }() -} - -func (h *eventHandler) handleNetworksClick() { - h.client.mNetworks.Disable() - go func() { - defer h.client.mNetworks.Enable() - h.runSelfCommand(h.client.ctx, "networks") - }() -} - -func (h *eventHandler) toggleCheckbox(item *systray.MenuItem) { - if item.Checked() { - item.Uncheck() - } else { - item.Check() - } -} - -func (h *eventHandler) updateConfigWithErr() error { - if err := h.client.updateConfig(); err != nil { - return err - } - - return nil -} - -func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) { - proc, err := os.Executable() - if err != nil { - log.Errorf("error getting executable path: %v", err) - return - } - - // Build the full command arguments - cmdArgs := []string{ - fmt.Sprintf("--%s=true", command), - fmt.Sprintf("--daemon-addr=%s", h.client.addr), - } - cmdArgs = append(cmdArgs, args...) - - cmd := exec.CommandContext(ctx, proc, cmdArgs...) - - if out := h.client.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("error closing log file %s: %v", h.client.logFile, err) - } - }() - } - - log.Printf("running command: %s", cmd.String()) - - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode()) - } - return - } - - log.Printf("command '%s' completed successfully", cmd.String()) -} - -func (h *eventHandler) logout(ctx context.Context) error { - client, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("failed to get service client: %w", err) - } - - _, err = client.Logout(ctx, &proto.LogoutRequest{}) - if err != nil { - return fmt.Errorf("logout failed: %w", err) - } - - h.client.getSrvConfig() - - return nil -} diff --git a/client/ui/font_bsd.go b/client/ui/font_bsd.go deleted file mode 100644 index 139f38f40..000000000 --- a/client/ui/font_bsd.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build freebsd || openbsd || netbsd || dragonfly - -package main - -import ( - "os" - "runtime" - - log "github.com/sirupsen/logrus" -) - -func (s *serviceClient) setDefaultFonts() { - paths := []string{ - "/usr/local/share/fonts/TTF/DejaVuSans.ttf", - "/usr/local/share/fonts/dejavu/DejaVuSans.ttf", - "/usr/local/share/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/liberation-fonts-ttf/LiberationSans-Regular.ttf", - } - - for _, fontPath := range paths { - if _, err := os.Stat(fontPath); err == nil { - os.Setenv("FYNE_FONT", fontPath) - log.Debugf("Using font: %s", fontPath) - return - } - } - - log.Errorf("Failed to find any suitable font files for %s", runtime.GOOS) -} diff --git a/client/ui/font_darwin.go b/client/ui/font_darwin.go deleted file mode 100644 index cafb72f59..000000000 --- a/client/ui/font_darwin.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "os" - - log "github.com/sirupsen/logrus" -) - -const defaultFontPath = "/Library/Fonts/Arial Unicode.ttf" - -func (s *serviceClient) setDefaultFonts() { - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} diff --git a/client/ui/font_linux.go b/client/ui/font_linux.go deleted file mode 100644 index 4aa92494a..000000000 --- a/client/ui/font_linux.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !386 - -package main - -func (s *serviceClient) setDefaultFonts() { - //TODO: Linux Multiple Language Support -} diff --git a/client/ui/font_windows.go b/client/ui/font_windows.go deleted file mode 100644 index 6346a9fb9..000000000 --- a/client/ui/font_windows.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "os" - "path" - "unsafe" - - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" -) - -func (s *serviceClient) setDefaultFonts() { - defaultFontPath := s.getWindowsFontFilePath() - - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} - -func (s *serviceClient) getWindowsFontFilePath() string { - var ( - fontFolder = "C:/Windows/Fonts" - fontMapping = map[string]string{ - "default": "Segoeui.ttf", - "zh-CN": "Segoeui.ttf", - "am-ET": "Ebrima.ttf", - "nirmala": "Nirmala.ttf", - "chr-CHER-US": "Gadugi.ttf", - "zh-HK": "Segoeui.ttf", - "zh-TW": "Segoeui.ttf", - "km-KH": "Leelawui.ttf", - "ko-KR": "Malgun.ttf", - "th-TH": "Leelawui.ttf", - "ti-ET": "Ebrima.ttf", - } - nirMalaLang = []string{ - "as-IN", - "bn-BD", - "bn-IN", - "gu-IN", - "hi-IN", - "kn-IN", - "kok-IN", - "ml-IN", - "mr-IN", - "ne-NP", - "or-IN", - "pa-IN", - "si-LK", - "ta-IN", - "te-IN", - } - ) - - // getUserDefaultLocaleName.Call() panics if the func is not found - defer func() { - if r := recover(); r != nil { - log.Errorf("Recovered from panic: %v", r) - } - }() - - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - getUserDefaultLocaleName := kernel32.NewProc("GetUserDefaultLocaleName") - - buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH is usually 85 - r, _, err := getUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) - // returns 0 on failure, err is always non-nil - // https://learn.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getuserdefaultlocalename - if r == 0 { - log.Errorf("GetUserDefaultLocaleName call failed: %v", err) - return path.Join(fontFolder, fontMapping["default"]) - } - - defaultLanguage := windows.UTF16ToString(buf) - - for _, lang := range nirMalaLang { - if defaultLanguage == lang { - return path.Join(fontFolder, fontMapping["nirmala"]) - } - } - - if font, ok := fontMapping[defaultLanguage]; ok { - return path.Join(fontFolder, font) - } - - return path.Join(fontFolder, fontMapping["default"]) -} diff --git a/client/ui/frontend/.prettierignore b/client/ui/frontend/.prettierignore new file mode 100644 index 000000000..c78cb7cc3 --- /dev/null +++ b/client/ui/frontend/.prettierignore @@ -0,0 +1,7 @@ +dist +build +node_modules +pnpm-lock.yaml +wailsjs +*.min.js +*.min.css diff --git a/client/ui/frontend/.prettierrc b/client/ui/frontend/.prettierrc new file mode 100644 index 000000000..e47a94f56 --- /dev/null +++ b/client/ui/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["cn", "clsx", "cva", "tw"] +} diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md new file mode 100644 index 000000000..494812d35 --- /dev/null +++ b/client/ui/frontend/WAILS-API.md @@ -0,0 +1,296 @@ +# Wails Go API reference (frontend) + +Reference for every binding method and model shape exposed to the frontend. Generated from `client/ui/services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`. + +Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run. + +## Imports + +```ts +// Services +import { + Connection, Peers, ProfileSwitcher, Profiles, + Settings, Networks, Forwarding, Debug, Update, WindowManager, + I18n, Preferences, +} from "@bindings/services"; + +// Models (types-only) +import type { + Status, PeerStatus, PeerLink, LocalPeer, SystemEvent, + Profile, ProfileRef, ActiveProfile, + Config, ConfigParams, SetConfigParams, Features, + Network, SelectNetworksParams, + ForwardingRule, PortInfo, PortRange, + LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams, + DebugBundleParams, DebugBundleResult, LogLevel, + UpdateResult, UpdateAvailable, UpdateProgress, +} from "@bindings/services/models.js"; + +// i18n / preferences models live in sibling packages, not services/models +import { LanguageCode, type Language } from "@bindings/i18n/models.js"; +import type { UIPreferences } from "@bindings/preferences/models.js"; +``` + +## Push events + +Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: }`. + +| Event | Payload | Fires on | +|---|---|---| +| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. | +| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. | +| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). | +| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/lib/i18n.ts` subscribes and calls `i18next.changeLanguage`. | +| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). | +| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). | +| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow. `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()`; no Go-side emitter today. | + +The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it. + +## `Connection` + +```ts +Connection.Login(p: LoginParams): Promise +Connection.WaitSSOLogin(p: WaitSSOParams): Promise // returns email +Connection.Up(p: UpParams): Promise // async on the daemon +Connection.Down(): Promise +Connection.Logout(p: LogoutParams): Promise +Connection.OpenURL(url: string): Promise // honors $BROWSER +``` + +`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`. + +Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`. + +## `Peers` + +```ts +Peers.Get(): Promise // one-shot snapshot +Peers.Watch(): Promise // already invoked from main.go +Peers.BeginProfileSwitch(): Promise +Peers.CancelProfileSwitch(): Promise +``` + +`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend. + +## `ProfileSwitcher` + +```ts +ProfileSwitcher.SwitchActive(p: ProfileRef): Promise +``` + +The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`. + +Reconnect policy (driven by `prevStatus` captured at entry): + +| Previous status | Action | Optimistic UI | Suppressed events until new flow | +|---|---|---|---| +| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle | +| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle | +| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — | +| Idle | Switch only | (no change) | — | + +## `Profiles` + +```ts +Profiles.Username(): Promise // current OS username +Profiles.List(username: string): Promise +Profiles.GetActive(): Promise +Profiles.Switch(p: ProfileRef): Promise // raw daemon RPC; prefer ProfileSwitcher.SwitchActive +Profiles.Add(p: ProfileRef): Promise +Profiles.Remove(p: ProfileRef): Promise +``` + +`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files. + +## `Settings` + +```ts +Settings.GetConfig(p: ConfigParams): Promise +Settings.SetConfig(p: SetConfigParams): Promise // partial update +Settings.GetFeatures(): Promise // operator-disabled UI sections +``` + +`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons. + +**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`. + +`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it. + +## `Networks` + +```ts +Networks.List(): Promise +Networks.Select(p: SelectNetworksParams): Promise +Networks.Deselect(p: SelectNetworksParams): Promise +``` + +`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All). + +Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side. + +## `Forwarding` + +```ts +Forwarding.List(): Promise +``` + +`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`). + +## `Debug` + +```ts +Debug.GetLogLevel(): Promise +Debug.SetLogLevel(lvl: LogLevel): Promise +Debug.Bundle(p: DebugBundleParams): Promise +Debug.RevealFile(path: string): Promise // OS file-manager focus +``` + +**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list. + +`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved). + +## `Update` + +```ts +Update.Trigger(): Promise // start the install +Update.GetInstallerResult(): Promise // poll the outcome (long-running) +Update.Quit(): Promise // 100ms later, app.Quit() +``` + +Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation. + +## `WindowManager` + +```ts +WindowManager.OpenSettings(): Promise +WindowManager.OpenBrowserLogin(uri: string): Promise // uri appended as ?uri=… +WindowManager.CloseBrowserLogin(): Promise +WindowManager.OpenError(title: string, message: string): Promise // custom branded error window; both query-escaped as ?title=…&message=… +WindowManager.CloseError(): Promise +``` + +Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised. + +Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`. + +## `I18n` + +```ts +I18n.Languages(): Promise // from _index.json +I18n.Bundle(code: LanguageCode): Promise> // full key→text map +``` + +Source of truth is `client/ui/i18n/locales/` (shared with the Go tray). The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite via the glob in `src/lib/i18n.ts`), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS. + +## `Preferences` + +```ts +Preferences.Get(): Promise // { language: string } +Preferences.SetLanguage(code: LanguageCode): Promise // rejects on unknown code +``` + +`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/lib/i18n.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read. + +## Daemon `Status.status` values + +Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label: + +| Value | Meaning | +|---|---| +| `"Idle"` | Tunnel down (Up never invoked or Down completed) | +| `"Connecting"` | Up in progress | +| `"Connected"` | Tunnel up | +| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up | +| `"LoginFailed"` | Previous Login attempt errored | +| `"SessionExpired"` | SSO token expired; needs re-Login | +| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. | + +The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that. + +## Model field reference + +`Status`: +```ts +{ status, daemonVersion: string; + management: PeerLink; signal: PeerLink; + local: LocalPeer; + peers: PeerStatus[]; + events: SystemEvent[]; } +``` + +`PeerLink`: `{ url: string; connected: boolean; error?: string }`. + +`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`. + +`PeerStatus`: +```ts +{ ip, pubKey, fqdn, connStatus: string; + connStatusUpdateUnix: number; + relayed: boolean; + localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|"" + localIceCandidateEndpoint, remoteIceCandidateEndpoint: string; + bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number; + relayAddress: string; // set when relayed=true + rosenpassEnabled: boolean; + networks: string[]; } +``` + +`SystemEvent`: +```ts +{ id: string; + severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped) + category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules) + message: string; // technical / log line + userMessage: string; // human-friendly — render this + timestamp: number; // unix seconds + metadata: Record; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", … +``` + +`Profile`: `{ name: string; isActive: boolean; email: string }`. + +`Config` (read-only mirror, all required): +```ts +{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string; + wireguardPort, mtu, sshJwtCacheTtl: number; + disableAutoConnect, serverSshAllowed, + rosenpassEnabled, rosenpassPermissive, + disableNotifications, lazyConnectionEnabled, blockInbound, + networkMonitor, disableClientRoutes, disableServerRoutes, + disableDns, disableIpv6, blockLanAccess, + enableSshRoot, enableSshSftp, + enableSshLocalPortForwarding, enableSshRemotePortForwarding, + disableSshAuth: boolean; } +``` + +`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings. + +`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`. + +`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`. + +`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`. + +`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated). + +`PortRange`: `{ start, end: number }` (inclusive). + +`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`. + +`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`. + +`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email. + +`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity). + +`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`. + +`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`. + +`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`). + +`UpdateResult`: `{ success: boolean; errorMsg: string }`. + +`UpdateAvailable`: `{ version: string; enforced: boolean }`. + +`UpdateProgress`: `{ action: string; version: string }`. diff --git a/client/ui/frontend/eslint.config.js b/client/ui/frontend/eslint.config.js new file mode 100644 index 000000000..f00623b68 --- /dev/null +++ b/client/ui/frontend/eslint.config.js @@ -0,0 +1,75 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import globals from "globals"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", "bindings/**", "sonar/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.{ts,tsx}"], + plugins: { + react, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "jsx-a11y": jsxA11y, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { ...globals.browser }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + // ----- a11y / semantic HTML (jsx-a11y recommended) ----- + ...jsxA11y.configs.recommended.rules, + "jsx-a11y/no-autofocus": ["warn", { ignoreNonDOM: true }], + + // ----- React ----- + ...react.configs.recommended.rules, + ...react.configs["jsx-runtime"].rules, + "react/prop-types": "off", + "react/jsx-no-target-blank": ["error", { allowReferrer: true }], + "react/self-closing-comp": "warn", + + // ----- React hooks ----- + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + // ----- Vite / HMR (Fast Refresh) ----- + "react-refresh/only-export-components": "off", + + // ----- TypeScript ----- + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/consistent-type-imports": [ + "warn", + { prefer: "type-imports", fixStyle: "inline-type-imports" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + + // ----- General correctness ----- + eqeqeq: ["error", "smart"], + "no-console": ["warn", { allow: ["warn", "error", "info"] }], + "no-debugger": "error", + "prefer-const": "warn", + }, + }, +); diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html new file mode 100644 index 000000000..e62139956 --- /dev/null +++ b/client/ui/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + NetBird + + + +

+ + + diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json new file mode 100644 index 000000000..3131b36cd --- /dev/null +++ b/client/ui/frontend/package.json @@ -0,0 +1,69 @@ +{ + "name": "netbird-ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build:dev": "tsc && vite build --minify false --mode development", + "build": "tsc && vite build --mode production", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "bindings": "cd .. && wails3 generate bindings -clean=true -ts", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", + "check": "pnpm lint && pnpm typecheck && pnpm format:check", + "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.4", + "@wailsio/runtime": "latest", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.38.0", + "i18next": "^26.2.0", + "lucide-react": "^0.566.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", + "react-loading-skeleton": "^3.5.0", + "react-router-dom": "^7.1.3", + "react-virtuoso": "^4.12.5", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.6.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.4", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.6.0", + "postcss": "^8.5.1", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3", + "typescript-eslint": "^8.61.1", + "vite": "^6.0.7" + }, + "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f" +} diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml new file mode 100644 index 000000000..b6b3dd336 --- /dev/null +++ b/client/ui/frontend/pnpm-lock.yaml @@ -0,0 +1,5240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: ^1.2.4 + version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wailsio/runtime': + specifier: latest + version: 3.0.0-alpha.79 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + framer-motion: + specifier: ^12.38.0 + version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + i18next: + specifier: ^26.2.0 + version: 26.3.0(typescript@5.9.3) + lucide-react: + specifier: ^0.566.0 + version: 0.566.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + react-loading-skeleton: + specifier: ^3.5.0 + version: 3.5.0(react@18.3.1) + react-router-dom: + specifier: ^7.1.3 + version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-virtuoso: + specifier: ^4.12.5 + version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.1 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@9.39.4(jiti@1.21.7)) + '@types/node': + specifier: ^25.6.0 + version: 25.9.1 + '@types/react': + specifier: ^18.3.18 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.4.20 + version: 10.5.0(postcss@8.5.15) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@1.21.7) + eslint-plugin-jsx-a11y: + specifier: ^6.10.2 + version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@9.39.4(jiti@1.21.7)) + globals: + specifier: ^17.6.0 + version: 17.6.0 + postcss: + specifier: ^8.5.1 + version: 8.5.15 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + prettier-plugin-tailwindcss: + specifier: ^0.8.0 + version: 0.8.0(prettier@3.8.3) + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.61.1 + version: 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + vite: + specifier: ^6.0.7 + version: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-visually-hidden@1.2.4': + resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@wailsio/runtime@3.0.0-alpha.79': + resolution: {integrity: sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.362: + resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.3: + resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.0: + resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.566.0: + resolution: {integrity: sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-loading-skeleton@3.5.0: + resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} + peerDependencies: + react: '>=16.8.0' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.15.1: + resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.1: + resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@10.0.1(eslint@9.39.4(jiti@1.21.7))': + optionalDependencies: + eslint: 9.39.4(jiti@1.21.7) + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.11': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + '@wailsio/runtime@3.0.0-alpha.79': {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.12.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.32: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.362 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001793: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.362: {} + + emoji-regex@9.2.2: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.3: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.12.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@1.21.7) + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.4(jiti@1.21.7) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.3 + eslint: 9.39.4(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fraction.js@5.3.4: {} + + framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.566.0(react@18.3.1): + dependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.15 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + dependencies: + prettier: 3.8.3 + + prettier@3.8.3: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-i18next@17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.0(typescript@5.9.3) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.3 + + react-is@16.13.1: {} + + react-loading-skeleton@3.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + react-remove-scroll@2.7.2(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.29)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.29)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + + react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + + react-style-singleton@2.2.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.8.4: {} + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.1: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.24.6: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + use-sidecar@1.1.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + jiti: 1.21.7 + + void-elements@3.1.0: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/client/ui/frontend/pnpm-workspace.yaml b/client/ui/frontend/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/client/ui/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/client/ui/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/client/ui/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx new file mode 100644 index 000000000..c7b12e538 --- /dev/null +++ b/client/ui/frontend/src/app.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./globals.css"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx"; +import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; +import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx"; +import ErrorDialog from "@/modules/error/ErrorDialog.tsx"; +import { AppLayout } from "@/layouts/AppLayout.tsx"; +import { MainPage } from "@/modules/main/MainPage.tsx"; +import { SettingsPage } from "@/modules/settings/SettingsPage.tsx"; +import { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { welcome } from "@/lib/welcome"; +import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; +import { initI18n } from "@/lib/i18n"; +import { initPlatform } from "@/lib/platform"; +import { initLogForwarding } from "@/lib/logs"; + +// Must run first so even init-time logs reach the Go log pipeline. +initLogForwarding(); + +welcome(); + +Promise.all([ + initI18n().catch((e) => { + console.error("i18n init failed:", e); + }), + initPlatform().catch((e) => { + console.error("platform init failed:", e); + }), +]).finally(() => { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + } + /> + } /> + } + /> + } /> + } /> + + }> + } /> + } /> + } /> + + + + + , + ); +}); diff --git a/client/ui/frontend/src/assets/fonts/inter-variable.ttf b/client/ui/frontend/src/assets/fonts/inter-variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4ab79e0102bbe0ffa1ed879b13e52ac8c6487833 GIT binary patch literal 879708 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 z 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

R*jRJ1?^iQ`xYAuqn>-7iqg^r+ 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

!+-?iHy`H`{?S!=5pudjt1=3|0 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⁣dUi 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

=;034=|=!0qfLoVZ{M)EKH3o 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 literal 0 HcmV?d00001 diff --git a/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b60e77f5dbf5505436c1904cb7a9ac4111ee76d0 GIT binary patch literal 303144 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


Y4US@-H<3gJcZD@1xi5O_ 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
+
+ + {t("settings.general.language.help")} +
+
+ + + + + + + + +
+
+ + +
+
+ + + + + +
+ {t("settings.general.language.empty")} +
+
+ + {sorted.map((lang) => { + const checked = lang.code === i18n.language; + return ( + void select(lang.code)} + className={cn( + "my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none", + "text-xs font-semibold text-nb-gray-200", + "data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50", + )} + > + + {labelFor(lang)} + + + {checked && ( + + )} + + + ); + })} +
+
+ + + +
+
+
+
+
+
+
+ ); +} diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx new file mode 100644 index 000000000..0083a767a --- /dev/null +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import netbirdLogo from "@/assets/logos/netbird.svg"; +import { SwitchItem } from "@/components/switches/SwitchItem"; +import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup"; +import { ManagementMode } from "@/hooks/useManagementUrl.ts"; + +type Props = { + value: ManagementMode; + onChange: (mode: ManagementMode) => void; + fullWidth?: boolean; +}; + +export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => { + const { t, i18n } = useTranslation(); + const itemClass = fullWidth ? "flex-1" : undefined; + return ( + onChange(v as ManagementMode)} + aria-label={t("settings.general.management.label")} + className={fullWidth ? "w-full" : undefined} + > + + {""} + {t("settings.general.management.cloud")} + + + {t("settings.general.management.selfHosted")} + + + ); +}; diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx new file mode 100644 index 000000000..e904d2de5 --- /dev/null +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -0,0 +1,37 @@ +import { type ComponentType } from "react"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type SquareIconVariant = "default" | "info" | "warning" | "danger"; + +const variantClass: Record = { + default: "text-white", + info: "text-sky-400", + warning: "text-netbird", + danger: "text-red-500", +}; + +type SquareIconProps = { + icon: ComponentType; + iconSize?: number; + variant?: SquareIconVariant; + className?: string; +}; + +export const SquareIcon = ({ + icon: Icon, + iconSize = 18, + variant = "default", + className, +}: SquareIconProps) => ( +
+ +
+); diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx new file mode 100644 index 000000000..2c77ba139 --- /dev/null +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -0,0 +1,98 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import * as RTooltip from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/cn"; + +type Props = { + content: ReactNode; + children: ReactNode; + side?: RTooltip.TooltipContentProps["side"]; + align?: RTooltip.TooltipContentProps["align"]; + delayDuration?: number; + sideOffset?: number; + alignOffset?: number; + interactive?: boolean; + keepOpenOnClick?: boolean; + contentClassName?: string; + closeDelay?: number; +}; + +export const Tooltip = ({ + content, + children, + side = "bottom", + align = "center", + delayDuration = 200, + sideOffset = 6, + alignOffset = 0, + interactive = false, + keepOpenOnClick = true, + contentClassName, + closeDelay = 0, +}: Props) => { + const [open, setOpen] = useState(false); + const hoveringRef = useRef(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + if (closeDelay <= 0) { + setOpen(false); + return; + } + closeTimer.current = setTimeout(() => setOpen(false), closeDelay); + }; + useEffect(() => () => cancelClose(), []); + + const handleOpenChange = (next: boolean) => { + if (!next && keepOpenOnClick && hoveringRef.current) return; + if (next) cancelClose(); + setOpen(next); + }; + + return ( + + + { + hoveringRef.current = true; + cancelClose(); + }} + onPointerLeave={() => { + hoveringRef.current = false; + scheduleClose(); + }} + > + {children} + + + e.preventDefault()} + className={cn( + "z-50 select-none text-xs text-nb-gray-100 shadow-lg", + "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", + !interactive && "pointer-events-none", + contentClassName ?? + "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + )} + > + {content} + + + + + ); +}; diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx new file mode 100644 index 000000000..5b2d2160c --- /dev/null +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -0,0 +1,32 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { Tooltip } from "@/components/Tooltip"; + +type Props = { + text: string; + className?: string; + tooltipContent?: ReactNode; + delayDuration?: number; +}; + +export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [text]); + + const span = ( + + {text} + + ); + if (!overflowing) return span; + return ( + + {span} + + ); +}; diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx new file mode 100644 index 000000000..1aedf82a6 --- /dev/null +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -0,0 +1,98 @@ +import { type ComponentType, type ReactNode, forwardRef } from "react"; +import * as Tabs from "@radix-ui/react-tabs"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; + +const Root = forwardRef>( + function VerticalTabsRoot({ className, ...props }, ref) { + return ( + + ); + }, +); + +const List = forwardRef(function VerticalTabsList( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +type TriggerProps = Tabs.TabsTriggerProps & { + icon: ComponentType; + title: string; + iconSize?: number; + adornment?: ReactNode; +}; + +const Trigger = forwardRef(function VerticalTabsTrigger( + { icon: Icon, title, iconSize = 16, adornment, className, ...props }, + ref, +) { + const isFocusVisible = useFocusVisible(); + return ( + + + + {title} + + {adornment && ( +
+ {adornment} +
+ )} +
+ ); +}); + +const Content = forwardRef(function VerticalTabsContent( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +export const VerticalTabs = Object.assign(Root, { List, Trigger, Content }); diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx new file mode 100644 index 000000000..6b151c17b --- /dev/null +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -0,0 +1,195 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, Copy, Loader2 } from "lucide-react"; +import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/cn"; + +type ButtonVariants = VariantProps; + +interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { + disabled?: boolean; + stopPropagation?: boolean; + copy?: string; + loading?: boolean; +} + +const buttonVariants = cva( + [ + "relative", + "cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2", + "inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1", + "disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300", + ], + { + variants: { + variant: { + default: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50", + ], + primary: [ + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900", + "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50", + ], + secondary: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white", + ], + secondaryLighter: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white", + ], + subtle: [ + "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60", + "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40", + "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950", + ], + input: [ + "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80", + ], + dropdown: [ + "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50", + ], + dotted: [ + "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white", + ], + tertiary: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + ], + white: [ + "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", + ], + outline: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50", + ], + "danger-outline": [ + "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", + ], + "danger-text": [ + "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", + ], + "default-outline": [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white", + ], + ghost: [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + ], + danger: [ + "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", + ], + }, + size: { + xs: "px-3.5 py-2.5 text-xs", + xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]", + sm: "px-4 py-[9px] text-sm", + md: "px-4 py-[9px]", + lg: "px-4 py-[9px] text-lg", + }, + rounded: { + true: "rounded-md", + false: "", + }, + border: { + 0: "border", + 1: "border border-transparent", + 2: "border border-b-0 border-t-0", + }, + }, + }, +); + +export const Button = forwardRef(function Button( + { + variant = "default", + rounded = true, + border = 1, + size = "md", + stopPropagation = true, + type = "button", + children, + className, + onClick, + disabled, + copy, + loading = false, + ...props + }, + ref, +) { + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + const iconSize = size === "xs" ? 12 : 14; + return ( + + ); +}); + +export default Button; diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx new file mode 100644 index 000000000..3d36bc111 --- /dev/null +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -0,0 +1,36 @@ +import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react"; +import { type LucideProps } from "lucide-react"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { cn } from "@/lib/cn"; + +type Props = ButtonHTMLAttributes & { + icon: ComponentType; + iconSize?: number; + iconClassName?: string; +}; + +export const IconButton = forwardRef(function IconButton( + { icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props }, + ref, +) { + const isFocusVisible = useFocusVisible(); + return ( + + ); +}); diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 000000000..caf98c2c8 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,35 @@ +import { type ReactNode, forwardRef } from "react"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type ConfirmDialogProps = { + children: ReactNode; + "aria-label"?: string; + "aria-labelledby"?: string; +}; + +export const ConfirmDialog = forwardRef(function ConfirmDialog( + { children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy }, + ref, +) { + return ( + +
+ {children} +
+
+ ); +}); diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx new file mode 100644 index 000000000..241a69ec9 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -0,0 +1,84 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Button } from "@/components/buttons/Button"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogActions } from "@/components/dialog/DialogActions"; + +type ConfirmModalProps = { + open: boolean; + title: ReactNode; + description: ReactNode; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +export const ConfirmModal = ({ + open, + title, + description, + confirmLabel, + cancelLabel, + danger = false, + busy = false, + onConfirm, + onCancel, +}: ConfirmModalProps) => { + const { t } = useTranslation(); + const resolvedCancel = cancelLabel ?? t("common.cancel"); + + const srTitle = typeof title === "string" ? title : undefined; + const srDescription = typeof description === "string" ? description : undefined; + + return ( + { + if (!next && !busy) onCancel(); + }} + > + e.preventDefault()} + > +
+
+ {title} + + {description} + +
+ + + + + +
+
+
+ ); +}; diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx new file mode 100644 index 000000000..fa8007d9f --- /dev/null +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -0,0 +1,159 @@ +import { + forwardRef, + type ComponentPropsWithoutRef, + type ElementRef, + type HTMLAttributes, +} from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; + +export const Root = DialogPrimitive.Root; + +type OverlayProps = ComponentPropsWithoutRef & { + exitAnimation?: boolean; +}; + +const Overlay = forwardRef, OverlayProps>( + function DialogOverlay({ className, exitAnimation = false, ...props }, ref) { + return ( + + ); + }, +); + +type ContentProps = ComponentPropsWithoutRef & { + showClose?: boolean; + maxWidthClass?: string; + exitAnimation?: boolean; + srTitle?: string; + srDescription?: string; +}; + +export const Content = forwardRef, ContentProps>( + function DialogContent( + { + className, + children, + showClose = true, + maxWidthClass = "max-w-md", + exitAnimation = false, + srTitle, + srDescription, + ...props + }, + ref, + ) { + const { t } = useTranslation(); + return ( + + + e.stopPropagation()} + {...props} + > + + + {srTitle ?? t("common.netbird")} + + + {srDescription && ( + + + {srDescription} + + + )} + {children} + {showClose && ( + + + + )} + + + + ); + }, +); + +export const Title = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function DialogTitle({ className, ...props }, ref) { + return ( + + ); +}); + +export const Description = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function DialogDescription({ className, ...props }, ref) { + return ( + + ); +}); + +type FooterProps = HTMLAttributes & { + separator?: boolean; +}; + +export const Footer = ({ className, separator = true, ...props }: FooterProps) => ( +
+
*]:w-full sm:[&>*]:w-auto", + "px-8 pt-6", + className, + )} + {...props} + /> +
+); diff --git a/client/ui/frontend/src/components/dialog/DialogActions.tsx b/client/ui/frontend/src/components/dialog/DialogActions.tsx new file mode 100644 index 000000000..3aa3a1fe5 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogActions.tsx @@ -0,0 +1,13 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogActionsProps = { + children: ReactNode; + className?: string; +}; + +export const DialogActions = ({ children, className }: DialogActionsProps) => ( +
+ {children} +
+); diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx new file mode 100644 index 000000000..12c358043 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx @@ -0,0 +1,26 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +type DialogDescriptionProps = { + children: ReactNode; + className?: string; + align?: DialogAlign; +}; + +export const DialogDescription = ({ + children, + className, + align = "center", +}: DialogDescriptionProps) => ( +

+ {children} +

+); diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx new file mode 100644 index 000000000..b9dda72a9 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -0,0 +1,35 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +type DialogHeadingProps = { + children: ReactNode; + className?: string; + align?: DialogAlign; + id?: string; +}; + +export const DialogHeading = ({ + children, + className, + align = "center", + id, +}: DialogHeadingProps) => ( +

+ {children} +

+); diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx new file mode 100644 index 000000000..4ee8c2740 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from "react-i18next"; +import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); +} + +export const DaemonOutdatedOverlay = () => { + const { t } = useTranslation(); + const { isDaemonOutdated } = useStatus(); + + if (!isDaemonOutdated) return null; + + return ( +
+
+
+ +
+ +
+

+ {t("daemon.outdated.title")} +

+

{t("daemon.outdated.description")}

+
+ +
+ +
+
+
+ ); +}; diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx new file mode 100644 index 000000000..89c121e21 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { AlertCircleIcon, BookText } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const DOCS_URL = "https://docs.netbird.io/how-to/installation"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); +} + +export const DaemonUnavailableOverlay = () => { + const { t } = useTranslation(); + const { isDaemonUnavailable } = useStatus(); + + if (!isDaemonUnavailable) return null; + + return ( +
+
+
+ +
+ +
+

+ {t("daemon.unavailable.title")} +

+

+ {t("daemon.unavailable.description")} +

+
+ +
+ +
+
+
+ ); +}; diff --git a/client/ui/frontend/src/components/empty-state/EmptyState.tsx b/client/ui/frontend/src/components/empty-state/EmptyState.tsx new file mode 100644 index 000000000..7d6890a98 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/EmptyState.tsx @@ -0,0 +1,31 @@ +import { type ComponentType } from "react"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { SquareIcon } from "@/components/SquareIcon"; +import { isMacOS } from "@/lib/platform"; + +// Knob to shift the centered main-window content up/down together. +export const contentVerticalOffset = (): string => (isMacOS() ? "0.6rem" : "-1.4rem"); +export const contentTop = (base: string) => `calc(${base} + ${contentVerticalOffset()})`; + +type Props = { + icon: ComponentType; + title: string; + description?: string; + className?: string; +}; + +export const EmptyState = ({ icon, title, description, className }: Props) => { + return ( +
+
+ +

{title}

+ {description &&

{description}

} +
+
+ ); +}; diff --git a/client/ui/frontend/src/components/empty-state/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx new file mode 100644 index 000000000..cf0995b37 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/NoResults.tsx @@ -0,0 +1,22 @@ +import { type ComponentType } from "react"; +import { FunnelXIcon, type LucideProps } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "./EmptyState"; + +type Props = { + icon?: ComponentType; + title?: string; + description?: string; +}; + +export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx new file mode 100644 index 000000000..2bcf70376 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx @@ -0,0 +1,16 @@ +import { GlobeOffIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "./EmptyState"; + +export const NotConnectedState = () => { + const { t } = useTranslation(); + return ( +
+ +
+ ); +}; diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx new file mode 100644 index 000000000..2dad80d7a --- /dev/null +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -0,0 +1,374 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react"; +import { + forwardRef, + type InputHTMLAttributes, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { Label } from "@/components/typography/Label"; + +type InputVariants = VariantProps; + +export interface InputProps extends InputHTMLAttributes, InputVariants { + label?: string; + customPrefix?: ReactNode; + customSuffix?: ReactNode; + maxWidthClass?: string; + icon?: ReactNode; + error?: string; + warning?: string; + prefixClassName?: string; + showPasswordToggle?: boolean; + copy?: boolean; +} + +const inputVariants = cva("", { + variants: { + variant: { + default: [ + "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + ], + darker: [ + "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", + "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + ], + error: [ + "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10", + ], + warning: [ + "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10", + ], + }, + prefixSuffixVariant: { + default: [ + "border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900", + ], + error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"], + }, + }, +}); + +function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number { + const stepAttr = el.step === "" ? 1 : Number(el.step); + const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1; + const min = el.min === "" ? -Infinity : Number(el.min); + const max = el.max === "" ? Infinity : Number(el.max); + const current = el.value === "" ? 0 : Number(el.value); + let next = (Number.isFinite(current) ? current : 0) + delta * step; + if (next < min) next = min; + if (next > max) next = max; + return next; +} + +function buildInputClassName( + opts: Readonly<{ + variant: InputVariants["variant"]; + hasCustomPrefix: boolean; + hasSuffix: boolean; + hasIcon: boolean; + readOnly?: boolean; + showStepper: boolean; + className?: string; + }>, +): string { + return cn( + inputVariants({ variant: opts.variant }), + "flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm", + "file:border-0 file:bg-transparent file:text-sm file:font-medium", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-40", + opts.hasCustomPrefix && "!rounded-l-none !border-l-0", + opts.hasSuffix && "!pr-9", + opts.hasIcon && "!pl-10", + "border", + opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350", + opts.showStepper && + "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + opts.className, + ); +} + +function InputAffix({ + content, + error, + disabled, + className, +}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) { + return ( +
+ {content} +
+ ); +} + +function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) { + return ( +
+ {icon} +
+ ); +} + +function InputSuffixSlot({ + suffix, + disabled, +}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) { + return ( +
+ {suffix} +
+ ); +} + +function NumberStepper({ + error, + disabled, + onStep, +}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} + +function FieldMessage({ + id, + error, + warning, +}: Readonly<{ id?: string; error?: string; warning?: string }>) { + if (!error && !warning) return null; + return ( + + {error ?? warning} + + ); +} + +export const Input = forwardRef(function Input( + { + className, + type, + label, + customSuffix, + customPrefix, + icon, + maxWidthClass = "", + error, + warning, + variant = "default", + prefixClassName, + showPasswordToggle = false, + copy = false, + id, + ...props + }, + ref, +) { + const { t } = useTranslation(); + const [showPassword, setShowPassword] = useState(false); + const [copied, setCopied] = useState(false); + const isPasswordType = type === "password"; + const inputType = isPasswordType && showPassword ? "text" : type; + const isNumber = type === "number"; + + const reactId = useId(); + const fallbackId = `input-${reactId}`; + const inputId = id ?? (label ? fallbackId : undefined); + const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined; + + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const internalRef = useRef(null); + const setRefs = (el: HTMLInputElement | null) => { + internalRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) ref.current = el; + }; + + const stepBy = (delta: 1 | -1) => { + const el = internalRef.current; + if (!el || el.disabled || el.readOnly) return; + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLInputElement.prototype, + "value", + )?.set; + const next = computeNextStepValue(el, delta); + setter?.call(el, String(next)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }; + + const passwordToggle = + isPasswordType && showPasswordToggle ? ( + + ) : null; + + const onCopy = async () => { + const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); + } catch (e) { + console.warn("copy to clipboard failed", e); + } + }; + + const copyToggle = copy ? ( + + ) : null; + + const suffix = passwordToggle || copyToggle || customSuffix; + const showStepper = isNumber; + const warningVariant = warning ? "warning" : variant; + const resolvedVariant = error ? "error" : warningVariant; + + const inputClassName = buildInputClassName({ + variant: resolvedVariant, + hasCustomPrefix: !!customPrefix, + hasSuffix: !!suffix, + hasIcon: !!icon, + readOnly: props.readOnly, + showStepper, + className, + }); + + return ( +
+ {label && } +
+ {customPrefix && ( + + )} + + {icon && } + +
+ + + {suffix && } +
+ + {showStepper && ( + + )} +
+ +
+ ); +}); + +export default Input; diff --git a/client/ui/frontend/src/components/inputs/SearchInput.tsx b/client/ui/frontend/src/components/inputs/SearchInput.tsx new file mode 100644 index 000000000..5f46e8fba --- /dev/null +++ b/client/ui/frontend/src/components/inputs/SearchInput.tsx @@ -0,0 +1,59 @@ +import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { SearchIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Props = InputHTMLAttributes & { + iconSize?: number; + shortcut?: ReactNode; +}; + +export const SearchInput = forwardRef(function SearchInput( + { iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props }, + ref, +) { + const { t } = useTranslation(); + return ( +
+ + + {shortcut && ( + + {shortcut} + + )} +
+ ); +}); diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx new file mode 100644 index 000000000..45e3e333a --- /dev/null +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch"; +import { cn } from "@/lib/cn"; + +interface Props { + value: boolean; + onChange: (value: boolean) => void; + helpText?: React.ReactNode; + label?: React.ReactNode; + children?: React.ReactNode; + disabled?: boolean; + loading?: boolean; + dataCy?: string; + className?: string; + labelClassName?: string; + textWrapperClassName?: string; +} + +export default function FancyToggleSwitch({ + value, + onChange, + helpText, + label, + children, + disabled = false, + loading = false, + dataCy, + className, + labelClassName, + textWrapperClassName = "max-w-lg", +}: Readonly) { + const switchId = React.useId(); + const descriptionId = React.useId(); + + if (loading) { + const shimmer = + "text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse"; + return ( +
+
+
+ + + + {helpText} + + +
+
+
+
+
+
+ ); + } + + return ( +
+
+
+ + + {helpText} + +
+
+ +
+
+ {children && value ?
{children}
: null} +
+ ); +} diff --git a/client/ui/frontend/src/components/switches/SwitchItem.tsx b/client/ui/frontend/src/components/switches/SwitchItem.tsx new file mode 100644 index 000000000..e23c73fe3 --- /dev/null +++ b/client/ui/frontend/src/components/switches/SwitchItem.tsx @@ -0,0 +1,42 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { motion } from "framer-motion"; +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; +import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup"; + +type Props = { + value: string; + children: ReactNode; + className?: string; +}; + +export const SwitchItem = ({ value, children, className }: Props) => { + const { value: activeValue, layoutId } = useSwitchItemGroup(); + const active = activeValue === value; + + return ( + + {active && ( + + )} + + {children} + + + ); +}; diff --git a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx new file mode 100644 index 000000000..b4361d530 --- /dev/null +++ b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx @@ -0,0 +1,60 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { createContext, type ReactNode, useContext, useId, useMemo } from "react"; +import { cn } from "@/lib/cn"; + +type SwitchItemGroupContextValue = { + value: string; + layoutId: string; +}; + +const SwitchItemGroupContext = createContext(null); + +export const useSwitchItemGroup = () => { + const ctx = useContext(SwitchItemGroupContext); + if (!ctx) { + throw new Error("SwitchItem must be used inside a SwitchItemGroup"); + } + return ctx; +}; + +type Props = { + value: string; + onChange: (value: string) => void; + children: ReactNode; + className?: string; + disabled?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; +}; + +export const SwitchItemGroup = ({ + value, + onChange, + children, + className, + disabled = false, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, +}: Props) => { + const layoutId = useId(); + const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); + + return ( + + + {children} + + + ); +}; diff --git a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx new file mode 100644 index 000000000..2d9f597e6 --- /dev/null +++ b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +type SwitchVariants = VariantProps; + +const switchVariants = cva("", { + variants: { + size: { + default: "h-[24px] w-[44px]", + small: "h-[18px] w-[36px]", + large: "h-[36px] w-[66px]", + }, + variant: { + default: [ + "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", + "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300", + ], + "red-green": [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + red: [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + }, + "thumb-size": { + default: + "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", + small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0", + large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]", + }, + }, +}); + +const ToggleSwitch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + SwitchVariants & { dataCy?: string } +>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => ( + { + e.stopPropagation(); + props.onClick?.(e); + }} + ref={ref} + > + + +)); +ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; + +export { ToggleSwitch }; diff --git a/client/ui/frontend/src/components/typography/HelpText.tsx b/client/ui/frontend/src/components/typography/HelpText.tsx new file mode 100644 index 000000000..8c52ff714 --- /dev/null +++ b/client/ui/frontend/src/components/typography/HelpText.tsx @@ -0,0 +1,24 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type Props = { + children?: ReactNode; + margin?: boolean; + className?: string; + disabled?: boolean; +}; + +export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => ( + + {children} + +); + +export default HelpText; diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx new file mode 100644 index 000000000..a8e1a446f --- /dev/null +++ b/client/ui/frontend/src/components/typography/Label.tsx @@ -0,0 +1,42 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react"; +import { cn } from "@/lib/cn"; + +const labelVariants = cva( + "mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100", +); + +type LabelProps = ComponentPropsWithoutRef & + VariantProps & { + as?: "label" | "div"; + disabled?: boolean; + }; + +export const Label = forwardRef(function Label( + { className, as = "label", disabled = false, children, ...props }, + ref, +) { + const classes = cn( + labelVariants(), + className, + "select-none transition-all duration-300", + disabled && "pointer-events-none opacity-30", + ); + + if (as === "div") { + return ( +
} className={classes}> + {children} +
+ ); + } + + return ( + } className={classes} {...props}> + {children} + + ); +}); + +export default Label; diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx new file mode 100644 index 000000000..c0699a9ee --- /dev/null +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -0,0 +1,114 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; + +import { Update as UpdateSvc, WindowManager } from "@bindings/services"; +import type { State as UpdateState } from "@bindings/updater/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const isDaemonUnavailable = (e: unknown): boolean => { + const msg = e instanceof Error ? e.message : String(e); + return msg.includes("code = Unavailable"); +}; + +type ClientVersionContextValue = { + updateAvailable: boolean; + updateVersion: string | null; + enforced: boolean; + installing: boolean; + triggerUpdate: () => void; + updating: boolean; +}; + +const EVENT_UPDATE_STATE = "netbird:update:state"; + +const emptyState: UpdateState = { + available: false, + version: "", + enforced: false, + installing: false, +}; + +const ClientVersionContext = createContext(null); + +export const useClientVersion = () => { + const ctx = useContext(ClientVersionContext); + if (!ctx) { + throw new Error("useClientVersion must be used inside ClientVersionProvider"); + } + return ctx; +}; + +export const ClientVersionProvider = ({ children }: { children: ReactNode }) => { + const [state, setState] = useState(emptyState); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + let cancelled = false; + UpdateSvc.GetState() + .then((s) => { + if (cancelled || !s) return; + setState(s); + }) + .catch((e) => { + if (cancelled || isDaemonUnavailable(e)) return; + void errorDialog({ + Title: i18next.t("update.error.loadStateTitle"), + Message: formatErrorMessage(e), + }); + }); + const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => { + if (ev?.data) setState(ev.data); + }); + return () => { + cancelled = true; + off?.(); + }; + }, []); + + const prevInstallingRef = useRef(false); + useEffect(() => { + if (state.installing && !prevInstallingRef.current) { + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); + } + prevInstallingRef.current = state.installing; + }, [state.installing, state.version]); + + const triggerUpdate = useCallback(() => { + setUpdating(true); + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); + UpdateSvc.Trigger() + .catch(async (e) => { + if (isDaemonUnavailable(e)) return; + WindowManager.CloseInstallProgress().catch(console.error); + await errorDialog({ + Title: i18next.t("update.error.triggerTitle"), + Message: formatErrorMessage(e), + }); + }) + .finally(() => setUpdating(false)); + }, [state.version]); + + const value = useMemo( + () => ({ + updateAvailable: state.available, + updateVersion: state.version || null, + enforced: state.enforced, + installing: state.installing, + triggerUpdate, + updating, + }), + [state, triggerUpdate, updating], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx new file mode 100644 index 000000000..a0a131fbf --- /dev/null +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -0,0 +1,314 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services"; +import type { DebugBundleResult } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { startConnection } from "@/lib/connection.ts"; + +const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; +const TRACE_LOG_FILE_COUNT = 5; +const PLAIN_LOG_FILE_COUNT = 1; +const TRACE_LOG_LEVEL = "trace"; +const DEFAULT_LOG_LEVEL = "info"; + +export type DebugStage = + | { kind: "idle" } + | { kind: "preparing-trace" } + | { kind: "reconnecting" } + | { kind: "capturing"; remainingSec: number; totalSec: number } + | { kind: "restoring-level" } + | { kind: "bundling" } + | { kind: "uploading" } + | { kind: "cancelling" } + | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }; + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + const onAbort = () => { + clearTimeout(id); + reject(new DOMException("aborted", "AbortError")); + }; + const id = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort); + }); + +const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError"; + +const throwIfAborted = (signal: AbortSignal) => { + if (signal.aborted) throw new DOMException("aborted", "AbortError"); +}; + +const setLogLevelBestEffort = async (level: string) => { + try { + await DebugSvc.SetLogLevel({ level }); + } catch (e) { + console.warn("[DebugBundle] best-effort set log level failed", e); + } +}; + +const stopCaptureBestEffort = async () => { + try { + await DebugSvc.StopBundleCapture(); + } catch (e) { + console.warn("[DebugBundle] best-effort stop packet capture failed", e); + } +}; + +type LevelState = { original: string; raised: boolean }; +type CaptureState = { started: boolean }; + +type BundleOptions = { + trace: boolean; + capture: boolean; + capturePackets: boolean; + hasWindow: boolean; + totalSec: number; + uploadUrl: string; + anonymize: boolean; + systemInfo: boolean; +}; + +const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => { + try { + // Mirror the CLI's safety margin: window + 30s, server caps at 10m. + await DebugSvc.StartBundleCapture(totalSec + 30); + pcap.started = true; + } catch (e) { + console.warn("[DebugBundle] start packet capture failed", e); + } +}; + +const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => { + if (pcap.started) { + await stopCaptureBestEffort(); + pcap.started = false; + } + if (restoreLevel && level.raised) { + await setLogLevelBestEffort(level.original); + } +}; + +const raiseToTrace = async ( + signal: AbortSignal, + level: LevelState, + setStage: (s: DebugStage) => void, +) => { + setStage({ kind: "preparing-trace" }); + try { + const cur = await DebugSvc.GetLogLevel(); + if (cur?.level) level.original = cur.level; + } catch (e) { + console.warn("[DebugBundle] read current log level failed", e); + } + throwIfAborted(signal); + await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL }); + level.raised = true; +}; + +const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => { + throwIfAborted(signal); + setStage({ kind: "reconnecting" }); + try { + await ConnectionSvc.Down(); + } catch (e) { + console.warn("[DebugBundle] disconnect before capture failed", e); + } + throwIfAborted(signal); + await startConnection(undefined, signal); +}; + +const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => { + setStage({ kind: "restoring-level" }); + try { + await DebugSvc.SetLogLevel({ level: level.original }); + level.raised = false; + } catch (e) { + console.warn("[DebugBundle] restore log level failed", e); + } +}; + +const waitCaptureWindow = async ( + signal: AbortSignal, + setStage: (s: DebugStage) => void, + totalSec: number, +) => { + for (let remaining = totalSec; remaining > 0; remaining--) { + setStage({ kind: "capturing", remainingSec: remaining, totalSec }); + await sleep(1000, signal); + } +}; + +const runBundleFlow = async ( + signal: AbortSignal, + opts: BundleOptions, + level: LevelState, + pcap: CaptureState, + setStage: (s: DebugStage) => void, + setLastBundlePath: (p: string) => void, +) => { + if (opts.trace) { + await raiseToTrace(signal, level, setStage); + } + throwIfAborted(signal); + + if (opts.capture) { + await cycleConnection(signal, setStage); + } + throwIfAborted(signal); + + if (opts.hasWindow && opts.capturePackets) { + await startCaptureBestEffort(opts.totalSec, pcap); + } + throwIfAborted(signal); + + if (opts.hasWindow) { + await waitCaptureWindow(signal, setStage, opts.totalSec); + } + + if (pcap.started) { + await stopCaptureBestEffort(); + pcap.started = false; + } + + if (level.raised) { + await restoreLogLevel(level, setStage); + } + + throwIfAborted(signal); + setStage({ kind: "bundling" }); + const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT; + + if (opts.uploadUrl) setStage({ kind: "uploading" }); + const result = await DebugSvc.Bundle({ + anonymize: opts.anonymize, + systemInfo: opts.systemInfo, + uploadUrl: opts.uploadUrl, + logFileCount, + }); + throwIfAborted(signal); + if (result.path) setLastBundlePath(result.path); + setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) }); +}; + +const useDebugBundle = () => { + const [anonymize, setAnonymize] = useState(false); + const [systemInfo, setSystemInfo] = useState(true); + const [upload, setUpload] = useState(true); + const [trace, setTrace] = useState(true); + const [capture, setCapture] = useState(false); + const [traceMinutes, setTraceMinutes] = useState(1); + const [capturePackets, setCapturePackets] = useState(true); + const [stage, setStage] = useState({ kind: "idle" }); + const [lastBundlePath, setLastBundlePath] = useState(""); + const abortRef = useRef(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const isRunning = stage.kind !== "idle" && stage.kind !== "done"; + + const reset = () => setStage({ kind: "idle" }); + + const cancel = () => { + if (!abortRef.current || abortRef.current.signal.aborted) return; + abortRef.current.abort(); + setStage({ kind: "cancelling" }); + }; + + const run = async () => { + const ctrl = new AbortController(); + abortRef.current = ctrl; + const signal = ctrl.signal; + + const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60; + const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false }; + const pcap: CaptureState = { started: false }; + const opts: BundleOptions = { + trace, + capture, + capturePackets, + hasWindow: capture && totalSec > 0, + totalSec, + uploadUrl: upload ? NETBIRD_UPLOAD_URL : "", + anonymize, + systemInfo, + }; + + try { + await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath); + } catch (e) { + if (isAbort(e)) { + setStage({ kind: "cancelling" }); + await cleanupBestEffort(pcap, level, true); + setStage({ kind: "idle" }); + return; + } + await cleanupBestEffort(pcap, level, false); + setStage({ kind: "idle" }); + await errorDialog({ + Title: i18next.t("settings.error.debugBundleTitle"), + Message: formatErrorMessage(e), + }); + } finally { + if (abortRef.current === ctrl) abortRef.current = null; + } + }; + + const openBundleDir = () => { + if (!lastBundlePath) return; + DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) => + console.error("[DebugBundleContext] reveal failed", err), + ); + }; + + return { + anonymize, + setAnonymize, + systemInfo, + setSystemInfo, + upload, + setUpload, + trace, + setTrace, + capture, + setCapture, + traceMinutes, + setTraceMinutes, + capturePackets, + setCapturePackets, + stage, + isRunning, + lastBundlePath, + run, + cancel, + reset, + openBundleDir, + }; +}; + +export type DebugBundleContextValue = ReturnType; + +const DebugBundleContext = createContext(null); + +export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { + const value = useDebugBundle(); + return {children}; +}; + +export const useDebugBundleContext = () => { + const ctx = useContext(DebugBundleContext); + if (!ctx) { + throw new Error("useDebugBundleContext must be used inside DebugBundleProvider"); + } + return ctx; +}; diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx new file mode 100644 index 000000000..8a52e0dd0 --- /dev/null +++ b/client/ui/frontend/src/contexts/DialogContext.tsx @@ -0,0 +1,68 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { ConfirmModal } from "@/components/dialog/ConfirmModal"; + +export type ConfirmOptions = { + title: ReactNode; + description: ReactNode; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; +}; + +type DialogContextValue = { + confirm: (options: ConfirmOptions) => Promise; +}; + +const DialogContext = createContext(null); + +export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) { + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(null); + const resolverRef = useRef<((result: boolean) => void) | null>(null); + + const confirm = useCallback((opts: ConfirmOptions) => { + setOptions(opts); + setOpen(true); + return new Promise((resolve) => { + resolverRef.current = resolve; + }); + }, []); + + const settle = (result: boolean) => { + resolverRef.current?.(result); + resolverRef.current = null; + setOpen(false); + }; + + const value = useMemo(() => ({ confirm }), [confirm]); + + return ( + + {children} + settle(true)} + onCancel={() => settle(false)} + /> + + ); +} + +export const useConfirm = () => { + const ctx = useContext(DialogContext); + if (!ctx) throw new Error("useConfirm must be used within a DialogProvider"); + return ctx.confirm; +}; diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx new file mode 100644 index 000000000..c08a3c824 --- /dev/null +++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; + +export type NavSection = "peers" | "networks"; + +type NavSectionContextValue = { + section: NavSection; + setSection: (s: NavSection) => void; +}; + +const NavSectionContext = createContext(null); + +export const useNavSection = (): NavSectionContextValue => { + const ctx = useContext(NavSectionContext); + if (!ctx) { + throw new Error("useNavSection must be used inside NavSectionProvider"); + } + return ctx; +}; + +export const NavSectionProvider = ({ children }: { children: ReactNode }) => { + const [section, setSection] = useState("peers"); + const value = useMemo(() => ({ section, setSection }), [section]); + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx new file mode 100644 index 000000000..ef7231700 --- /dev/null +++ b/client/ui/frontend/src/contexts/NetworksContext.tsx @@ -0,0 +1,222 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Networks as NetworksSvc } from "@bindings/services"; +import type { Network } from "@bindings/services/models.js"; +import { useStatus } from "@/contexts/StatusContext"; + +// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node. +// The daemon may merge a v4+v6 pair into a single comma-joined range string. +export const isExitNode = (range: string): boolean => + range.split(",").some((part) => { + const trimmed = part.trim(); + return trimmed === "0.0.0.0/0" || trimmed === "::/0"; + }); + +type NetworksContextValue = { + routes: Network[]; + networkRoutes: Network[]; + exitNodes: Network[]; + activeExitNode: Network | null; + refresh: () => Promise; + toggleNetwork: (id: string, selected: boolean) => Promise; + toggleExitNode: (id: string, selected: boolean) => Promise; + setNetworksSelected: (ids: string[], selected: boolean) => Promise; +}; + +const NetworksContext = createContext(null); + +export const useNetworks = () => { + const ctx = useContext(NetworksContext); + if (!ctx) { + throw new Error("useNetworks must be used inside NetworksProvider"); + } + return ctx; +}; + +export const NetworksProvider = ({ children }: { children: ReactNode }) => { + const { status } = useStatus(); + const [routes, setRoutes] = useState([]); + const [pending, setPending] = useState>(new Map()); + const pendingRef = useRef(pending); + useEffect(() => { + pendingRef.current = pending; + }, [pending]); + + // Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever. + const STUCK_OVERRIDE_MS = 4000; + const timersRef = useRef>>(new Map()); + + const clearTimer = useCallback((id: string) => { + const tid = timersRef.current.get(id); + if (tid !== undefined) { + clearTimeout(tid); + timersRef.current.delete(id); + } + }, []); + + const clearPendingFor = useCallback( + (ids: string[]) => { + for (const id of ids) clearTimer(id); + setPending((prev) => { + if (ids.every((id) => !prev.has(id))) return prev; + const next = new Map(prev); + for (const id of ids) next.delete(id); + return next; + }); + }, + [clearTimer], + ); + + const setPendingFor = useCallback( + (updates: Array<[string, boolean]>) => { + setPending((prev) => { + const next = new Map(prev); + for (const [id, sel] of updates) next.set(id, sel); + return next; + }); + for (const [id] of updates) { + clearTimer(id); + timersRef.current.set( + id, + setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS), + ); + } + }, + [clearTimer, clearPendingFor], + ); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const tid of timers.values()) clearTimeout(tid); + timers.clear(); + }; + }, []); + + const refresh = useCallback(async () => { + try { + const list = await NetworksSvc.List(); + setRoutes(list); + } catch (e) { + console.error("[NetworksContext] refresh failed", e); + } + }, []); + + const networksRevision = status?.networksRevision; + useEffect(() => { + refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err)); + }, [refresh, networksRevision]); + + useEffect(() => { + if (pendingRef.current.size === 0) return; + const confirmed: string[] = []; + for (const r of routes) { + const expected = pendingRef.current.get(r.id); + if (expected !== undefined && r.selected === expected) { + confirmed.push(r.id); + } + } + if (confirmed.length > 0) clearPendingFor(confirmed); + }, [routes, clearPendingFor]); + + const mutate = useCallback( + async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => { + try { + if (selected) { + await NetworksSvc.Select({ networkIds: ids, append: true, all: false }); + } else { + await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false }); + } + // Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back. + await refresh(); + } catch (e) { + console.error(e); + setPending((prev) => { + const next = new Map(prev); + for (const [id] of rollback) next.delete(id); + return next; + }); + throw e; + } + }, + [refresh], + ); + + const toggleNetwork = useCallback( + async (id: string, selected: boolean) => { + const target = !selected; + setPendingFor([[id, target]]); + await mutate([id], target, [[id, selected]]).catch(() => {}); + }, + [mutate, setPendingFor], + ); + + const setNetworksSelected = useCallback( + async (ids: string[], selected: boolean) => { + if (ids.length === 0) return; + const prevById = new Map(routes.map((r) => [r.id, r.selected])); + const rollback: Array<[string, boolean]> = ids.map((id) => [ + id, + prevById.get(id) ?? !selected, + ]); + setPendingFor(ids.map((id) => [id, selected])); + await mutate(ids, selected, rollback).catch(() => {}); + }, + [mutate, setPendingFor, routes], + ); + + // Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches. + const toggleExitNode = useCallback( + async (id: string, selected: boolean) => { + const target = !selected; + const updates: Array<[string, boolean]> = [[id, target]]; + const rollback: Array<[string, boolean]> = [[id, selected]]; + if (target) { + for (const r of routes) { + if (r.id !== id && isExitNode(r.range) && r.selected) { + updates.push([r.id, false]); + rollback.push([r.id, true]); + } + } + } + setPendingFor(updates); + await mutate([id], target, rollback).catch(() => {}); + }, + [mutate, setPendingFor, routes], + ); + + const value = useMemo(() => { + const effective = + pending.size === 0 + ? routes + : routes.map((r) => { + const override = pending.get(r.id); + return override === undefined || override === r.selected + ? r + : { ...r, selected: override }; + }); + const networkRoutes = effective.filter((r) => !isExitNode(r.range)); + const exitNodes = effective.filter((r) => isExitNode(r.range)); + const activeExitNode = exitNodes.find((r) => r.selected) ?? null; + return { + routes: effective, + networkRoutes, + exitNodes, + activeExitNode, + refresh, + toggleNetwork, + toggleExitNode, + setNetworksSelected, + }; + }, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx new file mode 100644 index 000000000..3ab20891f --- /dev/null +++ b/client/ui/frontend/src/contexts/PeerDetailContext.tsx @@ -0,0 +1,50 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PeerStatus } from "@bindings/services/models.js"; + +type PeerDetailContextValue = { + selected: PeerStatus | null; + setSelected: (p: PeerStatus | null) => void; +}; + +const PeerDetailContext = createContext(null); + +export const usePeerDetail = (): PeerDetailContextValue => { + const ctx = useContext(PeerDetailContext); + if (!ctx) { + throw new Error("usePeerDetail must be used inside PeerDetailProvider"); + } + return ctx; +}; + +export const PeerDetailProvider = ({ children }: { children: ReactNode }) => { + const [selected, setSelected] = useState(null); + const openerRef = useRef(null); + + const select = useCallback((p: PeerStatus | null) => { + if (p) { + const active = document.activeElement; + openerRef.current = active instanceof HTMLElement ? active : null; + } else { + const opener = openerRef.current; + openerRef.current = null; + if (opener?.isConnected) { + queueMicrotask(() => opener.focus()); + } + } + setSelected(p); + }, []); + + const value = useMemo( + () => ({ selected, setSelected: select }), + [selected, select], + ); + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx new file mode 100644 index 000000000..4dd3eaa7a --- /dev/null +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -0,0 +1,182 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services"; +import type { Profile } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const EVENT_PROFILE_CHANGED = "netbird:profile:changed"; + +type ProfileContextValue = { + username: string; + // activeProfile is the display NAME of the active profile (for rendering + // and the "default" check). activeProfileId is its stable on-disk ID, used + // as the handle for daemon requests and for active-profile comparisons, + // since display names can collide. + activeProfile: string; + activeProfileId: string; + profiles: Profile[]; + loaded: boolean; + refresh: () => Promise; + switchProfile: (id: string) => Promise; + addProfile: (name: string) => Promise; + removeProfile: (id: string) => Promise; + renameProfile: (id: string, newName: string) => Promise; + logoutProfile: (id: string) => Promise; +}; + +const ProfileContext = createContext(null); + +export const useProfile = () => { + const ctx = useContext(ProfileContext); + if (!ctx) { + throw new Error("useProfile must be used inside ProfileProvider"); + } + return ctx; +}; + +export const ProfileProvider = ({ children }: { children: ReactNode }) => { + const [username, setUsername] = useState(""); + const [activeProfile, setActiveProfile] = useState(""); + const [activeProfileId, setActiveProfileId] = useState(""); + const [profiles, setProfiles] = useState([]); + const [loaded, setLoaded] = useState(false); + const retryRef = useRef | null>(null); + + const refresh = useCallback(async () => { + if (retryRef.current) { + clearTimeout(retryRef.current); + retryRef.current = null; + } + try { + const u = await ProfilesSvc.Username(); + const [active, list] = await Promise.all([ + ProfilesSvc.GetActive(), + ProfilesSvc.List(u), + ]); + setUsername(u); + setActiveProfile(active.profileName || "default"); + setActiveProfileId(active.id || "default"); + setProfiles(list); + setLoaded(true); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("code = Unavailable")) { + retryRef.current = setTimeout(() => { + void refresh(); + }, 1000); + return; + } + setLoaded(true); + await errorDialog({ + Title: i18next.t("profile.error.loadTitle"), + Message: formatErrorMessage(e), + }); + } + }, []); + + useEffect(() => { + refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err)); + return () => { + if (retryRef.current) clearTimeout(retryRef.current); + }; + }, [refresh]); + + useEffect(() => { + const off = Events.On(EVENT_PROFILE_CHANGED, () => { + refresh().catch((err: unknown) => + console.error("[ProfileContext] refresh failed", err), + ); + }); + return () => { + off(); + }; + }, [refresh]); + + // id is a handle: the daemon resolves an exact ID, ID prefix, or unique + // display name. The UI passes the profile's ID for precision. + const switchProfile = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActive({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // addProfile creates a profile by display name and returns the + // daemon-generated ID, so the caller can immediately address it by ID. + const addProfile = useCallback( + async (name: string) => { + const id = await ProfilesSvc.Add({ profileName: name, username }); + await refresh(); + return id; + }, + [username, refresh], + ); + + const removeProfile = useCallback( + async (id: string) => { + await ProfilesSvc.Remove({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // The daemon resolves the handle (exact ID, ID prefix, or unique display + // name) — passing the ID is precise and avoids collisions on rename. + const renameProfile = useCallback( + async (id: string, newName: string) => { + await ProfilesSvc.Rename({ handle: id, newName, username }); + await refresh(); + }, + [username, refresh], + ); + + const logoutProfile = useCallback( + async (id: string) => { + await Connection.Logout({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + const value = useMemo( + () => ({ + username, + activeProfile, + activeProfileId, + profiles, + loaded, + refresh, + switchProfile, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + }), + [ + username, + activeProfile, + activeProfileId, + profiles, + loaded, + refresh, + switchProfile, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + ], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/RestrictionsContext.tsx b/client/ui/frontend/src/contexts/RestrictionsContext.tsx new file mode 100644 index 000000000..572bf17ec --- /dev/null +++ b/client/ui/frontend/src/contexts/RestrictionsContext.tsx @@ -0,0 +1,65 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { Restrictions } from "@bindings/services/models.js"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const EVENT_SYSTEM = "netbird:event"; +const EMPTY = new Restrictions(); + +const RestrictionsContext = createContext(EMPTY); + +export const useRestrictions = () => useContext(RestrictionsContext); + +export const RestrictionsProvider = ({ children }: { children: ReactNode }) => { + const [restrictions, setRestrictions] = useState(EMPTY); + const mounted = useRef(true); + const { status } = useStatus(); + + const refresh = useCallback(async () => { + try { + const r = await SettingsSvc.GetRestrictions(); + if (mounted.current) setRestrictions(r); + } catch (e) { + console.error("[RestrictionsContext] refresh failed", e); + } + }, []); + + useEffect(() => { + mounted.current = true; + + const off = Events.On( + EVENT_SYSTEM, + (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => { + if (e.data?.metadata?.type === "config_changed") refresh(); + }, + ); + + const onVisible = () => { + if (document.visibilityState === "visible") refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + mounted.current = false; + off(); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [refresh]); + + useEffect(() => { + if (status?.status) refresh(); + }, [status?.status, refresh]); + + return ( + {children} + ); +}; diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx new file mode 100644 index 000000000..37627bc76 --- /dev/null +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,267 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services"; +import type { Config } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx"; +import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts"; + +const SAVE_DEBOUNCE_MS = 400; + +const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err); + +export type AutostartState = { supported: boolean; enabled: boolean }; + +type SettingsContextValue = { + config: Config; + guiVersion: string; + setField: (k: K, v: Config[K]) => void; + saveField: (k: K, v: Config[K]) => Promise; + saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise; + saveNow: () => Promise; +}; + +type AutostartContextValue = { + autostart: AutostartState | null; + setAutostartEnabled: (enabled: boolean) => Promise; +}; + +const SettingsContext = createContext(null); +const AutostartContext = createContext(null); + +export const useSettings = () => { + const ctx = useContext(SettingsContext); + if (!ctx) { + throw new Error("useSettings must be used inside SettingsProvider"); + } + return ctx; +}; + +export const useAutostartSetting = () => { + const ctx = useContext(AutostartContext); + if (!ctx) { + throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider"); + } + return ctx; +}; + +type LoadedConfig = { profileName: string; data: Config }; + +const useSettingsState = () => { + const { username, activeProfileId, loaded: profileLoaded } = useProfile(); + const [loaded, setLoaded] = useState(null); + const [guiVersion, setGuiVersion] = useState("—"); + const saveTimer = useRef | null>(null); + const loadedRef = useRef(null); + + useEffect(() => { + loadedRef.current = loaded; + }, [loaded]); + + useEffect(() => { + if (!profileLoaded || !activeProfileId) return; + let cancelled = false; + + const load = async (showError: boolean) => { + try { + const data = await SettingsSvc.GetConfig({ + profileName: activeProfileId, + username, + }); + if (cancelled) return; + if (saveTimer.current) return; + setLoaded({ profileName: activeProfileId, data }); + } catch (e) { + if (cancelled || !showError) return; + await errorDialog({ + Title: i18next.t("settings.error.loadTitle"), + Message: errorMessage(e), + }); + } + }; + + load(true); + + const off = Events.On( + "netbird:event", + (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => { + if (e.data?.metadata?.type === "config_changed") load(false); + }, + ); + + return () => { + cancelled = true; + off(); + }; + }, [profileLoaded, activeProfileId, username]); + + useEffect(() => { + let cancelled = false; + Version.GUI().then((v) => { + if (!cancelled) setGuiVersion(v); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }, + [], + ); + + const save = useCallback( + async (profileName: string, next: Config, preSharedKey?: string) => { + const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey }; + try { + await SettingsSvc.SetConfig({ + ...next, + ...preSharedKeyWrite, + profileName, + username, + }); + } catch (e) { + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: errorMessage(e), + }); + } + }, + [username], + ); + + const setField = useCallback( + (k: K, v: Config[K]) => { + const cur = loadedRef.current; + if (!cur) return; + const next: LoadedConfig = { + profileName: cur.profileName, + data: { ...cur.data, [k]: v }, + }; + loadedRef.current = next; + setLoaded(next); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + saveTimer.current = null; + save(next.profileName, next.data).catch(logSaveError); + }, SAVE_DEBOUNCE_MS); + }, + [save], + ); + + const saveNow = useCallback(async () => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + await save(loaded.profileName, loaded.data); + }, [loaded, save]); + + const saveField = useCallback( + async (k: K, v: Config[K]) => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + const next = { ...loaded.data, [k]: v }; + setLoaded({ profileName: loaded.profileName, data: next }); + await save(loaded.profileName, next); + }, + [loaded, save], + ); + + const saveFields = useCallback( + async (partial: Partial, opts?: { preSharedKey?: string }) => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + + const merged: Config = { ...loaded.data, ...partial }; + const next: Config = + opts?.preSharedKey === undefined + ? merged + : { ...merged, preSharedKeySet: opts.preSharedKey !== "" }; + setLoaded({ profileName: loaded.profileName, data: next }); + await save(loaded.profileName, next, opts?.preSharedKey); + }, + [loaded, save], + ); + + return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow }; +}; + +export const SettingsProvider = ({ children }: { children: ReactNode }) => { + const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState(); + + const value = useMemo( + () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null), + [config, guiVersion, setField, saveField, saveFields, saveNow], + ); + + if (!value) { + return ( +
+ +
+ ); + } + + return {children}; +}; + +export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => { + const [autostart, setAutostart] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const supported = await Autostart.Supported(); + const enabled = supported ? await Autostart.IsEnabled() : false; + if (cancelled) return; + setAutostart({ supported, enabled }); + })().catch((err: unknown) => { + if (cancelled) return; + console.warn("[SettingsContext] load autostart state failed", err); + setAutostart({ supported: false, enabled: false }); + }); + return () => { + cancelled = true; + }; + }, []); + + const setAutostartEnabled = useCallback(async (enabled: boolean) => { + setAutostart((s) => (s ? { ...s, enabled } : s)); + try { + await Autostart.SetEnabled(enabled); + } catch (e) { + setAutostart((s) => (s ? { ...s, enabled: !enabled } : s)); + await errorDialog({ + Title: i18next.t("settings.general.autostart.errorTitle"), + Message: errorMessage(e), + }); + } + }, []); + + const value = useMemo( + () => ({ autostart, setAutostartEnabled }), + [autostart, setAutostartEnabled], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/StatusContext.tsx b/client/ui/frontend/src/contexts/StatusContext.tsx new file mode 100644 index 000000000..0ad9c7875 --- /dev/null +++ b/client/ui/frontend/src/contexts/StatusContext.tsx @@ -0,0 +1,106 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { DaemonFeed } from "@bindings/services"; +import { Status } from "@bindings/services/models.js"; +import { DaemonOutdatedOverlay } from "@/components/empty-state/DaemonOutdatedOverlay.tsx"; +import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx"; +import { isDaemonCompatible } from "@/lib/compat"; + +const EVENT_STATUS = "netbird:status"; + +type StatusContextValue = { + status: Status | null; + error: string | null; + refresh: () => Promise; + isReady: boolean; + isDaemonUnavailable: boolean; + isDaemonAvailable: boolean; + isDaemonOutdated: boolean; +}; + +const StatusContext = createContext(null); + +export const useStatus = () => { + const ctx = useContext(StatusContext); + if (!ctx) { + throw new Error("useStatus must be used inside StatusProvider"); + } + return ctx; +}; + +export const StatusProvider = ({ children }: { children: ReactNode }) => { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [isDaemonOutdated, setIsDaemonOutdated] = useState(false); + + const refresh = useCallback(async () => { + try { + const s = await DaemonFeed.Get(); + setStatus(s); + setError(null); + } catch (e) { + // Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise). + setStatus(Status.createFrom({ status: "DaemonUnavailable" })); + setError(String(e)); + } + }, []); + + useEffect(() => { + refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err)); + const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { + setStatus(ev.data); + setError(null); + }); + return () => { + off(); + }; + }, [refresh]); + + const isReady = status !== null; + const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable"; + const isDaemonAvailable = isReady && !isDaemonUnavailable; + + useEffect(() => { + if (!isDaemonAvailable) return; + let cancelled = false; + isDaemonCompatible() + .then((ok) => { + if (!cancelled) setIsDaemonOutdated(!ok); + }) + .catch((err) => { + console.error("[StatusContext] daemon compatible error", err); + }); + return () => { + cancelled = true; + }; + }, [isDaemonAvailable]); + + const value = useMemo( + () => ({ + status, + error, + refresh, + isReady, + isDaemonUnavailable, + isDaemonAvailable, + isDaemonOutdated, + }), + [status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated], + ); + + return ( + + {isDaemonAvailable && !isDaemonOutdated && children} + + + + ); +}; diff --git a/client/ui/frontend/src/contexts/ViewModeContext.tsx b/client/ui/frontend/src/contexts/ViewModeContext.tsx new file mode 100644 index 000000000..7db3abae0 --- /dev/null +++ b/client/ui/frontend/src/contexts/ViewModeContext.tsx @@ -0,0 +1,89 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Window } from "@wailsio/runtime"; +import { Preferences } from "@bindings/services"; +import { ViewMode as ViewModePref } from "@bindings/preferences/models.js"; + +export type ViewMode = "default" | "advanced"; + +// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px +// title bar) while creation is content, so re-asserting a constant chops the content on first switch. +export const VIEW_WIDTH: Record = { + default: 380, + advanced: 900, +}; + +type ViewModeContextValue = { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; +}; + +const ViewModeContext = createContext(null); + +export const ViewModeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setMode] = useState("default"); + const modeRef = useRef("default"); + + useEffect(() => { + let cancelled = false; + Preferences.Get() + .then((prefs) => { + if (cancelled) return; + const saved = prefs?.viewMode as ViewMode | undefined; + if (saved === "default" || saved === "advanced") { + modeRef.current = saved; + setMode(saved); + } + }) + .catch((err: unknown) => + console.warn("[ViewModeContext] load preferences failed", err), + ); + return () => { + cancelled = true; + }; + }, []); + + // Resize before flipping React state, else the layout paints into a window that hasn't grown yet. + const setViewMode = useCallback((mode: ViewMode) => { + if (modeRef.current === mode) return; + modeRef.current = mode; + (async () => { + const size = await Window.Size().catch((err: unknown) => { + console.warn("[ViewModeContext] read window size failed", err); + return null; + }); + const width = VIEW_WIDTH[mode]; + const height = size?.height ?? 640; + await Window.SetSize(width, height).catch((err: unknown) => + console.warn("[ViewModeContext] set window size failed", err), + ); + setMode(mode); + const pref = + mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault; + Preferences.SetViewMode(pref).catch((err: unknown) => + console.error("[ViewModeContext] SetViewMode failed", err), + ); + })().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err)); + }, []); + + const value = useMemo( + () => ({ viewMode: mode, setViewMode }), + [mode, setViewMode], + ); + + return {children}; +}; + +export const useViewMode = () => { + const ctx = useContext(ViewModeContext); + if (!ctx) throw new Error("useViewMode must be used inside ViewModeProvider"); + return ctx; +}; diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css new file mode 100644 index 000000000..84ddfdcfe --- /dev/null +++ b/client/ui/frontend/src/globals.css @@ -0,0 +1,45 @@ +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-weight: 100 900; + src: url("./assets/fonts/inter-variable.ttf") format("truetype"); +} + +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-weight: 100 800; + src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; + overflow: hidden; +} + +/* + * Body bg is fully opaque on purpose. The main window uses + * MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS + * lets the desktop wallpaper bleed through any non-opaque pixel. A 90% + * body alpha meant two machines with different wallpapers saw different + * effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray + * DEFAULT) here keeps things consistent regardless of the OS backdrop. + */ +body { + @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; +} + +.wails-draggable { + --wails-draggable: drag; + cursor: default; +} + +.wails-no-draggable { + --wails-draggable: no-drag; +} diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts new file mode 100644 index 000000000..d4f4d80b2 --- /dev/null +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -0,0 +1,60 @@ +import { useLayoutEffect, useRef } from "react"; +import { Window } from "@wailsio/runtime"; +import i18next from "@/lib/i18n"; +import { isLinux } from "@/lib/platform"; + +// Sizes the current Wails window to the measured content height (keeping `width`), +// then shows it. Re-applies on content resize and language change. +export function useAutoSizeWindow(width: number, ready: boolean = true) { + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let shown = false; + let raf1 = 0; + let raf2 = 0; + const showOnce = () => { + if (shown) return; + shown = true; + Window.Show().catch(() => {}); + Window.Focus().catch(() => {}); + }; + const apply = async () => { + if (!ready) return; + const h = Math.ceil(el.getBoundingClientRect().height); + if (h <= 0) return; + try { + // Window.SetSize takes the frame size, so add the OS title-bar height or content clips. + const frame = await Window.Size(); + const targetH = h + Math.max(0, frame.height - window.innerHeight); + // Linux: SetSize no-ops on a mapped non-resizable window (X11), so pin via min/max instead. + if (isLinux()) { + await Window.SetMinSize(width, targetH); + await Window.SetMaxSize(width, targetH); + } + await Window.SetSize(width, targetH); + showOnce(); + } catch { + // window gone / not ready — ignore + } + }; + const scheduleApply = () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(apply); + }); + }; + apply(); + const ro = new ResizeObserver(apply); + ro.observe(el); + i18next.on("languageChanged", scheduleApply); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + i18next.off("languageChanged", scheduleApply); + }; + }, [width, ready]); + return ref; +} diff --git a/client/ui/frontend/src/hooks/useFocusVisible.ts b/client/ui/frontend/src/hooks/useFocusVisible.ts new file mode 100644 index 000000000..6061beb9c --- /dev/null +++ b/client/ui/frontend/src/hooks/useFocusVisible.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; + +// Tracks the user's current input modality (keyboard vs pointer) at module +// scope, mirroring what @react-aria/interactions does. Radix programmatically +// focuses elements like Tabs triggers and Select triggers, which makes the +// browser's :focus-visible heuristic light up on mouse-driven interactions too. +// Gating focus styles on this hook lets us only paint a focus ring when the +// user is actually navigating with the keyboard. +// See react-aria's useFocusVisible for context. + +type Modality = "keyboard" | "pointer"; + +let currentModality: Modality = "pointer"; +const subscribers = new Set<(m: Modality) => void>(); + +const setModality = (m: Modality) => { + if (m === currentModality) return; + currentModality = m; + subscribers.forEach((cb) => cb(m)); +}; + +const isKeyboardEvent = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return false; + return e.key === "Tab" || e.key === "Escape" || e.key.startsWith("Arrow"); +}; + +if (globalThis.window !== undefined) { + globalThis.addEventListener( + "keydown", + (e) => { + if (isKeyboardEvent(e)) setModality("keyboard"); + }, + true, + ); + globalThis.addEventListener("pointerdown", () => setModality("pointer"), true); +} + +export const useFocusVisible = (): boolean => { + const [visible, setVisible] = useState(currentModality === "keyboard"); + useEffect(() => { + setVisible(currentModality === "keyboard"); + const cb = (m: Modality) => setVisible(m === "keyboard"); + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; + }, []); + return visible; +}; diff --git a/client/ui/frontend/src/hooks/useKeyboardShortcut.ts b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts new file mode 100644 index 000000000..ea67d8375 --- /dev/null +++ b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts @@ -0,0 +1,46 @@ +import { useEffect } from "react"; +import { isMacOS } from "@/lib/platform"; + +export type Shortcut = { + key: string; + cmd?: boolean; + shift?: boolean; + alt?: boolean; + preventDefault?: boolean; +}; + +export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => { + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return; + const mod = e.metaKey || e.ctrlKey; + if (!!shortcut.cmd !== mod) return; + if (!!shortcut.shift !== e.shiftKey) return; + if (!!shortcut.alt !== e.altKey) return; + if (shortcut.preventDefault !== false) e.preventDefault(); + callback(); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [ + shortcut.key, + shortcut.cmd, + shortcut.shift, + shortcut.alt, + shortcut.preventDefault, + callback, + enabled, + ]); +}; + +export const formatShortcut = (shortcut: Shortcut): string => { + // navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac. + const mac = isMacOS(); + const parts: string[] = []; + if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl"); + if (shortcut.shift) parts.push(mac ? "⇧" : "Shift"); + if (shortcut.alt) parts.push(mac ? "⌥" : "Alt"); + parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key); + return parts.join(mac ? "" : "+"); +}; diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts new file mode 100644 index 000000000..6a0ef1b81 --- /dev/null +++ b/client/ui/frontend/src/hooks/useManagementUrl.ts @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useConfirm } from "@/contexts/DialogContext.tsx"; + +export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443"; +const CLOUD_MANAGEMENT_URLS = new Set([ + CLOUD_MANAGEMENT_URL, + "https://api.wiretrustee.com:443", // legacy cloud endpoint +]); + +export function isNetbirdCloud(url: string): boolean { + if (!url || url.trim() === "") return true; + return CLOUD_MANAGEMENT_URLS.has(url); +} + +// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4. +// Syntactic validation only — reachability is checked via checkManagementUrlReachable. +export const URL_PATTERN = new RegExp( + String.raw`^(https?:\/\/)?` + + String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` + + String.raw`((\d{1,3}\.){3}\d{1,3}))` + + String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` + + String.raw`(\?[;&a-z\d%_.~+=-]*)?` + + String.raw`(\#[-a-z\d_]*)?$`, + "i", +); + +export function normalizeManagementUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function isValidManagementUrl(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + return URL_PATTERN.test(trimmed); +} + +// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block. +export async function checkManagementUrlReachable( + url: string, + timeoutMs: number = 5000, +): Promise { + const target = normalizeManagementUrl(url); + if (!target) return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal }); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +export enum ManagementMode { + Cloud = "cloud", + SelfHosted = "selfhosted", +} + +function modeFromUrl(url: string): ManagementMode { + return isNetbirdCloud(url) ? ManagementMode.Cloud : ManagementMode.SelfHosted; +} + +export function useManagementUrl() { + const { t } = useTranslation(); + const confirm = useConfirm(); + const { config, saveField } = useSettings(); + const [modeState, setModeState] = useState(modeFromUrl(config.managementUrl)); + const [url, setUrl] = useState( + isNetbirdCloud(config.managementUrl) ? "" : config.managementUrl, + ); + const [checking, setChecking] = useState(false); + const [unreachable, setUnreachable] = useState(false); + + useEffect(() => { + setModeState(modeFromUrl(config.managementUrl)); + if (!isNetbirdCloud(config.managementUrl)) { + setUrl(config.managementUrl); + } + }, [config.managementUrl]); + + useEffect(() => { + setUnreachable(false); + }, [url, modeState]); + + const setMode = async (next: ManagementMode) => { + if (next === ManagementMode.Cloud && !isNetbirdCloud(config.managementUrl)) { + const ok = await confirm({ + title: t("settings.general.management.switchCloudTitle"), + description: t("settings.general.management.switchCloudMessage"), + confirmLabel: t("settings.general.management.switchCloudConfirm"), + }); + if (!ok) return; + setModeState(ManagementMode.Cloud); + saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) => + console.error("save managementUrl failed", err), + ); + return; + } + setModeState(next); + }; + + const normalizedUrl = normalizeManagementUrl(url); + const urlValid = isValidManagementUrl(url); + const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl; + const dirty = targetUrl !== config.managementUrl; + const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid; + const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid); + const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url; + + const save = async () => { + if (modeState === ManagementMode.SelfHosted && !unreachable) { + setChecking(true); + const reachable = await checkManagementUrlReachable(targetUrl); + setChecking(false); + if (!reachable) { + setUnreachable(true); + return; + } + } + await saveField("managementUrl", targetUrl); + setUnreachable(false); + }; + + return { + mode: modeState, + setMode, + url, + setUrl, + displayUrl, + showError, + canSave, + save, + checking, + unreachable, + }; +} diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 000000000..1588d9d08 --- /dev/null +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,27 @@ +import { Outlet } from "react-router-dom"; +import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx"; +import { StatusProvider } from "@/contexts/StatusContext.tsx"; +import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx"; +import { ProfileProvider } from "@/contexts/ProfileContext.tsx"; +import { DialogProvider } from "@/contexts/DialogContext.tsx"; +import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx"; + +export const AppLayout = () => { + return ( +
+ + + + + + + + + + + + + +
+ ); +}; diff --git a/client/ui/frontend/src/layouts/AppRightPanel.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx new file mode 100644 index 000000000..8474bd71f --- /dev/null +++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/cn.ts"; + +type Props = { + children: ReactNode; + overlay?: ReactNode; + overlayOpen?: boolean; + className?: string; +}; + +const PANEL_TRANSITION = { + duration: 0.32, + ease: [0.32, 0.72, 0, 1] as [number, number, number, number], +}; + +export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => { + return ( +
+ + {children} + + {overlay} +
+ ); +}; diff --git a/client/ui/frontend/src/lib/cn.ts b/client/ui/frontend/src/lib/cn.ts new file mode 100644 index 000000000..e6a8be071 --- /dev/null +++ b/client/ui/frontend/src/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/client/ui/frontend/src/lib/compat.ts b/client/ui/frontend/src/lib/compat.ts new file mode 100644 index 000000000..b2981da90 --- /dev/null +++ b/client/ui/frontend/src/lib/compat.ts @@ -0,0 +1,19 @@ +import { Compat } from "@bindings/services"; + +let cached: boolean | null = null; + +/** + * isDaemonCompatible probes whether the running daemon implements the WailsUIReady + * RPC. A false result means the daemon predates this UI (Unimplemented) and is too + * old to drive it. The Go side returns an error instead when the daemon is simply + * unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown" + * and let the normal connection flow report it. + * + * The result is cached for the session: daemon identity does not change without a + * UI restart, and a freshly started daemon is reachable again under the same socket. + */ +export async function isDaemonCompatible(): Promise { + if (cached !== null) return cached; + cached = await Compat.DaemonReady(); + return cached; +} diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts new file mode 100644 index 000000000..b9e98bf24 --- /dev/null +++ b/client/ui/frontend/src/lib/connection.ts @@ -0,0 +1,121 @@ +import { Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; + +export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; +export const EVENT_TRIGGER_LOGIN = "trigger-login"; + +let connectionInFlight = false; + +type SsoState = { + cancelled: boolean; + offCancel?: () => void; + offSignal?: () => void; +}; + +async function openBrowserLoginUri(uri: string): Promise { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); + } +} + +function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + state.cancelled = true; + resolve(); + }); + if (!signal) return; + const onAbort = () => { + state.cancelled = true; + resolve(); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort); + state.offSignal = () => signal.removeEventListener("abort", onAbort); + }); +} + +async function runSsoLogin( + result: { verificationUri: string; verificationUriComplete: string; userCode: string }, + state: SsoState, + signal?: AbortSignal, +): Promise { + const uri = result.verificationUriComplete || result.verificationUri; + if (uri) await openBrowserLoginUri(uri); + + const cancelPromise = buildSsoCancelPromise(state, signal); + const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" }); + + try { + await Promise.race([waitPromise, cancelPromise]); + } finally { + WindowManager.CloseBrowserLogin().catch(console.error); + } + + if (state.cancelled) { + waitPromise.cancel?.(); + waitPromise.catch(() => {}); + } +} + +export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise { + if (connectionInFlight || signal?.aborted) { + onSettled?.(); + return; + } + connectionInFlight = true; + + const state: SsoState = { cancelled: false }; + let connectError: unknown; + + try { + const result = await Connection.Login({ + profileName: "", + username: "", + managementUrl: "", + setupKey: "", + preSharedKey: "", + hostname: "", + hint: "", + }); + + if (signal?.aborted) state.cancelled = true; + + if (!state.cancelled && result.needsSsoLogin) { + await runSsoLogin(result, state, signal); + } + + if (!state.cancelled && signal?.aborted) state.cancelled = true; + + if (!state.cancelled) { + await Connection.Up({ profileName: "", username: "" }); + } + } catch (e) { + WindowManager.CloseBrowserLogin().catch(console.error); + if (!state.cancelled) connectError = e; + } finally { + state.offCancel?.(); + state.offSignal?.(); + connectionInFlight = false; + onSettled?.(); + } + + if (connectError !== undefined) { + await errorDialog({ + Title: i18next.t("connect.error.loginTitle"), + Message: formatErrorMessage(connectError), + }); + return; + } + + if (state.cancelled && signal) { + throw new DOMException("aborted", "AbortError"); + } +} diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts new file mode 100644 index 000000000..34a90dace --- /dev/null +++ b/client/ui/frontend/src/lib/errors.ts @@ -0,0 +1,60 @@ +import { WindowManager } from "@bindings/services"; + +type ClassifiedError = { short: string; long: string }; + +const asObject = (v: unknown): Record | null => + v && typeof v === "object" ? (v as Record) : null; + +const parseJsonObject = (s: unknown): Record | null => { + if (typeof s !== "string") return null; + const t = s.trim(); + if (!t.startsWith("{") || !t.endsWith("}")) return null; + try { + return asObject(JSON.parse(t)); + } catch { + return null; + } +}; + +const toWailsEnvelope = (e: unknown): Record | null => { + const obj = asObject(e); + if (!obj) return null; + return asObject(obj.cause) ?? parseJsonObject(obj.message); +}; + +// Read { short, long } from wherever the classified error sits in the envelope +const toClassifiedError = (v: unknown): ClassifiedError | null => { + const o = asObject(v); + if (!o) return null; + const short = typeof o.short === "string" ? o.short : ""; + const long = typeof o.long === "string" ? o.long : ""; + return short || long ? { short, long } : null; +}; + +export const formatErrorMessage = (e: unknown): string => { + const envelope = toWailsEnvelope(e); + + // Prefer the structured { short, long } the daemon classifier produced. + const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope); + if (classified) { + const { short, long } = classified; + if (short && long && long !== short) return `${short} Details: ${long}`; + if (short) return short; + if (long) return long; + } + + // Unclassified (a service returned the raw daemon error) + const message = envelope?.message; + if (typeof message === "string" && message) return message; + if (e instanceof Error) return e.message; + return String(e); +}; + +export type ErrorDialogOptions = { + Title: string; + Message: string; +}; + +export function errorDialog(options: ErrorDialogOptions): Promise { + return WindowManager.OpenError(options.Title, options.Message); +} diff --git a/client/ui/frontend/src/lib/formatters.ts b/client/ui/frontend/src/lib/formatters.ts new file mode 100644 index 000000000..231967a38 --- /dev/null +++ b/client/ui/frontend/src/lib/formatters.ts @@ -0,0 +1,44 @@ +export const formatBytes = (bytes: number, decimals: number = 2): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); + + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]; +}; + +export const latencyColor = (ms: number): string => { + if (ms <= 0) return "text-nb-gray-400"; + if (ms < 100) return "text-green-400"; + return "text-yellow-400"; +}; + +export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => { + if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null; + const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds)); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +}; + +// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix. +export const shortenDns = (fqdn: string | undefined | null): string => { + if (!fqdn) return ""; + const dot = fqdn.indexOf("."); + return dot === -1 ? fqdn : fqdn.slice(0, dot); +}; + +// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows. +export const formatRemaining = (seconds: number): string => { + const s = Math.max(0, Math.trunc(seconds)); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const secs = s % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + return `${pad(minutes)}:${pad(secs)}`; +}; diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts new file mode 100644 index 000000000..dc7fc7902 --- /dev/null +++ b/client/ui/frontend/src/lib/i18n.ts @@ -0,0 +1,103 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import { Events } from "@wailsio/runtime"; + +import { Preferences, I18n } from "@bindings/services"; +import { type LanguageCode } from "@bindings/i18n/models.js"; + +// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups. +type BundleEntry = { message: string; description?: string }; +const bundleModules = import.meta.glob>( + "../../../i18n/locales/*/common.json", + { eager: true, import: "default" }, +); + +const resources: Record }> = {}; +for (const path in bundleModules) { + const match = /locales\/([^/]+)\/common\.json$/.exec(path); + if (match) { + const entries = bundleModules[path]; + const messages: Record = {}; + for (const key in entries) { + messages[key] = entries[key].message; + } + resources[match[1]] = { common: messages }; + } +} + +function detectBrowserLanguage(available: string[]): string | null { + const tags = [navigator.language, ...(navigator.languages ?? [])].filter( + (tag): tag is string => typeof tag === "string" && tag.length > 0, + ); + const byLower = new Map(available.map((code) => [code.toLowerCase(), code])); + for (const tag of tags) { + const lower = tag.toLowerCase(); + const exact = byLower.get(lower); + if (exact) return exact; + const base = byLower.get(lower.split("-")[0]); + if (base) return base; + } + return null; +} + +// An empty persisted language code is the Go-side signal for first run. +export async function initI18n(): Promise { + const available = Object.keys(resources); + let language = "en"; + let firstRun = false; + try { + const prefs = await Preferences.Get(); + if (prefs?.language) { + language = prefs.language; + } else { + firstRun = true; + language = detectBrowserLanguage(available) ?? "en"; + } + } catch (e) { + console.warn("read preferences for language failed, defaulting to en", e); + } + + if (firstRun) { + Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) => + console.warn("persist detected language failed", err), + ); + } + + await i18next.use(initReactI18next).init({ + lng: language, + fallbackLng: "en", + defaultNS: "common", + ns: ["common"], + resources, + interpolation: { + prefix: "{", + suffix: "}", + escapeValue: false, + }, + returnNull: false, + }); + + syncDocumentLang(); + i18next.on("languageChanged", syncDocumentLang); + + Events.On("netbird:preferences:changed", (e) => { + const next = e.data?.language; + if (next && next !== i18next.language) { + i18next.changeLanguage(next).catch((err: unknown) => { + console.error("changeLanguage failed", err); + }); + } + }); +} + +function syncDocumentLang() { + if (typeof document !== "undefined") { + document.documentElement.lang = i18next.language; + } +} + +export async function loadLanguages() { + return I18n.Languages(); +} + +export default i18next; diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts new file mode 100644 index 000000000..499023fa3 --- /dev/null +++ b/client/ui/frontend/src/lib/logs.ts @@ -0,0 +1,139 @@ +import { UILog } from "@bindings/services"; + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +const METHOD_LEVELS: Record = { + trace: "trace", + debug: "debug", + log: "info", + info: "info", + warn: "warn", + error: "error", +}; + +const IGNORED_SOURCES = new Set(["welcome.ts"]); + +const RATE_LIMIT = 50; +const RATE_WINDOW_MS = 1000; + +let installed = false; +let inForward = false; +let windowStart = 0; +let windowCount = 0; + +function describeCause(rawCause: unknown): string { + if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`; + if (typeof rawCause === "object" && rawCause !== null) { + try { + return JSON.stringify(rawCause); + } catch { + // Circular ref — fall through to a tag instead of "[object Object]". + return `<${rawCause.constructor?.name ?? "object"}>`; + } + } + return String(rawCause); +} + +function formatCause(rawCause: unknown): string { + if (rawCause === undefined) return ""; + return `\ncaused by ${describeCause(rawCause)}`; +} + +// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack, +// so a bare stack hides the real cause. Prepend name+message, then the stack. +function formatError(e: Error): string { + const head = `${e.name}: ${e.message}`; + const cause = formatCause((e as { cause?: unknown }).cause); + if (!e.stack) return `${head}${cause}`; + if (e.stack.startsWith(head)) return `${head}${cause}`; + return `${head}${cause}\n${e.stack}`; +} + +function format(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return formatError(a); + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +function parseStackLine(line: string): string { + // Find the file:line:col tail at the end of the path. + const colonCol = line.lastIndexOf(":"); + if (colonCol <= 0) return ""; + const colonLine = line.lastIndexOf(":", colonCol - 1); + if (colonLine <= 0) return ""; + const col = line.slice(colonCol + 1); + const lineNo = line.slice(colonLine + 1, colonCol); + if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return ""; + const before = line.slice(0, colonLine); + const sep = Math.max( + before.lastIndexOf("/"), + before.lastIndexOf("\\"), + before.lastIndexOf("("), + before.lastIndexOf(" "), + ); + const file = before.slice(sep + 1); + if (!file.includes(".")) return ""; + return `${file}:${lineNo}`; +} + +function callerSource(): string { + const stack = new Error().stack; + if (!stack) return ""; + for (const line of stack.split("\n").slice(1)) { + if (line.includes("/logs.ts")) continue; + const parsed = parseStackLine(line); + if (parsed) return parsed; + } + return ""; +} + +function forward(level: Level, args: unknown[]) { + if (inForward) return; + inForward = true; + try { + const now = Date.now(); + if (now - windowStart >= RATE_WINDOW_MS) { + windowStart = now; + windowCount = 0; + } + if (++windowCount > RATE_LIMIT) return; + + const source = callerSource(); + if (IGNORED_SOURCES.has(source.split(":")[0])) return; + // Don't touch console here — it would recurse back into forward(). + UILog.Log(level, source, format(args)).catch(() => {}); + } catch { + // Swallow — log forwarding must never throw back into the caller. + } finally { + inForward = false; + } +} + +export function initLogForwarding() { + if (installed) return; + installed = true; + + const c = console as unknown as Record void>; + for (const [method, level] of Object.entries(METHOD_LEVELS)) { + const original = c[method]?.bind(console); + c[method] = (...args: unknown[]) => { + original?.(...args); + forward(level, args); + }; + } + + globalThis.addEventListener("error", (e) => { + forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]); + }); + globalThis.addEventListener("unhandledrejection", (e) => { + forward("error", ["unhandled promise rejection:", e.reason]); + }); +} diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts new file mode 100644 index 000000000..c256dde23 --- /dev/null +++ b/client/ui/frontend/src/lib/platform.ts @@ -0,0 +1,39 @@ +import { System } from "@wailsio/runtime"; + +export type Platform = { + isWindows: boolean; + isMacOS: boolean; +}; + +let cached: Platform | null = null; + +export async function initPlatform(): Promise { + if (cached) return; + + const syncIsMac = System.IsMac(); + const syncIsWindows = System.IsWindows(); + + let env: Awaited> | null = null; + try { + env = await System.Environment(); + } catch (e) { + console.error("[platform] System.Environment() threw:", e); + } + + const os = (env?.OS ?? "").toLowerCase(); + cached = { + isWindows: os ? os === "windows" : syncIsWindows, + isMacOS: os ? os === "darwin" : syncIsMac, + }; +} + +function get(): Platform { + if (!cached) { + throw new Error("platform: initPlatform() must complete before sync getters are used"); + } + return cached; +} + +export const isWindows = (): boolean => get().isWindows; +export const isMacOS = (): boolean => get().isMacOS; +export const isLinux = (): boolean => !get().isWindows && !get().isMacOS; diff --git a/client/ui/frontend/src/lib/sorting.ts b/client/ui/frontend/src/lib/sorting.ts new file mode 100644 index 000000000..70622f705 --- /dev/null +++ b/client/ui/frontend/src/lib/sorting.ts @@ -0,0 +1,27 @@ +// Stable, order-preserving reconciliation for lists that re-fetch from the daemon +// on every status push (peers, networks, profiles). Re-sorting on each refresh would +// make rows jump around under the user, so instead: +// - items already on screen keep their existing order (from `prev`), +// - items that vanished are dropped, +// - newly-arrived items are sorted among themselves (`compareFresh`) and appended. +// Net effect: the only visible movement is new rows landing at the bottom. +// +// Must stay pure and idempotent: callers write the returned `order` into a ref +// during render (useMemo), so a rerun must reproduce the first pass — never +// branch on run count or read external mutable state. +export function reconcileOrder( + prev: string[], + items: T[], + keyOf: (item: T) => string, + compareFresh: (a: T, b: T) => number, +): { order: string[]; items: T[] } { + const byKey = new Map(items.map((i) => [keyOf(i), i])); + const kept = prev.filter((k) => byKey.has(k)); + const known = new Set(kept); + const fresh = items + .filter((i) => !known.has(keyOf(i))) + .sort(compareFresh) + .map(keyOf); + const order = [...kept, ...fresh]; + return { order, items: order.map((k) => byKey.get(k)!) }; +} diff --git a/client/ui/frontend/src/lib/welcome.ts b/client/ui/frontend/src/lib/welcome.ts new file mode 100644 index 000000000..d9df3fc8d --- /dev/null +++ b/client/ui/frontend/src/lib/welcome.ts @@ -0,0 +1,25 @@ +const ART = ` + _ __ __ ____ _ __ ______ __ __ __ + / | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / / + / |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ / + / /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ / +/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/ +`; + +export function welcome() { + const message = `%c${ART}%c +NetBird — The Only Secure Access Platform You'll Ever Need. + +WEBSITE: https://netbird.io/ +WE'RE HIRING: https://netbird.io/careers +OPEN SOURCE: https://github.com/netbirdio/netbird +`; + + // Intentional NetBird ASCII banner in the devtools console. + // eslint-disable-next-line no-console + console.log( + message, + "color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;", + "color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;", + ); +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx new file mode 100644 index 000000000..3345e0a9f --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx @@ -0,0 +1,27 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { ArrowUpCircleIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Props = HTMLAttributes & { + size?: number; +}; + +export const UpdateBadge = forwardRef(function UpdateBadge( + { size = 15, className, ...rest }, + ref, +) { + return ( +
+ + +
+ ); +}); diff --git a/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx new file mode 100644 index 000000000..1519613e0 --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Loader2, XCircle } from "lucide-react"; +import { Update as UpdateSvc, WindowManager } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const TIMEOUT_MS = 15 * 60 * 1000; +const POLL_INTERVAL_MS = 2000; +// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight). +const DAEMON_DOWN_GRACE_MS = 5000; +const WINDOW_WIDTH = 360; + +type Phase = + | { kind: "running" } + | { kind: "timeout" } + | { kind: "canceled" } + | { kind: "failed"; message: string }; + +export default function UpdateInProgressDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const version = params.get("version") ?? ""; + const [phase, setPhase] = useState({ kind: "running" }); + const phaseRef = useRef(phase); + phaseRef.current = phase; + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + + useEffect(() => { + let cancelled = false; + let done = false; + let timer: ReturnType | null = null; + const start = Date.now(); + let firstUnreachableAt: number | null = null; + + const poll = async () => { + if (cancelled || done) return; + if (phaseRef.current.kind !== "running") return; + + if (Date.now() - start > TIMEOUT_MS) { + done = true; + setPhase({ kind: "timeout" }); + return; + } + + try { + const r = await UpdateSvc.GetInstallerResult(); + if (cancelled || done || phaseRef.current.kind !== "running") return; + firstUnreachableAt = null; + if (r.success) { + done = true; + UpdateSvc.Quit().catch(console.error); + return; + } + if (r.errorMsg) { + done = true; + setPhase(mapInstallError(r.errorMsg)); + return; + } + } catch { + if (cancelled || done || phaseRef.current.kind !== "running") return; + const now = Date.now(); + if (firstUnreachableAt === null) { + firstUnreachableAt = now; + } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) { + done = true; + UpdateSvc.Quit().catch(console.error); + return; + } + } + + if (!cancelled && !done) { + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + }; + + timer = setTimeout(poll, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, []); + + const isError = phase.kind !== "running"; + const errorInfo = isError ? classifyPhase(phase, version, t) : null; + const updatingHeading = version + ? t("update.overlay.updatingVersion", { version }) + : t("update.overlay.updating"); + + return ( + + {isError ? ( + + ) : ( + + )} + +
+ + {errorInfo ? errorInfo.title : updatingHeading} + + + {errorInfo ? ( + <> + {errorInfo.description} + {errorInfo.message && ( + <> +
+ + {errorInfo.message} + + + )} + + ) : ( + t("update.overlay.description") + )} +
+
+ + {isError && ( + + + + )} +
+ ); +} + +function mapInstallError(msg: string): Phase { + const m = msg.trim().toLowerCase(); + if (m === "") return { kind: "failed", message: "" }; + if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) { + return { kind: "timeout" }; + } + if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) { + return { kind: "canceled" }; + } + return { kind: "failed", message: msg }; +} + +type Variant = { title: string; description: string; message?: string }; + +function classifyPhase( + phase: Phase, + version: string, + t: (key: string, options?: Record) => string, +): Variant { + const target = version + ? t("update.overlay.error.targetVersion", { version }) + : t("update.overlay.error.targetFallback"); + switch (phase.kind) { + case "timeout": + return { + title: t("update.overlay.error.timeoutTitle"), + description: t("update.overlay.error.timeoutDescription", { target }), + }; + case "canceled": + return { + title: t("update.overlay.error.canceledTitle"), + description: t("update.overlay.error.canceledDescription", { target }), + }; + case "failed": + return { + title: t("update.overlay.error.failTitle"), + description: t("update.overlay.error.failDescription", { target }), + message: phase.message || t("update.overlay.error.unknownMessage"), + }; + default: + return { title: "", description: "" }; + } +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx new file mode 100644 index 000000000..4fb5e2586 --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -0,0 +1,96 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { DownloadIcon, NotepadText } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; +import { cn } from "@/lib/cn"; + +const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); +} + +export function UpdateVersionCard() { + const { t } = useTranslation(); + const { updateVersion, enforced, triggerUpdate } = useClientVersion(); + + if (updateVersion) { + const titleKey = enforced + ? "update.card.versionAvailableInstall" + : "update.card.versionAvailableDownload"; + return ( + +
+ {t(titleKey, { version: updateVersion })} + + {t("update.card.whatsNew")} + +
+ {enforced ? ( + + ) : ( + + )} +
+ ); + } + + return ( + +
+ {t("update.card.onLatestVersion")} +

{t("update.card.autoCheckInterval")}

+
+ +
+ ); +} + +function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) { + return ( +
+ {children} +
+ ); +} + +function Title({ children }: Readonly<{ children: ReactNode }>) { + return

{children}

; +} + +function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) { + return ( + + ); +} diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx new file mode 100644 index 000000000..f7929fad2 --- /dev/null +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { AlertCircleIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const WINDOW_WIDTH = 380; + +export default function ErrorDialog() { + const { t } = useTranslation(); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [params] = useSearchParams(); + + const title = params.get("title") || t("window.title.error"); + const message = params.get("message") || ""; + + const close = useCallback(() => { + WindowManager.CloseError().catch(console.error); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [close]); + + return ( + + + +
+ + {title} + + {message && ( + + {message} + + )} +
+ + + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx new file mode 100644 index 000000000..efbd1ee84 --- /dev/null +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { Loader2 } from "lucide-react"; +import { Connection } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const EVENT_CANCEL = "browser-login:cancel"; +const WINDOW_WIDTH = 360; + +export default function LoginWaitingForBrowserDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const uri = params.get("uri") ?? ""; + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const openedRef = useRef(false); + + const reportOpenFailure = useCallback( + (e: unknown) => { + void errorDialog({ + Title: t("browserLogin.openFailedTitle"), + Message: formatErrorMessage(e), + }); + }, + [t], + ); + + // Open the browser only after mount, or it lands on top of the still-hidden popup. + useEffect(() => { + if (!uri || openedRef.current) return; + openedRef.current = true; + Connection.OpenURL(uri).catch(reportOpenFailure); + }, [uri, reportOpenFailure]); + + const tryAgain = useCallback(() => { + if (!uri) return; + Connection.OpenURL(uri).catch(reportOpenFailure); + }, [uri, reportOpenFailure]); + + const cancel = useCallback(() => { + Events.Emit(EVENT_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel", err), + ); + }, []); + + return ( + + + +
+ + {t("browserLogin.title")} + + + {t("browserLogin.notSeeing")}{" "} + + +
+ + + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx new file mode 100644 index 000000000..c2a648278 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { cn } from "@/lib/cn.ts"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { + startConnection, + EVENT_BROWSER_LOGIN_CANCEL, + EVENT_TRIGGER_LOGIN, +} from "@/lib/connection.ts"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { TruncatedText } from "@/components/TruncatedText"; +import { shortenDns } from "@/lib/formatters"; +import { contentTop } from "@/components/empty-state/EmptyState"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; +import * as Popover from "@radix-ui/react-popover"; +import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; + +enum ConnectionState { + Disconnected = "disconnected", + Connecting = "connecting", + Connected = "connected", + Disconnecting = "disconnecting", +} + +const STATUS_KEY: Record = { + [ConnectionState.Disconnected]: "connect.status.disconnected", + [ConnectionState.Connecting]: "connect.status.connecting", + [ConnectionState.Connected]: "connect.status.connected", + [ConnectionState.Disconnecting]: "connect.status.disconnecting", +}; + +const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); + +const FORCE_TOGGLE_DELAY_MS = 7000; + +const errorMessage = formatErrorMessage; + +export const MainConnectionStatusSwitch = () => { + const { t } = useTranslation(); + const { status, refresh } = useStatus(); + const { activeProfileId, username } = useProfile(); + + const daemonState = status?.status ?? "Idle"; + const needsLogin = NEEDS_LOGIN_STATES.has(daemonState); + const unreachable = daemonState === "DaemonUnavailable"; + + type Action = "connect" | "logging-in" | "disconnect" | null; + const [action, setAction] = useState(null); + + const loginGuard = useRef(false); + const driveLogin = useCallback(() => { + if (loginGuard.current) return; + loginGuard.current = true; + setAction("logging-in"); + void startConnection(() => { + loginGuard.current = false; + setAction(null); + refresh().catch((err: unknown) => console.error("refresh after login failed", err)); + }); + }, [refresh]); + + const connState: ConnectionState = useMemo(() => { + if (action === "disconnect" && daemonState === "Connected") { + return ConnectionState.Disconnecting; + } + if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") { + return ConnectionState.Connecting; + } + switch (daemonState) { + case "Connected": + return ConnectionState.Connected; + case "Connecting": + return ConnectionState.Connecting; + case "Idle": + case "NeedsLogin": + case "LoginFailed": + case "SessionExpired": + case "DaemonUnavailable": + return ConnectionState.Disconnected; + default: + return ConnectionState.Disconnected; + } + }, [daemonState, action]); + + const connect = async () => { + setAction("connect"); + try { + await Connection.Up({ + profileName: activeProfileId, + username, + }); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.connectTitle"), + Message: errorMessage(e), + }); + } + }; + + const disconnect = async () => { + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; + + const sawConnectingRef = useRef(false); + + useEffect(() => { + if (action === null) { + sawConnectingRef.current = false; + return; + } + if (daemonState === "Connecting") { + sawConnectingRef.current = true; + } + if (action === "connect") { + if (needsLogin) { + driveLogin(); + return; + } + if (daemonState === "Connected" || unreachable) { + setAction(null); + return; + } + if (sawConnectingRef.current && daemonState === "Idle") { + setAction(null); + } + return; + } + if (action === "disconnect") { + if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) { + setAction(null); + } + } + }, [action, daemonState, needsLogin, unreachable, driveLogin]); + + useEffect(() => { + const off = Events.On(EVENT_TRIGGER_LOGIN, () => { + driveLogin(); + }); + return () => off(); + }, [driveLogin]); + + const handleSwitch = (next: boolean) => { + if (unreachable) return; + if (isTransitioning) { + if (canForceCancel) void forceCancel(); + return; + } + if (action !== null) return; + if (needsLogin) { + driveLogin(); + return; + } + if (next && connState === ConnectionState.Disconnected) { + void connect(); + } else if (!next && connState === ConnectionState.Connected) { + void disconnect(); + } + }; + + const isTransitioning = + connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting; + const isOn = + connState === ConnectionState.Connected || connState === ConnectionState.Connecting; + + const [canForceCancel, setCanForceCancel] = useState(false); + useEffect(() => { + if (!isTransitioning) { + setCanForceCancel(false); + return; + } + const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS); + return () => clearTimeout(id); + }, [isTransitioning]); + + const forceCancel = async () => { + if (action === "logging-in") { + Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel failed", err), + ); + } + WindowManager.CloseBrowserLogin().catch((err: unknown) => + console.warn("close browser-login window failed", err), + ); + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; + const show = connState === ConnectionState.Connected; + const fqdn = status?.local.fqdn || ""; + const ip = status?.local.ip || ""; + const ipv6 = status?.local.ipv6 || ""; + + return ( +
+ {"NetBird"} + + + +
+

+ {t(STATUS_KEY[connState])} +

+ + + + +
+
+ ); +}; + +const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const isFocusVisible = useFocusVisible(); + const hasV6 = !!ipv6; + + if (!hasV6) { + return ( + + + {ip || " "} + + + ); + } + + return ( +
+ + + + + + e.preventDefault()} + className={cn( + "z-50 min-w-64 max-w-[280px] overflow-hidden", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 text-nb-gray-200 shadow-lg outline-none", + "flex flex-col", + )} + > + +
+ + + + +
+ ); +}; + +const IpRow = ({ value }: { value: string }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isFocusVisible = useFocusVisible(); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy IP to clipboard failed", e); + } + }; + return ( + + ); +}; diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx new file mode 100644 index 000000000..fc7ce37b2 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -0,0 +1,256 @@ +import { forwardRef, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { TruncatedText } from "@/components/TruncatedText"; +import { useNetworks } from "@/contexts/NetworksContext"; +import { useStatus } from "@/contexts/StatusContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; + +const NONE_VALUE = "__none__"; + +export const MainExitNodeSwitcher = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const { exitNodes, toggleExitNode } = useNetworks(); + const active = exitNodes.find((n) => n.selected) ?? null; + const isConnected = status?.status === "Connected"; + const hasAny = exitNodes.length > 0; + const disabled = !isConnected || !hasAny; + + const [open, setOpen] = useState(false); + const listRef = useRef(null); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open || disabled) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const handleSelect = (next: string) => { + setOpen(false); + if (next === NONE_VALUE) { + if (active) + toggleExitNode(active.id, true).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); + return; + } + if (active?.id === next) return; + toggleExitNode(next, false).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); + }; + + const title = active ? active.id : t("exitNodes.card.title"); + const activeDescription = active + ? t("exitNodes.card.statusActive") + : t("exitNodes.card.statusInactive"); + const description = hasAny ? activeDescription : t("exitNodes.empty.title"); + + return ( + + + + + + { + e.preventDefault(); + listRef.current?.focus(); + }} + style={{ width: "var(--radix-popover-trigger-width)" }} + className={cn( + "wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + className={"outline-none focus:outline-none focus-visible:outline-none"} + > + + handleSelect(NONE_VALUE)} /> + {hasAny &&
} + {hasAny && ( + + + {exitNodes.map((n) => ( + handleSelect(n.id)} + /> + ))} + + + + + + )} + + + + + + ); +}; + +type TriggerProps = React.ButtonHTMLAttributes & { + title: string; + description: string; + active?: boolean; +}; + +const ExitNodeTriggerCard = forwardRef( + function ExitNodeTriggerCard( + { title, description, disabled, active = false, className, ...props }, + ref, + ) { + const isFocusVisible = useFocusVisible(); + return ( + + ); + }, +); + +type NoneRowProps = { + isActive: boolean; + onSelect: () => void; +}; + +const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { + const { t } = useTranslation(); + return ( + + {t("exitNodes.dropdown.noneTitle")} + {isActive && ( + + )} + + ); +}; + +type ExitNodeRowProps = { + id: string; + label: string; + isActive: boolean; + onSelect: () => void; +}; + +const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( + + {label} + {isActive && } + +); + +const ExitNodeIcon = ({ size, ...props }: LucideProps) => ( + +); diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx new file mode 100644 index 000000000..becfde47c --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -0,0 +1,192 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowUpCircleIcon, + Check, + MoreVertical, + PanelsRightBottom, + RectangleVertical, + Settings, + type LucideIcon, +} from "lucide-react"; +import { WindowManager } from "@bindings/services"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import { IconButton } from "@/components/buttons/IconButton"; +import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; +import { cn } from "@/lib/cn"; +import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; +import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { isWindows } from "@/lib/platform.ts"; + +const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; + +export const MainHeader = () => { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const { viewMode, setViewMode } = useViewMode(); + const { updateAvailable } = useClientVersion(); + const { mdm, features } = useRestrictions(); + + const openSettings = useCallback(() => { + setMenuOpen(false); + WindowManager.OpenSettings("").catch((err: unknown) => + console.error("open settings window failed", err), + ); + }, []); + + useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings); + + const openAbout = () => { + setMenuOpen(false); + WindowManager.OpenSettings("about").catch((err: unknown) => + console.error("open settings (about) window failed", err), + ); + }; + + const openManageProfiles = () => { + WindowManager.OpenSettings("profiles").catch((err: unknown) => + console.error("open settings (profiles) window failed", err), + ); + }; + + const selectMode = (mode: ViewMode) => { + setMenuOpen(false); + setViewMode(mode); + }; + + const profileSlot = features.disableProfiles ? null : ( + + ); + + const settingsSlot = ( +
+ + + + + + {updateAvailable && ( + <> + +
+ + + {t("header.menu.updateAvailable")} + +
+
+ + + )} + +
+ + {t("header.menu.settings")} + + {formatShortcut(SETTINGS_SHORTCUT)} + +
+
+ {!mdm.disableAdvancedView && ( + <> + + selectMode("default")} + /> + selectMode("advanced")} + /> + + )} +
+
+ {updateAvailable && ( + + + + + )} +
+ ); + + return ( +
+ {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. + See https://github.com/wailsapp/wails/issues/3260 */} +
+
+
{profileSlot}
+
+
+
{settingsSlot}
+
+ ); +}; + +type ViewModeItemProps = { + icon: LucideIcon; + label: string; + selected: boolean; + onSelect: () => void; +}; + +const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => ( + +
+ + {label} + {selected && } +
+
+); diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx new file mode 100644 index 000000000..c05b3a025 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -0,0 +1,114 @@ +import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx"; +import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx"; +import { MainHeader } from "@/modules/main/MainHeader.tsx"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { Navigation } from "@/modules/main/advanced/Navigation.tsx"; +import { cn } from "@/lib/cn"; +import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext"; +import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext"; +import { useEffect } from "react"; +import { NotConnectedState } from "@/components/empty-state/NotConnectedState"; +import { useStatus } from "@/contexts/StatusContext"; +import { Peers } from "@/modules/main/advanced/peers/Peers"; +import { Networks } from "@/modules/main/advanced/networks/Networks"; +import { NetworksProvider } from "@/contexts/NetworksContext"; +import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel"; +import { isWindows } from "@/lib/platform.ts"; + +export const MainPage = () => { + return ( + + + + + + + + + ); +}; + +const MainBody = () => { + const { viewMode, setViewMode } = useViewMode(); + const { mdm, features } = useRestrictions(); + + // Force flip the view if MDM disabled advanced + useEffect(() => { + if (mdm.disableAdvancedView && viewMode === "advanced") { + setViewMode("default"); + } + }, [mdm.disableAdvancedView, viewMode, setViewMode]); + + const isAdvanced = viewMode === "advanced"; + + return ( +
+ {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. + See https://github.com/wailsapp/wails/issues/3260 */} +
+ + {!features.disableNetworks && ( +
+ +
+ )} +
+ {isAdvanced && ( + + + + )} +
+ ); +}; + +const AdvancedAppRightPanel = () => { + const { section } = useNavSection(); + const { selected } = usePeerDetail(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + + return ( + } + overlayOpen={selected !== null} + className={"m-5 ml-0"} + > +
{ + if (!el) return; + if (isConnected) el.removeAttribute("inert"); + else el.setAttribute("inert", ""); + }} + className={cn( + "flex min-h-0 min-w-0 flex-1 flex-col", + !isConnected && "pointer-events-none select-none", + )} + aria-hidden={!isConnected} + > + +
+ {section === "peers" && } + {section === "networks" && } +
+
+ {!isConnected && ( +
+ +
+ )} +
+ ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx new file mode 100644 index 000000000..8d69f3580 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -0,0 +1,134 @@ +import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; +import { useStatus } from "@/contexts/StatusContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; + +type TabEntry = { + value: NavSection; + label: string; + icon: ComponentType; +}; + +export const Navigation = () => { + const { t } = useTranslation(); + const { section, setSection } = useNavSection(); + const { status } = useStatus(); + const { features } = useRestrictions(); + const isConnected = status?.status === "Connected"; + + // Reset back to peers tab if mdm or feature flag flipped it + useEffect(() => { + if (features.disableNetworks && section === "networks") { + setSection("peers"); + } + }, [features.disableNetworks, section, setSection]); + + const tabs: TabEntry[] = [ + { + value: "peers", + label: t("nav.peers.title"), + icon: MonitorSmartphoneIcon, + }, + ]; + if (!features.disableNetworks) { + tabs.push({ + value: "networks", + label: t("nav.resources.title"), + icon: Layers3Icon, + }); + } + + const tabRefs = useRef>({}); + + const focusTab = (value: NavSection) => { + setSection(value); + requestAnimationFrame(() => tabRefs.current[value]?.focus()); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const enabled = tabs.filter((t) => isConnected || t.value === section); + if (enabled.length < 2) return; + const currentIndex = enabled.findIndex((t) => t.value === section); + if (currentIndex === -1) return; + let nextIndex: number; + switch (e.key) { + case "ArrowRight": + nextIndex = (currentIndex + 1) % enabled.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex - 1 + enabled.length) % enabled.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = enabled.length - 1; + break; + default: + return; + } + e.preventDefault(); + focusTab(enabled[nextIndex].value); + }; + + return ( +
+ {tabs.map((tab, index) => { + const isActive = tab.value === section; + const isDisabled = !isConnected && !isActive; + const isFirst = index === 0; + const isLast = index === tabs.length - 1; + const Icon = tab.icon; + return ( + + ); + })} +
+ ); +}; + +export type { NavSection } from "@/contexts/NavSectionContext"; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx new file mode 100644 index 000000000..ec1823f0b --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { CheckIcon, ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; + +export type NetworkFilter = "all" | "active" | "overlapping"; + +type Props = { + value: NetworkFilter; + onChange: (value: NetworkFilter) => void; + counts: Record; + disabled?: boolean; +}; + +export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const filters: { value: NetworkFilter; label: string }[] = [ + { value: "all", label: t("networks.filter.all") }, + { value: "active", label: t("networks.filter.active") }, + { value: "overlapping", label: t("networks.filter.overlapping") }, + ]; + const active = filters.find((f) => f.value === value) ?? filters[0]; + + const handleSelect = (v: NetworkFilter) => { + onChange(v); + setOpen(false); + }; + + return ( + + + + + {active.label} ({counts[active.value]}) + + + + + {filters.map((f) => { + const checked = f.value === value; + return ( + handleSelect(f.value)} + role={"menuitemradio"} + aria-checked={checked} + className={"gap-2"} + > + + {f.label}{" "} + ({counts[f.value]}) + + + {checked && } + + + ); + })} + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx new file mode 100644 index 000000000..f6fe23aaf --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -0,0 +1,528 @@ +import { + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; +import type { Network } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { useStatus } from "@/contexts/StatusContext"; +import { useNetworks } from "@/contexts/NetworksContext"; +import { type NetworkFilter, NetworkFilters } from "./NetworkFilters"; + +// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". +const INVALID_PREFIX = "invalid Prefix"; + +const isDnsRoute = (n: Network): boolean => + n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX); + +type ResourceType = "host" | "subnet" | "domain"; + +const isHostCidr = (cidr: string): boolean => { + const [addr, bitsStr] = cidr.split("/"); + if (!addr || !bitsStr) return false; + const bits = Number(bitsStr); + const isV6 = addr.includes(":"); + return isV6 ? bits === 128 : bits === 32; +}; + +const resourceTypeOf = (n: Network): ResourceType => { + if (isDnsRoute(n)) return "domain"; + const primary = n.range.split(",")[0].trim(); + return isHostCidr(primary) ? "host" : "subnet"; +}; + +const resourceIconFor = (type: ResourceType): ComponentType => { + if (type === "host") return WorkflowIcon; + if (type === "domain") return GlobeIcon; + return NetworkIcon; +}; + +const buildOverlapMap = ( + routes: { id: string; range: string; domains: string[] }[], +): Map => { + const byRange = new Map(); + for (const r of routes) { + if (r.domains.length > 0) continue; + const arr = byRange.get(r.range) ?? []; + arr.push(r.id); + byRange.set(r.range, arr); + } + const out = new Map(); + for (const [range, ids] of byRange) { + if (ids.length > 1) out.set(range, ids); + } + return out; +}; + +export const Networks = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [scrollParent, setScrollParent] = useState(null); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]); + + const overlapById = useMemo(() => { + const map = new Map(); + for (const ids of overlapGroups.values()) { + for (const id of ids) map.set(id, ids); + } + return map; + }, [overlapGroups]); + + const counts = useMemo>( + () => ({ + all: networkRoutes.length, + active: networkRoutes.filter((r) => r.selected).length, + overlapping: overlapById.size, + }), + [networkRoutes, overlapById], + ); + + const orderRef = useRef([]); + const ordered = useMemo(() => { + const { order, items } = reconcileOrder( + orderRef.current, + networkRoutes, + (r) => r.id, + (a, b) => { + if (a.selected !== b.selected) return a.selected ? -1 : 1; + return a.id.localeCompare(b.id); + }, + ); + orderRef.current = order; + return items; + }, [networkRoutes]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((r) => { + if (filter === "active" && !r.selected) return false; + if (filter === "overlapping" && !overlapById.has(r.id)) return false; + if (q) { + const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); + }, [ordered, search, filter, overlapById]); + + if (isConnected && networkRoutes.length === 0) { + return ( + + ); + } + + const selectedInView = filtered.filter((r) => r.selected).length; + const allSelected = filtered.length > 0 && selectedInView === filtered.length; + const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll"); + + const onBulkClick = () => { + if (filtered.length === 0) return; + if (allSelected) { + setNetworksSelected( + filtered.map((r) => r.id), + false, + ).catch((err: unknown) => console.error("disable all networks failed", err)); + } else { + const ids = filtered.filter((r) => !r.selected).map((r) => r.id); + setNetworksSelected(ids, true).catch((err: unknown) => + console.error("enable all networks failed", err), + ); + } + }; + + return ( +
+
+
+ setSearch(e.target.value)} + /> +
+ +
+ {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && ( + + )} + + + + + + )} + {filtered.length > 0 && ( +
+ + {t("networks.bulk.selectionCount", { + selected: selectedInView, + total: filtered.length, + })} + + +
+ )} +
+ ); +}; + +type NetworksListProps = { + data: Network[]; + onToggle: (id: string, selected: boolean) => void; + scrollParent: HTMLElement; +}; + +const NetworksHeader = () =>
; + +const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { + const virtuosoRef = useRef(null); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= data.length) return; + const row = data[index]; + const tryFocus = () => { + const el = rowRefs.current.get(row.id); + if (el) { + el.focus(); + return true; + } + return false; + }; + if (!tryFocus()) { + virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" }); + requestAnimationFrame(() => { + if (!tryFocus()) requestAnimationFrame(tryFocus); + }); + } + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusRow(Math.min(index + 1, data.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + focusRow(Math.max(index - 1, 0)); + break; + case "Home": + e.preventDefault(); + focusRow(0); + break; + case "End": + e.preventDefault(); + focusRow(data.length - 1); + break; + } + }; + + const setRowRef = (id: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(id, el); + else rowRefs.current.delete(id); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, onToggle], + ); + + return ( + + ref={virtuosoRef} + data={data} + customScrollParent={scrollParent} + increaseViewportBy={400} + computeItemKey={(_, n) => n.id} + components={{ Header: NetworksHeader }} + context={ctx} + itemContent={renderNetworkRow} + /> + ); +}; + +type NetworkRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => ( + +); + +type NetworkRowProps = { + network: Network; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => { + const { t } = useTranslation(); + // Same handler is attached to the overlay button and to the network-id copy + // button so arrow nav works wherever focus sits inside the row. + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
+
+ ); +}; + +const ResourceIconBadge = ({ type }: { type: ResourceType }) => { + const Icon = resourceIconFor(type); + return ( +
+ +
+ ); +}; + +type SubtitleProps = { + network: Network; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const Subtitle = ({ network, onKeyDown }: SubtitleProps) => { + if (isDnsRoute(network)) { + const domain = network.domains[0]; + const ips = network.resolvedIps[domain] ?? []; + return ; + } + + if (network.range && network.range !== INVALID_PREFIX) { + return ( +
+ + + +
+ ); + } + + return null; +}; + +type DomainSubtitleProps = { + domain: string; + ips: string[]; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => { + const span = ( + + {domain} + + ); + return ( +
+ + {ips.length > 0 ? ( + } + delayDuration={300} + closeDelay={300} + side={"right"} + align={"start"} + alignOffset={-8} + interactive + keepOpenOnClick + contentClassName={cn( + "max-h-72 max-w-[18rem] overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-2 pr-4", + )} + > + {span} + + ) : ( + span + )} + +
+ ); +}; + +const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => { + const { t } = useTranslation(); + return ( + <> +
+ {t("networks.ips.heading")} +
+
    + {ips.map((ip) => ( +
  • + + + {ip} + + +
  • + ))} +
+ + ); +}; + +type ToggleProps = { + checked: boolean; + mixed?: boolean; +}; + +const NetworkToggle = ({ checked, mixed }: ToggleProps) => { + const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + return ( + + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx new file mode 100644 index 000000000..16500589d --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -0,0 +1,575 @@ +import { + type ComponentType, + Fragment, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { AnimatePresence, motion, type Transition } from "framer-motion"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowUpDownIcon, + ArrowUpIcon, + Check as CheckIcon, + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + ClockIcon, + Copy as CopyIcon, + GaugeIcon, + HandshakeIcon, + KeyRoundIcon, + Layers3Icon, + type LucideProps, + MapPinIcon, + MonitorIcon, + Radio, + RefreshCwIcon, + WaypointsIcon, +} from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { peerStatusLabelKey } from "./Peers"; + +const DEFAULT_TRANSITION: Transition = { + duration: 0.32, + ease: [0.32, 0.72, 0, 1], +}; + +const DASH = "-"; + +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + +type Props = { + transition?: Transition; +}; + +export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { + const { t } = useTranslation(); + const { selected, setSelected } = usePeerDetail(); + const { status, refresh } = useStatus(); + + useEffect(() => { + if (!selected) return; + const peers = status?.peers ?? []; + const fresh = peers.find((p) => p.pubKey === selected.pubKey); + if (!fresh) { + setSelected(null); + return; + } + if (fresh !== selected) setSelected(fresh); + }, [status, selected, setSelected]); + + // Daemon updates latency/bytes/handshake without pushing a fresh status + // snapshot, so tick locally to keep relative timestamps live. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!selected) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [selected]); + + const [refreshing, setRefreshing] = useState(false); + const onRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const MIN_SPIN_MS = 600; + const minDelay = new Promise((r) => setTimeout(r, MIN_SPIN_MS)); + try { + await Promise.all([refresh(), minDelay]); + } finally { + setRefreshing(false); + } + }, [refresh, refreshing]); + + useEffect(() => { + if (!selected) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelected(null); + return; + } + if (e.key === "ArrowLeft") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; + setSelected(null); + } + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [selected, setSelected]); + + const dialogRef = useRef(null); + const backButtonRef = useRef(null); + + useEffect(() => { + if (!selected) return; + // Defer focus until the slide-in animation has started rendering. + // preventScroll avoids the browser scrolling the parent to chase the + // still-offscreen button, which lands as a stutter at the end of the slide. + requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected?.pubKey]); + + const getFocusable = (): HTMLElement[] => { + const root = dialogRef.current; + if (!root) return []; + const sel = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," + + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(root.querySelectorAll(sel)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + }; + + const onDialogKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== "Tab") return; + const focusables = getFocusable(); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !active || !dialogRef.current?.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else if (active === last) { + e.preventDefault(); + first.focus(); + } + }; + + return ( + + {selected && ( + +
+ + + + + + + {shortenDns(selected.fqdn) || selected.ip} + + + + + +
+ + + + + + + + +
+ )} +
+ ); +}; + +const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { + const { t } = useTranslation(); + const formatAge = (unix: number, fallback: string): string => { + if (!Number.isFinite(unix) || unix <= 0) return fallback; + const diff = Math.floor(now / 1000 - unix); + if (diff < 1) return t("peers.details.justNow"); + return formatRelative(unix, now) ?? fallback; + }; + const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never")); + const statusSince = formatAge(peer.connStatusUpdateUnix, DASH); + const isConnected = peer.connStatus === "Connected"; + const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p"); + + return ( +
    + + {peer.ip ? ( + + {peer.ip} + + ) : ( + DASH + )} + + {peer.ipv6 && ( + + + + + + )} + {isConnected && ( + + {connectionLabel} + + )} + {peer.relayed && ( + + {peer.relayAddress ? ( + + + + ) : ( + DASH + )} + + )} + {peer.latencyMs > 0 && ( + + + {peer.latencyMs} ms + + + )} + {(peer.bytesRx > 0 || peer.bytesTx > 0) && ( + +
    +
    + + {t("peers.details.bytesReceived")}: + {formatBytes(peer.bytesRx)} +
    +
    + + {t("peers.details.bytesSent")}: + {formatBytes(peer.bytesTx)} +
    +
    +
    + )} + + {lastHandshake} + + + {statusSince} + + {peer.networks.length > 0 && ( + + + + )} + + + + {peer.pubKey ? ( + + + + ) : ( + DASH + )} + +
+ ); +}; + +type RowProps = { + icon: ComponentType; + iconClassName?: string; + label: string; + children: ReactNode; +}; + +type IceRowProps = { + icon: ComponentType; + baseLabel: string; + type: string; + endpoint: string; +}; + +const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s); + +const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { + if (!type && !endpoint) return null; + const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel; + return ( + + {endpoint ? ( + + + + ) : ( + {capitalize(type)} + )} + + ); +}; + +const ResourcesValue = ({ networks }: { networks: string[] }) => ( + +); + +const ResourcesPopover = ({ networks }: { networks: string[] }) => { + const [open, setOpen] = useState(false); + + return ( + + + + + + e.preventDefault()} + className={cn( + "z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 text-nb-gray-200 shadow-lg outline-none", + "flex flex-col", + )} + > + {networks.map((n, i) => ( + + {i > 0 &&
} + + + ))} + + + + ); +}; + +const ResourceRow = ({ value }: { value: string }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isFocusVisible = useFocusVisible(); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy resource to clipboard failed", e); + } + }; + return ( + + ); +}; + +const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( + +); + +const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => ( +
  • + + {label} + + {children} + +
  • +); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx new file mode 100644 index 000000000..25d9ef822 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { CheckIcon, ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; + +export type StatusFilter = "all" | "online" | "offline"; + +type Props = { + value: StatusFilter; + onChange: (value: StatusFilter) => void; + counts: Record; + disabled?: boolean; +}; + +export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const filters: { value: StatusFilter; label: string }[] = [ + { value: "all", label: t("peers.filter.all") }, + { value: "online", label: t("peers.filter.online") }, + { value: "offline", label: t("peers.filter.offline") }, + ]; + const active = filters.find((f) => f.value === value) ?? filters[0]; + + const handleSelect = (v: StatusFilter) => { + onChange(v); + setOpen(false); + }; + + return ( + + + + + {active.label} ({counts[active.value]}) + + + + + {filters.map((f) => { + const checked = f.value === value; + return ( + handleSelect(f.value)} + role={"menuitemradio"} + aria-checked={checked} + className={"gap-2"} + > + + {f.label}{" "} + ({counts[f.value]}) + + + {checked && } + + + ); + })} + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx new file mode 100644 index 000000000..d61221758 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -0,0 +1,353 @@ +import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { latencyColor, shortenDns } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { PeerFilters, type StatusFilter } from "./PeerFilters"; + +const isOnline = (connStatus: string) => connStatus === "Connected"; + +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + +export const peerStatusLabelKey = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "peers.status.connected"; + case "Connecting": + return "peers.status.connecting"; + default: + return "peers.status.disconnected"; + } +}; + +export const Peers = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [scrollParent, setScrollParent] = useState(null); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const isConnected = status?.status === "Connected"; + const peers = useMemo(() => status?.peers ?? [], [status?.peers]); + + const counts = useMemo>(() => { + const online = peers.filter((p) => isOnline(p.connStatus)).length; + return { + all: peers.length, + online, + offline: peers.length - online, + }; + }, [peers]); + + // Stay in live-sort until every peer is stable. Right after Up the daemon + // emits all peers as "Connecting"; committing then would lock that + // alphabetical-only order forever. + const orderRef = useRef([]); + const stickyRef = useRef(false); + const ordered = useMemo(() => { + const compare = (a: PeerStatus, b: PeerStatus) => { + const aOnline = isOnline(a.connStatus); + const bOnline = isOnline(b.connStatus); + if (aOnline !== bOnline) return aOnline ? -1 : 1; + const aName = (a.fqdn || a.ip).toLowerCase(); + const bName = (b.fqdn || b.ip).toLowerCase(); + return aName.localeCompare(bName); + }; + + if (peers.length === 0) { + orderRef.current = []; + stickyRef.current = false; + return []; + } + + if (!stickyRef.current) { + const sorted = [...peers].sort(compare); + if (peers.every((p) => p.connStatus !== "Connecting")) { + orderRef.current = sorted.map((p) => p.pubKey); + stickyRef.current = true; + } + return sorted; + } + + const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare); + orderRef.current = order; + return items; + }, [peers]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((p) => { + if (statusFilter === "online" && !isOnline(p.connStatus)) return false; + if (statusFilter === "offline" && isOnline(p.connStatus)) return false; + return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q); + }); + }, [ordered, search, statusFilter]); + + if (isConnected && peers.length === 0) { + return ( + + ); + } + + return ( +
    +
    +
    + setSearch(e.target.value)} + /> +
    + +
    + {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && } + + + + + + )} +
    + ); +}; + +const ListTopSpacer = () =>
    ; + +type PeersListProps = { + data: PeerStatus[]; + scrollParent: HTMLElement; +}; + +const PeersList = ({ data, scrollParent }: PeersListProps) => { + const { setSelected } = usePeerDetail(); + const virtuosoRef = useRef(null); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= data.length) return; + const peer = data[index]; + const tryFocus = () => { + const el = rowRefs.current.get(peer.pubKey); + if (el) { + el.focus(); + return true; + } + return false; + }; + if (!tryFocus()) { + virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" }); + // Row may not be mounted yet — retry after Virtuoso renders it. + requestAnimationFrame(() => { + if (!tryFocus()) requestAnimationFrame(tryFocus); + }); + } + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusRow(Math.min(index + 1, data.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + focusRow(Math.max(index - 1, 0)); + break; + case "ArrowRight": + e.preventDefault(); + setSelected(data[index]); + break; + case "Home": + e.preventDefault(); + focusRow(0); + break; + case "End": + e.preventDefault(); + focusRow(data.length - 1); + break; + } + }; + + const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(pubKey, el); + else rowRefs.current.delete(pubKey); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, setSelected], + ); + + return ( + + ref={virtuosoRef} + data={data} + customScrollParent={scrollParent} + increaseViewportBy={400} + computeItemKey={(_, peer) => peer.pubKey} + components={{ Header: ListTopSpacer }} + context={ctx} + itemContent={renderPeerRow} + /> + ); +}; + +type PeerRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onSelect: (peer: PeerStatus) => void; + setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; +}; + +const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => ( + +); + +type PeerRowProps = { + peer: PeerStatus; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onSelect: (peer: PeerStatus) => void; + setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; +}; + +const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => { + const { t } = useTranslation(); + const isConnected = peer.connStatus === "Connected"; + const peerName = shortenDns(peer.fqdn) || peer.ip; + const statusLabel = t(peerStatusLabelKey(peer.connStatus)); + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
    +
    + ); +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx new file mode 100644 index 000000000..70d45acc5 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx @@ -0,0 +1,76 @@ +import { type ButtonHTMLAttributes, forwardRef } from "react"; +import { + Briefcase, + Building, + Cloud, + Construction, + FlaskConical, + Gamepad2, + GraduationCap, + House, + Radio, + Server, + SquareCode, + Terminal, + UserCircle, + UserPlus, + Users, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/cn"; + +// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage"). +const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ + [/(default|personal)/i, UserCircle], + [/(work|business|office|company|corp|corporate)/i, Briefcase], + [/(home|house|private)/i, House], + [/(dev|development|developer|code|coding|engineering)/i, SquareCode], + [/(local|localhost|loopback)/i, Terminal], + [/(stage|staging|preprod|pre-prod)/i, Construction], + [/(test|testing|qa)/i, FlaskConical], + [/(prod|production)/i, Cloud], + [/(live)/i, Radio], + [/(selfhosted|self-hosted|on-prem|onprem)/i, Server], + [/(school|university|edu|study|student)/i, GraduationCap], + [/(client|customer)/i, Building], + [/(family)/i, Users], + [/(gaming|game)/i, Gamepad2], + [/(guest)/i, UserPlus], +]; + +export const pickProfileIcon = (name: string | undefined): LucideIcon | null => { + if (!name) return null; + for (const [pattern, Icon] of ICON_MAP) { + if (pattern.test(name)) return Icon; + } + return null; +}; + +type Props = ButtonHTMLAttributes & { + name?: string; + size?: number; +}; + +export const ProfileAvatar = forwardRef(function ProfileAvatar( + { name = "", size = 28, className, type = "button", ...props }, + ref, +) { + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); +}); diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx new file mode 100644 index 000000000..19313ccb3 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -0,0 +1,263 @@ +import { type FormEvent, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Input } from "@/components/inputs/Input"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { Label } from "@/components/typography/Label"; +import { HelpText } from "@/components/typography/HelpText"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export type ProfileFormInitial = { + name: string; + managementUrl: string; +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (name: string, managementUrl: string) => void | Promise; + initial?: ProfileFormInitial; +}; + +const MAX_PROFILE_NAME_LEN = 128; + +export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => { + const { t } = useTranslation(); + const { mdm } = useRestrictions(); + const managedManagementUrl = mdm.managementURL; + const nameId = useId(); + const urlId = useId(); + const isEdit = !!initial; + const initialModeFromUrl = (u: string): ManagementMode => + u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud; + const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : ""); + + const [name, setName] = useState(initial?.name ?? ""); + const [nameError, setNameError] = useState(null); + const nameRef = useRef(null); + + const [mode, setMode] = useState( + initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud, + ); + const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : ""); + const [urlError, setUrlError] = useState(null); + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + const urlRef = useRef(null); + + useEffect(() => { + if (open) { + setName(initial?.name ?? ""); + setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud); + setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : ""); + setNameError(null); + setUrlError(null); + setUnreachable(false); + setChecking(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initial?.name, initial?.managementUrl]); + + const initialModeRef = useRef(ManagementMode.Cloud); + useEffect(() => { + if (!open) return; + initialModeRef.current = mode; + const id = globalThis.setTimeout(() => { + nameRef.current?.focus(); + nameRef.current?.select(); + }, 0); + return () => globalThis.clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // When the user toggles to Self-hosted inside the dialog (not on initial + // open), move focus to the URL input so they can start typing immediately. + useEffect(() => { + if (!open) return; + if (mode === initialModeRef.current) return; + if (mode !== ManagementMode.SelfHosted) return; + urlRef.current?.focus(); + }, [open, mode]); + + useEffect(() => { + setUrlError(null); + setUnreachable(false); + }, [url, mode]); + + const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => { + if (managedManagementUrl) { + return { url: managedManagementUrl, needsReachCheck: false }; + } + if (mode === ManagementMode.Cloud) { + return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false }; + } + const trimmed = url.trim(); + if (!trimmed || !isValidManagementUrl(trimmed)) { + setUrlError(t("settings.general.management.urlError")); + urlRef.current?.focus(); + return null; + } + const target = normalizeManagementUrl(trimmed); + + const unchanged = target === initial?.managementUrl; + return { url: target, needsReachCheck: !unchanged }; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (checking) return; + + const sanitized = name.trim(); + if (sanitized.length === 0) { + setNameError(t("profile.dialog.required")); + nameRef.current?.focus(); + return; + } + + const target = resolveTargetUrl(); + if (!target) return; + + if (target.needsReachCheck) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target.url); + setChecking(false); + if (!reachable && !unreachable) { + setUnreachable(true); + return; + } + } + + await onSubmit(sanitized, target.url); + onOpenChange(false); + }; + + const handleNameChange = (value: string) => { + setName(value); + if (nameError) setNameError(null); + }; + + const trimmedUrl = url.trim(); + const showUrlSyntaxError = + mode === ManagementMode.SelfHosted && + trimmedUrl !== "" && + !isValidManagementUrl(trimmedUrl); + const urlInputError = showUrlSyntaxError + ? t("settings.general.management.urlError") + : (urlError ?? undefined); + const urlInputWarning = + !urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined; + + return ( + + { + e.preventDefault(); + // Focus + select-all so editing an existing name is one + // keystroke away from overwriting it. + nameRef.current?.focus(); + nameRef.current?.select(); + }} + > +
    +
    +
    +
    + + + {t("profile.dialog.description")} + +
    + handleNameChange(e.target.value)} + error={nameError ?? undefined} + maxLength={MAX_PROFILE_NAME_LEN} + spellCheck={false} + autoComplete={"off"} + autoCapitalize={"off"} + /> +
    + + {!managedManagementUrl && ( +
    +
    + + + {t("profile.dialog.managementHelp")} + +
    +
    + + {mode === ManagementMode.SelfHosted && ( + setUrl(e.target.value)} + error={urlInputError} + warning={urlInputWarning} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> + )} +
    +
    + )} + + + + + +
    +
    +
    +
    + ); +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx new file mode 100644 index 000000000..e76e300c1 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -0,0 +1,293 @@ +import { forwardRef, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; +import type { Profile } from "@bindings/services/models.js"; +import { Tooltip } from "@/components/Tooltip"; +import { useProfile } from "@/contexts/ProfileContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { cn } from "@/lib/cn"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +type ProfileDropdownProps = { + onManageProfiles?: () => void; +}; + +const MANAGE_VALUE = "__manage_profiles__"; + +export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const { t } = useTranslation(); + const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const listRef = useRef(null); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const sortedProfiles = [...profiles].sort((a, b) => { + if (a.id === activeProfileId) return -1; + if (b.id === activeProfileId) return 1; + return a.name.localeCompare(b.name); + }); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await errorDialog({ + Title: title, + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSelect = (id: string) => { + setOpen(false); + if (id === activeProfileId) return; + void guarded(t("profile.error.switchTitle"), () => switchProfile(id)); + }; + + const handleManage = () => { + setOpen(false); + onManageProfiles?.(); + }; + + if (!loaded) return ; + + const hasProfile = !!activeProfileId; + const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name; + const displayName = hasProfile + ? (activeFromList ?? activeProfile) + : t("profile.selector.noProfile"); + + return ( + + + + + + { + e.preventDefault(); + listRef.current?.focus(); + }} + className={cn( + "wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:origin-top data-[side=top]:origin-bottom", + "data-[side=left]:origin-right data-[side=right]:origin-left", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + className={"outline-none focus:outline-none focus-visible:outline-none"} + > + + {sortedProfiles.length > 0 && ( + <> + + + {sortedProfiles.map((profile) => ( + + ))} + + + + + +
    + + )} + +
    + + + + {t("profile.dropdown.manageProfiles")} + + +
    + + + + + + ); +}; + +const ProfileTriggerSkeleton = () => ( +
    +
    +
    +
    +); + +type ProfileTriggerButtonProps = React.ButtonHTMLAttributes & { + name: string; +}; + +const ProfileTriggerButton = forwardRef( + function ProfileTriggerButton({ name, className, disabled, ...props }, ref) { + const { t } = useTranslation(); + const isFocusVisible = useFocusVisible(); + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); + }, +); + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + onSelect: (id: string) => void; +}; + +const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { + const showEmail = !!profile.email; + return ( + onSelect(profile.id)} + className={cn( + "flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1", + "cursor-default rounded-md text-sm outline-none", + "data-[selected=true]:bg-nb-gray-900", + showEmail ? "items-start" : "items-center", + )} + > +
    + {profile.name} + {showEmail && } +
    + {isActive && ( + + )} +
    + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx new file mode 100644 index 000000000..c1ce2e449 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -0,0 +1,679 @@ +import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + CircleMinus, + LogIn, + MoreVertical, + PencilLine, + PlusCircle, + Trash2, + UserCircle, +} from "lucide-react"; +import type { Profile } from "@bindings/services/models.js"; +import { Badge } from "@/components/Badge"; +import { Button } from "@/components/buttons/Button"; +import HelpText from "@/components/typography/HelpText"; +import { + ProfileCreationModal, + type ProfileFormInitial, +} from "@/modules/profiles/ProfileCreationModal"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; +import { Tooltip } from "@/components/Tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import i18next from "@/lib/i18n"; +import { useProfile } from "@/contexts/ProfileContext"; +import { useConfirm } from "@/contexts/DialogContext"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { SetConfigParams } from "@bindings/services/models.js"; +import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const DEFAULT_PROFILE_ID = "default"; + +export function ProfilesTab() { + const { t } = useTranslation(); + const { + profiles, + activeProfileId, + loaded, + username, + switchProfile, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + } = useProfile(); + + const confirm = useConfirm(); + const [newOpen, setNewOpen] = useState(false); + const [editTarget, setEditTarget] = useState<{ + profile: Profile; + initial: ProfileFormInitial; + } | null>(null); + const [busy, setBusy] = useState(false); + + // Order is held stable so switching only flips the badge, never reorders rows + // (else the clicked row jumps to the top under the cursor). + const orderRef = useRef([]); + const ordered = useMemo(() => { + const { order, items } = reconcileOrder( + orderRef.current, + profiles, + (p) => p.id, + (a, b) => { + if (a.id === activeProfileId) return -1; + if (b.id === activeProfileId) return 1; + return a.name.localeCompare(b.name); + }, + ); + orderRef.current = order; + return items; + }, [profiles, activeProfileId]); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await errorDialog({ + Title: title, + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSwitch = async (id: string, name: string) => { + const ok = await confirm({ + title: t("profile.switch.title", { name }), + description: t("profile.switch.message", { name }), + confirmLabel: t("profile.switch.confirm"), + }); + if (!ok) return; + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id)); + }; + + const handleDeregister = async (id: string, name: string) => { + const ok = await confirm({ + title: t("profile.deregister.title", { name }), + description: t("profile.deregister.message", { name }), + confirmLabel: t("profile.deregister.confirm"), + }); + if (!ok) return; + void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id)); + }; + + const handleDelete = async (id: string, name: string) => { + if (id === DEFAULT_PROFILE_ID) return; + const ok = await confirm({ + title: t("profile.delete.title", { name }), + description: t("profile.delete.message", { name }), + confirmLabel: t("common.delete"), + danger: true, + }); + if (!ok) return; + void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id)); + }; + + const handleCreate = async (name: string, managementUrl: string) => { + await guarded(i18next.t("profile.error.createTitle"), async () => { + const id = await addProfile(name); + // SetConfig is keyed by the new profile's ID, so it writes the + // not-yet-active profile. Write before switching so any reconnect + // targets the right deployment. + if (!isNetbirdCloud(managementUrl)) { + await SettingsSvc.SetConfig( + new SetConfigParams({ profileName: id, username, managementUrl }), + ); + } + await switchProfile(id); + }); + }; + + const handleEdit = async (id: string, name: string) => { + await guarded(i18next.t("profile.error.editTitle"), async () => { + const config = await SettingsSvc.GetConfig({ profileName: id, username }); + const profile = profiles.find((p) => p.id === id); + if (!profile) return; + setEditTarget({ + profile, + initial: { name, managementUrl: config.managementUrl }, + }); + }); + }; + + const handleSave = async (name: string, managementUrl: string) => { + if (!editTarget) return; + const { profile, initial } = editTarget; + await guarded(i18next.t("profile.error.editTitle"), async () => { + if (name !== initial.name) { + await renameProfile(profile.id, name); + } + if (managementUrl !== initial.managementUrl) { + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: profile.id, + username, + managementUrl, + }), + ); + } + }); + }; + + return ( +
    + + {t("settings.profiles.intro")} + +
    + + + {loaded && ordered.length === 0 && ( +
    + +

    + {t("settings.profiles.emptyTitle")} +

    +

    + {t("settings.profiles.emptyDescription")} +

    +
    + )} +
    + + + + +
    + + + + { + if (!o) setEditTarget(null); + }} + initial={editTarget?.initial} + onSubmit={handleSave} + /> +
    + ); +} + +type ProfilesTableProps = { + ordered: Profile[]; + activeProfileId: string | undefined; + onSwitch: (id: string, name: string) => void; + onEdit: (id: string, name: string) => void; + onDeregister: (id: string, name: string) => void; + onDelete: (id: string, name: string) => void; +}; + +const ProfilesTable = ({ + ordered, + activeProfileId, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: ProfilesTableProps) => { + const { t } = useTranslation(); + const [focusedIndex, setFocusedIndex] = useState(0); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= ordered.length) return; + setFocusedIndex(index); + const el = rowRefs.current.get(ordered[index].id); + el?.focus(); + }; + + const actionButtonsIn = (row: HTMLTableRowElement | undefined) => + Array.from( + row?.querySelectorAll( + "button:not([aria-hidden='true']):not([aria-disabled='true'])", + ) ?? [], + ); + + const handleRowKey = (e: KeyboardEvent, index: number): boolean => { + switch (e.key) { + case "ArrowDown": + focusRow(Math.min(index + 1, ordered.length - 1)); + return true; + case "ArrowUp": + focusRow(Math.max(index - 1, 0)); + return true; + case "Home": + focusRow(0); + return true; + case "End": + focusRow(ordered.length - 1); + return true; + } + return false; + }; + + const handleButtonKey = ( + e: KeyboardEvent, + index: number, + row: HTMLTableRowElement, + ): boolean => { + const buttons = actionButtonsIn(row); + const current = buttons.indexOf(e.target as HTMLButtonElement); + if (current === -1) return false; + + switch (e.key) { + case "ArrowDown": + focusRow(Math.min(index + 1, ordered.length - 1)); + return true; + case "ArrowUp": + focusRow(Math.max(index - 1, 0)); + return true; + case "Escape": + row.focus(); + return true; + case "Tab": + // At the last button: jump to the next row instead of exiting the table. + // At the first button with Shift+Tab: jump back to the row. + if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) { + focusRow(index + 1); + return true; + } + if (e.shiftKey && current === 0) { + row.focus(); + return true; + } + return false; + } + return false; + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + const row = rowRefs.current.get(ordered[index].id); + if (!row) return; + const onRow = e.target === row; + const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row); + if (handled) e.preventDefault(); + }; + + const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1)); + + return ( + + + {ordered.map((profile, index) => ( + { + if (el) rowRefs.current.set(profile.id, el); + else rowRefs.current.delete(profile.id); + }} + onKeyDown={(e) => handleRowKeyDown(e, index)} + onFocus={() => setFocusedIndex(index)} + onSwitch={() => onSwitch(profile.id, profile.name)} + onEdit={() => onEdit(profile.id, profile.name)} + onDeregister={() => onDeregister(profile.id, profile.name)} + onDelete={() => onDelete(profile.id, profile.name)} + /> + ))} + +
    + ); +}; + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + isFocused: boolean; + isFirst: boolean; + isLast: boolean; + rowRef: (el: HTMLTableRowElement | null) => void; + onKeyDown: (e: KeyboardEvent) => void; + onFocus: () => void; + onSwitch: () => void; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const ProfileRow = ({ + profile, + isActive, + isFocused, + isFirst, + isLast, + rowRef, + onKeyDown, + onFocus, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: ProfileRowProps) => { + const { t } = useTranslation(); + const Icon = pickProfileIcon(profile.name) ?? UserCircle; + const showEmail = !!profile.email; + + return ( + + + +
    +
    + + {profile.name} + + {isActive && {t("settings.profiles.active")}} +
    + {showEmail && } +
    + + + + + + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; + +type RowActionsProps = { + canSwitch: boolean; + canDeregister: boolean; + isDefault: boolean; + isActive: boolean; + rowFocused: boolean; + onSwitch: () => void; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowActions = ({ + canSwitch, + canDeregister, + isDefault, + isActive, + rowFocused, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: RowActionsProps) => { + const { t } = useTranslation(); + const deleteDisabled = isDefault || isActive; + let deleteDisabledReason: string | null = null; + if (isDefault) deleteDisabledReason = t("profile.delete.disabledDefault"); + else if (isActive) deleteDisabledReason = t("profile.delete.disabledActive"); + return ( +
    +
    + ); +}; + +type RowMoreMenuProps = { + canDeregister: boolean; + deleteDisabled: boolean; + deleteDisabledReason: string | null; + rowFocused: boolean; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowMoreMenu = ({ + canDeregister, + deleteDisabled, + deleteDisabledReason, + rowFocused, + onEdit, + onDeregister, + onDelete, +}: RowMoreMenuProps) => { + const { t } = useTranslation(); + const moreLabel = t("profile.selector.moreOptions"); + return ( + + + + + + +
    + + {t("profile.selector.edit")} +
    +
    + {canDeregister && ( + +
    + + {t("profile.selector.deregister")} +
    +
    + )} + +
    +
    + ); +}; + +type DeleteMenuItemProps = { + disabled: boolean; + disabledReason: string | null; + onDelete: () => void; +}; + +const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => { + const { t } = useTranslation(); + const item = ( + +
    + + {t("profile.selector.delete")} +
    +
    + ); + if (!disabled || !disabledReason) return item; + return ( + {disabledReason}} + side={"left"} + > + {item} + + ); +}; + +type ActionIconButtonProps = { + label: string; + icon: typeof CircleMinus; + onClick: () => void; + variant?: "default" | "danger"; + /** Occupies space but invisible and non-interactive (preserves row layout). */ + hidden?: boolean; + disabled?: boolean; + tabbable?: boolean; +}; + +const ActionIconButton = ({ + label, + icon: Icon, + onClick, + variant = "default", + hidden = false, + disabled = false, + tabbable = true, +}: ActionIconButtonProps) => { + const button = ( + + ); + if (hidden) return button; + return ( + {label}} + side={"top"} + > + {button} + + ); +}; diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx new file mode 100644 index 000000000..2ceb958d4 --- /dev/null +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { AlertCircleIcon, ClockIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { formatRemaining } from "@/lib/formatters"; + +const DEFAULT_SECONDS = 360; +const WINDOW_WIDTH = 360; +const SOON_THRESHOLD_SECONDS = 60 * 60; + +export default function SessionExpirationDialog() { + const { t } = useTranslation(); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [params] = useSearchParams(); + const initialSeconds = useMemo(() => { + const raw = params.get("seconds"); + if (!raw) return DEFAULT_SECONDS; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; + }, [params]); + + const [remaining, setRemaining] = useState(initialSeconds); + const [busy, setBusy] = useState(false); + const busyRef = useRef(busy); + busyRef.current = busy; + const expired = remaining <= 0; + const expiredRef = useRef(expired); + expiredRef.current = expired; + const soon = remaining <= SOON_THRESHOLD_SECONDS; + const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater"); + const activeDescription = soon + ? t("sessionExpiration.description") + : t("sessionExpiration.descriptionLater"); + + useEffect(() => { + setRemaining(initialSeconds); + }, [initialSeconds]); + + useEffect(() => { + const id = globalThis.setInterval(() => { + setRemaining((s) => (s <= 1 ? 0 : s - 1)); + }, 1000); + return () => globalThis.clearInterval(id); + }, [initialSeconds]); + + // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). + useEffect(() => { + const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status === "Connected") { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }); + return () => { + off(); + }; + }, []); + + const stay = useCallback(async () => { + if (busy) return; + setBusy(true); + + let offCancel: (() => void) | undefined; + + try { + const start = await Session.RequestExtend({ hint: "" }); + const uri = start.verificationUriComplete || start.verificationUri; + + // The popup opens the URL and (Go-side) hides this window, restoring it on close. + if (uri) { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); + } + } + + const cancelPromise = new Promise((resolve) => { + offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + resolve(); + }); + }); + + const waitPromise = Session.WaitExtend({ + deviceCode: start.deviceCode, + userCode: start.userCode, + }); + + const outcome = await Promise.race([ + waitPromise.then((r) => ({ kind: "done" as const, result: r })), + cancelPromise.then(() => ({ kind: "cancel" as const })), + ]); + + if (outcome.kind === "cancel") { + waitPromise.cancel?.(); + waitPromise.catch(() => {}); + return; + } + + // Another surface owns this flow; keep the dialog open to retry. + if (outcome.result.preempted) { + return; + } + + // Close before the popup so the restore can't flash this window back. + WindowManager.CloseSessionExpiration().catch(console.error); + } catch (e) { + await errorDialog({ + Title: t("sessionExpiration.extendFailedTitle"), + Message: formatErrorMessage(e), + }); + } finally { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + } + }, [busy, t]); + + const logout = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const username = await ProfilesSvc.Username(); + const active = await ProfilesSvc.GetActive(); + await Connection.Logout({ + profileName: active.id || "default", + username, + }); + WindowManager.CloseSessionExpiration().catch(console.error); + } catch (e) { + await errorDialog({ + Title: t("sessionExpiration.logoutFailedTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }, [busy, t]); + + const close = useCallback(() => { + WindowManager.CloseSessionExpiration().catch(console.error); + }, []); + + return ( + + + +
    + + {expired ? t("sessionExpiration.expired") : activeTitle} + + + {expired ? t("sessionExpiration.expiredDescription") : activeDescription} + +
    + + {!expired && ( +
    + {formatRemaining(remaining)} +
    + )} + + + + + +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx new file mode 100644 index 000000000..8ba1009ba --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -0,0 +1,169 @@ +import type { ComponentType, SVGProps } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react"; +import netbirdFull from "@/assets/logos/netbird-full.svg"; + +// Brand glyphs from simpleicons.org (lucide deprecated its brand icons). +const GithubIcon = (props: SVGProps) => ( + + + +); +const SlackIcon = (props: SVGProps) => ( + + + +); +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; +import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); +} + +export function SettingsAbout() { + const { t } = useTranslation(); + const { status } = useStatus(); + const { guiVersion } = useSettings(); + const daemonVersion = status?.daemonVersion ?? "—"; + + const handleVersionClick = useAccentTrigger(); + + const COMMUNITY_LINKS: { + label: string; + url: string; + Icon: ComponentType>; + iconClassName?: string; + }[] = [ + { + label: t("settings.about.community.github"), + url: "https://github.com/netbirdio/netbird", + Icon: GithubIcon, + iconClassName: "h-3 w-3", + }, + { + label: t("settings.about.community.slack"), + url: "https://docs.netbird.io/slack-url", + Icon: SlackIcon, + iconClassName: "h-3 w-3", + }, + { + label: t("settings.about.community.forum"), + url: "https://forum.netbird.io", + Icon: MessagesSquare, + }, + { + label: t("settings.about.community.documentation"), + url: "https://docs.netbird.io", + Icon: BookOpen, + }, + { + label: t("settings.about.community.feedback"), + url: "https://forms.gle/TeLw2zrXEdw6RcQ36", + Icon: MessageSquareText, + }, + ]; + + const LEGAL_LINKS: { label: string; url: string }[] = [ + { label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" }, + { label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" }, + { label: t("settings.about.links.cla"), url: "https://netbird.io/cla" }, + { label: t("settings.about.links.terms"), url: "https://netbird.io/terms" }, + ]; + + return ( +
    + {t("common.netbird")} +
    + +

    + {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )} +

    +
    + + + +

    + {t("settings.about.copyright", { year: new Date().getFullYear() })} +

    +
    + {COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => ( + + ))} +
    +
    + {LEGAL_LINKS.map((link) => ( + + ))} +
    +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx new file mode 100644 index 000000000..b7d9c96c8 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; + +export function useAccentTrigger() { + const clicksRef = useRef(0); + const lastClickRef = useRef(0); + + return useCallback(() => { + const now = performance.now(); + if (now - lastClickRef.current > 400) { + clicksRef.current = 0; + } + lastClickRef.current = now; + clicksRef.current += 1; + if (clicksRef.current >= 10) { + clicksRef.current = 0; + triggerAccent(); + } + }, []); +} + +function triggerAccent() { + if (document.getElementById("nb-accent-root")) return; + + const container = document.createElement("div"); + container.id = "nb-accent-root"; + document.body.appendChild(container); + const root = createRoot(container); + + const cleanup = () => { + root.unmount(); + container.remove(); + }; + + root.render(); +} + +function Accent({ onDone }: Readonly<{ onDone: () => void }>) { + const canvasRef = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const resize = () => { + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + canvas.style.width = `${window.innerWidth}px`; + canvas.style.height = `${window.innerHeight}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + resize(); + window.addEventListener("resize", resize); + + const chars = "TEAMNETBIRD"; + const fontSize = 16; + const columns = Math.floor(window.innerWidth / fontSize); + const drops = Array.from({ length: columns }, () => Math.random() * -50); + + let raf = 0; + let last = 0; + const draw = (t: number) => { + if (t - last > 50) { + last = t; + + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "rgba(0, 0, 0, 0.12)"; + ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); + + ctx.globalCompositeOperation = "source-over"; + ctx.font = `${fontSize}px ui-monospace, monospace`; + ctx.fillStyle = "#f68330"; + + for (let i = 0; i < drops.length; i++) { + const ch = chars[Math.floor(Math.random() * chars.length)]; + const y = drops[i] * fontSize; + ctx.fillText(ch, i * fontSize, y); + if (y > window.innerHeight && Math.random() > 0.975) { + drops[i] = 0; + } + drops[i]++; + } + } + raf = requestAnimationFrame(draw); + }; + raf = requestAnimationFrame(draw); + + const timeout = globalThis.setTimeout(() => { + setVisible(false); + globalThis.setTimeout(onDone, 500); + }, 9000); + + return () => { + cancelAnimationFrame(raf); + globalThis.clearTimeout(timeout); + window.removeEventListener("resize", resize); + }; + }, [onDone]); + + return ( +
    + +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx new file mode 100644 index 000000000..37b1932d9 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { System } from "@wailsio/runtime"; +import Button from "@/components/buttons/Button"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +// macOS daemon/CLI only accept utun (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars. +const IS_MAC = System.IsMac(); +const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/; +const INTERFACE_NAME_ERROR_KEY = IS_MAC + ? "settings.advanced.interfaceName.errorMac" + : "settings.advanced.interfaceName.error"; + +// Port 0 lets the daemon pick a random free port. +const PORT_MIN = 0; +const PORT_MAX = 65535; + +// Mirrors client/iface/iface.go MinMTU / MaxMTU. +const MTU_MIN = 576; +const MTU_MAX = 8192; + +const PSK_MASK = "**********"; + +export function SettingsAdvanced() { + const { t } = useTranslation(); + const { config, saveFields } = useSettings(); + const { mdm } = useRestrictions(); + + const initialPsk = config.preSharedKeySet ? PSK_MASK : ""; + + const [values, setValues] = useState({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + }); + + const [pskInputValue, setPskInputValue] = useState(initialPsk); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setValues({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + }); + setPskInputValue(config.preSharedKeySet ? PSK_MASK : ""); + }, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]); + + const errors = useMemo(() => { + const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {}; + if (!INTERFACE_NAME_RE.test(values.interfaceName)) { + out.interfaceName = t(INTERFACE_NAME_ERROR_KEY); + } + if ( + !Number.isInteger(values.wireguardPort) || + values.wireguardPort < PORT_MIN || + values.wireguardPort > PORT_MAX + ) { + out.wireguardPort = t("settings.advanced.port.error", { + min: PORT_MIN, + max: PORT_MAX, + }); + } + if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) { + out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX }); + } + return out; + }, [values.interfaceName, values.wireguardPort, values.mtu, t]); + + const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors; + const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined); + const pskChanged = pskInputValue !== initialPsk; + const hasChanges = + values.interfaceName !== config.interfaceName || + (!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) || + values.mtu !== config.mtu || + (!mdm.preSharedKey && pskChanged); + + const handleSave = async () => { + if (!hasChanges || saving || hasErrors) return; + setSaving(true); + try { + const partial: typeof values = { ...values }; + if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort; + + const pskEdited = !mdm.preSharedKey && pskChanged && pskInputValue !== PSK_MASK; + const pskOpts = pskEdited ? { preSharedKey: pskInputValue } : undefined; + await saveFields(partial, pskOpts); + if (pskEdited) setPskInputValue(pskInputValue === "" ? "" : PSK_MASK); + } finally { + setSaving(false); + } + }; + + return ( + <> + + setValues((v) => ({ ...v, interfaceName: e.target.value }))} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
    + {!mdm.wireguardPort && ( +
    + + setValues((v) => ({ + ...v, + wireguardPort: Number(e.target.value), + })) + } + /> + + {t("settings.advanced.port.help")} + +
    + )} + setValues((v) => ({ ...v, mtu: Number(e.target.value) }))} + /> +
    +
    + + {!mdm.preSharedKey && ( + +
    + + {t("settings.advanced.psk.help")} + setPskInputValue(e.target.value)} + spellCheck={false} + autoComplete={"new-password"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
    +
    + )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx new file mode 100644 index 000000000..05d40e15c --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -0,0 +1,113 @@ +import { useEffect, useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"; +import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts"; +import { LanguagePicker } from "@/components/LanguagePicker.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsGeneral() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { autostart, setAutostartEnabled } = useAutostartSetting(); + const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } = + useManagementUrl(); + const { mdm, features } = useRestrictions(); + + const inputRef = useRef(null); + const managementUrlId = useId(); + const prevMode = useRef(mode); + useEffect(() => { + if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) { + inputRef.current?.focus(); + } + prevMode.current = mode; + }, [mode]); + + return ( + <> + + + setField("disableNotifications", !v)} + label={t("settings.general.notifications.label")} + helpText={t("settings.general.notifications.help")} + /> + {!mdm.disableAutoConnect && !features.disableUpdateSettings && ( + setField("disableAutoConnect", !v)} + label={t("settings.general.connectOnStartup.label")} + helpText={t("settings.general.connectOnStartup.help")} + /> + )} + {(autostart === null || autostart.supported) && ( + + )} + + + {!mdm.managementURL && !features.disableUpdateSettings && ( + +
    +
    +
    + + {t("settings.general.management.help")} +
    + +
    + {mode === ManagementMode.SelfHosted && ( +
    + setUrl(e.target.value)} + placeholder={t("settings.general.management.urlPlaceholder")} + error={ + showError + ? t("settings.general.management.urlError") + : undefined + } + warning={ + unreachable + ? t("settings.general.management.urlUnreachable") + : undefined + } + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> + +
    + )} +
    +
    + )} + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx new file mode 100644 index 000000000..816dcb71f --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@/components/Tooltip.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx"; +import { useClientVersion } from "@/contexts/ClientVersionContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { + BoltIcon, + InfoIcon, + LifeBuoyIcon, + NetworkIcon, + ShieldIcon, + SlidersHorizontalIcon, + SquareTerminalIcon, + UserCircleIcon, +} from "lucide-react"; + +export const SettingsNavigation = () => { + const { t } = useTranslation(); + const { updateAvailable } = useClientVersion(); + const { mdm, features } = useRestrictions(); + const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings; + + const aboutAdornment = updateAvailable ? ( + + + + ) : undefined; + + return ( +
    + + + {!features.disableUpdateSettings && ( + <> + + + + )} + {!features.disableProfiles && ( + + )} + {showSsh && ( + + )} + {!features.disableUpdateSettings && ( + + )} + + + +
    + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx new file mode 100644 index 000000000..7c903b634 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsNetwork() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); + + return ( + <> + + setField("networkMonitor", v)} + label={t("settings.network.monitor.label")} + helpText={t("settings.network.monitor.help")} + /> + + + + setField("disableDns", !v)} + label={t("settings.network.dns.label")} + helpText={t("settings.network.dns.help")} + /> + {!mdm.disableClientRoutes && ( + setField("disableClientRoutes", !v)} + label={t("settings.network.clientRoutes.label")} + helpText={t("settings.network.clientRoutes.help")} + /> + )} + {!mdm.disableServerRoutes && ( + setField("disableServerRoutes", !v)} + label={t("settings.network.serverRoutes.label")} + helpText={t("settings.network.serverRoutes.help")} + /> + )} + setField("disableIpv6", !v)} + label={t("settings.network.ipv6.label")} + helpText={t("settings.network.ipv6.help")} + /> + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx new file mode 100644 index 000000000..bf0db3bec --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/cn"; +import { isMacOS } from "@/lib/platform"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx"; +import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx"; +import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; +import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; +import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; +import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx"; +import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; +import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; +import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; +import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +const EVENT_SETTINGS_OPEN = "netbird:settings:open"; + +const enum Tab { + General = "general", + Network = "network", + Security = "security", + Profiles = "profiles", + SSH = "ssh", + Advanced = "advanced", + Troubleshooting = "troubleshooting", + About = "about", +} + +const TAB_CONTENT: Record = { + [Tab.General]: , + [Tab.Network]: , + [Tab.Security]: , + [Tab.Profiles]: , + [Tab.SSH]: , + [Tab.Advanced]: , + [Tab.Troubleshooting]: , + [Tab.About]: , +}; + +export const SettingsPage = () => { + const location = useLocation(); + const navState = location.state as { tab?: string } | null; + const { mdm, features } = useRestrictions(); + + const visibleTabs = useMemo(() => { + const editable = !features.disableUpdateSettings; + const visibility: Record = { + [Tab.General]: true, + [Tab.Network]: editable, + [Tab.Security]: editable, + [Tab.Profiles]: !features.disableProfiles, + [Tab.SSH]: mdm.allowServerSSH ?? editable, + [Tab.Advanced]: editable, + [Tab.Troubleshooting]: true, + [Tab.About]: true, + }; + return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]); + }, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]); + + const defaultTab = visibleTabs[0]; + const [active, setActive] = useState(() => navState?.tab ?? defaultTab); + + useEffect(() => { + if (navState?.tab) setActive(navState.tab); + }, [navState?.tab, location.key]); + + useEffect(() => { + return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => { + setActive(e.data || defaultTab); + }); + }, [defaultTab]); + + // Reset active tab if it got disabled by any feature flag or mdm restrictions + useEffect(() => { + if (!visibleTabs.includes(active as Tab)) setActive(defaultTab); + }, [visibleTabs, active, defaultTab]); + + return ( + <> + {isMacOS() ? ( +
    + ) : ( +
    + )} +
    + + + + + + + +
    + {visibleTabs.map((tab) => ( + + {TAB_CONTENT[tab]} + + ))} +
    +
    + + + +
    +
    +
    +
    +
    +
    + + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx new file mode 100644 index 000000000..f18fd9493 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { cn } from "@/lib/cn"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { type ChangeEvent, useEffect, useId, useState } from "react"; + +export function SettingsSSH() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const isSSHServerEnabled = config.serverSshAllowed; + const jwtTtlId = useId(); + const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl)); + + useEffect(() => { + setJwtTtlInput(String(config.sshJwtCacheTtl)); + }, [config.sshJwtCacheTtl]); + + const handleJwtTtlChange = (e: ChangeEvent) => { + const v = e.target.value; + setJwtTtlInput(v); + if (v === "") return; + const n = Number(v); + if (Number.isFinite(n) && n >= 0) { + setField("sshJwtCacheTtl", n); + } + }; + + const handleJwtTtlBlur = () => { + if (jwtTtlInput === "") { + setJwtTtlInput("0"); + setField("sshJwtCacheTtl", 0); + return; + } + const n = Number(jwtTtlInput); + if (!Number.isFinite(n) || n < 0) { + setJwtTtlInput(String(config.sshJwtCacheTtl)); + } + }; + return ( + <> + + setField("serverSshAllowed", v)} + label={t("settings.ssh.server.label")} + helpText={t("settings.ssh.server.help")} + /> + + + + setField("enableSshRoot", v)} + label={t("settings.ssh.root.label")} + helpText={t("settings.ssh.root.help")} + /> + setField("enableSshSftp", v)} + label={t("settings.ssh.sftp.label")} + helpText={t("settings.ssh.sftp.help")} + /> + setField("enableSshLocalPortForwarding", v)} + label={t("settings.ssh.localForward.label")} + helpText={t("settings.ssh.localForward.help")} + /> + setField("enableSshRemotePortForwarding", v)} + label={t("settings.ssh.remoteForward.label")} + helpText={t("settings.ssh.remoteForward.help")} + /> + + + + setField("disableSshAuth", !v)} + label={t("settings.ssh.jwt.label")} + helpText={t("settings.ssh.jwt.help")} + /> +
    +
    + + {t("settings.ssh.jwtTtl.help")} +
    +
    + +
    +
    +
    + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx new file mode 100644 index 000000000..adba65cdc --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +export const SectionGroup = ({ + title, + children, + disabled = false, +}: { + title: string; + children: ReactNode; + disabled?: boolean; +}) => ( +
    +

    + {title} +

    +
    {children}
    +
    +); + +export const SettingsBottomBar = ({ children }: { children: ReactNode }) => ( + <> +
    +
    +
    + {children} +
    +
    + +); diff --git a/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx new file mode 100644 index 000000000..db1ad9c8f --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsSecurity() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); + const hideRosenpassEnabled = mdm.rosenpassEnabled; + const hideRosenpassPermissive = + mdm.rosenpassPermissive || (mdm.rosenpassEnabled && !config.rosenpassEnabled); + const showEncryptionSection = !(hideRosenpassEnabled && hideRosenpassPermissive); + + return ( + <> + + {!mdm.blockInbound && ( + setField("blockInbound", v)} + label={t("settings.security.blockInbound.label")} + helpText={t("settings.security.blockInbound.help")} + /> + )} + setField("blockLanAccess", v)} + label={t("settings.security.blockLan.label")} + helpText={t("settings.security.blockLan.help")} + /> + + + {showEncryptionSection && ( + + {!hideRosenpassEnabled && ( + { + setField("rosenpassEnabled", v); + if (!v) setField("rosenpassPermissive", false); + }} + label={t("settings.security.rosenpass.label")} + helpText={t("settings.security.rosenpass.help")} + /> + )} + {!hideRosenpassPermissive && ( + setField("rosenpassPermissive", v)} + label={t("settings.security.rosenpassPermissive.label")} + helpText={t("settings.security.rosenpassPermissive.help")} + disabled={!config.rosenpassEnabled} + /> + )} + + )} + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx new file mode 100644 index 000000000..afce192e6 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx @@ -0,0 +1,27 @@ +import Skeleton from "react-loading-skeleton"; + +export const SettingsSkeleton = () => { + return ( +
    +
    + +
    + + +
    +
    + + +
    +
    +
    + +
    + + + +
    +
    +
    + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx new file mode 100644 index 000000000..991937719 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -0,0 +1,337 @@ +import { useId, type ReactNode } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Debug as DebugSvc } from "@bindings/services"; +import type { DebugBundleResult } from "@bindings/services/models.js"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import HelpText from "@/components/typography/HelpText.tsx"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SquareIcon } from "@/components/SquareIcon"; +import { formatRemaining } from "@/lib/formatters"; +import type { DebugStage } from "@/contexts/DebugBundleContext"; +import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; + +const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues"; + +export function SettingsTroubleshooting() { + const { t } = useTranslation(); + const durationId = useId(); + const { + anonymize, + setAnonymize, + systemInfo, + setSystemInfo, + upload, + setUpload, + trace, + setTrace, + capture, + setCapture, + traceMinutes, + setTraceMinutes, + capturePackets, + setCapturePackets, + run, + stage, + cancel, + reset, + } = useDebugBundleContext(); + + if (stage.kind === "done") { + return ( + + ); + } + if (stage.kind !== "idle") { + return ; + } + + return ( + + + + + + +
    + +
    +
    + + + {t("settings.troubleshooting.duration.help")} + +
    +
    + + setTraceMinutes( + Math.max(1, Math.min(30, Number(e.target.value) || 1)), + ) + } + customSuffix={t("settings.troubleshooting.duration.suffix")} + disabled={!capture} + /> +
    +
    +
    + + + + +
    + ); +} + +function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) { + return ( +
    + {children} +
    + ); +} + +function ProgressSection({ + stage, + onCancel, +}: Readonly<{ stage: DebugStage; onCancel: () => void }>) { + const { t } = useTranslation(); + const cancelling = stage.kind === "cancelling"; + return ( + + + +
    + {stageLabel(stage, t)} + + {t("settings.troubleshooting.progress.description")} + +
    + + {stage.kind === "capturing" && ( +
    + {formatRemaining(stage.remainingSec)} +
    + )} + + + + +
    + ); +} + +function DoneResult({ + result, + uploaded, + onClose, +}: Readonly<{ + result: DebugBundleResult; + uploaded: boolean; + onClose: () => void; +}>) { + const { t } = useTranslation(); + const showKey = uploaded && Boolean(result.uploadedKey); + const uploadFailed = uploaded && !result.uploadedKey; + const onRevealPath = () => { + if (!result.path) return; + DebugSvc.RevealFile(result.path).catch((err: unknown) => + console.error("reveal debug bundle file", err), + ); + }; + return ( + + + +
    + + {showKey + ? t("settings.troubleshooting.done.uploadedTitle") + : t("settings.troubleshooting.done.savedTitle")} + + + {showKey ? ( + { + e.preventDefault(); + Browser.OpenURL(SUPPORT_DOCS_URL).catch(() => + globalThis.open(SUPPORT_DOCS_URL, "_blank"), + ); + }} + className={"text-netbird hover:underline"} + > + {/* content is provided by */} + + + ), + }} + /> + ) : ( + t("settings.troubleshooting.done.savedDescription") + )} + +
    + +
    + {showKey && } + + {result.path && !showKey && ( + + + + } + /> + )} + + {uploadFailed && ( +
    + {result.uploadFailureReason + ? t("settings.troubleshooting.uploadFailedWithReason", { + reason: result.uploadFailureReason, + }) + : t("settings.troubleshooting.uploadFailed")} +
    + )} +
    + + + {showKey ? ( + + ) : ( + result.path && ( + + ) + )} + + +
    + ); +} + +const stageLabel = ( + stage: DebugStage, + t: (key: string, options?: Record) => string, +): string => { + switch (stage.kind) { + case "reconnecting": + return t("settings.troubleshooting.stage.reconnecting"); + case "capturing": + return t("settings.troubleshooting.stage.capturing"); + case "bundling": + return t("settings.troubleshooting.stage.bundling"); + case "uploading": + return t("settings.troubleshooting.stage.uploading"); + case "cancelling": + return t("settings.troubleshooting.stage.cancelling"); + default: + return ""; + } +}; diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx new file mode 100644 index 000000000..7be59f102 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Preferences, + Profiles as ProfilesSvc, + Settings as SettingsSvc, + WindowManager, +} from "@bindings/services"; +import { Restrictions, SetConfigParams } from "@bindings/services/models.js"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import i18next from "@/lib/i18n"; +import { isNetbirdCloud } from "@/hooks/useManagementUrl"; +import { WelcomeStepTray } from "./WelcomeStepTray"; +import { WelcomeStepManagement } from "./WelcomeStepManagement"; + +const WINDOW_WIDTH = 360; + +type WelcomeStep = "tray" | "management"; + +function shouldShowManagementStep( + activeProfileId: string, + email: string, + managementUrl: string, + managedManagementUrl: string, +): boolean { + if (managedManagementUrl) return false; + // The default profile's ID equals the literal "default", so this check + // holds whether we pass an ID or the legacy name. + if (activeProfileId !== "default") return false; + if (email.trim() !== "") return false; + return isNetbirdCloud(managementUrl); +} + +type InitialState = { + profileName: string; + username: string; + managementUrl: string; + needsManagementStep: boolean; +}; + +export default function WelcomeDialog() { + const [step, setStep] = useState("tray"); + const [initial, setInitial] = useState(null); + const [closing, setClosing] = useState(false); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH, initial !== null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [username, active] = await Promise.all([ + ProfilesSvc.Username(), + ProfilesSvc.GetActive(), + ]); + const profileId = active.id || "default"; + const [config, list, restrictions] = await Promise.all([ + SettingsSvc.GetConfig({ profileName: profileId, username }), + ProfilesSvc.List(username), + SettingsSvc.GetRestrictions().catch(() => new Restrictions()), + ]); + const profile = list.find((p) => p.id === profileId); + const email = profile?.email ?? ""; + if (cancelled) return; + setInitial({ + profileName: profileId, + username, + managementUrl: config.managementUrl, + needsManagementStep: shouldShowManagementStep( + profileId, + email, + config.managementUrl, + restrictions.mdm.managementURL, + ), + }); + } catch (e) { + console.error("welcome: initial probe failed", e); + if (cancelled) return; + setInitial({ + profileName: "default", + username: "", + managementUrl: "", + needsManagementStep: false, + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const finish = useCallback(async () => { + if (closing) return; + setClosing(true); + try { + await Preferences.SetOnboardingCompleted(true); + } catch (e) { + console.error("persist onboarding flag:", e); + } + try { + await WindowManager.OpenMain(); + } catch (e) { + console.error("open main window:", e); + } + try { + await WindowManager.CloseWelcome(); + } catch (e) { + console.error("close welcome window:", e); + } + }, [closing]); + + const handleTrayContinue = useCallback(async () => { + if (initial?.needsManagementStep) { + setStep("management"); + } else { + await finish(); + } + }, [initial, finish]); + + const handleManagementContinue = useCallback( + async (url: string) => { + if (!initial) return; + try { + // SetConfig is a partial update — undefined fields are preserved Go-side. + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: initial.profileName, + username: initial.username, + managementUrl: url, + }), + ); + } catch (e) { + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + throw e; + } + setInitial((s) => (s ? { ...s, managementUrl: url } : s)); + await finish(); + }, + [initial, finish], + ); + + const content = useMemo(() => { + if (!initial) { + return null; + } + switch (step) { + case "tray": + return ; + case "management": + return ( + + ); + } + }, [initial, step, handleTrayContinue, handleManagementContinue]); + + return ( + + {content} + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx new file mode 100644 index 000000000..5cb04d484 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { Input } from "@/components/inputs/Input"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isNetbirdCloud, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type WelcomeStepManagementProps = { + initialUrl: string; + onContinue: (url: string) => Promise; +}; + +export function WelcomeStepManagement({ + initialUrl, + onContinue, +}: Readonly) { + const { t } = useTranslation(); + const startsCloud = isNetbirdCloud(initialUrl); + const [mode, setMode] = useState( + startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted, + ); + const [url, setUrl] = useState(startsCloud ? "" : initialUrl); + const [syntaxError, setSyntaxError] = useState(null); + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + + const trimmedUrl = url.trim(); + const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl); + const inputRef = useRef(null); + const initialMountRef = useRef(true); + const initialSelfHostedRef = useRef(!startsCloud); + + useEffect(() => { + setSyntaxError(null); + setUnreachable(false); + }, [url, mode]); + + useEffect(() => { + if (initialMountRef.current && initialSelfHostedRef.current) { + inputRef.current?.focus(); + } + initialMountRef.current = false; + }, []); + + const handleContinue = useCallback(async () => { + if (checking) return; + if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) { + setSyntaxError(t("welcome.management.urlInvalid")); + inputRef.current?.focus(); + return; + } + const target = + mode === ManagementMode.Cloud + ? CLOUD_MANAGEMENT_URL + : normalizeManagementUrl(trimmedUrl); + if (mode === ManagementMode.SelfHosted && !unreachable) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target); + setChecking(false); + if (!reachable) { + setUnreachable(true); + return; + } + } + try { + await onContinue(target); + } catch (e) { + console.error("save management url:", e); + } + }, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]); + + const inputError = syntaxError ?? undefined; + const inputWarning = useMemo( + () => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined), + [syntaxError, unreachable, t], + ); + + return ( + <> +
    + + {t("welcome.management.title")} + + + {t("welcome.management.description")} + +
    + +
    + +
    + + {mode === ManagementMode.SelfHosted && ( +
    + setUrl(e.target.value)} + error={inputError} + warning={inputWarning} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
    + )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx new file mode 100644 index 000000000..fe06abc20 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { isMacOS, isWindows } from "@/lib/platform"; +import trayScreenshotDarwin from "@/assets/img/tray-darwin.png"; +import trayScreenshotWindows from "@/assets/img/tray-windows.png"; +import trayScreenshotLinux from "@/assets/img/tray-linux.png"; + +// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows. +function trayScreenshotForOS(): string { + if (isMacOS()) return trayScreenshotDarwin; + if (isWindows()) return trayScreenshotWindows; + return trayScreenshotLinux; +} + +type WelcomeStepTrayProps = { + onContinue: () => void; +}; + +export function WelcomeStepTray({ onContinue }: Readonly) { + const { t } = useTranslation(); + const trayScreenshot = trayScreenshotForOS(); + + return ( + <> +
    + {""} +
    + +
    + + {t("welcome.title")} + + {t("welcome.description")} +
    + + + + + + ); +} diff --git a/client/ui/frontend/src/vite-env.d.ts b/client/ui/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/client/ui/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/ui/frontend/tailwind.config.ts b/client/ui/frontend/tailwind.config.ts new file mode 100644 index 000000000..93ff39eea --- /dev/null +++ b/client/ui/frontend/tailwind.config.ts @@ -0,0 +1,177 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", + theme: { + fontFamily: { + sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'monospace'], + }, + extend: { + colors: { + "nb-gray": { + DEFAULT: "#181A1D", + 50: "#f4f6f7", + 100: "#e4e7e9", + 200: "#cbd2d6", + 250: "#b7c0c6", + 300: "#a3adb5", + 350: "#8f9ca8", + 400: "#7c8994", + 500: "#616e79", + 600: "#535d67", + 700: "#474e57", + 800: "#3f444b", + 850: "#363b40", + 900: "#2e3238", + 910: "#2b2f33", + 920: "#25282d", + 925: "#1e2123", + 930: "#25282c", + 935: "#1f2124", + 940: "#1c1e21", + 950: "#181a1d", + 960: "#16181b", + }, + gray: { + 50: "#F9FAFB", + 100: "#F3F4F6", + 200: "#E5E7EB", + 300: "#D1D5DB", + 400: "#9CA3AF", + 500: "#6B7280", + 600: "#4B5563", + 700: "#374151", + 800: "#1F2937", + 900: "#111827", + }, + red: { + 50: "#FDF2F2", + 100: "#FDE8E8", + 200: "#FBD5D5", + 300: "#F8B4B4", + 400: "#F98080", + 500: "#F05252", + 600: "#E02424", + 700: "#C81E1E", + 800: "#9B1C1C", + 900: "#771D1D", + }, + yellow: { + 50: "#FDFDEA", + 100: "#FDF6B2", + 200: "#FCE96A", + 300: "#FACA15", + 400: "#E3A008", + 500: "#C27803", + 600: "#9F580A", + 700: "#8E4B10", + 800: "#723B13", + 900: "#633112", + }, + green: { + 50: "#F3FAF7", + 100: "#DEF7EC", + 200: "#BCF0DA", + 300: "#84E1BC", + 400: "#31C48D", + 500: "#0E9F6E", + 600: "#057A55", + 700: "#046C4E", + 800: "#03543F", + 900: "#014737", + }, + blue: { + 50: "#EBF5FF", + 100: "#E1EFFE", + 200: "#C3DDFD", + 300: "#A4CAFE", + 400: "#76A9FA", + 500: "#3F83F8", + 600: "#1C64F2", + 700: "#1A56DB", + 800: "#1E429F", + 900: "#233876", + }, + indigo: { + 50: "#F0F5FF", + 100: "#E5EDFF", + 200: "#CDDBFE", + 300: "#B4C6FC", + 400: "#8DA2FB", + 500: "#6875F5", + 600: "#5850EC", + 700: "#5145CD", + 800: "#42389D", + 900: "#362F78", + }, + purple: { + 50: "#F6F5FF", + 100: "#EDEBFE", + 200: "#DCD7FE", + 300: "#CABFFD", + 400: "#AC94FA", + 500: "#9061F9", + 600: "#7E3AF2", + 700: "#6C2BD9", + 800: "#5521B5", + 900: "#4A1D96", + }, + pink: { + 50: "#FDF2F8", + 100: "#FCE8F3", + 200: "#FAD1E8", + 300: "#F8B4D9", + 400: "#F17EB8", + 500: "#E74694", + 600: "#D61F69", + 700: "#BF125D", + 800: "#99154B", + 900: "#751A3D", + }, + netbird: { + DEFAULT: "#f68330", + 50: "#fff6ed", + 100: "#feecd6", + 150: "#ffdfb8", + 200: "#ffd4a6", + 300: "#fab677", + 400: "#f68330", + 500: "#f46d1b", + 600: "#e55311", + 700: "#be3e10", + 800: "#973215", + 900: "#7a2b14", + 950: "#421308", + }, + }, + backgroundImage: { + "conic-netbird": "conic-gradient(from 0deg, #e55311 0%, #f68330 10%, #e55311 20%, #e55311 100%)", + }, + keyframes: { + "pulse-reverse": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.4" }, + }, + "spin-slow": { + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, + }, + "ping-slow": { + "0%": { transform: "scale(1)", opacity: "1" }, + "75%, 100%": { transform: "scale(2)", opacity: "0" }, + }, + }, + animation: { + "ping-slow": "ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite", + "pulse-slow": "pulse-reverse 2s cubic-bezier(0.5, 0, 0.6, 1) infinite", + "pulse-slower": "pulse-reverse 3s cubic-bezier(0.5, 0, 0.6, 1) infinite", + "spin-slow": "spin-slow 2s linear infinite", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; + +export default config; diff --git a/client/ui/frontend/tsconfig.json b/client/ui/frontend/tsconfig.json new file mode 100644 index 000000000..f95ce9015 --- /dev/null +++ b/client/ui/frontend/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@bindings/*": ["bindings/github.com/netbirdio/netbird/client/ui/*"] + } + }, + "include": ["src", "bindings"], +} diff --git a/client/ui/frontend/vite.config.ts b/client/ui/frontend/vite.config.ts new file mode 100644 index 000000000..48b1c5630 --- /dev/null +++ b/client/ui/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import wails from "@wailsio/runtime/plugins/vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@bindings": path.resolve( + __dirname, + "./bindings/github.com/netbirdio/netbird/client/ui", + ), + }, + }, + plugins: [react(), wails("./bindings")], + server: { + host: "127.0.0.1", + port: 9245, + strictPort: true, + fs: { + // The i18n bundles live at ../i18n/locales (shared with the Go tray). + // Whitelist the parent dir so Vite's dev server serves them. + allow: [path.resolve(__dirname, ".."), __dirname], + }, + }, +}); diff --git a/client/ui/grpc.go b/client/ui/grpc.go new file mode 100644 index 000000000..c8e3aed76 --- /dev/null +++ b/client/ui/grpc.go @@ -0,0 +1,67 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "runtime" + "strings" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/desktop" +) + +// Conn is the lazy, lock-protected gRPC connection shared by all services so they reuse one channel. +type Conn struct { + addr string + + mu sync.Mutex + client proto.DaemonServiceClient +} + +func NewConn(addr string) *Conn { + return &Conn{addr: addr} +} + +func (c *Conn) Client() (proto.DaemonServiceClient, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.client != nil { + return c.client, nil + } + + cc, err := grpc.NewClient( + strings.TrimPrefix(c.addr, "tcp://"), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUserAgent(desktop.GetUIUserAgent()), + // Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would + // leave the UI waiting 30-60s to notice a freshly-started daemon. + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 1 * time.Second, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 5 * time.Second, + }, + }), + ) + if err != nil { + return nil, fmt.Errorf("dial daemon: %w", err) + } + c.client = proto.NewDaemonServiceClient(cc) + return c.client, nil +} + +// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows. +func DaemonAddr() string { + if runtime.GOOS == "windows" { + return "tcp://127.0.0.1:41731" + } + return "unix:///var/run/netbird.sock" +} diff --git a/client/ui/guilog/debuglog.go b/client/ui/guilog/debuglog.go new file mode 100644 index 000000000..3a25c26c8 --- /dev/null +++ b/client/ui/guilog/debuglog.go @@ -0,0 +1,77 @@ +//go:build !android && !ios && !freebsd && !js + +// Package guilog manages gui-client.log, which follows the daemon's log level: +// in debug/trace the GUI attaches a rotated file alongside the console so its +// (and the React frontend's forwarded) output is captured for the debug bundle. +package guilog + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/util" +) + +// DebugLog attaches/detaches gui-client.log based on the daemon's log level, +// fed via Apply. The file is left on disk for the debug bundle to collect. +// Disabled (and never touches logging) when the user set --log-file explicitly. +type DebugLog struct { + uiPath string + enabled bool + + mu sync.Mutex + fileOn bool +} + +// NewDebugLog builds the GUI debug log. enabled is false when the user passed +// --log-file (manual override). +func NewDebugLog(uiPath string, enabled bool) *DebugLog { + return &DebugLog{uiPath: uiPath, enabled: enabled} +} + +// Path returns the GUI log path to register with the daemon, or "" when disabled +// so the daemon won't collect a file the GUI never writes. +func (d *DebugLog) Path() string { + if !d.enabled { + return "" + } + return d.uiPath +} + +// Apply reacts to a daemon log level (the logrus name, e.g. "debug"). +// Idempotent via the fileOn guard, so the startup replay plus a racing +// change-event are harmless. +func (d *DebugLog) Apply(level string) { + if !d.enabled { + return + } + + // Compared numerically so there are no hard-coded level-name literals. + lvl, err := log.ParseLevel(level) + if err != nil { + lvl = log.InfoLevel + } + debug := lvl >= log.DebugLevel + + d.mu.Lock() + defer d.mu.Unlock() + + switch { + case debug && !d.fileOn: + if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole, d.uiPath); err != nil { + log.Errorf("attach GUI file log %s: %v", d.uiPath, err) + return + } + log.SetLevel(lvl) + d.fileOn = true + log.Infof("GUI file logging enabled (daemon level %s), writing to %s", level, d.uiPath) + case !debug && d.fileOn: + if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole); err != nil { + log.Errorf("detach GUI file log: %v", err) + } + log.SetLevel(log.InfoLevel) + d.fileOn = false + log.Infof("GUI file logging disabled (daemon level: %s)", level) + } +} diff --git a/client/ui/i18n/TRANSLATING.md b/client/ui/i18n/TRANSLATING.md new file mode 100644 index 000000000..88cbb8b11 --- /dev/null +++ b/client/ui/i18n/TRANSLATING.md @@ -0,0 +1,133 @@ +# Translating the NetBird UI + +A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating). + +**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."* + +> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means. + +--- + +## What NetBird is + +A **business zero-trust VPN** — an encrypted **overlay mesh** between a company's devices, built on **WireGuard®**, connecting peers directly with a **relay** fallback. This is the **desktop client** (tray app + windows) someone runs to connect, switch profiles, browse peers, and pick an exit node — *not* the admin dashboard. + +**Audience:** IT-literate professionals. **Tone:** clear and professional, never consumer-cute. + +**The vocabulary you'll meet:** + +| Term | What it means here | +|---|---| +| **Peer** | A device on the network (laptop, server, phone) | +| **Resource / Network** | A routed network or service reachable through NetBird (UI calls these "Resources") | +| **Exit Node** | A peer that routes *all* internet traffic, like a full-tunnel gateway | +| **Profile** | A saved connection identity you can switch between | +| **Daemon** | The background service the UI talks to | +| **Management server** | The control plane — *Cloud* (hosted) or *self-hosted* (customer-run) | +| **Relay** | Forwards traffic when two peers can't connect directly | +| **Rosenpass** | Post-quantum security layered over WireGuard® | +| **Handshake** | The periodic WireGuard® key sync between peers | + +--- + +## The files + +``` +i18n/locales/_index.json shipped-language list +i18n/locales/en/common.json source of truth — message + description +i18n/locales//common.json a target — message only +``` + +Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**. + +| ✅ Do | ❌ Don't | +|---|---| +| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) | +| Put **only `message`** in target bundles | Copy `description` into a target bundle | +| Give every key a non-empty `message` | Leave keys missing or empty | +| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON | + +--- + +## Hard rules — get these exactly right + +These are the usual ways a translation *breaks the app*, not just reads oddly. + +| ✅ Do | ❌ Don't | +|---|---| +| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) | +| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder | +| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) | +| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags | + +**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply. + +**Agreement:** a `{placeholder}` drops a value into a fixed frame, so the words around it must fit *every* value the app can supply. In inflected languages, write the frame in the case the surrounding preposition demands — German's duration fragments are **dative** because they land inside "…in {remaining}" (`in {count} Tagen`, `weniger als einer Minute`), not nominative `Tage`. Check the key that *consumes* the fragment (here `tray.session.expiresIn`) before choosing the form. + +--- + +## Glossary + +**Tier A — never translate (brands):** `NetBird` · `WireGuard®` · `Rosenpass` · `GitHub` · `ICE` · company/product names · sample URLs · version numbers. + +When a brand sits beside a common noun, keep its exact spelling but join them the way your language builds such phrases — a hyphen, a connector word, an inflected noun — rather than copying English's bare noun-stack. + +**Tier B — keep as-is (acronyms):** `SSO` · `MFA` · `DNS` · `IP`/`IPv6` · `ACL` · `SSH` · `GUI` · `P2P` · `URL` · `TCP`/`UDP`. + +**Tier C — judgment.** One rule decides every term: + +> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it. + +Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it. + +Two checks before you commit a term: + +- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google). +- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it. + +--- + +## Style + +| ✅ Do | ❌ Don't | +|---|---| +| Use the **formal "you"** (de *Sie*, fr *vous*, ru *вы*, it *Lei*, zh 您) | Use casual/informal address | +| Keep **buttons, menu, and tray** items short, in your language's action form (de "Speichern", fr "Enregistrer") | Let a label run much longer than the English — space is tight | +| Follow **locale punctuation** (fr NBSP + « », de „…", zh full-width), including around a quoted UI label | Carry over English Title Case (use sentence case; German nouns excepted) | +| Translate a term the **same way everywhere** | Vary wording for the same concept across screens | + +Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them. + +A few habits that keep a bundle reading like one product rather than a word-for-word port: + +- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque. +- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling. +- **Mirror opposites.** A status should read as the natural counterpart of its pair: translate *Disconnected* as the opposite of however you rendered *Connected*, not as an unrelated word. Same for Active/Inactive, Selected/Not selected. +- **Give a standalone label its subject.** A bare button or title can lose the context the surrounding English UI implied — add the noun back if it would otherwise read ambiguously. + +--- + +## Procedure + +**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales//common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`). + +**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check. + +--- + +## QA before you finish + +- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields +- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept +- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language) +- [ ] Buttons & tray short · locale punctuation and capitalization applied +- [ ] New language added to `_index.json` +- [ ] **Tested in the running app** ↓ + +--- + +## Test it in the app + +A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens. + +How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go new file mode 100644 index 000000000..892916999 --- /dev/null +++ b/client/ui/i18n/bundle.go @@ -0,0 +1,196 @@ +//go:build !android && !ios && !freebsd && !js + +// Package i18n loads and serves translation strings for both the tray (Go) +// and the React UI (via the services.I18n facade). +// +// The locale tree is passed in as an fs.FS so the embed directive can live in +// the main binary. +package i18n + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) + +const ( + localeIndexFile = "_index.json" + + // commonBundleFile shape is Chrome-extension JSON (key -> "message" plus + // optional Crowdin "description"); loadBundle flattens to key->message. + commonBundleFile = "common.json" +) + +// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). +type LanguageCode string + +// DefaultLanguage is the fallback bundle for missing keys and the default +// when no preference is on disk. +const DefaultLanguage LanguageCode = "en" + +var ErrUnsupportedLanguage = errors.New("unsupported language") + +// Language describes one shipped UI locale. DisplayName is in the locale's +// own script (a Hungarian entry reads "Magyar" regardless of UI language). +type Language struct { + Code LanguageCode `json:"code"` + DisplayName string `json:"displayName"` + EnglishName string `json:"englishName"` +} + +type localeIndex struct { + Languages []Language `json:"languages"` +} + +// Bundle holds the parsed translation bundles. Loaded once at construction +// and never mutated. +type Bundle struct { + mu sync.RWMutex + languages []Language + bundles map[LanguageCode]map[string]string +} + +// NewBundle parses _index.json plus every /common.json in the locale +// tree. Hard-fails only when the default language is missing; other locales +// without a bundle are dropped with a warning. +func NewBundle(localesFS fs.FS) (*Bundle, error) { + idx, err := loadLocaleIndex(localesFS) + if err != nil { + return nil, fmt.Errorf("load locale index: %w", err) + } + + bundles := make(map[LanguageCode]map[string]string, len(idx.Languages)) + available := make([]Language, 0, len(idx.Languages)) + for _, l := range idx.Languages { + b, err := loadBundle(localesFS, l.Code) + if err != nil { + log.Warnf("skip language %q: %v", l.Code, err) + continue + } + bundles[l.Code] = b + available = append(available, l) + } + + if _, ok := bundles[DefaultLanguage]; !ok { + return nil, fmt.Errorf("default language %q bundle missing", DefaultLanguage) + } + + sort.Slice(available, func(i, j int) bool { return available[i].Code < available[j].Code }) + + return &Bundle{ + languages: available, + bundles: bundles, + }, nil +} + +// Languages returns a copy of the available locales. +func (b *Bundle) Languages() []Language { + b.mu.RLock() + defer b.mu.RUnlock() + out := make([]Language, len(b.languages)) + copy(out, b.languages) + return out +} + +func (b *Bundle) HasLanguage(code LanguageCode) bool { + b.mu.RLock() + defer b.mu.RUnlock() + _, ok := b.bundles[code] + return ok +} + +// BundleFor returns a copy of the full key->text map for one language. +func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + bundle, ok := b.bundles[code] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnsupportedLanguage, code) + } + out := make(map[string]string, len(bundle)) + for k, v := range bundle { + out[k] = v + } + return out, nil +} + +// Translate resolves key for lang, substituting args given as name/value +// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to +// the default language, then to the key itself so a miss is visible in the UI. +func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string { + b.mu.RLock() + defer b.mu.RUnlock() + + if v, ok := b.bundles[lang][key]; ok { + return applyPlaceholders(v, args) + } + if lang != DefaultLanguage { + if v, ok := b.bundles[DefaultLanguage][key]; ok { + return applyPlaceholders(v, args) + } + } + return key +} + +// applyPlaceholders substitutes {name} in s using args as flat name/value +// pairs. An odd-length args drops the trailing item. +func applyPlaceholders(s string, args []string) string { + if len(args) == 0 { + return s + } + if len(args)%2 != 0 { + log.Debugf("i18n placeholder args not paired: %d items, last dropped", len(args)) + args = args[:len(args)-1] + } + for j := 0; j < len(args); j += 2 { + s = strings.ReplaceAll(s, "{"+args[j]+"}", args[j+1]) + } + return s +} + +func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) { + data, err := fs.ReadFile(localesFS, localeIndexFile) + if err != nil { + return nil, err + } + var idx localeIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse %s: %w", localeIndexFile, err) + } + if len(idx.Languages) == 0 { + return nil, errors.New("no languages declared") + } + return &idx, nil +} + +// bundleEntry is one translation key on disk; Description is Crowdin context, +// ignored at runtime. +type bundleEntry struct { + Message string `json:"message"` + Description string `json:"description,omitempty"` +} + +func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) { + p := path.Join(string(code), commonBundleFile) + data, err := fs.ReadFile(localesFS, p) + if err != nil { + return nil, err + } + var entries map[string]bundleEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("parse %s: %w", p, err) + } + bundle := make(map[string]string, len(entries)) + for k, e := range entries { + bundle[k] = e.Message + } + return bundle, nil +} diff --git a/client/ui/i18n/bundle_test.go b/client/ui/i18n/bundle_test.go new file mode 100644 index 000000000..d0b0d24d7 --- /dev/null +++ b/client/ui/i18n/bundle_test.go @@ -0,0 +1,156 @@ +//go:build !android && !ios && !freebsd && !js + +package i18n + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeLocales returns an in-memory FS that mirrors the real +// client/ui/i18n/locales layout (root-level _index.json plus +// /common.json bundles). Used by every Bundle test so we don't +// depend on the embedded production bundles staying stable. +func fakeLocales() fstest.MapFS { + return fstest.MapFS{ + "_index.json": {Data: []byte(`{ + "languages": [ + {"code": "en", "displayName": "English", "englishName": "English"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"} + ] + }`)}, + "en/common.json": {Data: []byte(`{ + "tray.menu.connect": {"message": "Connect", "description": "Tray menu item"}, + "tray.menu.installVersion": {"message": "Install version {version}"}, + "notify.update.body": {"message": "NetBird {version} is available."} + }`)}, + "hu/common.json": {Data: []byte(`{ + "tray.menu.connect": {"message": "Csatlakozás"}, + "tray.menu.installVersion": {"message": "{version} telepítése"} + }`)}, + } +} + +func TestBundle_LoadsAllLanguages(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + langs := b.Languages() + require.Len(t, langs, 2) + codes := []LanguageCode{langs[0].Code, langs[1].Code} + assert.ElementsMatch(t, []LanguageCode{"en", "hu"}, codes, "Languages should list every bundle that loaded") +} + +func TestBundle_TranslateLooksUpKey(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "Csatlakozás", b.Translate("hu", "tray.menu.connect")) + assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect")) +} + +func TestBundle_TranslateSubstitutesPlaceholders(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "Install version 1.2.3", + b.Translate("en", "tray.menu.installVersion", "version", "1.2.3"), + "placeholders should substitute by name") + assert.Equal(t, "1.2.3 telepítése", + b.Translate("hu", "tray.menu.installVersion", "version", "1.2.3")) +} + +func TestBundle_TranslateFallsBackToEnglish(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + // notify.update.body is missing from the hu bundle; English fallback + // applies so the user always sees a populated label rather than the + // raw key. + got := b.Translate("hu", "notify.update.body", "version", "9.9.9") + assert.Equal(t, "NetBird 9.9.9 is available.", got, "missing hu key should fall back to en bundle") +} + +func TestBundle_TranslateUnknownKeyReturnsKey(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "tray.missing", b.Translate("en", "tray.missing"), + "unknown key should return the key itself for debugability") +} + +func TestBundle_BundleForReturnsCopy(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + m, err := b.BundleFor("en") + require.NoError(t, err) + require.NotEmpty(t, m, "BundleFor should return populated map for known language") + + m["tray.menu.connect"] = "Mutated" + assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"), + "BundleFor must return a copy, not the live map") +} + +func TestBundle_BundleForUnknownLanguage(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + _, err = b.BundleFor("xx") + assert.ErrorIs(t, err, ErrUnsupportedLanguage) +} + +func TestBundle_HasLanguage(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.True(t, b.HasLanguage("en")) + assert.True(t, b.HasLanguage("hu")) + assert.False(t, b.HasLanguage("de")) +} + +func TestBundle_MissingDefaultBundleFails(t *testing.T) { + // Without an en bundle we have nothing to fall back to, so construction + // must hard-fail. Catches packaging accidents where someone drops the + // English locale. + fs := fstest.MapFS{ + "_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)}, + "hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, + } + _, err := NewBundle(fs) + require.Error(t, err) + assert.Contains(t, err.Error(), "default language") +} + +func TestBundle_MissingBundleSkipsLanguage(t *testing.T) { + // A language declared in the index but missing its bundle file is + // dropped from Languages with a warning — adding a new language must + // be a two-step process (declare + ship), not declare-only. + fs := fstest.MapFS{ + "_index.json": {Data: []byte(`{"languages":[ + {"code":"en","displayName":"English","englishName":"English"}, + {"code":"de","displayName":"Deutsch","englishName":"German"} + ]}`)}, + "en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, + } + b, err := NewBundle(fs) + require.NoError(t, err) + + langs := b.Languages() + require.Len(t, langs, 1) + assert.Equal(t, LanguageCode("en"), langs[0].Code, "language without a bundle file must be dropped") + assert.False(t, b.HasLanguage("de")) +} + +func TestBundle_OddPlaceholderArgsDoNotPanic(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + // Trailing dangling arg should be dropped, not panic — preserves UI + // stability when a caller passes an unpaired placeholder by mistake. + got := b.Translate("en", "tray.menu.installVersion", "version", "1.2.3", "extra") + assert.Equal(t, "Install version 1.2.3", got) +} diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json new file mode 100644 index 000000000..58b5c484f --- /dev/null +++ b/client/ui/i18n/locales/_index.json @@ -0,0 +1,13 @@ +{ + "languages": [ + {"code": "en", "displayName": "English (US)", "englishName": "English (US)"}, + {"code": "de", "displayName": "Deutsch", "englishName": "German"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}, + {"code": "ru", "displayName": "Русский", "englishName": "Russian"}, + {"code": "es", "displayName": "Español", "englishName": "Spanish"}, + {"code": "fr", "displayName": "Français", "englishName": "French"}, + {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, + {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"} + ] +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json new file mode 100644 index 000000000..5e0d8096d --- /dev/null +++ b/client/ui/i18n/locales/de/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Nicht verbunden" + }, + "tray.status.daemonUnavailable": { + "message": "Nicht aktiv" + }, + "tray.status.error": { + "message": "Fehler" + }, + "tray.status.connected": { + "message": "Verbunden" + }, + "tray.status.connecting": { + "message": "Wird verbunden" + }, + "tray.status.needsLogin": { + "message": "Anmeldung erforderlich" + }, + "tray.status.loginFailed": { + "message": "Anmeldung fehlgeschlagen" + }, + "tray.status.sessionExpired": { + "message": "Sitzung abgelaufen" + }, + "tray.session.expiresIn": { + "message": "Sitzung läuft ab in {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "weniger als einer Minute" + }, + "tray.session.unit.minute": { + "message": "1 Minute" + }, + "tray.session.unit.minutes": { + "message": "{count} Minuten" + }, + "tray.session.unit.hour": { + "message": "1 Stunde" + }, + "tray.session.unit.hours": { + "message": "{count} Stunden" + }, + "tray.session.unit.day": { + "message": "1 Tag" + }, + "tray.session.unit.days": { + "message": "{count} Tagen" + }, + "tray.menu.open": { + "message": "NetBird öffnen" + }, + "tray.menu.connect": { + "message": "Verbinden" + }, + "tray.menu.disconnect": { + "message": "Trennen" + }, + "tray.menu.exitNode": { + "message": "Exit Node" + }, + "tray.menu.networks": { + "message": "Ressourcen" + }, + "tray.menu.profiles": { + "message": "Profile" + }, + "tray.menu.manageProfiles": { + "message": "Profile verwalten" + }, + "tray.menu.settings": { + "message": "Einstellungen …" + }, + "tray.menu.debugBundle": { + "message": "Debug-Paket erstellen" + }, + "tray.menu.about": { + "message": "Hilfe & Support" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentation" + }, + "tray.menu.troubleshoot": { + "message": "Fehlerbehebung" + }, + "tray.menu.downloadLatest": { + "message": "Neueste Version herunterladen" + }, + "tray.menu.installVersion": { + "message": "Version {version} installieren" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird beenden" + }, + "notify.daemonOutdated.title": { + "message": "NetBird-Dienst ist veraltet" + }, + "notify.daemonOutdated.body": { + "message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden." + }, + "notify.update.title": { + "message": "NetBird-Update verfügbar" + }, + "notify.update.body": { + "message": "NetBird {version} ist verfügbar." + }, + "notify.update.enforcedSuffix": { + "message": " Ihr Administrator verlangt dieses Update." + }, + "notify.error.title": { + "message": "Fehler" + }, + "notify.error.connect": { + "message": "Verbindung fehlgeschlagen" + }, + "notify.error.disconnect": { + "message": "Trennen fehlgeschlagen" + }, + "notify.error.switchProfile": { + "message": "Wechsel zu {profile} fehlgeschlagen" + }, + "notify.error.exitNode": { + "message": "Exit Node {name} konnte nicht aktualisiert werden" + }, + "notify.sessionExpired.title": { + "message": "NetBird-Sitzung abgelaufen" + }, + "notify.sessionExpired.body": { + "message": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "notify.sessionWarning.title": { + "message": "Sitzung läuft bald ab" + }, + "notify.sessionWarning.body": { + "message": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.extend": { + "message": "Jetzt verlängern" + }, + "notify.sessionWarning.dismiss": { + "message": "Verwerfen" + }, + "notify.sessionWarning.failed": { + "message": "NetBird-Sitzung konnte nicht verlängert werden" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird-Sitzung verlängert" + }, + "notify.sessionWarning.successBody": { + "message": "Ihre Sitzung wurde erneuert." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Ungültige Sitzungsablaufzeit" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird-Einstellungen aktualisiert" + }, + "notify.mdm.policyApplied.body": { + "message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert." + }, + "common.cancel": { + "message": "Abbrechen" + }, + "common.save": { + "message": "Speichern" + }, + "common.saveChanges": { + "message": "Änderungen speichern" + }, + "common.saving": { + "message": "Speichert…" + }, + "common.close": { + "message": "Schließen" + }, + "common.copy": { + "message": "Kopieren" + }, + "common.togglePasswordVisibility": { + "message": "Passwortsichtbarkeit umschalten" + }, + "common.increase": { + "message": "Erhöhen" + }, + "common.decrease": { + "message": "Verringern" + }, + "common.delete": { + "message": "Löschen" + }, + "common.create": { + "message": "Erstellen" + }, + "common.add": { + "message": "Hinzufügen" + }, + "common.remove": { + "message": "Entfernen" + }, + "common.refresh": { + "message": "Aktualisieren" + }, + "common.loading": { + "message": "Lädt…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Keine Ergebnisse gefunden" + }, + "common.noResults.description": { + "message": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter." + }, + "notConnected.title": { + "message": "Nicht verbunden" + }, + "notConnected.description": { + "message": "Verbinden Sie sich zuerst mit NetBird, um detaillierte Informationen zu Ihren Peers, Netzwerkressourcen und Exit Nodes einzusehen." + }, + "connect.status.disconnected": { + "message": "Nicht verbunden" + }, + "connect.status.connecting": { + "message": "Wird verbunden…" + }, + "connect.status.connected": { + "message": "Verbunden" + }, + "connect.status.disconnecting": { + "message": "Wird getrennt…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nicht verfügbar" + }, + "connect.status.loginRequired": { + "message": "Anmeldung erforderlich" + }, + "connect.error.loginTitle": { + "message": "Anmeldung fehlgeschlagen" + }, + "connect.error.connectTitle": { + "message": "Verbindung fehlgeschlagen" + }, + "connect.error.disconnectTitle": { + "message": "Trennen fehlgeschlagen" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} von {total} verbunden" + }, + "nav.resources.title": { + "message": "Ressourcen" + }, + "nav.resources.description": { + "message": "{active} von {total} aktiv" + }, + "nav.exitNode.title": { + "message": "Exit Nodes" + }, + "nav.exitNode.none": { + "message": "Nicht aktiv" + }, + "nav.exitNode.using": { + "message": "Über {name}" + }, + "header.openSettings": { + "message": "Einstellungen öffnen" + }, + "header.togglePanel": { + "message": "Seitenleiste umschalten" + }, + "profile.selector.loading": { + "message": "Lädt…" + }, + "profile.selector.noProfile": { + "message": "Kein Profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil nach Namen suchen…" + }, + "profile.selector.emptyTitle": { + "message": "Keine Profile gefunden" + }, + "profile.selector.emptyDescription": { + "message": "Versuchen Sie einen anderen Suchbegriff oder erstellen Sie ein neues Profil." + }, + "profile.selector.newProfile": { + "message": "Neues Profil" + }, + "profile.selector.moreOptions": { + "message": "Weitere Optionen" + }, + "profile.selector.deregister": { + "message": "Abmelden" + }, + "profile.selector.delete": { + "message": "Profil löschen" + }, + "profile.selector.switchTo": { + "message": "Zu diesem Profil wechseln" + }, + "profile.selector.edit": { + "message": "Bearbeiten" + }, + "profile.edit.title": { + "message": "Profil bearbeiten" + }, + "profile.edit.submit": { + "message": "Änderungen speichern" + }, + "profile.dialog.title": { + "message": "Neues Profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilname" + }, + "profile.dialog.description": { + "message": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest." + }, + "profile.dialog.placeholder": { + "message": "z. B. Arbeit" + }, + "profile.dialog.submit": { + "message": "Profil hinzufügen" + }, + "profile.dialog.required": { + "message": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud oder Ihr eigener Server." + }, + "profile.dialog.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist." + }, + "header.menu.settings": { + "message": "Einstellungen …" + }, + "header.menu.defaultView": { + "message": "Standardansicht" + }, + "header.menu.advancedView": { + "message": "Erweiterte Ansicht" + }, + "header.menu.updateAvailable": { + "message": "Update verfügbar" + }, + "header.menu.open": { + "message": "Menü öffnen" + }, + "header.profile.switch": { + "message": "Profil wechseln" + }, + "connect.toggle.label": { + "message": "NetBird-Verbindung umschalten" + }, + "connect.localIp.label": { + "message": "Lokale IP-Adressen" + }, + "common.search": { + "message": "Suchen" + }, + "common.filter": { + "message": "Filtern" + }, + "exitNodes.dropdown.trigger": { + "message": "Exit-Node auswählen" + }, + "peers.row.label": { + "message": "Details öffnen für {name}, {status}" + }, + "peers.dialog.title": { + "message": "Peer-Details" + }, + "networks.row.toggle": { + "message": "{name} umschalten" + }, + "networks.bulk.label": { + "message": "Alle sichtbaren Ressourcen umschalten" + }, + "settings.nav.label": { + "message": "Einstellungsbereiche" + }, + "profile.switch.title": { + "message": "Zu Profil \"{name}\" wechseln?" + }, + "profile.switch.message": { + "message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt." + }, + "profile.switch.confirm": { + "message": "Bestätigen" + }, + "profile.deregister.title": { + "message": "Profil \"{name}\" abmelden?" + }, + "profile.deregister.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen." + }, + "profile.deregister.confirm": { + "message": "Abmelden" + }, + "profile.delete.title": { + "message": "Profil \"{name}\" löschen?" + }, + "profile.delete.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden." + }, + "profile.delete.disabledActive": { + "message": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen." + }, + "profile.delete.disabledDefault": { + "message": "Das Standardprofil kann nicht gelöscht werden." + }, + "profile.error.switchTitle": { + "message": "Profilwechsel fehlgeschlagen" + }, + "profile.error.deregisterTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "profile.error.deleteTitle": { + "message": "Löschen des Profils fehlgeschlagen" + }, + "profile.error.createTitle": { + "message": "Erstellen des Profils fehlgeschlagen" + }, + "profile.error.editTitle": { + "message": "Bearbeiten des Profils fehlgeschlagen" + }, + "profile.error.loadTitle": { + "message": "Laden der Profile fehlgeschlagen" + }, + "profile.dropdown.activeProfile": { + "message": "Aktives Profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profil wechseln" + }, + "profile.dropdown.noEmail": { + "message": "Andere" + }, + "profile.dropdown.addProfile": { + "message": "Profil hinzufügen" + }, + "profile.dropdown.manageProfiles": { + "message": "Profile verwalten" + }, + "profile.dropdown.settings": { + "message": "Einstellungen" + }, + "settings.profiles.section.profiles": { + "message": "Profile" + }, + "settings.profiles.intro": { + "message": "Verwalten Sie mehrere NetBird-Profile parallel, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie." + }, + "settings.profiles.addProfile": { + "message": "Profil hinzufügen" + }, + "settings.profiles.active": { + "message": "Aktiv" + }, + "settings.profiles.emptyTitle": { + "message": "Keine Profile" + }, + "settings.profiles.emptyDescription": { + "message": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden." + }, + "settings.error.loadTitle": { + "message": "Laden der Einstellungen fehlgeschlagen" + }, + "settings.error.saveTitle": { + "message": "Speichern der Einstellungen fehlgeschlagen" + }, + "settings.error.debugBundleTitle": { + "message": "Debug-Paket fehlgeschlagen" + }, + "settings.tabs.general": { + "message": "Allgemein" + }, + "settings.tabs.network": { + "message": "Netzwerk" + }, + "settings.tabs.security": { + "message": "Sicherheit" + }, + "settings.tabs.profiles": { + "message": "Profile" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Erweitert" + }, + "settings.tabs.troubleshooting": { + "message": "Fehlerbehebung" + }, + "settings.tabs.about": { + "message": "Über" + }, + "settings.tabs.updateAvailable": { + "message": "Update verfügbar" + }, + "settings.general.section.general": { + "message": "Allgemein" + }, + "settings.general.section.connection": { + "message": "Verbindung" + }, + "settings.general.connectOnStartup.label": { + "message": "Beim Start verbinden" + }, + "settings.general.connectOnStartup.help": { + "message": "Beim Start des Dienstes automatisch eine Verbindung herstellen." + }, + "settings.general.notifications.label": { + "message": "Desktop-Benachrichtigungen" + }, + "settings.general.notifications.help": { + "message": "Desktop-Benachrichtigungen für neue Updates und Verbindungsereignisse anzeigen." + }, + "settings.general.autostart.label": { + "message": "NetBird-UI beim Anmelden starten" + }, + "settings.general.autostart.help": { + "message": "Die NetBird-Oberfläche beim Anmelden automatisch starten. Dies betrifft nur die grafische Oberfläche, nicht den Hintergrunddienst." + }, + "settings.general.autostart.errorTitle": { + "message": "Ändern des Autostarts fehlgeschlagen" + }, + "settings.general.language.label": { + "message": "Anzeigesprache" + }, + "settings.general.language.help": { + "message": "Wählen Sie die Sprache der NetBird-Oberfläche." + }, + "settings.general.language.search": { + "message": "Sprache suchen…" + }, + "settings.general.language.empty": { + "message": "Keine Sprachen gefunden." + }, + "settings.general.management.label": { + "message": "Management-Server" + }, + "settings.general.management.help": { + "message": "Mit NetBird Cloud oder Ihrem eigenen self-hosted Management-Server verbinden. Änderungen lösen eine Neuverbindung des Clients aus." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist." + }, + "settings.general.management.switchCloudTitle": { + "message": "Zu NetBird Cloud wechseln?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Zu Cloud wechseln" + }, + "settings.network.section.connectivity": { + "message": "Konnektivität" + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS" + }, + "settings.network.monitor.label": { + "message": "Bei Netzwerkwechsel neu verbinden" + }, + "settings.network.monitor.help": { + "message": "Das Netzwerk überwachen und bei Änderungen (z. B. WLAN-Wechsel, Ethernet-Änderungen oder Rückkehr aus dem Ruhezustand) automatisch neu verbinden." + }, + "settings.network.dns.label": { + "message": "DNS aktivieren" + }, + "settings.network.dns.help": { + "message": "NetBird-verwaltete DNS-Einstellungen auf den Host-Resolver anwenden." + }, + "settings.network.clientRoutes.label": { + "message": "Client-Routen aktivieren" + }, + "settings.network.clientRoutes.help": { + "message": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen." + }, + "settings.network.serverRoutes.label": { + "message": "Server-Routen aktivieren" + }, + "settings.network.serverRoutes.help": { + "message": "Lokale Routen dieses Hosts an andere Peers ankündigen." + }, + "settings.network.ipv6.label": { + "message": "IPv6 aktivieren" + }, + "settings.network.ipv6.help": { + "message": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Verschlüsselung" + }, + "settings.security.blockInbound.label": { + "message": "Eingehenden Verkehr blockieren" + }, + "settings.security.blockInbound.help": { + "message": "Unaufgeforderte Verbindungen von Peers zu diesem Gerät und den von ihm gerouteten Netzwerken ablehnen. Ausgehender Verkehr ist nicht betroffen." + }, + "settings.security.blockLan.label": { + "message": "LAN-Zugriff blockieren" + }, + "settings.security.blockLan.help": { + "message": "Verhindert, dass Peers Ihr lokales Netzwerk oder dessen Geräte erreichen, wenn dieses Gerät deren Verkehr routet." + }, + "settings.security.rosenpass.label": { + "message": "Quantenresistenz aktivieren" + }, + "settings.security.rosenpass.help": { + "message": "Einen Post-Quanten-Schlüsselaustausch über Rosenpass zusätzlich zu WireGuard® hinzufügen." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Permissiven Modus aktivieren" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Verbindungen zu Peers ohne Quantenresistenz-Unterstützung erlauben." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funktionen" + }, + "settings.ssh.section.authentication": { + "message": "Authentifizierung" + }, + "settings.ssh.server.label": { + "message": "SSH-Server aktivieren" + }, + "settings.ssh.server.help": { + "message": "Den NetBird SSH-Server auf diesem Host ausführen, damit andere Peers sich verbinden können." + }, + "settings.ssh.root.label": { + "message": "Root-Login erlauben" + }, + "settings.ssh.root.help": { + "message": "Peers dürfen sich als root anmelden. Deaktivieren, um ein nicht-privilegiertes Konto zu erfordern." + }, + "settings.ssh.sftp.label": { + "message": "SFTP erlauben" + }, + "settings.ssh.sftp.help": { + "message": "Dateien sicher über native SFTP- oder SCP-Clients übertragen." + }, + "settings.ssh.localForward.label": { + "message": "Lokale Portweiterleitung" + }, + "settings.ssh.localForward.help": { + "message": "Verbundene Peers können lokale Ports zu von diesem Host erreichbaren Diensten tunneln." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote-Portweiterleitung" + }, + "settings.ssh.remoteForward.help": { + "message": "Verbundene Peers können Ports dieses Hosts an ihren eigenen Rechner weitergeben." + }, + "settings.ssh.jwt.label": { + "message": "JWT-Authentifizierung aktivieren" + }, + "settings.ssh.jwt.help": { + "message": "Jede SSH-Sitzung gegen Ihren IdP für Identität und Audit prüfen. Deaktivieren, um sich nur auf Netzwerk-ACL-Richtlinien zu verlassen — sinnvoll, wenn kein IdP verfügbar ist." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT-Cache-TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "Wie lange dieser Client ein JWT zwischenspeichert, bevor bei ausgehenden SSH-Verbindungen erneut nachgefragt wird. Auf 0 setzen, um den Cache zu deaktivieren und bei jeder Verbindung zu authentifizieren." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Sekunde(n)" + }, + "settings.advanced.section.interface": { + "message": "Schnittstelle" + }, + "settings.advanced.section.security": { + "message": "Sicherheit" + }, + "settings.advanced.interfaceName.label": { + "message": "Name" + }, + "settings.advanced.interfaceName.error": { + "message": "Verwenden Sie 1–15 Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Geben Sie einen Port zwischen {min} und {max} ein." + }, + "settings.advanced.port.help": { + "message": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Geben Sie einen MTU-Wert zwischen {min} und {max} ein." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key" + }, + "settings.advanced.psk.help": { + "message": "Optionaler WireGuard-PSK für zusätzliche symmetrische Verschlüsselung. Nicht identisch mit einem NetBird Setup-Key. Sie kommunizieren nur mit Peers, die denselben Pre-shared Key verwenden." + }, + "settings.troubleshooting.section.title": { + "message": "Debug-Paket" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Sensible Informationen anonymisieren" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Systeminformationen einschließen" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS, Kernel, Netzwerkschnittstellen und Routing-Tabellen einschließen." + }, + "settings.troubleshooting.upload.label": { + "message": "Paket an NetBird-Server hochladen" + }, + "settings.troubleshooting.upload.help": { + "message": "Gibt einen Upload-Schlüssel zurück, den Sie mit dem NetBird-Support teilen können." + }, + "settings.troubleshooting.trace.label": { + "message": "Trace-Logs aktivieren" + }, + "settings.troubleshooting.trace.help": { + "message": "Hebt das Log-Level auf TRACE an und stellt es danach wieder her." + }, + "settings.troubleshooting.capture.label": { + "message": "Aufzeichnungssitzung" + }, + "settings.troubleshooting.capture.help": { + "message": "Stellt die Verbindung neu her und wartet, damit Sie das Problem reproduzieren können." + }, + "settings.troubleshooting.packets.label": { + "message": "Netzwerkpakete aufzeichnen" + }, + "settings.troubleshooting.packets.help": { + "message": "Speichert eine .pcap-Datei des Netzwerkverkehrs während der Aufzeichnung." + }, + "settings.troubleshooting.duration.label": { + "message": "Aufzeichnungsdauer" + }, + "settings.troubleshooting.duration.help": { + "message": "Wie lange die Aufzeichnungssitzung läuft." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(n)" + }, + "settings.troubleshooting.create": { + "message": "Debug-Paket erstellen" + }, + "settings.troubleshooting.progress.description": { + "message": "Logs, Systemdetails und Verbindungszustand werden gesammelt. Dies dauert in der Regel einen Moment — lassen Sie dieses Fenster geöffnet, bis es abgeschlossen ist." + }, + "settings.troubleshooting.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug-Paket erfolgreich hochgeladen!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paket gespeichert" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Teilen Sie den unten angezeigten Upload-Schlüssel mit dem NetBird-Support. Eine lokale Kopie wurde ebenfalls auf Ihrem Gerät gespeichert." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Ihr Debug-Paket wurde lokal gespeichert." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Schlüssel kopieren" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ordner öffnen" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Speicherort öffnen" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload fehlgeschlagen: {reason} Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload fehlgeschlagen. Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird wird neu verbunden…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Debug-Logs werden erfasst" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Debug-Paket wird erstellt…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Wird zu NetBird hochgeladen…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Entwicklung]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Alle Rechte vorbehalten." + }, + "settings.about.links.imprint": { + "message": "Impressum" + }, + "settings.about.links.privacy": { + "message": "Datenschutz" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Nutzungsbedingungen" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Dokumentation" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} ist installationsbereit." + }, + "update.banner.later": { + "message": "Später" + }, + "update.banner.installNow": { + "message": "Jetzt installieren" + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} ist zum Herunterladen verfügbar." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} ist zur Installation verfügbar." + }, + "update.card.whatsNew": { + "message": "Was ist neu?" + }, + "update.card.installNow": { + "message": "Jetzt installieren" + }, + "update.card.getInstaller": { + "message": "Herunterladen" + }, + "update.card.autoCheckInterval": { + "message": "NetBird sucht im Hintergrund nach Updates." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sie verwenden die neueste Version" + }, + "update.header.tooltip": { + "message": "Update verfügbar" + }, + "update.overlay.updatingVersion": { + "message": "NetBird wird auf v{version} aktualisiert" + }, + "update.overlay.updating": { + "message": "NetBird wird aktualisiert" + }, + "update.overlay.description": { + "message": "Eine neuere Version ist verfügbar und wird installiert. NetBird startet nach Abschluss des Updates automatisch neu." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update dauert zu lange" + }, + "update.overlay.error.timeoutDescription": { + "message": "Die Installation von {target} hat zu lange gedauert und wurde nicht abgeschlossen." + }, + "update.overlay.error.canceledTitle": { + "message": "Update wurde abgebrochen" + }, + "update.overlay.error.canceledDescription": { + "message": "Das Update auf {target} wurde vor dem Abschluss abgebrochen." + }, + "update.overlay.error.failTitle": { + "message": "Update konnte nicht installiert werden" + }, + "update.overlay.error.failDescription": { + "message": "{target} konnte nicht installiert werden." + }, + "update.overlay.error.unknownMessage": { + "message": "unbekannter Fehler" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "die neue Version" + }, + "update.error.loadStateTitle": { + "message": "Laden des Update-Status fehlgeschlagen" + }, + "update.error.triggerTitle": { + "message": "Update-Start fehlgeschlagen" + }, + "update.page.versionLine": { + "message": "Client wird aktualisiert auf: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Client wird aktualisiert." + }, + "update.page.outdated": { + "message": "Ihre Client-Version ist älter als die im Management eingestellte Auto-Update-Version." + }, + "update.page.status.running": { + "message": "Wird aktualisiert" + }, + "update.page.status.timeout": { + "message": "Zeitüberschreitung beim Update. Bitte erneut versuchen." + }, + "update.page.status.canceled": { + "message": "Update abgebrochen." + }, + "update.page.status.failed": { + "message": "Update fehlgeschlagen: {message}" + }, + "update.page.status.unknownError": { + "message": "unbekannter Update-Fehler" + }, + "update.page.failedTitle": { + "message": "Update fehlgeschlagen" + }, + "update.page.timeoutMessage": { + "message": "Zeitüberschreitung beim Update." + }, + "update.page.dontClose": { + "message": "Bitte schließen Sie dieses Fenster nicht." + }, + "update.page.updating": { + "message": "Wird aktualisiert…" + }, + "update.page.complete": { + "message": "Update abgeschlossen" + }, + "update.page.failed": { + "message": "Update fehlgeschlagen" + }, + "window.title.settings": { + "message": "Einstellungen" + }, + "window.title.signIn": { + "message": "Anmeldung" + }, + "window.title.sessionExpiration": { + "message": "Sitzung läuft ab" + }, + "window.title.updating": { + "message": "Aktualisierung" + }, + "window.title.welcome": { + "message": "Willkommen bei NetBird" + }, + "window.title.error": { + "message": "Fehler" + }, + "welcome.title": { + "message": "Suchen Sie NetBird in der Taskleiste" + }, + "welcome.description": { + "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, + "welcome.continue": { + "message": "Weiter" + }, + "welcome.back": { + "message": "Zurück" + }, + "welcome.management.title": { + "message": "NetBird einrichten" + }, + "welcome.management.description": { + "message": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie „Self-hosted“, wenn Sie einen eigenen NetBird-Server haben." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Verbindung zu Ihrem eigenen Management-Server." + }, + "welcome.management.urlLabel": { + "message": "URL des Management-Servers" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist." + }, + "welcome.management.checking": { + "message": "Wird geprüft …" + }, + "browserLogin.title": { + "message": "Anmeldung im Browser abschließen" + }, + "browserLogin.notSeeing": { + "message": "Sehen Sie den Browser-Tab nicht?" + }, + "browserLogin.tryAgain": { + "message": "Erneut versuchen" + }, + "browserLogin.openFailedTitle": { + "message": "Browser konnte nicht geöffnet werden" + }, + "sessionExpiration.title": { + "message": "Sitzung läuft bald ab" + }, + "sessionExpiration.titleLater": { + "message": "Ihre Sitzung läuft ab" + }, + "sessionExpiration.description": { + "message": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich." + }, + "sessionExpiration.descriptionLater": { + "message": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden." + }, + "sessionExpiration.stay": { + "message": "Sitzung erneuern" + }, + "sessionExpiration.authenticate": { + "message": "Anmelden" + }, + "sessionExpiration.logout": { + "message": "Abmelden" + }, + "sessionExpiration.expired": { + "message": "Sitzung abgelaufen" + }, + "sessionExpiration.expiredDescription": { + "message": "Gerät getrennt. Mit Browser-Anmeldung authentifizieren, um erneut zu verbinden." + }, + "sessionExpiration.close": { + "message": "Schließen" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Sitzungsverlängerung fehlgeschlagen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "peers.search.placeholder": { + "message": "Nach Name oder IP suchen" + }, + "peers.filter.all": { + "message": "Alle" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Keine Peers verfügbar" + }, + "peers.empty.description": { + "message": "Sie haben entweder keine Peers verfügbar oder keinen Zugriff auf einen davon." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird-IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird-IPv6" + }, + "peers.details.publicKey": { + "message": "Öffentlicher Schlüssel" + }, + "peers.details.connection": { + "message": "Verbindung" + }, + "peers.details.latency": { + "message": "Latenz" + }, + "peers.details.lastHandshake": { + "message": "Letzter Handshake" + }, + "peers.details.statusSince": { + "message": "Letzte Verbindungsaktualisierung" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Gesendet" + }, + "peers.details.bytesReceived": { + "message": "Empfangen" + }, + "peers.details.localIce": { + "message": "Lokales ICE" + }, + "peers.details.remoteIce": { + "message": "Remote ICE" + }, + "peers.details.never": { + "message": "Nie" + }, + "peers.details.justNow": { + "message": "Gerade eben" + }, + "peers.details.refresh": { + "message": "Aktualisieren" + }, + "peers.status.connected": { + "message": "Verbunden" + }, + "peers.status.connecting": { + "message": "Wird verbunden" + }, + "peers.status.disconnected": { + "message": "Nicht verbunden" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Ressourcen" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass aktiviert" + }, + "networks.search.placeholder": { + "message": "Nach Netzwerk oder Domain suchen" + }, + "networks.filter.all": { + "message": "Alle" + }, + "networks.filter.active": { + "message": "Aktiv" + }, + "networks.filter.overlapping": { + "message": "Überlappend" + }, + "networks.empty.title": { + "message": "Keine Ressourcen verfügbar" + }, + "networks.empty.description": { + "message": "Sie haben entweder keine Netzwerkressourcen verfügbar oder keinen Zugriff auf eine davon." + }, + "networks.selected": { + "message": "Ausgewählt" + }, + "networks.unselected": { + "message": "Nicht ausgewählt" + }, + "networks.ips.heading": { + "message": "Aufgelöste IPs" + }, + "networks.bulk.selectionCount": { + "message": "{selected} von {total} aktiv" + }, + "networks.bulk.enableAll": { + "message": "Alle aktivieren" + }, + "networks.bulk.disableAll": { + "message": "Alle deaktivieren" + }, + "exitNodes.search.placeholder": { + "message": "Exit Nodes suchen" + }, + "exitNodes.none": { + "message": "Keiner" + }, + "exitNodes.empty.title": { + "message": "Keine Exit Nodes verfügbar" + }, + "exitNodes.empty.description": { + "message": "Für diesen Peer wurden keine Exit Nodes freigegeben." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktiv" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktiv" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Keiner" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direkte Verbindung ohne Exit Node" + }, + "quickActions.connect": { + "message": "Verbinden" + }, + "quickActions.disconnect": { + "message": "Trennen" + }, + "daemon.unavailable.title": { + "message": "NetBird-Dienst läuft nicht" + }, + "daemon.unavailable.description": { + "message": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentation" + }, + "daemon.outdated.title": { + "message": "NetBird-Dienst ist veraltet" + }, + "daemon.outdated.description": { + "message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden." + }, + "error.jwt_clock_skew": { + "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." + }, + "error.jwt_expired": { + "message": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.jwt_signature_invalid": { + "message": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator." + }, + "error.session_expired": { + "message": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.invalid_setup_key": { + "message": "Der Setup-Key fehlt oder ist ungültig." + }, + "error.permission_denied": { + "message": "Die Anmeldung wurde vom Server abgelehnt." + }, + "error.daemon_unreachable": { + "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." + }, + "error.unknown": { + "message": "Vorgang fehlgeschlagen." + } +} diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json new file mode 100644 index 000000000..42d40ec30 --- /dev/null +++ b/client/ui/i18n/locales/en/common.json @@ -0,0 +1,1766 @@ +{ + "tray.tooltip": { + "message": "NetBird", + "description": "Hover tooltip on the system-tray / menu-bar icon. Brand name — do not translate." + }, + "tray.status.disconnected": { + "message": "Disconnected", + "description": "Connection status surfaced through the tray icon: the client is not connected to the network." + }, + "tray.status.daemonUnavailable": { + "message": "Not running", + "description": "Tray status: the background NetBird service (daemon) is not running." + }, + "tray.status.error": { + "message": "Error", + "description": "Tray status: the client is in an error state." + }, + "tray.status.connected": { + "message": "Connected", + "description": "Tray status: connected to the NetBird network." + }, + "tray.status.connecting": { + "message": "Connecting", + "description": "Tray status: a connection is being established." + }, + "tray.status.needsLogin": { + "message": "Login required", + "description": "Tray status: the user must sign in before connecting." + }, + "tray.status.loginFailed": { + "message": "Login failed", + "description": "Tray status: the last sign-in attempt failed." + }, + "tray.status.sessionExpired": { + "message": "Session expired", + "description": "Tray status: the authenticated session expired; the user must sign in again." + }, + "tray.session.expiresIn": { + "message": "Session expires in {remaining}", + "description": "Tray row showing time left before the session expires. {remaining} is a human-readable duration such as '5 minutes', built from the tray.session.unit.* strings. Keep {remaining} unchanged." + }, + "tray.session.unit.lessThanMinute": { + "message": "less than a minute", + "description": "Duration fragment substituted into {remaining} (see tray.session.expiresIn). Used when under one minute remains." + }, + "tray.session.unit.minute": { + "message": "1 minute", + "description": "Duration fragment for exactly one minute, substituted into {remaining}." + }, + "tray.session.unit.minutes": { + "message": "{count} minutes", + "description": "Duration fragment for several minutes, substituted into {remaining}. {count} is the number of minutes; keep {count}." + }, + "tray.session.unit.hour": { + "message": "1 hour", + "description": "Duration fragment for exactly one hour, substituted into {remaining}." + }, + "tray.session.unit.hours": { + "message": "{count} hours", + "description": "Duration fragment for several hours. {count} is the number of hours; keep {count}." + }, + "tray.session.unit.day": { + "message": "1 day", + "description": "Duration fragment for exactly one day, substituted into {remaining}." + }, + "tray.session.unit.days": { + "message": "{count} days", + "description": "Duration fragment for several days. {count} is the number of days; keep {count}." + }, + "tray.menu.open": { + "message": "Open NetBird", + "description": "Tray menu item that opens the main NetBird window. Keep short." + }, + "tray.menu.connect": { + "message": "Connect", + "description": "Tray menu item that connects to the network. Keep short." + }, + "tray.menu.disconnect": { + "message": "Disconnect", + "description": "Tray menu item that disconnects from the network. Keep short." + }, + "tray.menu.exitNode": { + "message": "Exit Node", + "description": "Tray submenu title for choosing an exit node (route all traffic through another peer)." + }, + "tray.menu.networks": { + "message": "Resources", + "description": "Tray submenu title listing network resources the user can reach. Labelled 'Resources' in the UI even though the key says networks." + }, + "tray.menu.profiles": { + "message": "Profiles", + "description": "Tray submenu title listing connection profiles." + }, + "tray.menu.manageProfiles": { + "message": "Manage Profiles", + "description": "Tray menu item that opens Settings → Profiles." + }, + "tray.menu.settings": { + "message": "Settings...", + "description": "Tray menu item that opens the Settings window. The trailing '...' signals that a window opens; keep it." + }, + "tray.menu.debugBundle": { + "message": "Create Debug Bundle", + "description": "Tray menu item that generates a diagnostic log bundle for support." + }, + "tray.menu.about": { + "message": "Help & Support", + "description": "Tray submenu title for help and support links." + }, + "tray.menu.github": { + "message": "GitHub", + "description": "Tray menu link to the GitHub repository. Brand name — do not translate." + }, + "tray.menu.documentation": { + "message": "Documentation", + "description": "Tray menu link to the online documentation." + }, + "tray.menu.troubleshoot": { + "message": "Troubleshoot", + "description": "Tray menu link to troubleshooting help." + }, + "tray.menu.downloadLatest": { + "message": "Download latest version", + "description": "Tray menu item to download the latest available version." + }, + "tray.menu.installVersion": { + "message": "Install version {version}", + "description": "Tray menu item to install a specific update. {version} is a version number like 0.30.1; keep {version}." + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}", + "description": "Tray menu row showing the installed UI version. 'GUI' = the graphical app. {version} is a version number; keep it." + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}", + "description": "Tray menu row showing the background-service version. 'Daemon' = the background NetBird service. {version} is a version number; keep it." + }, + "tray.menu.versionUnknown": { + "message": "—", + "description": "Placeholder shown in version rows when the version can't be determined. It is an em dash — leave as-is." + }, + "tray.menu.quit": { + "message": "Quit NetBird", + "description": "Tray menu item that fully exits the app and removes the tray icon. Keep short." + }, + "notify.daemonOutdated.title": { + "message": "NetBird service is outdated", + "description": "Title of the OS desktop notification shown when the running daemon is too old for this UI." + }, + "notify.daemonOutdated.body": { + "message": "Update the NetBird service to use this app.", + "description": "Body of the desktop notification telling the user to upgrade the daemon." + }, + "notify.update.title": { + "message": "NetBird update available", + "description": "Title of the OS desktop notification shown when an app update is available." + }, + "notify.update.body": { + "message": "NetBird {version} is available.", + "description": "Body of the update-available desktop notification. {version} is the new version number; keep it." + }, + "notify.update.enforcedSuffix": { + "message": " Your administrator requires this update.", + "description": "Sentence appended to the update notification body when the admin has made the update mandatory. Note the leading space — keep it." + }, + "notify.error.title": { + "message": "Error", + "description": "Title of a generic error desktop notification." + }, + "notify.error.connect": { + "message": "Failed to connect", + "description": "Error notification body shown when connecting failed." + }, + "notify.error.disconnect": { + "message": "Failed to disconnect", + "description": "Error notification body shown when disconnecting failed." + }, + "notify.error.switchProfile": { + "message": "Failed to switch to {profile}", + "description": "Error notification shown when switching profiles failed. {profile} is the target profile name; keep it." + }, + "notify.error.exitNode": { + "message": "Failed to update exit node {name}", + "description": "Error notification shown when updating the exit node failed. {name} is the exit node name; keep it." + }, + "notify.sessionExpired.title": { + "message": "NetBird session expired", + "description": "Title of the desktop notification shown when the session has expired." + }, + "notify.sessionExpired.body": { + "message": "Your NetBird session has expired. Please log in again.", + "description": "Body of the session-expired desktop notification." + }, + "notify.sessionWarning.title": { + "message": "Session expires soon", + "description": "Title of the desktop notification warning that the session will expire soon." + }, + "notify.sessionWarning.body": { + "message": "Your NetBird session expires in {remaining}. Click Extend now to renew.", + "description": "Body of the session-expiry warning notification. {remaining} is a human-readable duration (see tray.session.unit.*); keep it. 'Extend now' refers to the action button notify.sessionWarning.extend." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Your NetBird session is about to expire. Click Extend now to renew.", + "description": "Generic session-expiry warning body used when the exact remaining time isn't known." + }, + "notify.sessionWarning.extend": { + "message": "Extend now", + "description": "Action button on the session-expiry notification that renews the session. Keep short." + }, + "notify.sessionWarning.dismiss": { + "message": "Dismiss", + "description": "Action button on the session-expiry notification that dismisses it. Keep short." + }, + "notify.sessionWarning.failed": { + "message": "Failed to extend NetBird session", + "description": "Notification shown when renewing the session failed." + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird session extended", + "description": "Title of the notification confirming the session was renewed." + }, + "notify.sessionWarning.successBody": { + "message": "Your session has been refreshed.", + "description": "Body of the notification confirming the session was renewed." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Session deadline rejected", + "description": "Title of the notification shown when the server sent an invalid session deadline." + }, + "notify.sessionDeadlineRejected.body": { + "message": "The server sent an invalid session deadline. Please sign in again.", + "description": "Body explaining the server sent an invalid session deadline and the user must sign in again." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird settings updated", + "description": "Title of the desktop notification shown when an MDM (IT-managed) policy changed the daemon configuration at runtime." + }, + "notify.mdm.policyApplied.body": { + "message": "Your NetBird configuration was updated by your IT policy.", + "description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy." + }, + "common.cancel": { + "message": "Cancel", + "description": "Generic Cancel button label, reused across dialogs. Keep short." + }, + "common.save": { + "message": "Save", + "description": "Generic Save button label. Keep short." + }, + "common.saveChanges": { + "message": "Save Changes", + "description": "Button label to save edited settings. Keep short." + }, + "common.saving": { + "message": "Saving…", + "description": "Button label / status shown while a save is in progress. Ends with an ellipsis." + }, + "common.close": { + "message": "Close", + "description": "Generic Close button label. Keep short." + }, + "common.copy": { + "message": "Copy", + "description": "Generic Copy button label (copy to clipboard). Keep short." + }, + "common.togglePasswordVisibility": { + "message": "Toggle password visibility", + "description": "Accessibility label for the show/hide button inside a password field." + }, + "common.increase": { + "message": "Increase", + "description": "Accessibility label for the increment (+) button on a numeric stepper input." + }, + "common.decrease": { + "message": "Decrease", + "description": "Accessibility label for the decrement (−) button on a numeric stepper input." + }, + "common.delete": { + "message": "Delete", + "description": "Generic Delete button label. Keep short." + }, + "common.create": { + "message": "Create", + "description": "Generic Create button label. Keep short." + }, + "common.add": { + "message": "Add", + "description": "Generic Add button label. Keep short." + }, + "common.remove": { + "message": "Remove", + "description": "Generic Remove button label. Keep short." + }, + "common.refresh": { + "message": "Refresh", + "description": "Generic Refresh button label. Keep short." + }, + "common.loading": { + "message": "Loading…", + "description": "Generic loading indicator text. Ends with an ellipsis." + }, + "common.netbird": { + "message": "NetBird", + "description": "The product name. Brand — do not translate." + }, + "common.noResults.title": { + "message": "Could not find any results", + "description": "Title of the empty state shown when a search or filter returns nothing." + }, + "common.noResults.description": { + "message": "We couldn't find any results. Please try a different search term or change your filters.", + "description": "Body of the no-results empty state, suggesting a different search term or filters." + }, + "notConnected.title": { + "message": "Disconnected", + "description": "Title of the placeholder shown on data screens while disconnected." + }, + "notConnected.description": { + "message": "Connect to NetBird first to view detailed information about your peers, network resources, and exit nodes.", + "description": "Body explaining the user must connect to NetBird first to see peer, resource, and exit-node details." + }, + "connect.status.disconnected": { + "message": "Disconnected", + "description": "Label on the main-window connection toggle: not connected." + }, + "connect.status.connecting": { + "message": "Connecting...", + "description": "Connection toggle label while connecting. Ends with an ellipsis." + }, + "connect.status.connected": { + "message": "Connected", + "description": "Connection toggle label when connected." + }, + "connect.status.disconnecting": { + "message": "Disconnecting...", + "description": "Connection toggle label while disconnecting. Ends with an ellipsis." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon unavailable", + "description": "Connection toggle label when the background service is unavailable. 'Daemon' = background service." + }, + "connect.status.loginRequired": { + "message": "Login required", + "description": "Connection toggle label when sign-in is required before connecting." + }, + "connect.error.loginTitle": { + "message": "Login Failed", + "description": "Error-dialog title shown when sign-in fails. The action-named '… Failed' style is intentional — keep it." + }, + "connect.error.connectTitle": { + "message": "Connect Failed", + "description": "Error-dialog title shown when connecting fails." + }, + "connect.error.disconnectTitle": { + "message": "Disconnect Failed", + "description": "Error-dialog title shown when disconnecting fails." + }, + "nav.peers.title": { + "message": "Peers", + "description": "Navigation label for the Peers section (other devices in the network)." + }, + "nav.peers.description": { + "message": "{connected} of {total} connected", + "description": "Sub-label under Peers showing how many are connected. {connected} and {total} are numbers; keep both." + }, + "nav.resources.title": { + "message": "Resources", + "description": "Navigation label for the Resources section (routed networks)." + }, + "nav.resources.description": { + "message": "{active} of {total} active", + "description": "Sub-label under Resources showing how many are active. {active} and {total} are numbers; keep both." + }, + "nav.exitNode.title": { + "message": "Exit Nodes", + "description": "Navigation label for the Exit Nodes section." + }, + "nav.exitNode.none": { + "message": "Not active", + "description": "Sub-label under Exit Nodes when no exit node is in use." + }, + "nav.exitNode.using": { + "message": "Via {name}", + "description": "Sub-label under Exit Nodes when one is active. {name} is the exit node's name; keep it." + }, + "header.openSettings": { + "message": "Open settings", + "description": "Accessibility label / tooltip for the gear icon that opens Settings." + }, + "header.togglePanel": { + "message": "Toggle side panel", + "description": "Accessibility label / tooltip for the button that shows or hides the side panel." + }, + "profile.selector.loading": { + "message": "Loading...", + "description": "Shown in the profile picker while profiles load. Ends with an ellipsis." + }, + "profile.selector.noProfile": { + "message": "No profile", + "description": "Shown in the profile picker when no profile is selected." + }, + "profile.selector.searchPlaceholder": { + "message": "Search profile by name...", + "description": "Placeholder text in the profile picker's search field." + }, + "profile.selector.emptyTitle": { + "message": "No Profiles Found", + "description": "Title shown in the profile picker when a search matches no profiles." + }, + "profile.selector.emptyDescription": { + "message": "Try a different search term or create a new profile.", + "description": "Body shown when no profiles match the search, suggesting a different term or creating one." + }, + "profile.selector.newProfile": { + "message": "New Profile", + "description": "Button in the profile picker to create a new profile. Keep short." + }, + "profile.selector.moreOptions": { + "message": "More options", + "description": "Accessibility label for the per-profile kebab (⋯) menu." + }, + "profile.selector.deregister": { + "message": "Deregister", + "description": "Per-profile menu action: deregister (sign out of the profile but keep it)." + }, + "profile.selector.delete": { + "message": "Delete", + "description": "Per-profile menu action: delete the profile." + }, + "profile.selector.switchTo": { + "message": "Switch to this profile", + "description": "Tooltip / label for the action that switches to a profile." + }, + "profile.selector.edit": { + "message": "Edit", + "description": "Per-profile menu action: open the edit dialog to rename or change the management server." + }, + "profile.edit.title": { + "message": "Edit Profile", + "description": "Title of the dialog for editing an existing profile." + }, + "profile.edit.submit": { + "message": "Save Changes", + "description": "Submit button on the edit-profile dialog. Keep short." + }, + "profile.dialog.title": { + "message": "Enter Profile Name", + "description": "Title of the dialog for naming a new profile." + }, + "profile.dialog.nameLabel": { + "message": "Profile Name", + "description": "Field label for the profile name." + }, + "profile.dialog.description": { + "message": "Set an easily identifiable name for your profile.", + "description": "Helper text under the profile-name field." + }, + "profile.dialog.placeholder": { + "message": "e.g. Work", + "description": "Example placeholder shown in the profile-name field. 'Work' is a sample value; translate it to a natural example." + }, + "profile.dialog.submit": { + "message": "Add Profile", + "description": "Submit button on the add-profile dialog. Keep short." + }, + "profile.dialog.required": { + "message": "Please enter a profile name, e.g. work, home", + "description": "Validation message shown when the profile name is empty. The examples (work, home) may be localized." + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud or your own server.", + "description": "Helper text noting the profile can use NetBird Cloud or a self-hosted server." + }, + "profile.dialog.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.", + "description": "Soft warning when the entered server URL couldn't be reached; the user may add the profile anyway." + }, + "header.menu.settings": { + "message": "Settings...", + "description": "'More' menu item in the header that opens Settings. The trailing '...' signals a window opens." + }, + "header.menu.defaultView": { + "message": "Default View", + "description": "'More' menu item that switches the main window to the compact default view." + }, + "header.menu.advancedView": { + "message": "Advanced View", + "description": "'More' menu item that switches the main window to the wider advanced view." + }, + "header.menu.updateAvailable": { + "message": "Update Available", + "description": "'More' menu item / badge shown when an update is available." + }, + "header.menu.open": { + "message": "Open menu", + "description": "Accessibility label for the header's more (⋮) button that opens the menu." + }, + "header.profile.switch": { + "message": "Switch profile", + "description": "Accessibility label for the header's profile selector button." + }, + "connect.toggle.label": { + "message": "Toggle NetBird connection", + "description": "Accessibility label for the large connect/disconnect toggle on the main page." + }, + "connect.localIp.label": { + "message": "Local IP addresses", + "description": "Accessibility label for the local IP selector that toggles between IPv4 and IPv6 on the main page." + }, + "common.search": { + "message": "Search", + "description": "Accessibility label for a generic search input." + }, + "common.filter": { + "message": "Filter", + "description": "Accessibility label for a generic filter control." + }, + "exitNodes.dropdown.trigger": { + "message": "Select exit node", + "description": "Accessibility label for the exit-node picker button at the bottom of the main page." + }, + "peers.row.label": { + "message": "Open details for {name}, {status}", + "description": "Accessibility label for a peer row in the list. {name} is the peer name and {status} is the connection status; keep both placeholders." + }, + "peers.dialog.title": { + "message": "Peer details", + "description": "Accessibility title (announced to screen readers) for the peer details dialog/panel." + }, + "networks.row.toggle": { + "message": "Toggle {name}", + "description": "Accessibility label for the row-wide toggle on a network/resource. {name} is the resource id; keep the placeholder." + }, + "networks.bulk.label": { + "message": "Toggle all visible resources", + "description": "Accessibility label for the bulk enable/disable button at the bottom of the resources list." + }, + "profile.switch.title": { + "message": "Switch Profile to \"{name}\"?", + "description": "Confirmation-dialog title for switching profiles. {name} is the target profile name, shown in quotes; keep {name} and the surrounding quotes." + }, + "profile.switch.message": { + "message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.", + "description": "Confirmation body for switching profiles. Contains a line break (\\n) — keep it." + }, + "profile.switch.confirm": { + "message": "Confirm", + "description": "Confirm button on the switch-profile dialog. Keep short." + }, + "profile.deregister.title": { + "message": "Deregister Profile \"{name}\"?", + "description": "Confirmation-dialog title for deregistering a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.deregister.message": { + "message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.", + "description": "Confirmation body for deregistering; warns the user must sign in again. Contains a line break (\\n) — keep it." + }, + "profile.deregister.confirm": { + "message": "Deregister", + "description": "Confirm button on the deregister dialog. Keep short." + }, + "profile.delete.title": { + "message": "Delete Profile \"{name}\"?", + "description": "Confirmation-dialog title for deleting a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.delete.message": { + "message": "Are you sure you want to delete this profile?\nThis action cannot be undone.", + "description": "Confirmation body for deleting; warns the action can't be undone. Contains a line break (\\n) — keep it." + }, + "profile.delete.disabledActive": { + "message": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.", + "description": "Tooltip explaining the active profile can't be deleted until the user switches away." + }, + "profile.delete.disabledDefault": { + "message": "The default profile cannot be deleted.", + "description": "Tooltip explaining the default profile can't be deleted." + }, + "profile.error.switchTitle": { + "message": "Switch Profile Failed", + "description": "Error-dialog title when switching profiles fails." + }, + "profile.error.deregisterTitle": { + "message": "Deregister Profile Failed", + "description": "Error-dialog title when deregistering a profile fails." + }, + "profile.error.deleteTitle": { + "message": "Delete Profile Failed", + "description": "Error-dialog title when deleting a profile fails." + }, + "profile.error.createTitle": { + "message": "Create Profile Failed", + "description": "Error-dialog title when creating a profile fails." + }, + "profile.error.editTitle": { + "message": "Edit Profile Failed", + "description": "Error-dialog title when editing a profile (rename or management URL change) fails." + }, + "profile.error.loadTitle": { + "message": "Load Profiles Failed", + "description": "Error-dialog title when loading profiles fails." + }, + "profile.dropdown.activeProfile": { + "message": "Active profile", + "description": "Section heading in the header profile dropdown for the current profile." + }, + "profile.dropdown.switchProfile": { + "message": "Switch Profile", + "description": "Section heading / action in the profile dropdown to switch profiles." + }, + "profile.dropdown.noEmail": { + "message": "Other", + "description": "Label shown for a profile that has no associated email address." + }, + "profile.dropdown.addProfile": { + "message": "Add Profile", + "description": "Profile dropdown action to add a profile." + }, + "profile.dropdown.manageProfiles": { + "message": "Manage Profiles", + "description": "Profile dropdown action that opens Settings → Profiles." + }, + "profile.dropdown.settings": { + "message": "Settings", + "description": "Profile dropdown action that opens Settings." + }, + "settings.profiles.section.profiles": { + "message": "Profiles", + "description": "Section heading on the Profiles settings tab." + }, + "settings.profiles.intro": { + "message": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.", + "description": "Intro paragraph on the Profiles settings tab explaining what profiles are for." + }, + "settings.profiles.addProfile": { + "message": "Add Profile", + "description": "Button on the Profiles settings tab to add a profile." + }, + "settings.profiles.active": { + "message": "Active", + "description": "Badge marking the currently active profile in the profiles table." + }, + "settings.profiles.emptyTitle": { + "message": "No Profiles", + "description": "Title of the empty state when there are no profiles." + }, + "settings.profiles.emptyDescription": { + "message": "Create a profile to connect to a NetBird management server.", + "description": "Body of the no-profiles empty state." + }, + "settings.error.loadTitle": { + "message": "Load Settings Failed", + "description": "Error-dialog title when loading settings fails." + }, + "settings.error.saveTitle": { + "message": "Save Settings Failed", + "description": "Error-dialog title when saving settings fails." + }, + "settings.error.debugBundleTitle": { + "message": "Debug Bundle Failed", + "description": "Error-dialog title when creating the debug bundle fails." + }, + "settings.nav.label": { + "message": "Settings sections", + "description": "Accessibility label for the Settings page's side navigation (list of section tabs)." + }, + "settings.tabs.general": { + "message": "General", + "description": "Settings tab label: General. Keep short." + }, + "settings.tabs.network": { + "message": "Network", + "description": "Settings tab label: Network. Keep short." + }, + "settings.tabs.security": { + "message": "Security", + "description": "Settings tab label: Security. Keep short." + }, + "settings.tabs.profiles": { + "message": "Profiles", + "description": "Settings tab label: Profiles. Keep short." + }, + "settings.tabs.ssh": { + "message": "SSH", + "description": "Settings tab label: SSH. Acronym — keep as-is." + }, + "settings.tabs.advanced": { + "message": "Advanced", + "description": "Settings tab label: Advanced. Keep short." + }, + "settings.tabs.troubleshooting": { + "message": "Troubleshoot", + "description": "Settings tab label: Troubleshoot. Keep short." + }, + "settings.tabs.about": { + "message": "About", + "description": "Settings tab label: About. Keep short." + }, + "settings.tabs.updateAvailable": { + "message": "Update Available", + "description": "Settings tab label / badge shown when an update is available." + }, + "settings.general.section.general": { + "message": "General", + "description": "Section heading on the General settings tab." + }, + "settings.general.section.connection": { + "message": "Connection", + "description": "Section heading for connection-related options on the General tab." + }, + "settings.general.connectOnStartup.label": { + "message": "Connect on Startup", + "description": "Toggle label: connect automatically when the service starts." + }, + "settings.general.connectOnStartup.help": { + "message": "Automatically establish a connection when the service starts.", + "description": "Helper text for the connect-on-startup toggle." + }, + "settings.general.notifications.label": { + "message": "Desktop Notifications", + "description": "Toggle label: enable desktop notifications." + }, + "settings.general.notifications.help": { + "message": "Show desktop notifications for new updates and connection events.", + "description": "Helper text for the desktop-notifications toggle." + }, + "settings.general.autostart.label": { + "message": "Launch NetBird UI at Login", + "description": "Toggle label: launch the NetBird UI at login." + }, + "settings.general.autostart.help": { + "message": "Start the NetBird interface automatically when you log in. This affects the graphical interface only, not the background service.", + "description": "Helper text clarifying autostart affects only the UI, not the background service." + }, + "settings.general.autostart.errorTitle": { + "message": "Autostart Change Failed", + "description": "Error-dialog title when changing the autostart setting fails." + }, + "settings.general.language.label": { + "message": "Display Language", + "description": "Label for the display-language picker." + }, + "settings.general.language.help": { + "message": "Choose the language for the NetBird interface.", + "description": "Helper text for the language picker." + }, + "settings.general.language.search": { + "message": "Search language…", + "description": "Placeholder in the language picker's search field." + }, + "settings.general.language.empty": { + "message": "No languages match.", + "description": "Shown when no languages match the search." + }, + "settings.general.management.label": { + "message": "Management Server", + "description": "Label for the management-server selector." + }, + "settings.general.management.help": { + "message": "Connect to NetBird Cloud or your own self-hosted management server. Changes will reconnect the client.", + "description": "Helper text explaining Cloud vs self-hosted management server; warns that changes reconnect the client." + }, + "settings.general.management.cloud": { + "message": "Cloud", + "description": "Option label for using NetBird Cloud as the management server." + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted", + "description": "Option label for using a self-hosted management server. 'Self-hosted' is a common technical term." + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder for the self-hosted server field. It is a sample URL — do not translate the URL itself." + }, + "settings.general.management.urlError": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid management-server URL. The example URL stays as-is." + }, + "settings.general.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.", + "description": "Soft warning when the management URL couldn't be reached; the user may save anyway." + }, + "settings.general.management.switchCloudTitle": { + "message": "Switch to NetBird Cloud?", + "description": "Confirmation-dialog title for switching to NetBird Cloud." + }, + "settings.general.management.switchCloudMessage": { + "message": "This disconnects your self-hosted server.\nYou may need to log in again.", + "description": "Confirmation body warning the self-hosted server will be disconnected. Contains a line break (\\n) — keep it." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Switch to Cloud", + "description": "Confirm button for switching to Cloud. Keep short." + }, + "settings.network.section.connectivity": { + "message": "Connectivity", + "description": "Section heading for connectivity options on the Network tab." + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS", + "description": "Section heading for routing and DNS options. 'DNS' is an acronym — keep it." + }, + "settings.network.monitor.label": { + "message": "Reconnect on Network Change", + "description": "Toggle label: reconnect automatically on network change." + }, + "settings.network.monitor.help": { + "message": "Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep.", + "description": "Helper text for the network-change reconnect toggle." + }, + "settings.network.dns.label": { + "message": "Enable DNS", + "description": "Toggle label: enable DNS. 'DNS' — keep acronym." + }, + "settings.network.dns.help": { + "message": "Apply NetBird-managed DNS settings to the host resolver.", + "description": "Helper text for the enable-DNS toggle." + }, + "settings.network.clientRoutes.label": { + "message": "Enable Client Routes", + "description": "Toggle label: enable client routes." + }, + "settings.network.clientRoutes.help": { + "message": "Accept routes from other peers to reach their networks.", + "description": "Helper text for client routes (accept routes from other peers)." + }, + "settings.network.serverRoutes.label": { + "message": "Enable Server Routes", + "description": "Toggle label: enable server routes." + }, + "settings.network.serverRoutes.help": { + "message": "Advertise this host's local routes to other peers.", + "description": "Helper text for server routes (advertise this host's local routes to other peers)." + }, + "settings.network.ipv6.label": { + "message": "Enable IPv6", + "description": "Toggle label: enable IPv6. 'IPv6' — keep as-is." + }, + "settings.network.ipv6.help": { + "message": "Use IPv6 addressing for the NetBird overlay network.", + "description": "Helper text for the IPv6 toggle." + }, + "settings.security.section.firewall": { + "message": "Firewall", + "description": "Section heading: Firewall." + }, + "settings.security.section.encryption": { + "message": "Encryption", + "description": "Section heading: Encryption." + }, + "settings.security.blockInbound.label": { + "message": "Block Inbound Traffic", + "description": "Toggle label: block inbound traffic." + }, + "settings.security.blockInbound.help": { + "message": "Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected.", + "description": "Helper text for blocking inbound traffic." + }, + "settings.security.blockLan.label": { + "message": "Block LAN Access", + "description": "Toggle label: block LAN access. 'LAN' — keep acronym." + }, + "settings.security.blockLan.help": { + "message": "Prevent peers from reaching your local network or its devices when this device routes their traffic.", + "description": "Helper text for blocking LAN access." + }, + "settings.security.rosenpass.label": { + "message": "Enable Quantum-Resistance", + "description": "Toggle label: enable quantum-resistance." + }, + "settings.security.rosenpass.help": { + "message": "Add a post-quantum key exchange via Rosenpass on top of WireGuard®.", + "description": "Helper text: adds a post-quantum key exchange via Rosenpass on top of WireGuard®. 'Rosenpass' and 'WireGuard®' are product names — do not translate; keep the ® symbol." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Enable Permissive Mode", + "description": "Toggle label: enable permissive mode (for quantum-resistance)." + }, + "settings.security.rosenpassPermissive.help": { + "message": "Allow connections to peers without quantum-resistance support.", + "description": "Helper text for permissive mode (allow peers without quantum-resistance support)." + }, + "settings.ssh.section.server": { + "message": "Server", + "description": "Section heading: Server (SSH settings)." + }, + "settings.ssh.section.capabilities": { + "message": "Capabilities", + "description": "Section heading: Capabilities (SSH features)." + }, + "settings.ssh.section.authentication": { + "message": "Authentication", + "description": "Section heading: Authentication (SSH)." + }, + "settings.ssh.server.label": { + "message": "Enable SSH Server", + "description": "Toggle label: enable the SSH server." + }, + "settings.ssh.server.help": { + "message": "Run the NetBird SSH server on this host so other peers can connect to it.", + "description": "Helper text for the SSH server toggle." + }, + "settings.ssh.root.label": { + "message": "Allow Root Login", + "description": "Toggle label: allow root login over SSH. 'root' is the Unix superuser account — keep as-is." + }, + "settings.ssh.root.help": { + "message": "Let peers sign in as the root user. Disable to require a non-privileged account.", + "description": "Helper text for allowing root login." + }, + "settings.ssh.sftp.label": { + "message": "Allow SFTP", + "description": "Toggle label: allow SFTP. 'SFTP' — keep acronym." + }, + "settings.ssh.sftp.help": { + "message": "Transfer files securely using native SFTP or SCP clients.", + "description": "Helper text about SFTP/SCP file transfer. Keep the acronyms." + }, + "settings.ssh.localForward.label": { + "message": "Local Port Forwarding", + "description": "Toggle label: local port forwarding." + }, + "settings.ssh.localForward.help": { + "message": "Let connecting peers tunnel local ports to services reachable from this host.", + "description": "Helper text for local port forwarding." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote Port Forwarding", + "description": "Toggle label: remote port forwarding." + }, + "settings.ssh.remoteForward.help": { + "message": "Let connecting peers expose ports on this host back to their own machine.", + "description": "Helper text for remote port forwarding." + }, + "settings.ssh.jwt.label": { + "message": "Enable JWT Authentication", + "description": "Toggle label: enable JWT authentication. 'JWT' — keep acronym." + }, + "settings.ssh.jwt.help": { + "message": "Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available.", + "description": "Helper text for JWT auth. 'IdP' (identity provider) and 'ACL' are acronyms — keep them." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT Cache TTL", + "description": "Label for the JWT cache time-to-live field. 'JWT' and 'TTL' — keep acronyms." + }, + "settings.ssh.jwtTtl.help": { + "message": "How long this client caches a JWT before prompting again on outgoing SSH connections. Set to 0 to disable caching and authenticate on every connection.", + "description": "Helper text for the JWT cache TTL; mentions setting 0 to disable caching." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Second(s)", + "description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural." + }, + "settings.advanced.section.interface": { + "message": "Interface", + "description": "Section heading: Interface (network-interface settings)." + }, + "settings.advanced.section.security": { + "message": "Security", + "description": "Section heading: Security (advanced)." + }, + "settings.advanced.interfaceName.label": { + "message": "Name", + "description": "Field label for the WireGuard interface name." + }, + "settings.advanced.interfaceName.error": { + "message": "Use 1-15 letters, digits, dots, hyphens, or underscores.", + "description": "Validation message for the interface name (allowed characters)." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Must start with \"utun\" followed by a number (e.g. utun100).", + "description": "Validation message specific to macOS, where the name must start with 'utun' followed by a number. Keep 'utun' and the example." + }, + "settings.advanced.port.label": { + "message": "Port", + "description": "Field label: Port." + }, + "settings.advanced.port.error": { + "message": "Enter a port between {min} and {max}.", + "description": "Validation message for the port range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.port.help": { + "message": "If set to 0, a random free port will be used.", + "description": "Helper text: 0 means a random free port is used." + }, + "settings.advanced.mtu.label": { + "message": "MTU", + "description": "Field label: MTU. 'MTU' — keep acronym." + }, + "settings.advanced.mtu.error": { + "message": "Enter an MTU value between {min} and {max}.", + "description": "Validation message for the MTU range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key", + "description": "Field label: Pre-shared Key." + }, + "settings.advanced.psk.help": { + "message": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.", + "description": "Helper text for the WireGuard PSK. 'WireGuard', 'PSK', and 'NetBird Setup Key' are product/technical terms — keep them." + }, + "settings.troubleshooting.section.title": { + "message": "Debug bundle", + "description": "Section heading: Debug bundle." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymize Sensitive Information", + "description": "Toggle label: anonymize sensitive information in the bundle." + }, + "settings.troubleshooting.anonymize.help": { + "message": "Hides public IP addresses and non-NetBird domains from logs.", + "description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Include System Information", + "description": "Toggle label: include system information in the bundle." + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, network interfaces, and routing tables.", + "description": "Helper text listing the system info included (OS, kernel, interfaces, routing tables)." + }, + "settings.troubleshooting.upload.label": { + "message": "Upload Bundle to NetBird Servers", + "description": "Toggle label: upload the bundle to NetBird servers." + }, + "settings.troubleshooting.upload.help": { + "message": "Returns an upload key to share with NetBird support.", + "description": "Helper text for uploading the bundle." + }, + "settings.troubleshooting.trace.label": { + "message": "Enable Trace Logs", + "description": "Toggle label: raise daemon log level to TRACE while the bundle is built. 'TRACE' is a log level." + }, + "settings.troubleshooting.trace.help": { + "message": "Raises the log level to TRACE and restores it after.", + "description": "Helper text for the trace toggle. 'TRACE' is a log level — keep as-is." + }, + "settings.troubleshooting.capture.label": { + "message": "Capture Session", + "description": "Toggle label: open a capture session — reconnect NetBird, wait a duration, optionally record packets." + }, + "settings.troubleshooting.capture.help": { + "message": "Reconnects and waits so you can reproduce the issue.", + "description": "Helper text for the master Capture Session toggle." + }, + "settings.troubleshooting.packets.label": { + "message": "Capture Network Packets", + "description": "Toggle label: capture packets to a .pcap during the capture session." + }, + "settings.troubleshooting.packets.help": { + "message": "Saves a .pcap of network traffic during the capture window.", + "description": "Helper text for the packet recording toggle. '.pcap' is a file extension — keep it." + }, + "settings.troubleshooting.duration.label": { + "message": "Capture Duration", + "description": "Label for the trace-capture duration field." + }, + "settings.troubleshooting.duration.help": { + "message": "How long the capture session runs.", + "description": "Helper text for the capture duration." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(s)", + "description": "Unit suffix after the duration field. The '(s)' marks an optional plural." + }, + "settings.troubleshooting.create": { + "message": "Create Bundle", + "description": "Button to create the debug bundle. Keep short." + }, + "settings.troubleshooting.progress.description": { + "message": "Collecting logs, system details, and connection state. This usually takes a moment. You can keep using NetBird or close Settings while it finishes.", + "description": "Status text shown while the debug bundle is being collected; reassures the user they can keep using NetBird or close Settings meanwhile." + }, + "settings.troubleshooting.cancelling": { + "message": "Canceling…", + "description": "Status shown while cancelling bundle creation. Ends with an ellipsis." + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug bundle successfully uploaded!", + "description": "Success title after the bundle was uploaded." + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Bundle saved", + "description": "Title shown when the bundle was saved locally (not uploaded)." + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Share the upload key below with NetBird support. A local copy was also saved on your device.", + "description": "Body after upload, telling the user to share the upload key with support. '' wraps the inline link to the NetBird support docs — keep the tags exactly and wrap the phrase that means 'NetBird support'." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Your debug bundle has been saved locally.", + "description": "Body shown when the bundle was only saved locally." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copy Key", + "description": "Button to copy the upload key. Keep short." + }, + "settings.troubleshooting.done.openFolder": { + "message": "Open Folder", + "description": "Button to open the folder containing the bundle. Keep short." + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Open file location", + "description": "Button to reveal the bundle file in the OS file manager." + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload failed: {reason} The bundle is still saved locally.", + "description": "Shown when upload failed but the bundle was saved locally. {reason} is the server error text; keep it." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload failed. The bundle is still saved locally.", + "description": "Shown when upload failed (no specific reason) but the bundle was saved locally." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnecting NetBird…", + "description": "Progress stage: reconnecting NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturing debug logs", + "description": "Progress stage: capturing logs. {elapsed} and {total} are time values (e.g. 0:30 / 2:00); keep both." + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generating debug bundle…", + "description": "Progress stage: generating the debug bundle. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.uploading": { + "message": "Uploading to NetBird…", + "description": "Progress stage: uploading to NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Canceling…", + "description": "Progress stage: cancelling. Ends with an ellipsis." + }, + "settings.about.client": { + "message": "NetBird Client v{version}", + "description": "About tab: client product name and version. {version} is a version number; keep it. 'NetBird Client' — keep brand." + }, + "settings.about.clientName": { + "message": "NetBird Client", + "description": "About tab: the client product name shown when no version is available. Brand — do not translate." + }, + "settings.about.development": { + "message": "[Development]", + "description": "Badge shown next to the version on development builds. Keep the square brackets." + }, + "settings.about.gui": { + "message": "GUI v{version}", + "description": "About tab: UI component name and version. {version} is a version number; keep it. 'GUI' = the graphical app." + }, + "settings.about.guiName": { + "message": "GUI", + "description": "About tab: the UI component name shown without a version. 'GUI' = the graphical app." + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved.", + "description": "Copyright line. {year} is the current year; keep it. 'NetBird' — brand." + }, + "settings.about.links.imprint": { + "message": "Imprint", + "description": "Footer link: Imprint (legal notice / Impressum)." + }, + "settings.about.links.privacy": { + "message": "Privacy", + "description": "Footer link: Privacy policy." + }, + "settings.about.links.cla": { + "message": "CLA", + "description": "Footer link: CLA (Contributor License Agreement). Acronym — keep as-is." + }, + "settings.about.links.terms": { + "message": "Terms of Service", + "description": "Footer link: Terms of Service." + }, + "settings.about.community.github": { + "message": "GitHub", + "description": "Community link: GitHub. Brand — do not translate." + }, + "settings.about.community.slack": { + "message": "Slack", + "description": "Community link: Slack. Brand — do not translate." + }, + "settings.about.community.forum": { + "message": "Forum", + "description": "Community link: Forum." + }, + "settings.about.community.documentation": { + "message": "Documentation", + "description": "Community link: Documentation." + }, + "settings.about.community.feedback": { + "message": "Feedback", + "description": "Community link: Feedback." + }, + "update.banner.message": { + "message": "NetBird {version} is ready to install.", + "description": "Update banner text when a new version is ready to install. {version} is the version number; keep it." + }, + "update.banner.later": { + "message": "Later", + "description": "Banner button to postpone the update. Keep short." + }, + "update.banner.installNow": { + "message": "Install now", + "description": "Banner button to install the update now. Keep short." + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} is available for download.", + "description": "Update card text when a version is available to download. {version} is the version number; keep it." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} is available for install.", + "description": "Update card text when a version is available to install. {version} is the version number; keep it." + }, + "update.card.whatsNew": { + "message": "What's new?", + "description": "Link / label that opens the release notes." + }, + "update.card.installNow": { + "message": "Install Now", + "description": "Update card button: install now. Keep short." + }, + "update.card.getInstaller": { + "message": "Download", + "description": "Update card button: download the installer." + }, + "update.card.autoCheckInterval": { + "message": "NetBird checks for updates in the background.", + "description": "Note that NetBird checks for updates in the background." + }, + "update.card.changelog": { + "message": "Changelog", + "description": "Link / label: Changelog." + }, + "update.card.onLatestVersion": { + "message": "You're on the latest version", + "description": "Shown when the client is already up to date." + }, + "update.header.tooltip": { + "message": "Update Available", + "description": "Tooltip on the header update badge." + }, + "update.overlay.updatingVersion": { + "message": "Updating NetBird to v{version}", + "description": "Heading in the install-progress window while updating to a specific version. {version} is the version number; keep it. The 'v' prefix is part of the version display." + }, + "update.overlay.updating": { + "message": "Updating NetBird", + "description": "Heading in the install-progress window while updating when the target version isn't known." + }, + "update.overlay.description": { + "message": "A newer version is available and is being installed. NetBird will restart automatically once the update is finished.", + "description": "Install-progress window body explaining NetBird will restart automatically after the update." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update Is Taking Too Long", + "description": "Install-progress window error title: the update took too long." + }, + "update.overlay.error.timeoutDescription": { + "message": "Installing {target} took too long and didn't finish.", + "description": "Install-progress window error body for a timeout. {target} is the target-version label (see update.overlay.error.targetVersion / targetFallback); keep it." + }, + "update.overlay.error.canceledTitle": { + "message": "Update Was Stopped", + "description": "Install-progress window error title: the update was stopped / canceled." + }, + "update.overlay.error.canceledDescription": { + "message": "The update to {target} was canceled before it finished.", + "description": "Install-progress window error body for a canceled update. {target} is the target-version label; keep it." + }, + "update.overlay.error.failTitle": { + "message": "Couldn't Install the Update", + "description": "Install-progress window error title: the update couldn't be installed." + }, + "update.overlay.error.failDescription": { + "message": "{target} couldn't be installed.", + "description": "Install-progress window error body for a failed install. {target} is the target-version label; keep it." + }, + "update.overlay.error.unknownMessage": { + "message": "unknown error", + "description": "Fallback text for an unspecified error, used inside other messages." + }, + "update.overlay.error.targetVersion": { + "message": "v{version}", + "description": "The target-version label substituted into {target}. {version} is a number; keep the 'v' prefix." + }, + "update.overlay.error.targetFallback": { + "message": "the new version", + "description": "Fallback target label used when the version isn't known, substituted into {target}." + }, + "update.error.loadStateTitle": { + "message": "Load Update State Failed", + "description": "Error-dialog title when loading update state fails." + }, + "update.error.triggerTitle": { + "message": "Start Update Failed", + "description": "Error-dialog title when starting the update fails." + }, + "update.page.versionLine": { + "message": "Updating client to: {version}.", + "description": "Install-progress window text naming the target version. {version} is the version number; keep it." + }, + "update.page.versionLineGeneric": { + "message": "Updating client.", + "description": "Install-progress text used when the target version isn't known." + }, + "update.page.outdated": { + "message": "Your client version is older than the auto-update version set in Management.", + "description": "Note that the client is older than the auto-update version set in Management. 'Management' = the NetBird management server / console." + }, + "update.page.status.running": { + "message": "Updating", + "description": "Install status: updating." + }, + "update.page.status.timeout": { + "message": "Update timed out. Please try again.", + "description": "Install status: timed out; asks the user to retry." + }, + "update.page.status.canceled": { + "message": "Update canceled.", + "description": "Install status: canceled." + }, + "update.page.status.failed": { + "message": "Update failed: {message}", + "description": "Install status: failed. {message} is the error detail; keep it." + }, + "update.page.status.unknownError": { + "message": "unknown update error", + "description": "Fallback text for an unknown update error, used inside update.page.status.failed." + }, + "update.page.failedTitle": { + "message": "Update Failed", + "description": "Heading shown when the install failed." + }, + "update.page.timeoutMessage": { + "message": "Update timed out.", + "description": "Message shown when the install timed out." + }, + "update.page.dontClose": { + "message": "Please don't close this window.", + "description": "Warning asking the user not to close the install window." + }, + "update.page.updating": { + "message": "Updating…", + "description": "Short status: updating. Ends with an ellipsis." + }, + "update.page.complete": { + "message": "Update complete", + "description": "Short status: update complete." + }, + "update.page.failed": { + "message": "Update failed", + "description": "Short status: update failed." + }, + "window.title.settings": { + "message": "Settings", + "description": "OS window-chrome title for the Settings window. The app prefixes it with 'NetBird - '." + }, + "window.title.signIn": { + "message": "Sign-in", + "description": "OS window-chrome title for the sign-in window." + }, + "window.title.sessionExpiration": { + "message": "Session Expiring", + "description": "OS window-chrome title for the session-expiration window." + }, + "window.title.updating": { + "message": "Updating", + "description": "OS window-chrome title for the update / install window." + }, + "window.title.welcome": { + "message": "Welcome to NetBird", + "description": "OS window-chrome title for the first-launch welcome window. 'NetBird' — brand." + }, + "window.title.error": { + "message": "Error", + "description": "OS window-chrome title for the error window." + }, + "welcome.title": { + "message": "Look for NetBird in your tray", + "description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar." + }, + "welcome.description": { + "message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step explaining the tray icon." + }, + "welcome.continue": { + "message": "Continue", + "description": "Primary button to advance the onboarding. Keep short." + }, + "welcome.back": { + "message": "Back", + "description": "Button to go back a step in onboarding. Keep short." + }, + "welcome.management.title": { + "message": "Set up NetBird", + "description": "Heading on the onboarding step for choosing a management server." + }, + "welcome.management.description": { + "message": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.", + "description": "Body of the management step; mentions choosing Self-hosted if the user runs their own server." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud", + "description": "Option title: NetBird Cloud. Brand — do not translate 'NetBird Cloud'." + }, + "welcome.management.cloud.description": { + "message": "Use our hosted service. No setup required.", + "description": "Option body for NetBird Cloud (hosted service, no setup required)." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted", + "description": "Option title: Self-hosted." + }, + "welcome.management.selfHosted.description": { + "message": "Connect to your own management server.", + "description": "Option body for connecting to your own management server." + }, + "welcome.management.urlLabel": { + "message": "Management server URL", + "description": "Field label for the management-server URL." + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder. Sample URL — do not translate it." + }, + "welcome.management.urlInvalid": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid URL. The example URL stays as-is." + }, + "welcome.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.", + "description": "Soft warning when the server couldn't be reached during onboarding; the user may continue anyway." + }, + "welcome.management.checking": { + "message": "Checking…", + "description": "Status shown while checking the server URL. Ends with an ellipsis." + }, + "browserLogin.title": { + "message": "Complete login in your browser", + "description": "Heading telling the user to finish signing in via their browser." + }, + "browserLogin.notSeeing": { + "message": "We opened a browser tab so you can finish signing in. Not seeing it?", + "description": "Prompt asking whether the user doesn't see the opened browser tab." + }, + "browserLogin.tryAgain": { + "message": "Try again", + "description": "Button to re-open the browser sign-in page. Keep short." + }, + "browserLogin.openFailedTitle": { + "message": "Open Browser Failed", + "description": "Error-dialog title when the browser couldn't be opened." + }, + "sessionExpiration.title": { + "message": "Session expiring soon", + "description": "Heading warning the session will expire soon." + }, + "sessionExpiration.titleLater": { + "message": "Your session will expire", + "description": "Alternate, less-urgent heading: the session will expire." + }, + "sessionExpiration.description": { + "message": "This device will disconnect soon. Renew with a browser sign-in.", + "description": "Body warning the device will disconnect soon; renew via a browser sign-in." + }, + "sessionExpiration.descriptionLater": { + "message": "A browser sign-in keeps this device connected to your network.", + "description": "Body for the less-urgent variant; a browser sign-in keeps the device connected." + }, + "sessionExpiration.stay": { + "message": "Renew session", + "description": "Button to renew the session (keep connected). Keep short." + }, + "sessionExpiration.authenticate": { + "message": "Authenticate", + "description": "Button to authenticate / sign in. Keep short." + }, + "sessionExpiration.logout": { + "message": "Logout", + "description": "Button to log out. Keep short." + }, + "sessionExpiration.expired": { + "message": "Session expired", + "description": "Heading shown once the session has actually expired." + }, + "sessionExpiration.expiredDescription": { + "message": "Device disconnected. Authenticate with a browser sign-in to reconnect.", + "description": "Body shown after expiry; authenticate via a browser sign-in to reconnect." + }, + "sessionExpiration.close": { + "message": "Close", + "description": "Close button on the session window. Keep short." + }, + "sessionExpiration.extendFailedTitle": { + "message": "Extend Session Failed", + "description": "Error-dialog title when extending the session fails." + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Logout Failed", + "description": "Error-dialog title when logging out fails." + }, + "peers.search.placeholder": { + "message": "Search by name or IP", + "description": "Placeholder in the peers search field." + }, + "peers.filter.all": { + "message": "All", + "description": "Peers filter option: All. Keep short." + }, + "peers.filter.online": { + "message": "Online", + "description": "Peers filter option: Online. Keep short." + }, + "peers.filter.offline": { + "message": "Offline", + "description": "Peers filter option: Offline. Keep short." + }, + "peers.empty.title": { + "message": "No peers available", + "description": "Title of the empty state when no peers are available." + }, + "peers.empty.description": { + "message": "You either don't have any peers available or have no access to any of them.", + "description": "Body of the no-peers empty state." + }, + "peers.details.domain": { + "message": "Domain", + "description": "Peer detail label: Domain." + }, + "peers.details.netbirdIp": { + "message": "NetBird IP", + "description": "Peer detail label: NetBird IP address. 'NetBird' — brand; 'IP' — keep acronym." + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6", + "description": "Peer detail label: NetBird IPv6 address. Keep 'NetBird' and 'IPv6'." + }, + "peers.details.publicKey": { + "message": "Public key", + "description": "Peer detail label: WireGuard public key." + }, + "peers.details.connection": { + "message": "Connection", + "description": "Peer detail label: Connection." + }, + "peers.details.latency": { + "message": "Latency", + "description": "Peer detail label: Latency." + }, + "peers.details.lastHandshake": { + "message": "Last handshake", + "description": "Peer detail label: time of the last WireGuard handshake." + }, + "peers.details.statusSince": { + "message": "Last connection update", + "description": "Peer detail label: time since the last connection-status update." + }, + "peers.details.bytes": { + "message": "Bytes", + "description": "Peer detail label: Bytes (data transferred)." + }, + "peers.details.bytesSent": { + "message": "Sent", + "description": "Peer detail label: bytes sent." + }, + "peers.details.bytesReceived": { + "message": "Received", + "description": "Peer detail label: bytes received." + }, + "peers.details.localIce": { + "message": "Local ICE", + "description": "Peer detail label: local ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.remoteIce": { + "message": "Remote ICE", + "description": "Peer detail label: remote ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.never": { + "message": "Never", + "description": "Value shown when an event has never happened (e.g. last handshake = Never)." + }, + "peers.details.justNow": { + "message": "Just now", + "description": "Relative-time value meaning a moment ago." + }, + "peers.details.refresh": { + "message": "Refresh", + "description": "Button to refresh peer details. Keep short." + }, + "peers.status.connected": { + "message": "Connected", + "description": "Peer status: connected." + }, + "peers.status.connecting": { + "message": "Connecting", + "description": "Peer status: connecting." + }, + "peers.status.disconnected": { + "message": "Disconnected", + "description": "Peer status: disconnected." + }, + "peers.details.relayAddress": { + "message": "Relay", + "description": "Peer detail label: Relay (relay-server address)." + }, + "peers.details.networks": { + "message": "Resources", + "description": "Peer detail label for the peer's network resources. Labelled 'Resources' in the UI." + }, + "peers.details.relayed": { + "message": "Relayed", + "description": "Connection-type value: the connection is relayed (not direct). Technical term." + }, + "peers.details.p2p": { + "message": "P2P", + "description": "Connection-type value: peer-to-peer (direct). 'P2P' — keep as-is." + }, + "peers.details.rosenpass": { + "message": "Rosenpass enabled", + "description": "Peer detail indicating Rosenpass (quantum-resistance) is enabled. 'Rosenpass' — product name, do not translate." + }, + "networks.search.placeholder": { + "message": "Search by network or domain", + "description": "Placeholder in the resources search field." + }, + "networks.filter.all": { + "message": "All", + "description": "Resources filter: All. Keep short." + }, + "networks.filter.active": { + "message": "Active", + "description": "Resources filter: Active. Keep short." + }, + "networks.filter.overlapping": { + "message": "Overlapping", + "description": "Resources filter: Overlapping (overlapping IP ranges). Keep short." + }, + "networks.empty.title": { + "message": "No resources available", + "description": "Title of the empty state when no resources are available." + }, + "networks.empty.description": { + "message": "You either don't have any network resources available or have no access to any of them.", + "description": "Body of the no-resources empty state." + }, + "networks.selected": { + "message": "Selected", + "description": "Label / badge: the resource is selected (enabled)." + }, + "networks.unselected": { + "message": "Not selected", + "description": "Label / badge: the resource is not selected." + }, + "networks.ips.heading": { + "message": "Resolved IPs", + "description": "Heading for the list of a resource's resolved IP addresses." + }, + "networks.bulk.selectionCount": { + "message": "{selected} of {total} Active", + "description": "Header showing how many resources are active. {selected} and {total} are numbers; keep both." + }, + "networks.bulk.enableAll": { + "message": "Enable all", + "description": "Button to enable all resources. Keep short." + }, + "networks.bulk.disableAll": { + "message": "Disable all", + "description": "Button to disable all resources. Keep short." + }, + "exitNodes.search.placeholder": { + "message": "Search exit nodes", + "description": "Placeholder in the exit-nodes search field." + }, + "exitNodes.none": { + "message": "None", + "description": "Option meaning no exit node. Keep short." + }, + "exitNodes.empty.title": { + "message": "No exit nodes available", + "description": "Title of the empty state when no exit nodes are available." + }, + "exitNodes.empty.description": { + "message": "No exit nodes have been shared with this peer.", + "description": "Body of the no-exit-nodes empty state." + }, + "exitNodes.card.title": { + "message": "Exit Node", + "description": "Card heading: Exit Node." + }, + "exitNodes.card.statusActive": { + "message": "Active", + "description": "Exit node card status: Active." + }, + "exitNodes.card.statusInactive": { + "message": "Inactive", + "description": "Exit node card status: Inactive." + }, + "exitNodes.dropdown.noneTitle": { + "message": "None", + "description": "Dropdown option title: None (no exit node)." + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direct connection without an exit node", + "description": "Dropdown option body for None: direct connection without an exit node." + }, + "quickActions.connect": { + "message": "Connect", + "description": "Quick-action button: Connect. Keep short." + }, + "quickActions.disconnect": { + "message": "Disconnect", + "description": "Quick-action button: Disconnect. Keep short." + }, + "daemon.unavailable.title": { + "message": "NetBird Service Is Not Running", + "description": "Title of the overlay shown when the NetBird background service isn't running." + }, + "daemon.unavailable.description": { + "message": "The app will reconnect automatically once the service is running.", + "description": "Body reassuring the user the app will reconnect once the service is running." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation", + "description": "Documentation link on the daemon-unavailable overlay." + }, + "daemon.outdated.title": { + "message": "NetBird Service Is Outdated", + "description": "Title of the overlay shown when the NetBird background service is too old to drive this UI." + }, + "daemon.outdated.description": { + "message": "Update the NetBird service to use this app.", + "description": "Body of the daemon-outdated overlay telling the user to upgrade the service." + }, + "error.jwt_clock_skew": { + "message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", + "description": "Sign-in error shown to the user: the device clock is out of sync with the server." + }, + "error.jwt_expired": { + "message": "Your sign-in token has expired. Please sign in again.", + "description": "Sign-in error: the sign-in token expired; sign in again." + }, + "error.jwt_signature_invalid": { + "message": "Sign-in failed: the token signature is invalid. Please contact your administrator.", + "description": "Sign-in error: the token signature is invalid; contact the administrator." + }, + "error.session_expired": { + "message": "Your session has expired. Please sign in again.", + "description": "Error: the session expired; sign in again." + }, + "error.invalid_setup_key": { + "message": "The setup key is missing or invalid.", + "description": "Error: the setup key is missing or invalid. 'setup key' is a NetBird term." + }, + "error.permission_denied": { + "message": "Sign-in was rejected by the server.", + "description": "Error: the server rejected the sign-in." + }, + "error.daemon_unreachable": { + "message": "The NetBird daemon is not responding. Please check that the service is running.", + "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." + }, + "error.unknown": { + "message": "Operation failed.", + "description": "Generic fallback error message used when no specific error applies." + } +} diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json new file mode 100644 index 000000000..47faee61f --- /dev/null +++ b/client/ui/i18n/locales/es/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "No está en ejecución" + }, + "tray.status.error": { + "message": "Error" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requerido" + }, + "tray.status.loginFailed": { + "message": "Error al iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión expirada" + }, + "tray.session.expiresIn": { + "message": "La sesión expira en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de salida" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfiles" + }, + "tray.menu.manageProfiles": { + "message": "Gestionar perfiles" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de diagnóstico" + }, + "tray.menu.about": { + "message": "Ayuda y soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Solucionar problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar la última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar la versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Salir de NetBird" + }, + "notify.daemonOutdated.title": { + "message": "El servicio de NetBird está desactualizado" + }, + "notify.daemonOutdated.body": { + "message": "Actualice el servicio de NetBird para usar esta aplicación." + }, + "notify.update.title": { + "message": "Actualización de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} está disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Su administrador requiere esta actualización." + }, + "notify.error.title": { + "message": "Error" + }, + "notify.error.connect": { + "message": "Error al conectar" + }, + "notify.error.disconnect": { + "message": "Error al desconectar" + }, + "notify.error.switchProfile": { + "message": "Error al cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Error al actualizar el nodo de salida {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión de NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "Su sesión de NetBird ha expirado. Inicie sesión de nuevo." + }, + "notify.sessionWarning.title": { + "message": "La sesión expira pronto" + }, + "notify.sessionWarning.body": { + "message": "Su sesión de NetBird expira en {remaining}. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Su sesión de NetBird está a punto de expirar. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.extend": { + "message": "Renovar ahora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Error al renovar la sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "Su sesión se ha renovado." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Plazo de sesión rechazado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo." + }, + "notify.mdm.policyApplied.title": { + "message": "Configuración de NetBird actualizada" + }, + "notify.mdm.policyApplied.body": { + "message": "Su configuración de NetBird fue actualizada por su política de TI." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Guardar" + }, + "common.saveChanges": { + "message": "Guardar cambios" + }, + "common.saving": { + "message": "Guardando…" + }, + "common.close": { + "message": "Cerrar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Mostrar u ocultar la contraseña" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Disminuir" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Añadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "No se encontraron resultados" + }, + "common.noResults.description": { + "message": "No se encontraron resultados. Pruebe con otro término de búsqueda o cambie los filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conéctese primero a NetBird para ver información detallada sobre sus peers, recursos de red y nodos de salida." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon no disponible" + }, + "connect.status.loginRequired": { + "message": "Inicio de sesión requerido" + }, + "connect.error.loginTitle": { + "message": "Error al iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Error al conectar" + }, + "connect.error.disconnectTitle": { + "message": "Error al desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de salida" + }, + "nav.exitNode.none": { + "message": "Inactivo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Mostrar u ocultar el panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sin perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nombre..." + }, + "profile.selector.emptyTitle": { + "message": "No se encontraron perfiles" + }, + "profile.selector.emptyDescription": { + "message": "Pruebe con otro término de búsqueda o cree un perfil nuevo." + }, + "profile.selector.newProfile": { + "message": "Nuevo perfil" + }, + "profile.selector.moreOptions": { + "message": "Más opciones" + }, + "profile.selector.deregister": { + "message": "Anular registro" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Guardar cambios" + }, + "profile.dialog.title": { + "message": "Introduzca el nombre del perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nombre del perfil" + }, + "profile.dialog.description": { + "message": "Asigne un nombre fácil de identificar a su perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ej. trabajo" + }, + "profile.dialog.submit": { + "message": "Añadir perfil" + }, + "profile.dialog.required": { + "message": "Introduzca un nombre de perfil, p. ej. trabajo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud o su propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o añada el perfil de todos modos si está seguro de que es correcta." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización disponible" + }, + "header.menu.open": { + "message": "Abrir menú" + }, + "header.profile.switch": { + "message": "Cambiar de perfil" + }, + "connect.toggle.label": { + "message": "Conmutar conexión NetBird" + }, + "connect.localIp.label": { + "message": "Direcciones IP locales" + }, + "common.search": { + "message": "Buscar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleccionar nodo de salida" + }, + "peers.row.label": { + "message": "Abrir detalles de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalles del par" + }, + "networks.row.toggle": { + "message": "Conmutar {name}" + }, + "networks.bulk.label": { + "message": "Conmutar todos los recursos visibles" + }, + "settings.nav.label": { + "message": "Secciones de configuración" + }, + "profile.switch.title": { + "message": "¿Cambiar el perfil a «{name}»?" + }, + "profile.switch.message": { + "message": "¿Seguro que desea cambiar de perfil?\nSu perfil actual se desconectará." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "¿Anular el registro del perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "¿Seguro que desea anular el registro de este perfil?\nDeberá iniciar sesión de nuevo para usarlo." + }, + "profile.deregister.confirm": { + "message": "Anular registro" + }, + "profile.delete.title": { + "message": "¿Eliminar el perfil «{name}»?" + }, + "profile.delete.message": { + "message": "¿Seguro que desea eliminar este perfil?\nEsta acción no se puede deshacer." + }, + "profile.delete.disabledActive": { + "message": "Los perfiles activos no se pueden eliminar. Cambie a otro antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "El perfil predeterminado no se puede eliminar." + }, + "profile.error.switchTitle": { + "message": "Error al cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Error al anular el registro del perfil" + }, + "profile.error.deleteTitle": { + "message": "Error al eliminar el perfil" + }, + "profile.error.createTitle": { + "message": "Error al crear el perfil" + }, + "profile.error.editTitle": { + "message": "Error al editar el perfil" + }, + "profile.error.loadTitle": { + "message": "Error al cargar los perfiles" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Otro" + }, + "profile.dropdown.addProfile": { + "message": "Añadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestionar perfiles" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfiles" + }, + "settings.profiles.intro": { + "message": "Mantenga identidades de NetBird independientes en paralelo, por ejemplo cuentas de trabajo y personales, o distintos servidores de gestión. Añada, anule el registro o elimine perfiles a continuación." + }, + "settings.profiles.addProfile": { + "message": "Añadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sin perfiles" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de gestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Error al cargar la configuración" + }, + "settings.error.saveTitle": { + "message": "Error al guardar la configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Error en el paquete de diagnóstico" + }, + "settings.tabs.general": { + "message": "General" + }, + "settings.tabs.network": { + "message": "Red" + }, + "settings.tabs.security": { + "message": "Seguridad" + }, + "settings.tabs.profiles": { + "message": "Perfiles" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Solución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización disponible" + }, + "settings.general.section.general": { + "message": "General" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar al iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece una conexión automáticamente cuando se inicia el servicio." + }, + "settings.general.notifications.label": { + "message": "Notificaciones de escritorio" + }, + "settings.general.notifications.help": { + "message": "Muestra notificaciones de escritorio para nuevas actualizaciones y eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar la interfaz de NetBird al iniciar sesión" + }, + "settings.general.autostart.help": { + "message": "Inicia la interfaz de NetBird automáticamente al iniciar sesión. Esto afecta solo a la interfaz gráfica, no al servicio en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Error al cambiar el inicio automático" + }, + "settings.general.language.label": { + "message": "Idioma de la interfaz" + }, + "settings.general.language.help": { + "message": "Elija el idioma de la interfaz de NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.management.label": { + "message": "Servidor de gestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud o a su propio servidor de gestión autoalojado. Los cambios reconectarán el cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoalojado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o guarde de todos modos si está seguro de que es correcta." + }, + "settings.general.management.switchCloudTitle": { + "message": "¿Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Esto desconecta su servidor autoalojado.\nEs posible que deba iniciar sesión de nuevo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividad" + }, + "settings.network.section.routingDns": { + "message": "Enrutamiento y DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar al cambiar de red" + }, + "settings.network.monitor.help": { + "message": "Monitoriza la red y reconecta automáticamente ante cambios como cambios de Wi-Fi, cambios de Ethernet o la reanudación tras la suspensión." + }, + "settings.network.dns.label": { + "message": "Habilitar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica la configuración de DNS gestionada por NetBird al resolutor del host." + }, + "settings.network.clientRoutes.label": { + "message": "Habilitar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas de otros peers para alcanzar sus redes." + }, + "settings.network.serverRoutes.label": { + "message": "Habilitar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia las rutas locales de este host a otros peers." + }, + "settings.network.ipv6.label": { + "message": "Habilitar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa direccionamiento IPv6 para la red superpuesta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafuegos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rechaza conexiones no solicitadas de peers hacia este dispositivo y cualquier red que enrute. El tráfico saliente no se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a la LAN" + }, + "settings.security.blockLan.help": { + "message": "Impide que los peers alcancen su red local o sus dispositivos cuando este dispositivo enruta su tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Habilitar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Añade un intercambio de claves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Habilitar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexiones con peers sin compatibilidad con resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Habilitar el servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Ejecuta el servidor SSH de NetBird en este host para que otros peers puedan conectarse a él." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión como root" + }, + "settings.ssh.root.help": { + "message": "Permite que los peers inicien sesión como usuario root. Desactívelo para exigir una cuenta sin privilegios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfiere archivos de forma segura mediante clientes SFTP o SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de puertos local" + }, + "settings.ssh.localForward.help": { + "message": "Permite que los peers conectados tunelicen puertos locales hacia servicios accesibles desde este host." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de puertos remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que los peers conectados expongan puertos de este host de vuelta a su propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Habilitar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH contra su IdP para la identidad del usuario y la auditoría. Desactívelo para basarse solo en las políticas de ACL de red, útil cuando no hay ningún IdP disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL de la caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Cuánto tiempo almacena en caché este cliente un JWT antes de volver a solicitarlo en conexiones SSH salientes. Establézcalo en 0 para desactivar la caché y autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interfaz" + }, + "settings.advanced.section.security": { + "message": "Seguridad" + }, + "settings.advanced.interfaceName.label": { + "message": "Nombre" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, puntos, guiones o guiones bajos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe empezar por \"utun\" seguido de un número (p. ej. utun100)." + }, + "settings.advanced.port.label": { + "message": "Puerto" + }, + "settings.advanced.port.error": { + "message": "Introduzca un puerto entre {min} y {max}." + }, + "settings.advanced.port.help": { + "message": "Si se establece en 0, se usará un puerto libre aleatorio." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduzca un valor de MTU entre {min} y {max}." + }, + "settings.advanced.psk.label": { + "message": "Clave precompartida" + }, + "settings.advanced.psk.help": { + "message": "PSK de WireGuard opcional para cifrado simétrico adicional. No es lo mismo que una clave de instalación de NetBird. Solo se comunicará con peers que usen la misma clave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de diagnóstico" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información sensible" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir información del sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluye el OS, el kernel, las interfaces de red y las tablas de enrutamiento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir el paquete a los servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Devuelve una clave de subida para compartir con el soporte de NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activar registros de seguimiento" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva el nivel de registro a TRACE y lo restaura después." + }, + "settings.troubleshooting.capture.label": { + "message": "Sesión de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Vuelve a conectar y espera para que pueda reproducir el problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar paquetes de red" + }, + "settings.troubleshooting.packets.help": { + "message": "Guarda un .pcap del tráfico de red durante la sesión de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración de la captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Cuánto tiempo se ejecuta la sesión de captura." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recopilando registros, detalles del sistema y estado de la conexión. Esto suele tardar un momento; mantenga esta ventana abierta hasta que termine." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "¡Paquete de diagnóstico subido correctamente!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete guardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta la clave de subida de abajo con el soporte de NetBird. También se guardó una copia local en su dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Su paquete de diagnóstico se ha guardado localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar clave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir carpeta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir ubicación del archivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Error en la subida: {reason} El paquete sigue guardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Error en la subida. El paquete sigue guardado localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando registros de depuración" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generando el paquete de diagnóstico…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subiendo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Desarrollo]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos los derechos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidad" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Términos del servicio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Más tarde" + }, + "update.banner.installNow": { + "message": "Instalar ahora" + }, + "update.card.versionAvailableDownload": { + "message": "La versión {version} está disponible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "La versión {version} está disponible para instalar." + }, + "update.card.whatsNew": { + "message": "¿Qué hay de nuevo?" + }, + "update.card.installNow": { + "message": "Instalar ahora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizaciones en segundo plano." + }, + "update.card.changelog": { + "message": "Registro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Tiene la última versión" + }, + "update.header.tooltip": { + "message": "Actualización disponible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird a v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Hay una versión más reciente disponible y se está instalando. NetBird se reiniciará automáticamente cuando finalice la actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "La actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "La instalación de {target} tardó demasiado y no finalizó." + }, + "update.overlay.error.canceledTitle": { + "message": "La actualización se detuvo" + }, + "update.overlay.error.canceledDescription": { + "message": "La actualización a {target} se canceló antes de finalizar." + }, + "update.overlay.error.failTitle": { + "message": "No se pudo instalar la actualización" + }, + "update.overlay.error.failDescription": { + "message": "No se pudo instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "error desconocido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nueva versión" + }, + "update.error.loadStateTitle": { + "message": "Error al cargar el estado de la actualización" + }, + "update.error.triggerTitle": { + "message": "Error al iniciar la actualización" + }, + "update.page.versionLine": { + "message": "Actualizando el cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando el cliente." + }, + "update.page.outdated": { + "message": "La versión de su cliente es anterior a la versión de actualización automática configurada en Management." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Se agotó el tiempo de la actualización. Inténtelo de nuevo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Error en la actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "error de actualización desconocido" + }, + "update.page.failedTitle": { + "message": "Error en la actualización" + }, + "update.page.timeoutMessage": { + "message": "Se agotó el tiempo de la actualización." + }, + "update.page.dontClose": { + "message": "No cierre esta ventana." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Error en la actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Inicio de sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de expirar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Bienvenido a NetBird" + }, + "window.title.error": { + "message": "Error" + }, + "welcome.title": { + "message": "Busque NetBird en su bandeja del sistema" + }, + "welcome.description": { + "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Atrás" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Haga clic en Continuar para empezar, o elija Autoalojado si tiene su propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use nuestro servicio alojado. No requiere configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoalojado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese a su propio servidor de gestión." + }, + "welcome.management.urlLabel": { + "message": "URL del servidor de gestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o su red y, a continuación, continúe si está seguro de que es correcta." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Continúe en su navegador para completar el inicio de sesión" + }, + "browserLogin.notSeeing": { + "message": "¿No ve la pestaña del navegador?" + }, + "browserLogin.tryAgain": { + "message": "Reintentar" + }, + "browserLogin.openFailedTitle": { + "message": "Error al abrir el navegador" + }, + "sessionExpiration.title": { + "message": "La sesión expira pronto" + }, + "sessionExpiration.titleLater": { + "message": "Su sesión expirará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo se desconectará pronto. Renuévela iniciando sesión en el navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Iniciar sesión en el navegador mantiene este dispositivo conectado a su red." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Cerrar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese iniciando sesión en el navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Cerrar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Error al renovar la sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Error al cerrar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nombre o IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En línea" + }, + "peers.filter.offline": { + "message": "Sin conexión" + }, + "peers.empty.title": { + "message": "No hay peers disponibles" + }, + "peers.empty.description": { + "message": "No tiene ningún peer disponible o no tiene acceso a ninguno de ellos." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Clave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de la conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Justo ahora" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitida" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass habilitado" + }, + "networks.search.placeholder": { + "message": "Buscar por red o dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Solapados" + }, + "networks.empty.title": { + "message": "No hay recursos disponibles" + }, + "networks.empty.description": { + "message": "No tiene ningún recurso de red disponible o no tiene acceso a ninguno de ellos." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "No seleccionado" + }, + "networks.ips.heading": { + "message": "IP resueltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} activos" + }, + "networks.bulk.enableAll": { + "message": "Habilitar todos" + }, + "networks.bulk.disableAll": { + "message": "Deshabilitar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de salida" + }, + "exitNodes.none": { + "message": "Ninguno" + }, + "exitNodes.empty.title": { + "message": "No hay nodos de salida disponibles" + }, + "exitNodes.empty.description": { + "message": "No se ha compartido ningún nodo de salida con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de salida" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ninguno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sin nodo de salida" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "El servicio de NetBird no está en ejecución" + }, + "daemon.unavailable.description": { + "message": "La aplicación se reconectará automáticamente cuando el servicio esté en ejecución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "daemon.outdated.title": { + "message": "El servicio de NetBird está desactualizado" + }, + "daemon.outdated.description": { + "message": "Actualice el servicio de NetBird para usar esta aplicación." + }, + "error.jwt_clock_skew": { + "message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo." + }, + "error.jwt_expired": { + "message": "Su token de inicio de sesión ha expirado. Inicie sesión de nuevo." + }, + "error.jwt_signature_invalid": { + "message": "Error al iniciar sesión: la firma del token no es válida. Póngase en contacto con su administrador." + }, + "error.session_expired": { + "message": "Su sesión ha expirado. Inicie sesión de nuevo." + }, + "error.invalid_setup_key": { + "message": "La clave de instalación falta o no es válida." + }, + "error.permission_denied": { + "message": "El servidor rechazó el inicio de sesión." + }, + "error.daemon_unreachable": { + "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." + }, + "error.unknown": { + "message": "La operación falló." + } +} diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json new file mode 100644 index 000000000..be0836e93 --- /dev/null +++ b/client/ui/i18n/locales/fr/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Déconnecté" + }, + "tray.status.daemonUnavailable": { + "message": "Non démarré" + }, + "tray.status.error": { + "message": "Erreur" + }, + "tray.status.connected": { + "message": "Connecté" + }, + "tray.status.connecting": { + "message": "Connexion" + }, + "tray.status.needsLogin": { + "message": "Connexion requise" + }, + "tray.status.loginFailed": { + "message": "Échec de la connexion" + }, + "tray.status.sessionExpired": { + "message": "Session expirée" + }, + "tray.session.expiresIn": { + "message": "La session expire dans {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "moins d’une minute" + }, + "tray.session.unit.minute": { + "message": "1 minute" + }, + "tray.session.unit.minutes": { + "message": "{count} minutes" + }, + "tray.session.unit.hour": { + "message": "1 heure" + }, + "tray.session.unit.hours": { + "message": "{count} heures" + }, + "tray.session.unit.day": { + "message": "1 jour" + }, + "tray.session.unit.days": { + "message": "{count} jours" + }, + "tray.menu.open": { + "message": "Ouvrir NetBird" + }, + "tray.menu.connect": { + "message": "Se connecter" + }, + "tray.menu.disconnect": { + "message": "Se déconnecter" + }, + "tray.menu.exitNode": { + "message": "Nœud de sortie" + }, + "tray.menu.networks": { + "message": "Ressources" + }, + "tray.menu.profiles": { + "message": "Profils" + }, + "tray.menu.manageProfiles": { + "message": "Gérer les profils" + }, + "tray.menu.settings": { + "message": "Paramètres..." + }, + "tray.menu.debugBundle": { + "message": "Créer un lot de diagnostic" + }, + "tray.menu.about": { + "message": "Aide et assistance" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentation" + }, + "tray.menu.troubleshoot": { + "message": "Dépannage" + }, + "tray.menu.downloadLatest": { + "message": "Télécharger la dernière version" + }, + "tray.menu.installVersion": { + "message": "Installer la version {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI : {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon : {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Quitter NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Le service NetBird est obsolète" + }, + "notify.daemonOutdated.body": { + "message": "Mettez à jour le service NetBird pour utiliser cette application." + }, + "notify.update.title": { + "message": "Mise à jour de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} est disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Votre administrateur impose cette mise à jour." + }, + "notify.error.title": { + "message": "Erreur" + }, + "notify.error.connect": { + "message": "Échec de la connexion" + }, + "notify.error.disconnect": { + "message": "Échec de la déconnexion" + }, + "notify.error.switchProfile": { + "message": "Échec du passage à {profile}" + }, + "notify.error.exitNode": { + "message": "Échec de la mise à jour du nœud de sortie {name}" + }, + "notify.sessionExpired.title": { + "message": "Session NetBird expirée" + }, + "notify.sessionExpired.body": { + "message": "Votre session NetBird a expiré. Veuillez vous reconnecter." + }, + "notify.sessionWarning.title": { + "message": "La session expire bientôt" + }, + "notify.sessionWarning.body": { + "message": "Votre session NetBird expire dans {remaining}. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Votre session NetBird est sur le point d’expirer. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.extend": { + "message": "Prolonger" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignorer" + }, + "notify.sessionWarning.failed": { + "message": "Échec de la prolongation de la session NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Session NetBird prolongée" + }, + "notify.sessionWarning.successBody": { + "message": "Votre session a été renouvelée." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Échéance de session rejetée" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter." + }, + "notify.mdm.policyApplied.title": { + "message": "Paramètres NetBird mis à jour" + }, + "notify.mdm.policyApplied.body": { + "message": "Votre configuration NetBird a été mise à jour par votre politique informatique." + }, + "common.cancel": { + "message": "Annuler" + }, + "common.save": { + "message": "Enregistrer" + }, + "common.saveChanges": { + "message": "Enregistrer les modifications" + }, + "common.saving": { + "message": "Enregistrement…" + }, + "common.close": { + "message": "Fermer" + }, + "common.copy": { + "message": "Copier" + }, + "common.togglePasswordVisibility": { + "message": "Afficher ou masquer le mot de passe" + }, + "common.increase": { + "message": "Augmenter" + }, + "common.decrease": { + "message": "Diminuer" + }, + "common.delete": { + "message": "Supprimer" + }, + "common.create": { + "message": "Créer" + }, + "common.add": { + "message": "Ajouter" + }, + "common.remove": { + "message": "Retirer" + }, + "common.refresh": { + "message": "Actualiser" + }, + "common.loading": { + "message": "Chargement…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Aucun résultat trouvé" + }, + "common.noResults.description": { + "message": "Nous n’avons trouvé aucun résultat. Essayez un autre terme de recherche ou modifiez vos filtres." + }, + "notConnected.title": { + "message": "Déconnecté" + }, + "notConnected.description": { + "message": "Connectez-vous d’abord à NetBird pour consulter les informations détaillées sur vos pairs, vos ressources réseau et vos nœuds de sortie." + }, + "connect.status.disconnected": { + "message": "Déconnecté" + }, + "connect.status.connecting": { + "message": "Connexion..." + }, + "connect.status.connected": { + "message": "Connecté" + }, + "connect.status.disconnecting": { + "message": "Déconnexion..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponible" + }, + "connect.status.loginRequired": { + "message": "Connexion requise" + }, + "connect.error.loginTitle": { + "message": "Échec de la connexion" + }, + "connect.error.connectTitle": { + "message": "Échec de la connexion" + }, + "connect.error.disconnectTitle": { + "message": "Échec de la déconnexion" + }, + "nav.peers.title": { + "message": "Pairs" + }, + "nav.peers.description": { + "message": "{connected} sur {total} connectés" + }, + "nav.resources.title": { + "message": "Ressources" + }, + "nav.resources.description": { + "message": "{active} sur {total} actives" + }, + "nav.exitNode.title": { + "message": "Nœuds de sortie" + }, + "nav.exitNode.none": { + "message": "Inactif" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Ouvrir les paramètres" + }, + "header.togglePanel": { + "message": "Afficher ou masquer le panneau latéral" + }, + "profile.selector.loading": { + "message": "Chargement..." + }, + "profile.selector.noProfile": { + "message": "Aucun profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Rechercher un profil par nom..." + }, + "profile.selector.emptyTitle": { + "message": "Aucun profil trouvé" + }, + "profile.selector.emptyDescription": { + "message": "Essayez un autre terme de recherche ou créez un nouveau profil." + }, + "profile.selector.newProfile": { + "message": "Nouveau profil" + }, + "profile.selector.moreOptions": { + "message": "Plus d’options" + }, + "profile.selector.deregister": { + "message": "Désinscrire" + }, + "profile.selector.delete": { + "message": "Supprimer" + }, + "profile.selector.switchTo": { + "message": "Basculer vers ce profil" + }, + "profile.selector.edit": { + "message": "Modifier" + }, + "profile.edit.title": { + "message": "Modifier le profil" + }, + "profile.edit.submit": { + "message": "Enregistrer les modifications" + }, + "profile.dialog.title": { + "message": "Saisir le nom du profil" + }, + "profile.dialog.nameLabel": { + "message": "Nom du profil" + }, + "profile.dialog.description": { + "message": "Choisissez un nom facilement identifiable pour votre profil." + }, + "profile.dialog.placeholder": { + "message": "ex. travail" + }, + "profile.dialog.submit": { + "message": "Ajouter un profil" + }, + "profile.dialog.required": { + "message": "Veuillez saisir un nom de profil, ex. travail, domicile" + }, + "profile.dialog.managementHelp": { + "message": "Utilisez NetBird Cloud ou votre propre serveur." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou ajoutez le profil quand même si vous êtes sûr qu’elle est correcte." + }, + "header.menu.settings": { + "message": "Paramètres..." + }, + "header.menu.defaultView": { + "message": "Vue par défaut" + }, + "header.menu.advancedView": { + "message": "Vue avancée" + }, + "header.menu.updateAvailable": { + "message": "Mise à jour disponible" + }, + "header.menu.open": { + "message": "Ouvrir le menu" + }, + "header.profile.switch": { + "message": "Changer de profil" + }, + "connect.toggle.label": { + "message": "Activer/désactiver la connexion NetBird" + }, + "connect.localIp.label": { + "message": "Adresses IP locales" + }, + "common.search": { + "message": "Rechercher" + }, + "common.filter": { + "message": "Filtrer" + }, + "exitNodes.dropdown.trigger": { + "message": "Sélectionner un nœud de sortie" + }, + "peers.row.label": { + "message": "Ouvrir les détails pour {name}, {status}" + }, + "peers.dialog.title": { + "message": "Détails du pair" + }, + "networks.row.toggle": { + "message": "Activer/désactiver {name}" + }, + "networks.bulk.label": { + "message": "Activer/désactiver toutes les ressources visibles" + }, + "settings.nav.label": { + "message": "Sections des paramètres" + }, + "profile.switch.title": { + "message": "Basculer vers le profil « {name} » ?" + }, + "profile.switch.message": { + "message": "Voulez-vous vraiment changer de profil ?\nVotre profil actuel sera déconnecté." + }, + "profile.switch.confirm": { + "message": "Confirmer" + }, + "profile.deregister.title": { + "message": "Désinscrire le profil « {name} » ?" + }, + "profile.deregister.message": { + "message": "Voulez-vous vraiment désinscrire ce profil ?\nVous devrez vous reconnecter pour l’utiliser." + }, + "profile.deregister.confirm": { + "message": "Désinscrire" + }, + "profile.delete.title": { + "message": "Supprimer le profil « {name} » ?" + }, + "profile.delete.message": { + "message": "Voulez-vous vraiment supprimer ce profil ?\nCette action est irréversible." + }, + "profile.delete.disabledActive": { + "message": "Les profils actifs ne peuvent pas être supprimés. Basculez vers un autre profil avant de supprimer celui-ci." + }, + "profile.delete.disabledDefault": { + "message": "Le profil par défaut ne peut pas être supprimé." + }, + "profile.error.switchTitle": { + "message": "Échec du changement de profil" + }, + "profile.error.deregisterTitle": { + "message": "Échec de la désinscription du profil" + }, + "profile.error.deleteTitle": { + "message": "Échec de la suppression du profil" + }, + "profile.error.createTitle": { + "message": "Échec de la création du profil" + }, + "profile.error.editTitle": { + "message": "Échec de la modification du profil" + }, + "profile.error.loadTitle": { + "message": "Échec du chargement des profils" + }, + "profile.dropdown.activeProfile": { + "message": "Profil actif" + }, + "profile.dropdown.switchProfile": { + "message": "Changer de profil" + }, + "profile.dropdown.noEmail": { + "message": "Autre" + }, + "profile.dropdown.addProfile": { + "message": "Ajouter un profil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gérer les profils" + }, + "profile.dropdown.settings": { + "message": "Paramètres" + }, + "settings.profiles.section.profiles": { + "message": "Profils" + }, + "settings.profiles.intro": { + "message": "Conservez plusieurs identités NetBird côte à côte, par exemple des comptes professionnels et personnels, ou différents serveurs de gestion. Ajoutez, désinscrivez ou supprimez des profils ci-dessous." + }, + "settings.profiles.addProfile": { + "message": "Ajouter un profil" + }, + "settings.profiles.active": { + "message": "Actif" + }, + "settings.profiles.emptyTitle": { + "message": "Aucun profil" + }, + "settings.profiles.emptyDescription": { + "message": "Créez un profil pour vous connecter à un serveur de gestion NetBird." + }, + "settings.error.loadTitle": { + "message": "Échec du chargement des paramètres" + }, + "settings.error.saveTitle": { + "message": "Échec de l’enregistrement des paramètres" + }, + "settings.error.debugBundleTitle": { + "message": "Échec du lot de diagnostic" + }, + "settings.tabs.general": { + "message": "Général" + }, + "settings.tabs.network": { + "message": "Réseau" + }, + "settings.tabs.security": { + "message": "Sécurité" + }, + "settings.tabs.profiles": { + "message": "Profils" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avancé" + }, + "settings.tabs.troubleshooting": { + "message": "Dépannage" + }, + "settings.tabs.about": { + "message": "À propos" + }, + "settings.tabs.updateAvailable": { + "message": "Mise à jour disponible" + }, + "settings.general.section.general": { + "message": "Général" + }, + "settings.general.section.connection": { + "message": "Connexion" + }, + "settings.general.connectOnStartup.label": { + "message": "Se connecter au démarrage" + }, + "settings.general.connectOnStartup.help": { + "message": "Établir automatiquement une connexion au démarrage du service." + }, + "settings.general.notifications.label": { + "message": "Notifications du bureau" + }, + "settings.general.notifications.help": { + "message": "Afficher des notifications du bureau pour les nouvelles mises à jour et les événements de connexion." + }, + "settings.general.autostart.label": { + "message": "Lancer l’interface NetBird à l’ouverture de session" + }, + "settings.general.autostart.help": { + "message": "Démarrer automatiquement l’interface NetBird à l’ouverture de votre session. Cela n’affecte que l’interface graphique, pas le service en arrière-plan." + }, + "settings.general.autostart.errorTitle": { + "message": "Échec de la modification du démarrage automatique" + }, + "settings.general.language.label": { + "message": "Langue d’affichage" + }, + "settings.general.language.help": { + "message": "Choisissez la langue de l’interface NetBird." + }, + "settings.general.language.search": { + "message": "Rechercher une langue…" + }, + "settings.general.language.empty": { + "message": "Aucune langue ne correspond." + }, + "settings.general.management.label": { + "message": "Serveur de gestion" + }, + "settings.general.management.help": { + "message": "Connectez-vous à NetBird Cloud ou à votre propre serveur de gestion auto-hébergé. Les modifications reconnecteront le client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hébergé" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou enregistrez quand même si vous êtes sûr qu’elle est correcte." + }, + "settings.general.management.switchCloudTitle": { + "message": "Basculer vers NetBird Cloud ?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Cela déconnecte votre serveur auto-hébergé.\nVous devrez peut-être vous reconnecter." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Basculer vers Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connectivité" + }, + "settings.network.section.routingDns": { + "message": "Routage et DNS" + }, + "settings.network.monitor.label": { + "message": "Reconnecter en cas de changement de réseau" + }, + "settings.network.monitor.help": { + "message": "Surveiller le réseau et se reconnecter automatiquement lors de changements tels qu’un basculement Wi-Fi, une modification Ethernet ou une reprise après la mise en veille." + }, + "settings.network.dns.label": { + "message": "Activer le DNS" + }, + "settings.network.dns.help": { + "message": "Appliquer les paramètres DNS gérés par NetBird au résolveur de l’hôte." + }, + "settings.network.clientRoutes.label": { + "message": "Activer les routes client" + }, + "settings.network.clientRoutes.help": { + "message": "Accepter les routes d’autres pairs pour atteindre leurs réseaux." + }, + "settings.network.serverRoutes.label": { + "message": "Activer les routes serveur" + }, + "settings.network.serverRoutes.help": { + "message": "Annoncer les routes locales de cet hôte aux autres pairs." + }, + "settings.network.ipv6.label": { + "message": "Activer IPv6" + }, + "settings.network.ipv6.help": { + "message": "Utiliser l’adressage IPv6 pour le réseau overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Pare-feu" + }, + "settings.security.section.encryption": { + "message": "Chiffrement" + }, + "settings.security.blockInbound.label": { + "message": "Bloquer le trafic entrant" + }, + "settings.security.blockInbound.help": { + "message": "Rejeter les connexions non sollicitées des pairs vers cet appareil et les réseaux qu’il route. Le trafic sortant n’est pas affecté." + }, + "settings.security.blockLan.label": { + "message": "Bloquer l’accès au LAN" + }, + "settings.security.blockLan.help": { + "message": "Empêcher les pairs d’atteindre votre réseau local ou ses appareils lorsque cet appareil route leur trafic." + }, + "settings.security.rosenpass.label": { + "message": "Activer la résistance quantique" + }, + "settings.security.rosenpass.help": { + "message": "Ajouter un échange de clés post-quantique via Rosenpass au-dessus de WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activer le mode permissif" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Autoriser les connexions vers des pairs sans prise en charge de la résistance quantique." + }, + "settings.ssh.section.server": { + "message": "Serveur" + }, + "settings.ssh.section.capabilities": { + "message": "Fonctionnalités" + }, + "settings.ssh.section.authentication": { + "message": "Authentification" + }, + "settings.ssh.server.label": { + "message": "Activer le serveur SSH" + }, + "settings.ssh.server.help": { + "message": "Exécuter le serveur SSH NetBird sur cet hôte afin que d’autres pairs puissent s’y connecter." + }, + "settings.ssh.root.label": { + "message": "Autoriser la connexion en root" + }, + "settings.ssh.root.help": { + "message": "Permettre aux pairs de se connecter en tant qu’utilisateur root. Désactivez pour exiger un compte non privilégié." + }, + "settings.ssh.sftp.label": { + "message": "Autoriser SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transférer des fichiers en toute sécurité à l’aide de clients SFTP ou SCP natifs." + }, + "settings.ssh.localForward.label": { + "message": "Redirection de port locale" + }, + "settings.ssh.localForward.help": { + "message": "Permettre aux pairs connectés de tunneliser des ports locaux vers des services accessibles depuis cet hôte." + }, + "settings.ssh.remoteForward.label": { + "message": "Redirection de port distante" + }, + "settings.ssh.remoteForward.help": { + "message": "Permettre aux pairs connectés d’exposer des ports de cet hôte vers leur propre machine." + }, + "settings.ssh.jwt.label": { + "message": "Activer l’authentification JWT" + }, + "settings.ssh.jwt.help": { + "message": "Vérifier chaque session SSH auprès de votre IdP pour l’identité de l’utilisateur et l’audit. Désactivez pour vous appuyer uniquement sur les politiques ACL réseau, utile lorsqu’aucun IdP n’est disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL du cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Durée pendant laquelle ce client met en cache un JWT avant de redemander lors des connexions SSH sortantes. Définissez 0 pour désactiver la mise en cache et vous authentifier à chaque connexion." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Sécurité" + }, + "settings.advanced.interfaceName.label": { + "message": "Nom" + }, + "settings.advanced.interfaceName.error": { + "message": "Utilisez 1 à 15 lettres, chiffres, points, tirets ou traits de soulignement." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Doit commencer par « utun » suivi d’un nombre (ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Saisissez un port compris entre {min} et {max}." + }, + "settings.advanced.port.help": { + "message": "Si la valeur est 0, un port libre aléatoire sera utilisé." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Saisissez une valeur MTU comprise entre {min} et {max}." + }, + "settings.advanced.psk.label": { + "message": "Clé pré-partagée" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard facultative pour un chiffrement symétrique supplémentaire. Différente d’une clé d’installation NetBird. Vous ne communiquerez qu’avec les pairs utilisant la même clé pré-partagée." + }, + "settings.troubleshooting.section.title": { + "message": "Lot de diagnostic" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymiser les informations sensibles" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Inclure les informations système" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclure l’OS, le noyau, les interfaces réseau et les tables de routage." + }, + "settings.troubleshooting.upload.label": { + "message": "Envoyer le lot aux serveurs NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Renvoie une clé d’envoi à partager avec l’assistance NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activer les journaux trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Élève le niveau de journalisation à TRACE, puis le rétablit ensuite." + }, + "settings.troubleshooting.capture.label": { + "message": "Session de capture" + }, + "settings.troubleshooting.capture.help": { + "message": "Se reconnecte et attend pour que vous puissiez reproduire le problème." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturer les paquets réseau" + }, + "settings.troubleshooting.packets.help": { + "message": "Enregistre un .pcap du trafic réseau pendant la session de capture." + }, + "settings.troubleshooting.duration.label": { + "message": "Durée de capture" + }, + "settings.troubleshooting.duration.help": { + "message": "Durée d’exécution de la session de capture." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Créer le lot" + }, + "settings.troubleshooting.progress.description": { + "message": "Collecte des journaux, des détails système et de l’état de connexion. Cela prend généralement un instant — gardez cette fenêtre ouverte jusqu’à la fin." + }, + "settings.troubleshooting.cancelling": { + "message": "Annulation…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Lot de diagnostic envoyé avec succès !" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Lot enregistré" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Partagez la clé d’envoi ci-dessous avec l’assistance NetBird. Une copie locale a également été enregistrée sur votre appareil." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Votre lot de diagnostic a été enregistré localement." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copier la clé" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ouvrir le dossier" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Ouvrir l’emplacement du fichier" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Échec de l’envoi : {reason} Le lot reste enregistré localement." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Échec de l’envoi. Le lot reste enregistré localement." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnexion de NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capture des journaux de débogage" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Génération du lot de diagnostic…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Envoi vers NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annulation…" + }, + "settings.about.client": { + "message": "Client NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Client NetBird" + }, + "settings.about.development": { + "message": "[Développement]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tous droits réservés." + }, + "settings.about.links.imprint": { + "message": "Mentions légales" + }, + "settings.about.links.privacy": { + "message": "Confidentialité" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Conditions d’utilisation" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentation" + }, + "settings.about.community.feedback": { + "message": "Retour d’expérience" + }, + "update.banner.message": { + "message": "NetBird {version} est prêt à être installé." + }, + "update.banner.later": { + "message": "Plus tard" + }, + "update.banner.installNow": { + "message": "Installer maintenant" + }, + "update.card.versionAvailableDownload": { + "message": "La version {version} est disponible au téléchargement." + }, + "update.card.versionAvailableInstall": { + "message": "La version {version} est disponible à l’installation." + }, + "update.card.whatsNew": { + "message": "Quoi de neuf ?" + }, + "update.card.installNow": { + "message": "Installer maintenant" + }, + "update.card.getInstaller": { + "message": "Télécharger" + }, + "update.card.autoCheckInterval": { + "message": "NetBird vérifie les mises à jour en arrière-plan." + }, + "update.card.changelog": { + "message": "Journal des modifications" + }, + "update.card.onLatestVersion": { + "message": "Vous utilisez la dernière version" + }, + "update.header.tooltip": { + "message": "Mise à jour disponible" + }, + "update.overlay.updatingVersion": { + "message": "Mise à jour de NetBird vers v{version}" + }, + "update.overlay.updating": { + "message": "Mise à jour de NetBird" + }, + "update.overlay.description": { + "message": "Une version plus récente est disponible et en cours d’installation. NetBird redémarrera automatiquement une fois la mise à jour terminée." + }, + "update.overlay.error.timeoutTitle": { + "message": "La mise à jour prend trop de temps" + }, + "update.overlay.error.timeoutDescription": { + "message": "L’installation de {target} a pris trop de temps et ne s’est pas terminée." + }, + "update.overlay.error.canceledTitle": { + "message": "La mise à jour a été interrompue" + }, + "update.overlay.error.canceledDescription": { + "message": "La mise à jour vers {target} a été annulée avant de se terminer." + }, + "update.overlay.error.failTitle": { + "message": "Impossible d’installer la mise à jour" + }, + "update.overlay.error.failDescription": { + "message": "Impossible d’installer {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erreur inconnue" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nouvelle version" + }, + "update.error.loadStateTitle": { + "message": "Échec du chargement de l’état de mise à jour" + }, + "update.error.triggerTitle": { + "message": "Échec du démarrage de la mise à jour" + }, + "update.page.versionLine": { + "message": "Mise à jour du client vers : {version}." + }, + "update.page.versionLineGeneric": { + "message": "Mise à jour du client." + }, + "update.page.outdated": { + "message": "La version de votre client est antérieure à la version de mise à jour automatique définie dans Management." + }, + "update.page.status.running": { + "message": "Mise à jour en cours" + }, + "update.page.status.timeout": { + "message": "La mise à jour a expiré. Veuillez réessayer." + }, + "update.page.status.canceled": { + "message": "Mise à jour annulée." + }, + "update.page.status.failed": { + "message": "Échec de la mise à jour : {message}" + }, + "update.page.status.unknownError": { + "message": "erreur de mise à jour inconnue" + }, + "update.page.failedTitle": { + "message": "Échec de la mise à jour" + }, + "update.page.timeoutMessage": { + "message": "La mise à jour a expiré." + }, + "update.page.dontClose": { + "message": "Veuillez ne pas fermer cette fenêtre." + }, + "update.page.updating": { + "message": "Mise à jour…" + }, + "update.page.complete": { + "message": "Mise à jour terminée" + }, + "update.page.failed": { + "message": "Échec de la mise à jour" + }, + "window.title.settings": { + "message": "Paramètres" + }, + "window.title.signIn": { + "message": "Connexion" + }, + "window.title.sessionExpiration": { + "message": "Expiration de session" + }, + "window.title.updating": { + "message": "Mise à jour" + }, + "window.title.welcome": { + "message": "Bienvenue dans NetBird" + }, + "window.title.error": { + "message": "Erreur" + }, + "welcome.title": { + "message": "Cherchez NetBird dans votre barre d’état système" + }, + "welcome.description": { + "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, + "welcome.continue": { + "message": "Continuer" + }, + "welcome.back": { + "message": "Retour" + }, + "welcome.management.title": { + "message": "Configurer NetBird" + }, + "welcome.management.description": { + "message": "Cliquez sur Continuer pour commencer, ou choisissez Auto-hébergé si vous disposez de votre propre serveur NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Utilisez notre service hébergé. Aucune configuration requise." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hébergé" + }, + "welcome.management.selfHosted.description": { + "message": "Connectez-vous à votre propre serveur de gestion." + }, + "welcome.management.urlLabel": { + "message": "URL du serveur de gestion" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL ou votre réseau, puis continuez si vous êtes sûr qu’elle est correcte." + }, + "welcome.management.checking": { + "message": "Vérification…" + }, + "browserLogin.title": { + "message": "Continuez dans votre navigateur pour terminer la connexion" + }, + "browserLogin.notSeeing": { + "message": "Vous ne voyez pas l’onglet du navigateur ?" + }, + "browserLogin.tryAgain": { + "message": "Réessayer" + }, + "browserLogin.openFailedTitle": { + "message": "Échec de l’ouverture du navigateur" + }, + "sessionExpiration.title": { + "message": "La session expire bientôt" + }, + "sessionExpiration.titleLater": { + "message": "Votre session va expirer" + }, + "sessionExpiration.description": { + "message": "Cet appareil sera bientôt déconnecté. Renouvelez via une connexion dans le navigateur." + }, + "sessionExpiration.descriptionLater": { + "message": "Une connexion dans le navigateur maintient cet appareil connecté à votre réseau." + }, + "sessionExpiration.stay": { + "message": "Renouveler la session" + }, + "sessionExpiration.authenticate": { + "message": "S’authentifier" + }, + "sessionExpiration.logout": { + "message": "Se déconnecter" + }, + "sessionExpiration.expired": { + "message": "Session expirée" + }, + "sessionExpiration.expiredDescription": { + "message": "Appareil déconnecté. Authentifiez-vous via une connexion dans le navigateur pour vous reconnecter." + }, + "sessionExpiration.close": { + "message": "Fermer" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Échec de la prolongation de la session" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Échec de la déconnexion" + }, + "peers.search.placeholder": { + "message": "Rechercher par nom ou IP" + }, + "peers.filter.all": { + "message": "Tous" + }, + "peers.filter.online": { + "message": "En ligne" + }, + "peers.filter.offline": { + "message": "Hors ligne" + }, + "peers.empty.title": { + "message": "Aucun pair disponible" + }, + "peers.empty.description": { + "message": "Soit vous n’avez aucun pair disponible, soit vous n’y avez pas accès." + }, + "peers.details.domain": { + "message": "Domaine" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Clé publique" + }, + "peers.details.connection": { + "message": "Connexion" + }, + "peers.details.latency": { + "message": "Latence" + }, + "peers.details.lastHandshake": { + "message": "Dernier handshake" + }, + "peers.details.statusSince": { + "message": "Dernière mise à jour de connexion" + }, + "peers.details.bytes": { + "message": "Octets" + }, + "peers.details.bytesSent": { + "message": "Envoyés" + }, + "peers.details.bytesReceived": { + "message": "Reçus" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE distant" + }, + "peers.details.never": { + "message": "Jamais" + }, + "peers.details.justNow": { + "message": "À l’instant" + }, + "peers.details.refresh": { + "message": "Actualiser" + }, + "peers.status.connected": { + "message": "Connecté" + }, + "peers.status.connecting": { + "message": "Connexion" + }, + "peers.status.disconnected": { + "message": "Déconnecté" + }, + "peers.details.relayAddress": { + "message": "Relais" + }, + "peers.details.networks": { + "message": "Ressources" + }, + "peers.details.relayed": { + "message": "Relayée" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activé" + }, + "networks.search.placeholder": { + "message": "Rechercher par réseau ou domaine" + }, + "networks.filter.all": { + "message": "Toutes" + }, + "networks.filter.active": { + "message": "Actives" + }, + "networks.filter.overlapping": { + "message": "Chevauchantes" + }, + "networks.empty.title": { + "message": "Aucune ressource disponible" + }, + "networks.empty.description": { + "message": "Soit vous n’avez aucune ressource réseau disponible, soit vous n’y avez pas accès." + }, + "networks.selected": { + "message": "Sélectionnée" + }, + "networks.unselected": { + "message": "Non sélectionnée" + }, + "networks.ips.heading": { + "message": "IP résolues" + }, + "networks.bulk.selectionCount": { + "message": "{selected} sur {total} actives" + }, + "networks.bulk.enableAll": { + "message": "Tout activer" + }, + "networks.bulk.disableAll": { + "message": "Tout désactiver" + }, + "exitNodes.search.placeholder": { + "message": "Rechercher des nœuds de sortie" + }, + "exitNodes.none": { + "message": "Aucun" + }, + "exitNodes.empty.title": { + "message": "Aucun nœud de sortie disponible" + }, + "exitNodes.empty.description": { + "message": "Aucun nœud de sortie n’a été partagé avec ce pair." + }, + "exitNodes.card.title": { + "message": "Nœud de sortie" + }, + "exitNodes.card.statusActive": { + "message": "Actif" + }, + "exitNodes.card.statusInactive": { + "message": "Inactif" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Aucun" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connexion directe sans nœud de sortie" + }, + "quickActions.connect": { + "message": "Se connecter" + }, + "quickActions.disconnect": { + "message": "Se déconnecter" + }, + "daemon.unavailable.title": { + "message": "Le service NetBird n’est pas en cours d’exécution" + }, + "daemon.unavailable.description": { + "message": "L’application se reconnectera automatiquement une fois le service en cours d’exécution." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation" + }, + "daemon.outdated.title": { + "message": "Le service NetBird est obsolète" + }, + "daemon.outdated.description": { + "message": "Mettez à jour le service NetBird pour utiliser cette application." + }, + "error.jwt_clock_skew": { + "message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer." + }, + "error.jwt_expired": { + "message": "Votre jeton de connexion a expiré. Veuillez vous reconnecter." + }, + "error.jwt_signature_invalid": { + "message": "Échec de la connexion : la signature du jeton est invalide. Veuillez contacter votre administrateur." + }, + "error.session_expired": { + "message": "Votre session a expiré. Veuillez vous reconnecter." + }, + "error.invalid_setup_key": { + "message": "La clé d’installation est manquante ou invalide." + }, + "error.permission_denied": { + "message": "La connexion a été rejetée par le serveur." + }, + "error.daemon_unreachable": { + "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." + }, + "error.unknown": { + "message": "L’opération a échoué." + } +} diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json new file mode 100644 index 000000000..b54918364 --- /dev/null +++ b/client/ui/i18n/locales/hu/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Lecsatlakozva" + }, + "tray.status.daemonUnavailable": { + "message": "Nem fut" + }, + "tray.status.error": { + "message": "Hiba" + }, + "tray.status.connected": { + "message": "Csatlakozva" + }, + "tray.status.connecting": { + "message": "Csatlakozás" + }, + "tray.status.needsLogin": { + "message": "Bejelentkezés szükséges" + }, + "tray.status.loginFailed": { + "message": "Sikertelen bejelentkezés" + }, + "tray.status.sessionExpired": { + "message": "Munkamenet lejárt" + }, + "tray.session.expiresIn": { + "message": "Munkamenet lejár {remaining} múlva" + }, + "tray.session.unit.lessThanMinute": { + "message": "egy percnél kevesebb" + }, + "tray.session.unit.minute": { + "message": "1 perc" + }, + "tray.session.unit.minutes": { + "message": "{count} perc" + }, + "tray.session.unit.hour": { + "message": "1 óra" + }, + "tray.session.unit.hours": { + "message": "{count} óra" + }, + "tray.session.unit.day": { + "message": "1 nap" + }, + "tray.session.unit.days": { + "message": "{count} nap" + }, + "tray.menu.open": { + "message": "NetBird megnyitása" + }, + "tray.menu.connect": { + "message": "Csatlakozás" + }, + "tray.menu.disconnect": { + "message": "Bontás" + }, + "tray.menu.exitNode": { + "message": "Exit Node" + }, + "tray.menu.networks": { + "message": "Erőforrások" + }, + "tray.menu.profiles": { + "message": "Profilok" + }, + "tray.menu.manageProfiles": { + "message": "Profilok kezelése" + }, + "tray.menu.settings": { + "message": "Beállítások…" + }, + "tray.menu.debugBundle": { + "message": "Hibakeresési csomag készítése" + }, + "tray.menu.about": { + "message": "Súgó és támogatás" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentáció" + }, + "tray.menu.troubleshoot": { + "message": "Hibakeresés" + }, + "tray.menu.downloadLatest": { + "message": "Legfrissebb verzió letöltése" + }, + "tray.menu.installVersion": { + "message": "{version} verzió telepítése" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird bezárása" + }, + "notify.daemonOutdated.title": { + "message": "A NetBird szolgáltatás elavult" + }, + "notify.daemonOutdated.body": { + "message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához." + }, + "notify.update.title": { + "message": "NetBird frissítés elérhető" + }, + "notify.update.body": { + "message": "Elérhető a NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " A rendszergazda kötelezővé tette ezt a frissítést." + }, + "notify.error.title": { + "message": "Hiba" + }, + "notify.error.connect": { + "message": "Csatlakozás sikertelen" + }, + "notify.error.disconnect": { + "message": "Bontás sikertelen" + }, + "notify.error.switchProfile": { + "message": "Átváltás sikertelen erre: {profile}" + }, + "notify.error.exitNode": { + "message": "Az Exit Node frissítése sikertelen: {name}" + }, + "notify.sessionExpired.title": { + "message": "NetBird munkamenet lejárt" + }, + "notify.sessionExpired.body": { + "message": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "notify.sessionWarning.title": { + "message": "Munkamenet hamarosan lejár" + }, + "notify.sessionWarning.body": { + "message": "A NetBird munkamenet {remaining} múlva lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A NetBird munkamenet hamarosan lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.extend": { + "message": "Meghosszabbítás" + }, + "notify.sessionWarning.dismiss": { + "message": "Elvetés" + }, + "notify.sessionWarning.failed": { + "message": "A NetBird munkamenet meghosszabbítása sikertelen" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird munkamenet meghosszabbítva" + }, + "notify.sessionWarning.successBody": { + "message": "A munkamenet frissítve." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Munkamenet-határidő elutasítva" + }, + "notify.sessionDeadlineRejected.body": { + "message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird beállítások frissítve" + }, + "notify.mdm.policyApplied.body": { + "message": "A NetBird konfigurációt az IT-szabályzat frissítette." + }, + "common.cancel": { + "message": "Mégse" + }, + "common.save": { + "message": "Mentés" + }, + "common.saveChanges": { + "message": "Módosítások mentése" + }, + "common.saving": { + "message": "Mentés…" + }, + "common.close": { + "message": "Bezárás" + }, + "common.copy": { + "message": "Másolás" + }, + "common.togglePasswordVisibility": { + "message": "Jelszó láthatóságának váltása" + }, + "common.increase": { + "message": "Növelés" + }, + "common.decrease": { + "message": "Csökkentés" + }, + "common.delete": { + "message": "Törlés" + }, + "common.create": { + "message": "Létrehozás" + }, + "common.add": { + "message": "Hozzáadás" + }, + "common.remove": { + "message": "Eltávolítás" + }, + "common.refresh": { + "message": "Frissítés" + }, + "common.loading": { + "message": "Betöltés…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nincs találat" + }, + "common.noResults.description": { + "message": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket." + }, + "notConnected.title": { + "message": "Lecsatlakozva" + }, + "notConnected.description": { + "message": "Csatlakozzon először a NetBirdhöz, hogy részletes információkat láthasson a Peerekről, a hálózati erőforrásokról és az Exit Node-okról." + }, + "connect.status.disconnected": { + "message": "Lecsatlakozva" + }, + "connect.status.connecting": { + "message": "Csatlakozás…" + }, + "connect.status.connected": { + "message": "Csatlakozva" + }, + "connect.status.disconnecting": { + "message": "Lecsatlakozás…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nem elérhető" + }, + "connect.status.loginRequired": { + "message": "Bejelentkezés szükséges" + }, + "connect.error.loginTitle": { + "message": "Bejelentkezés sikertelen" + }, + "connect.error.connectTitle": { + "message": "Csatlakozás sikertelen" + }, + "connect.error.disconnectTitle": { + "message": "Bontás sikertelen" + }, + "nav.peers.title": { + "message": "Peerek" + }, + "nav.peers.description": { + "message": "{connected} / {total} csatlakoztatva" + }, + "nav.resources.title": { + "message": "Erőforrások" + }, + "nav.resources.description": { + "message": "{active} / {total} aktív" + }, + "nav.exitNode.title": { + "message": "Exit Node-ok" + }, + "nav.exitNode.none": { + "message": "Nem aktív" + }, + "nav.exitNode.using": { + "message": "Ezen át: {name}" + }, + "header.openSettings": { + "message": "Beállítások megnyitása" + }, + "header.togglePanel": { + "message": "Oldalsó panel váltása" + }, + "profile.selector.loading": { + "message": "Betöltés…" + }, + "profile.selector.noProfile": { + "message": "Nincs profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil keresése név alapján…" + }, + "profile.selector.emptyTitle": { + "message": "Nem található profil" + }, + "profile.selector.emptyDescription": { + "message": "Próbáljon más keresőkifejezést, vagy hozzon létre új profilt." + }, + "profile.selector.newProfile": { + "message": "Új profil" + }, + "profile.selector.moreOptions": { + "message": "További műveletek" + }, + "profile.selector.deregister": { + "message": "Leválasztás" + }, + "profile.selector.delete": { + "message": "Profil törlése" + }, + "profile.selector.switchTo": { + "message": "Váltás erre a profilra" + }, + "profile.selector.edit": { + "message": "Szerkesztés" + }, + "profile.edit.title": { + "message": "Profil szerkesztése" + }, + "profile.edit.submit": { + "message": "Módosítások mentése" + }, + "profile.dialog.title": { + "message": "Új profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilnév" + }, + "profile.dialog.description": { + "message": "Adjon profiljának egy könnyen azonosítható nevet." + }, + "profile.dialog.placeholder": { + "message": "pl. Munka" + }, + "profile.dialog.submit": { + "message": "Profil hozzáadása" + }, + "profile.dialog.required": { + "message": "Adjon meg egy profilnevet, pl. Munka, Otthon" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud vagy saját kiszolgáló." + }, + "profile.dialog.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes." + }, + "header.menu.settings": { + "message": "Beállítások…" + }, + "header.menu.defaultView": { + "message": "Alapnézet" + }, + "header.menu.advancedView": { + "message": "Speciális nézet" + }, + "header.menu.updateAvailable": { + "message": "Frissítés elérhető" + }, + "header.menu.open": { + "message": "Menü megnyitása" + }, + "header.profile.switch": { + "message": "Profilváltás" + }, + "connect.toggle.label": { + "message": "NetBird-kapcsolat be/ki" + }, + "connect.localIp.label": { + "message": "Helyi IP-címek" + }, + "common.search": { + "message": "Keresés" + }, + "common.filter": { + "message": "Szűrés" + }, + "exitNodes.dropdown.trigger": { + "message": "Kilépőcsomópont kiválasztása" + }, + "peers.row.label": { + "message": "Részletek megnyitása: {name}, {status}" + }, + "peers.dialog.title": { + "message": "Társ részletei" + }, + "networks.row.toggle": { + "message": "{name} be/ki" + }, + "networks.bulk.label": { + "message": "Összes látható erőforrás be/ki" + }, + "settings.nav.label": { + "message": "Beállítások szakaszai" + }, + "profile.switch.title": { + "message": "Váltás a(z) \"{name}\" profilra?" + }, + "profile.switch.message": { + "message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva." + }, + "profile.switch.confirm": { + "message": "Megerősítés" + }, + "profile.deregister.title": { + "message": "\"{name}\" profil leválasztása?" + }, + "profile.deregister.message": { + "message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához." + }, + "profile.deregister.confirm": { + "message": "Leválasztás" + }, + "profile.delete.title": { + "message": "\"{name}\" profil törlése?" + }, + "profile.delete.message": { + "message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza." + }, + "profile.delete.disabledActive": { + "message": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt." + }, + "profile.delete.disabledDefault": { + "message": "Az alapértelmezett profil nem törölhető." + }, + "profile.error.switchTitle": { + "message": "Profilváltás sikertelen" + }, + "profile.error.deregisterTitle": { + "message": "Leválasztás sikertelen" + }, + "profile.error.deleteTitle": { + "message": "Profil törlése sikertelen" + }, + "profile.error.createTitle": { + "message": "Profil létrehozása sikertelen" + }, + "profile.error.editTitle": { + "message": "Profil szerkesztése sikertelen" + }, + "profile.error.loadTitle": { + "message": "Profilok betöltése sikertelen" + }, + "profile.dropdown.activeProfile": { + "message": "Aktív profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profilváltás" + }, + "profile.dropdown.noEmail": { + "message": "Egyéb" + }, + "profile.dropdown.addProfile": { + "message": "Profil hozzáadása" + }, + "profile.dropdown.manageProfiles": { + "message": "Profilok kezelése" + }, + "profile.dropdown.settings": { + "message": "Beállítások" + }, + "settings.profiles.section.profiles": { + "message": "Profilok" + }, + "settings.profiles.intro": { + "message": "Kezeljen több NetBird-profilt párhuzamosan, például munkahelyi és személyes fiókokat, vagy különböző felügyeleti szervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat." + }, + "settings.profiles.addProfile": { + "message": "Profil hozzáadása" + }, + "settings.profiles.active": { + "message": "Aktív" + }, + "settings.profiles.emptyTitle": { + "message": "Nincsenek profilok" + }, + "settings.profiles.emptyDescription": { + "message": "Hozzon létre egy profilt a NetBird felügyeleti szerverhez való csatlakozáshoz." + }, + "settings.error.loadTitle": { + "message": "Beállítások betöltése sikertelen" + }, + "settings.error.saveTitle": { + "message": "Beállítások mentése sikertelen" + }, + "settings.error.debugBundleTitle": { + "message": "Hibakeresési csomag sikertelen" + }, + "settings.tabs.general": { + "message": "Általános" + }, + "settings.tabs.network": { + "message": "Hálózat" + }, + "settings.tabs.security": { + "message": "Biztonság" + }, + "settings.tabs.profiles": { + "message": "Profilok" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Speciális" + }, + "settings.tabs.troubleshooting": { + "message": "Hibaelhárítás" + }, + "settings.tabs.about": { + "message": "Névjegy" + }, + "settings.tabs.updateAvailable": { + "message": "Frissítés elérhető" + }, + "settings.general.section.general": { + "message": "Általános" + }, + "settings.general.section.connection": { + "message": "Kapcsolat" + }, + "settings.general.connectOnStartup.label": { + "message": "Csatlakozás indításkor" + }, + "settings.general.connectOnStartup.help": { + "message": "A szolgáltatás indulásakor automatikusan kapcsolatot létesít." + }, + "settings.general.notifications.label": { + "message": "Asztali értesítések" + }, + "settings.general.notifications.help": { + "message": "Asztali értesítések megjelenítése új frissítésekről és kapcsolati eseményekről." + }, + "settings.general.autostart.label": { + "message": "NetBird UI indítása bejelentkezéskor" + }, + "settings.general.autostart.help": { + "message": "A NetBird felület automatikus indítása bejelentkezéskor. Ez csak a grafikus felületet érinti, a háttérszolgáltatást nem." + }, + "settings.general.autostart.errorTitle": { + "message": "Az automatikus indítás módosítása sikertelen" + }, + "settings.general.language.label": { + "message": "Megjelenítési nyelv" + }, + "settings.general.language.help": { + "message": "Válassza ki a NetBird felület nyelvét." + }, + "settings.general.language.search": { + "message": "Nyelv keresése…" + }, + "settings.general.language.empty": { + "message": "Nincs találat." + }, + "settings.general.management.label": { + "message": "Felügyeleti szerver" + }, + "settings.general.management.help": { + "message": "Csatlakozás a NetBird Cloudhoz vagy saját üzemeltetésű felügyeleti szerverhez. A módosítások újracsatlakozást váltanak ki." + }, + "settings.general.management.cloud": { + "message": "Felhő" + }, + "settings.general.management.selfHosted": { + "message": "Saját üzemeltetésű" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes." + }, + "settings.general.management.switchCloudTitle": { + "message": "Átváltás a NetBird Cloudra?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Váltás a felhőre" + }, + "settings.network.section.connectivity": { + "message": "Kapcsolódás" + }, + "settings.network.section.routingDns": { + "message": "Útválasztás és DNS" + }, + "settings.network.monitor.label": { + "message": "Újracsatlakozás hálózatváltáskor" + }, + "settings.network.monitor.help": { + "message": "A hálózat figyelése és automatikus újracsatlakozás változások (pl. Wi-Fi-váltás, Ethernet-változás vagy alvó állapotból való visszatérés) esetén." + }, + "settings.network.dns.label": { + "message": "DNS engedélyezése" + }, + "settings.network.dns.help": { + "message": "A NetBird által kezelt DNS-beállítások alkalmazása a gazda DNS-feloldójára." + }, + "settings.network.clientRoutes.label": { + "message": "Kliens útvonalak engedélyezése" + }, + "settings.network.clientRoutes.help": { + "message": "Más Peerek útvonalainak elfogadása a hálózataik eléréséhez." + }, + "settings.network.serverRoutes.label": { + "message": "Szerver útvonalak engedélyezése" + }, + "settings.network.serverRoutes.help": { + "message": "Ennek a gazdának a helyi útvonalainak meghirdetése más Peerek számára." + }, + "settings.network.ipv6.label": { + "message": "IPv6 engedélyezése" + }, + "settings.network.ipv6.help": { + "message": "IPv6-címzés használata a NetBird overlay hálózathoz." + }, + "settings.security.section.firewall": { + "message": "Tűzfal" + }, + "settings.security.section.encryption": { + "message": "Titkosítás" + }, + "settings.security.blockInbound.label": { + "message": "Bejövő forgalom blokkolása" + }, + "settings.security.blockInbound.help": { + "message": "Visszautasítja a Peerektől érkező nem kért kapcsolatokat ezen eszközhöz és az általa irányított hálózatokhoz. A kimenő forgalmat nem érinti." + }, + "settings.security.blockLan.label": { + "message": "LAN-hozzáférés blokkolása" + }, + "settings.security.blockLan.help": { + "message": "Megakadályozza, hogy a Peerek elérjék a helyi hálózatot vagy annak eszközeit, amikor ez az eszköz irányítja a forgalmukat." + }, + "settings.security.rosenpass.label": { + "message": "Kvantumellenálló titkosítás engedélyezése" + }, + "settings.security.rosenpass.help": { + "message": "Post-kvantum kulcscsere hozzáadása Rosenpass segítségével a WireGuard® tetejére." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Engedékeny mód engedélyezése" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli Peerekkel." + }, + "settings.ssh.section.server": { + "message": "Szerver" + }, + "settings.ssh.section.capabilities": { + "message": "Képességek" + }, + "settings.ssh.section.authentication": { + "message": "Hitelesítés" + }, + "settings.ssh.server.label": { + "message": "SSH szerver engedélyezése" + }, + "settings.ssh.server.help": { + "message": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más Peerek csatlakozhassanak." + }, + "settings.ssh.root.label": { + "message": "Root bejelentkezés engedélyezése" + }, + "settings.ssh.root.help": { + "message": "A Peerek bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges." + }, + "settings.ssh.sftp.label": { + "message": "SFTP engedélyezése" + }, + "settings.ssh.sftp.help": { + "message": "Fájlok biztonságos átvitele natív SFTP- vagy SCP-kliensekkel." + }, + "settings.ssh.localForward.label": { + "message": "Helyi porttovábbítás" + }, + "settings.ssh.localForward.help": { + "message": "A csatlakozó Peerek helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz." + }, + "settings.ssh.remoteForward.label": { + "message": "Távoli porttovábbítás" + }, + "settings.ssh.remoteForward.help": { + "message": "A csatlakozó Peerek ezen a gazdán lévő portokat tehetnek elérhetővé a saját gépük számára." + }, + "settings.ssh.jwt.label": { + "message": "JWT-hitelesítés engedélyezése" + }, + "settings.ssh.jwt.help": { + "message": "Minden SSH-munkamenet ellenőrzése az IdP-vel a felhasználói identitás és audit céljából. Tiltsa le, ha csak a hálózati ACL-szabályokra kíván támaszkodni — hasznos, ha nincs elérhető IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT gyorsítótár TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "Mennyi ideig őrzi meg a kliens a JWT-t, mielőtt újra kérné a kimenő SSH-kapcsolatoknál. 0 érték esetén a gyorsítótárazás kikapcsol, és minden kapcsolatnál hitelesít." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "másodperc" + }, + "settings.advanced.section.interface": { + "message": "Interfész" + }, + "settings.advanced.section.security": { + "message": "Biztonság" + }, + "settings.advanced.interfaceName.label": { + "message": "Név" + }, + "settings.advanced.interfaceName.error": { + "message": "Használjon 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "„utun” után számmal kezdődjön (pl. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Adjon meg egy portot {min} és {max} között." + }, + "settings.advanced.port.help": { + "message": "Ha 0-ra állítja, egy véletlenszerű szabad portot használ." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Adjon meg egy MTU értéket {min} és {max} között." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared kulcs" + }, + "settings.advanced.psk.help": { + "message": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan Peerekkel kommunikál, akik ugyanazt a pre-shared kulcsot használják." + }, + "settings.troubleshooting.section.title": { + "message": "Hibakeresési csomag" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Érzékeny információk anonimizálása" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Rendszerinformációk beillesztése" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Tartalmazza az OS-t, a kernelt, a hálózati interfészeket és az útválasztási táblákat." + }, + "settings.troubleshooting.upload.label": { + "message": "Csomag feltöltése a NetBird szerverekre" + }, + "settings.troubleshooting.upload.help": { + "message": "Egy feltöltési kulcsot ad vissza, amelyet megoszthat a NetBird támogatással." + }, + "settings.troubleshooting.trace.label": { + "message": "Trace naplók engedélyezése" + }, + "settings.troubleshooting.trace.help": { + "message": "TRACE szintre emeli a naplózást, majd utána visszaállítja." + }, + "settings.troubleshooting.capture.label": { + "message": "Rögzítési munkamenet" + }, + "settings.troubleshooting.capture.help": { + "message": "Újra csatlakozik és vár, hogy reprodukálhassa a problémát." + }, + "settings.troubleshooting.packets.label": { + "message": "Hálózati csomagok rögzítése" + }, + "settings.troubleshooting.packets.help": { + "message": "A rögzítés ideje alatt elmenti a hálózati forgalom .pcap fájlját." + }, + "settings.troubleshooting.duration.label": { + "message": "Rögzítés időtartama" + }, + "settings.troubleshooting.duration.help": { + "message": "Mennyi ideig fusson a rögzítési munkamenet." + }, + "settings.troubleshooting.duration.suffix": { + "message": "perc" + }, + "settings.troubleshooting.create": { + "message": "Hibakeresési csomag létrehozása" + }, + "settings.troubleshooting.progress.description": { + "message": "Naplók, rendszerinformációk és kapcsolati állapot gyűjtése folyamatban. Általában néhány pillanatot vesz igénybe — tartsa nyitva ezt az ablakot a befejezésig." + }, + "settings.troubleshooting.cancelling": { + "message": "Megszakítás…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "A hibakeresési csomag feltöltése sikeres!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Csomag elmentve" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Ossza meg az alábbi feltöltési kulcsot a NetBird támogatással. A helyi másolat is elmentve van az eszközén." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "A hibakeresési csomag helyileg elmentve." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Kulcs másolása" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Mappa megnyitása" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Fájl helyének megnyitása" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Feltöltés sikertelen: {reason} A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Feltöltés sikertelen. A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird újracsatlakoztatása…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Hibakeresési naplók rögzítése" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Hibakeresési csomag generálása…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Feltöltés a NetBirdhöz…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Megszakítás…" + }, + "settings.about.client": { + "message": "NetBird Kliens v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Kliens" + }, + "settings.about.development": { + "message": "[Fejlesztés]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Minden jog fenntartva." + }, + "settings.about.links.imprint": { + "message": "Impresszum" + }, + "settings.about.links.privacy": { + "message": "Adatvédelem" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Felhasználási feltételek" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Dokumentáció" + }, + "settings.about.community.feedback": { + "message": "Visszajelzés" + }, + "update.banner.message": { + "message": "A NetBird {version} telepítésre kész." + }, + "update.banner.later": { + "message": "Később" + }, + "update.banner.installNow": { + "message": "Telepítés most" + }, + "update.card.versionAvailableDownload": { + "message": "A {version} verzió letöltésre elérhető." + }, + "update.card.versionAvailableInstall": { + "message": "A {version} verzió telepítésre elérhető." + }, + "update.card.whatsNew": { + "message": "Mi az újdonság?" + }, + "update.card.installNow": { + "message": "Telepítés most" + }, + "update.card.getInstaller": { + "message": "Letöltés" + }, + "update.card.autoCheckInterval": { + "message": "A NetBird a háttérben keres frissítéseket." + }, + "update.card.changelog": { + "message": "Változásnapló" + }, + "update.card.onLatestVersion": { + "message": "A legfrissebb verziót használja" + }, + "update.header.tooltip": { + "message": "Frissítés elérhető" + }, + "update.overlay.updatingVersion": { + "message": "NetBird frissítése a következőre: v{version}" + }, + "update.overlay.updating": { + "message": "NetBird frissítése" + }, + "update.overlay.description": { + "message": "Egy újabb verzió elérhető és települ. A NetBird automatikusan újraindul a frissítés befejeztével." + }, + "update.overlay.error.timeoutTitle": { + "message": "A frissítés túl sokáig tart" + }, + "update.overlay.error.timeoutDescription": { + "message": "A(z) {target} telepítése túl sokáig tartott, és nem fejeződött be." + }, + "update.overlay.error.canceledTitle": { + "message": "A frissítés megszakítva" + }, + "update.overlay.error.canceledDescription": { + "message": "A(z) {target} frissítését megszakították a befejezés előtt." + }, + "update.overlay.error.failTitle": { + "message": "A frissítés nem telepíthető" + }, + "update.overlay.error.failDescription": { + "message": "A(z) {target} nem volt telepíthető." + }, + "update.overlay.error.unknownMessage": { + "message": "ismeretlen hiba" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "az új verzió" + }, + "update.error.loadStateTitle": { + "message": "Frissítési állapot betöltése sikertelen" + }, + "update.error.triggerTitle": { + "message": "Frissítés indítása sikertelen" + }, + "update.page.versionLine": { + "message": "Kliens frissítése erre: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Kliens frissítése." + }, + "update.page.outdated": { + "message": "Az Ön kliensverziója régebbi, mint a Managementben beállított automatikus frissítési verzió." + }, + "update.page.status.running": { + "message": "Frissítés" + }, + "update.page.status.timeout": { + "message": "A frissítés időtúllépés miatt megszakadt. Kérjük, próbálja újra." + }, + "update.page.status.canceled": { + "message": "Frissítés megszakítva." + }, + "update.page.status.failed": { + "message": "Frissítés sikertelen: {message}" + }, + "update.page.status.unknownError": { + "message": "ismeretlen frissítési hiba" + }, + "update.page.failedTitle": { + "message": "Frissítés sikertelen" + }, + "update.page.timeoutMessage": { + "message": "Frissítés időtúllépés." + }, + "update.page.dontClose": { + "message": "Kérjük, ne zárja be ezt az ablakot." + }, + "update.page.updating": { + "message": "Frissítés…" + }, + "update.page.complete": { + "message": "Frissítés kész" + }, + "update.page.failed": { + "message": "Frissítés sikertelen" + }, + "window.title.settings": { + "message": "Beállítások" + }, + "window.title.signIn": { + "message": "Bejelentkezés" + }, + "window.title.sessionExpiration": { + "message": "Munkamenet lejár" + }, + "window.title.updating": { + "message": "Frissítés" + }, + "window.title.welcome": { + "message": "Üdvözli a NetBird" + }, + "window.title.error": { + "message": "Hiba" + }, + "welcome.title": { + "message": "Keresse a NetBirdöt a tálcán" + }, + "welcome.description": { + "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, + "welcome.continue": { + "message": "Folytatás" + }, + "welcome.back": { + "message": "Vissza" + }, + "welcome.management.title": { + "message": "NetBird beállítása" + }, + "welcome.management.description": { + "message": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a „Saját üzemeltetésű” lehetőséget, ha saját NetBird-szervere van." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra." + }, + "welcome.management.selfHosted.title": { + "message": "Saját üzemeltetésű" + }, + "welcome.management.selfHosted.description": { + "message": "Csatlakozás a saját felügyeleti szerveréhez." + }, + "welcome.management.urlLabel": { + "message": "Felügyeleti szerver URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes." + }, + "welcome.management.checking": { + "message": "Ellenőrzés…" + }, + "browserLogin.title": { + "message": "Folytassa a böngészőben a bejelentkezés befejezéséhez" + }, + "browserLogin.notSeeing": { + "message": "Nem látja a böngésző fülét?" + }, + "browserLogin.tryAgain": { + "message": "Próbálja újra" + }, + "browserLogin.openFailedTitle": { + "message": "A böngésző megnyitása sikertelen" + }, + "sessionExpiration.title": { + "message": "A munkamenet hamarosan lejár" + }, + "sessionExpiration.titleLater": { + "message": "A munkamenete lejár" + }, + "sessionExpiration.description": { + "message": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell." + }, + "sessionExpiration.descriptionLater": { + "message": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt." + }, + "sessionExpiration.stay": { + "message": "Munkamenet megújítása" + }, + "sessionExpiration.authenticate": { + "message": "Bejelentkezés" + }, + "sessionExpiration.logout": { + "message": "Kijelentkezés" + }, + "sessionExpiration.expired": { + "message": "Munkamenet lejárt" + }, + "sessionExpiration.expiredDescription": { + "message": "Eszköz lecsatlakozott. Hitelesítés böngészős bejelentkezéssel az újracsatlakozáshoz." + }, + "sessionExpiration.close": { + "message": "Bezárás" + }, + "sessionExpiration.extendFailedTitle": { + "message": "A munkamenet meghosszabbítása sikertelen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Kijelentkezés sikertelen" + }, + "peers.search.placeholder": { + "message": "Keresés név vagy IP alapján" + }, + "peers.filter.all": { + "message": "Összes" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nincs elérhető Peer" + }, + "peers.empty.description": { + "message": "Önnek vagy nincsenek elérhető Peerei, vagy nincs hozzáférése egyikhez sem." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Nyilvános kulcs" + }, + "peers.details.connection": { + "message": "Kapcsolat" + }, + "peers.details.latency": { + "message": "Késleltetés" + }, + "peers.details.lastHandshake": { + "message": "Utolsó Handshake" + }, + "peers.details.statusSince": { + "message": "Utolsó kapcsolati frissítés" + }, + "peers.details.bytes": { + "message": "Bájtok" + }, + "peers.details.bytesSent": { + "message": "Küldve" + }, + "peers.details.bytesReceived": { + "message": "Fogadva" + }, + "peers.details.localIce": { + "message": "Helyi ICE" + }, + "peers.details.remoteIce": { + "message": "Távoli ICE" + }, + "peers.details.never": { + "message": "Soha" + }, + "peers.details.justNow": { + "message": "Épp most" + }, + "peers.details.refresh": { + "message": "Frissítés" + }, + "peers.status.connected": { + "message": "Csatlakozva" + }, + "peers.status.connecting": { + "message": "Csatlakozás" + }, + "peers.status.disconnected": { + "message": "Lecsatlakozva" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Erőforrások" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass engedélyezve" + }, + "networks.search.placeholder": { + "message": "Keresés hálózat vagy domain alapján" + }, + "networks.filter.all": { + "message": "Összes" + }, + "networks.filter.active": { + "message": "Aktív" + }, + "networks.filter.overlapping": { + "message": "Átfedő" + }, + "networks.empty.title": { + "message": "Nincs elérhető erőforrás" + }, + "networks.empty.description": { + "message": "Önnek vagy nincsenek elérhető hálózati erőforrásai vagy nincs hozzáférése egyikhez sem." + }, + "networks.selected": { + "message": "Kiválasztva" + }, + "networks.unselected": { + "message": "Nincs kiválasztva" + }, + "networks.ips.heading": { + "message": "Feloldott IP-címek" + }, + "networks.bulk.selectionCount": { + "message": "{selected} / {total} aktív" + }, + "networks.bulk.enableAll": { + "message": "Összes engedélyezése" + }, + "networks.bulk.disableAll": { + "message": "Összes letiltása" + }, + "exitNodes.search.placeholder": { + "message": "Keresés az Exit Node-ok között" + }, + "exitNodes.none": { + "message": "Egyik sem" + }, + "exitNodes.empty.title": { + "message": "Nincs elérhető Exit Node" + }, + "exitNodes.empty.description": { + "message": "Ehhez a Peerhez nem osztottak meg Exit Node-okat." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktív" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktív" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Egyik sem" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Közvetlen kapcsolat Exit Node nélkül" + }, + "quickActions.connect": { + "message": "Csatlakozás" + }, + "quickActions.disconnect": { + "message": "Bontás" + }, + "daemon.unavailable.title": { + "message": "A NetBird szolgáltatás nem fut" + }, + "daemon.unavailable.description": { + "message": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentáció" + }, + "daemon.outdated.title": { + "message": "A NetBird szolgáltatás elavult" + }, + "daemon.outdated.description": { + "message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához." + }, + "error.jwt_clock_skew": { + "message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra." + }, + "error.jwt_expired": { + "message": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra." + }, + "error.jwt_signature_invalid": { + "message": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával." + }, + "error.session_expired": { + "message": "A munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "error.invalid_setup_key": { + "message": "A telepítőkulcs hiányzik vagy érvénytelen." + }, + "error.permission_denied": { + "message": "A szerver elutasította a bejelentkezést." + }, + "error.daemon_unreachable": { + "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." + }, + "error.unknown": { + "message": "A művelet meghiúsult." + } +} diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json new file mode 100644 index 000000000..603364fa2 --- /dev/null +++ b/client/ui/i18n/locales/it/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Disconnesso" + }, + "tray.status.daemonUnavailable": { + "message": "Non in esecuzione" + }, + "tray.status.error": { + "message": "Errore" + }, + "tray.status.connected": { + "message": "Connesso" + }, + "tray.status.connecting": { + "message": "Connessione" + }, + "tray.status.needsLogin": { + "message": "Accesso richiesto" + }, + "tray.status.loginFailed": { + "message": "Accesso non riuscito" + }, + "tray.status.sessionExpired": { + "message": "Sessione scaduta" + }, + "tray.session.expiresIn": { + "message": "La sessione scade tra {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "meno di un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minuti" + }, + "tray.session.unit.hour": { + "message": "1 ora" + }, + "tray.session.unit.hours": { + "message": "{count} ore" + }, + "tray.session.unit.day": { + "message": "1 giorno" + }, + "tray.session.unit.days": { + "message": "{count} giorni" + }, + "tray.menu.open": { + "message": "Apri NetBird" + }, + "tray.menu.connect": { + "message": "Connetti" + }, + "tray.menu.disconnect": { + "message": "Disconnetti" + }, + "tray.menu.exitNode": { + "message": "Nodo di uscita" + }, + "tray.menu.networks": { + "message": "Risorse" + }, + "tray.menu.profiles": { + "message": "Profili" + }, + "tray.menu.manageProfiles": { + "message": "Gestisci profili" + }, + "tray.menu.settings": { + "message": "Impostazioni..." + }, + "tray.menu.debugBundle": { + "message": "Crea pacchetto di debug" + }, + "tray.menu.about": { + "message": "Guida e supporto" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentazione" + }, + "tray.menu.troubleshoot": { + "message": "Risoluzione dei problemi" + }, + "tray.menu.downloadLatest": { + "message": "Scarica l'ultima versione" + }, + "tray.menu.installVersion": { + "message": "Installa la versione {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Esci da NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Il servizio NetBird è obsoleto" + }, + "notify.daemonOutdated.body": { + "message": "Aggiorna il servizio NetBird per usare questa app." + }, + "notify.update.title": { + "message": "Aggiornamento NetBird disponibile" + }, + "notify.update.body": { + "message": "NetBird {version} è disponibile." + }, + "notify.update.enforcedSuffix": { + "message": " L'amministratore richiede questo aggiornamento." + }, + "notify.error.title": { + "message": "Errore" + }, + "notify.error.connect": { + "message": "Connessione non riuscita" + }, + "notify.error.disconnect": { + "message": "Disconnessione non riuscita" + }, + "notify.error.switchProfile": { + "message": "Impossibile passare a {profile}" + }, + "notify.error.exitNode": { + "message": "Impossibile aggiornare il nodo di uscita {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessione NetBird scaduta" + }, + "notify.sessionExpired.body": { + "message": "La sessione NetBird è scaduta. Effettui di nuovo l'accesso." + }, + "notify.sessionWarning.title": { + "message": "La sessione sta per scadere" + }, + "notify.sessionWarning.body": { + "message": "La sessione NetBird scade tra {remaining}. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "La sessione NetBird sta per scadere. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.extend": { + "message": "Rinnova ora" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignora" + }, + "notify.sessionWarning.failed": { + "message": "Impossibile rinnovare la sessione NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessione NetBird rinnovata" + }, + "notify.sessionWarning.successBody": { + "message": "La sessione è stata rinnovata." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Scadenza sessione rifiutata" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso." + }, + "notify.mdm.policyApplied.title": { + "message": "Impostazioni NetBird aggiornate" + }, + "notify.mdm.policyApplied.body": { + "message": "La configurazione di NetBird è stata aggiornata dalla policy IT." + }, + "common.cancel": { + "message": "Annulla" + }, + "common.save": { + "message": "Salva" + }, + "common.saveChanges": { + "message": "Salva modifiche" + }, + "common.saving": { + "message": "Salvataggio…" + }, + "common.close": { + "message": "Chiudi" + }, + "common.copy": { + "message": "Copia" + }, + "common.togglePasswordVisibility": { + "message": "Mostra/nascondi password" + }, + "common.increase": { + "message": "Aumenta" + }, + "common.decrease": { + "message": "Diminuisci" + }, + "common.delete": { + "message": "Elimina" + }, + "common.create": { + "message": "Crea" + }, + "common.add": { + "message": "Aggiungi" + }, + "common.remove": { + "message": "Rimuovi" + }, + "common.refresh": { + "message": "Aggiorna" + }, + "common.loading": { + "message": "Caricamento…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nessun risultato trovato" + }, + "common.noResults.description": { + "message": "Non è stato trovato alcun risultato. Provi un altro termine di ricerca o modifichi i filtri." + }, + "notConnected.title": { + "message": "Disconnesso" + }, + "notConnected.description": { + "message": "Si connetta prima a NetBird per visualizzare informazioni dettagliate su peer, risorse di rete e nodi di uscita." + }, + "connect.status.disconnected": { + "message": "Disconnesso" + }, + "connect.status.connecting": { + "message": "Connessione..." + }, + "connect.status.connected": { + "message": "Connesso" + }, + "connect.status.disconnecting": { + "message": "Disconnessione..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non disponibile" + }, + "connect.status.loginRequired": { + "message": "Accesso richiesto" + }, + "connect.error.loginTitle": { + "message": "Accesso non riuscito" + }, + "connect.error.connectTitle": { + "message": "Connessione non riuscita" + }, + "connect.error.disconnectTitle": { + "message": "Disconnessione non riuscita" + }, + "nav.peers.title": { + "message": "Peer" + }, + "nav.peers.description": { + "message": "{connected} di {total} connessi" + }, + "nav.resources.title": { + "message": "Risorse" + }, + "nav.resources.description": { + "message": "{active} di {total} attive" + }, + "nav.exitNode.title": { + "message": "Nodi di uscita" + }, + "nav.exitNode.none": { + "message": "Non attivo" + }, + "nav.exitNode.using": { + "message": "Tramite {name}" + }, + "header.openSettings": { + "message": "Apri impostazioni" + }, + "header.togglePanel": { + "message": "Mostra/nascondi pannello laterale" + }, + "profile.selector.loading": { + "message": "Caricamento..." + }, + "profile.selector.noProfile": { + "message": "Nessun profilo" + }, + "profile.selector.searchPlaceholder": { + "message": "Cerca profilo per nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nessun profilo trovato" + }, + "profile.selector.emptyDescription": { + "message": "Provi un altro termine di ricerca o crei un nuovo profilo." + }, + "profile.selector.newProfile": { + "message": "Nuovo profilo" + }, + "profile.selector.moreOptions": { + "message": "Altre opzioni" + }, + "profile.selector.deregister": { + "message": "Annulla registrazione" + }, + "profile.selector.delete": { + "message": "Elimina" + }, + "profile.selector.switchTo": { + "message": "Passa a questo profilo" + }, + "profile.selector.edit": { + "message": "Modifica" + }, + "profile.edit.title": { + "message": "Modifica profilo" + }, + "profile.edit.submit": { + "message": "Salva modifiche" + }, + "profile.dialog.title": { + "message": "Inserisci il nome del profilo" + }, + "profile.dialog.nameLabel": { + "message": "Nome del profilo" + }, + "profile.dialog.description": { + "message": "Imposti un nome facilmente riconoscibile per il profilo." + }, + "profile.dialog.placeholder": { + "message": "es. lavoro" + }, + "profile.dialog.submit": { + "message": "Aggiungi profilo" + }, + "profile.dialog.required": { + "message": "Inserisca un nome per il profilo, es. lavoro, casa" + }, + "profile.dialog.managementHelp": { + "message": "Usi NetBird Cloud o il suo server." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure aggiunga comunque il profilo se è certo che sia corretto." + }, + "header.menu.settings": { + "message": "Impostazioni..." + }, + "header.menu.defaultView": { + "message": "Vista predefinita" + }, + "header.menu.advancedView": { + "message": "Vista avanzata" + }, + "header.menu.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "header.menu.open": { + "message": "Apri menu" + }, + "header.profile.switch": { + "message": "Cambia profilo" + }, + "connect.toggle.label": { + "message": "Attiva/disattiva connessione NetBird" + }, + "connect.localIp.label": { + "message": "Indirizzi IP locali" + }, + "common.search": { + "message": "Cerca" + }, + "common.filter": { + "message": "Filtra" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleziona nodo di uscita" + }, + "peers.row.label": { + "message": "Apri dettagli di {name}, {status}" + }, + "peers.dialog.title": { + "message": "Dettagli peer" + }, + "networks.row.toggle": { + "message": "Attiva/disattiva {name}" + }, + "networks.bulk.label": { + "message": "Attiva/disattiva tutte le risorse visibili" + }, + "settings.nav.label": { + "message": "Sezioni delle impostazioni" + }, + "profile.switch.title": { + "message": "Passare al profilo «{name}»?" + }, + "profile.switch.message": { + "message": "Vuole davvero cambiare profilo?\nIl profilo attuale verrà disconnesso." + }, + "profile.switch.confirm": { + "message": "Conferma" + }, + "profile.deregister.title": { + "message": "Annullare la registrazione del profilo «{name}»?" + }, + "profile.deregister.message": { + "message": "Vuole davvero annullare la registrazione di questo profilo?\nDovrà accedere di nuovo per usarlo." + }, + "profile.deregister.confirm": { + "message": "Annulla registrazione" + }, + "profile.delete.title": { + "message": "Eliminare il profilo «{name}»?" + }, + "profile.delete.message": { + "message": "Vuole davvero eliminare questo profilo?\nQuesta azione non può essere annullata." + }, + "profile.delete.disabledActive": { + "message": "I profili attivi non possono essere eliminati. Passi a un altro profilo prima di eliminare questo." + }, + "profile.delete.disabledDefault": { + "message": "Il profilo predefinito non può essere eliminato." + }, + "profile.error.switchTitle": { + "message": "Cambio profilo non riuscito" + }, + "profile.error.deregisterTitle": { + "message": "Annullamento registrazione non riuscito" + }, + "profile.error.deleteTitle": { + "message": "Eliminazione profilo non riuscita" + }, + "profile.error.createTitle": { + "message": "Creazione profilo non riuscita" + }, + "profile.error.editTitle": { + "message": "Modifica del profilo non riuscita" + }, + "profile.error.loadTitle": { + "message": "Caricamento profili non riuscito" + }, + "profile.dropdown.activeProfile": { + "message": "Profilo attivo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambia profilo" + }, + "profile.dropdown.noEmail": { + "message": "Altro" + }, + "profile.dropdown.addProfile": { + "message": "Aggiungi profilo" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestisci profili" + }, + "profile.dropdown.settings": { + "message": "Impostazioni" + }, + "settings.profiles.section.profiles": { + "message": "Profili" + }, + "settings.profiles.intro": { + "message": "Mantenga affiancate identità NetBird separate, ad esempio account di lavoro e personali, oppure server di gestione diversi. Aggiunga, annulli la registrazione o elimini i profili qui sotto." + }, + "settings.profiles.addProfile": { + "message": "Aggiungi profilo" + }, + "settings.profiles.active": { + "message": "Attivo" + }, + "settings.profiles.emptyTitle": { + "message": "Nessun profilo" + }, + "settings.profiles.emptyDescription": { + "message": "Crei un profilo per connettersi a un server di gestione NetBird." + }, + "settings.error.loadTitle": { + "message": "Caricamento impostazioni non riuscito" + }, + "settings.error.saveTitle": { + "message": "Salvataggio impostazioni non riuscito" + }, + "settings.error.debugBundleTitle": { + "message": "Pacchetto di debug non riuscito" + }, + "settings.tabs.general": { + "message": "Generale" + }, + "settings.tabs.network": { + "message": "Rete" + }, + "settings.tabs.security": { + "message": "Sicurezza" + }, + "settings.tabs.profiles": { + "message": "Profili" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzate" + }, + "settings.tabs.troubleshooting": { + "message": "Risoluzione problemi" + }, + "settings.tabs.about": { + "message": "Informazioni" + }, + "settings.tabs.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "settings.general.section.general": { + "message": "Generale" + }, + "settings.general.section.connection": { + "message": "Connessione" + }, + "settings.general.connectOnStartup.label": { + "message": "Connetti all'avvio" + }, + "settings.general.connectOnStartup.help": { + "message": "Stabilisce automaticamente una connessione all'avvio del servizio." + }, + "settings.general.notifications.label": { + "message": "Notifiche desktop" + }, + "settings.general.notifications.help": { + "message": "Mostra notifiche desktop per nuovi aggiornamenti ed eventi di connessione." + }, + "settings.general.autostart.label": { + "message": "Avvia l'interfaccia NetBird all'accesso" + }, + "settings.general.autostart.help": { + "message": "Avvia automaticamente l'interfaccia NetBird quando effettua l'accesso. Riguarda solo l'interfaccia grafica, non il servizio in background." + }, + "settings.general.autostart.errorTitle": { + "message": "Modifica avvio automatico non riuscita" + }, + "settings.general.language.label": { + "message": "Lingua dell'interfaccia" + }, + "settings.general.language.help": { + "message": "Scelga la lingua dell'interfaccia NetBird." + }, + "settings.general.language.search": { + "message": "Cerca lingua…" + }, + "settings.general.language.empty": { + "message": "Nessuna lingua corrisponde." + }, + "settings.general.management.label": { + "message": "Server di gestione" + }, + "settings.general.management.help": { + "message": "Si connetta a NetBird Cloud o al suo server di gestione self-hosted. Le modifiche riconnettono il client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure salvi comunque se è certo che sia corretto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Passare a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Questo disconnette il suo server self-hosted.\nPotrebbe dover accedere di nuovo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Passa a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connettività" + }, + "settings.network.section.routingDns": { + "message": "Routing e DNS" + }, + "settings.network.monitor.label": { + "message": "Riconnetti al cambio di rete" + }, + "settings.network.monitor.help": { + "message": "Monitora la rete e si riconnette automaticamente ai cambiamenti, come il passaggio di Wi-Fi, le modifiche Ethernet o la ripresa dalla sospensione." + }, + "settings.network.dns.label": { + "message": "Abilita DNS" + }, + "settings.network.dns.help": { + "message": "Applica le impostazioni DNS gestite da NetBird al resolver dell'host." + }, + "settings.network.clientRoutes.label": { + "message": "Abilita route client" + }, + "settings.network.clientRoutes.help": { + "message": "Accetta le route da altri peer per raggiungere le loro reti." + }, + "settings.network.serverRoutes.label": { + "message": "Abilita route server" + }, + "settings.network.serverRoutes.help": { + "message": "Annuncia le route locali di questo host agli altri peer." + }, + "settings.network.ipv6.label": { + "message": "Abilita IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa l'indirizzamento IPv6 per la rete overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Crittografia" + }, + "settings.security.blockInbound.label": { + "message": "Blocca traffico in entrata" + }, + "settings.security.blockInbound.help": { + "message": "Rifiuta le connessioni non richieste dai peer verso questo dispositivo e le reti che instrada. Il traffico in uscita non è interessato." + }, + "settings.security.blockLan.label": { + "message": "Blocca accesso alla LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedisce ai peer di raggiungere la sua rete locale o i suoi dispositivi quando questo dispositivo instrada il loro traffico." + }, + "settings.security.rosenpass.label": { + "message": "Abilita resistenza quantistica" + }, + "settings.security.rosenpass.help": { + "message": "Aggiunge uno scambio di chiavi post-quantistico tramite Rosenpass sopra WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Abilita modalità permissiva" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Consente le connessioni con peer privi del supporto alla resistenza quantistica." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funzionalità" + }, + "settings.ssh.section.authentication": { + "message": "Autenticazione" + }, + "settings.ssh.server.label": { + "message": "Abilita server SSH" + }, + "settings.ssh.server.help": { + "message": "Esegue il server SSH di NetBird su questo host in modo che altri peer possano connettersi." + }, + "settings.ssh.root.label": { + "message": "Consenti accesso come root" + }, + "settings.ssh.root.help": { + "message": "Permette ai peer di accedere come utente root. Disabiliti per richiedere un account senza privilegi." + }, + "settings.ssh.sftp.label": { + "message": "Consenti SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Trasferisca file in modo sicuro usando client SFTP o SCP nativi." + }, + "settings.ssh.localForward.label": { + "message": "Inoltro porte locale" + }, + "settings.ssh.localForward.help": { + "message": "Permette ai peer in connessione di inoltrare porte locali verso servizi raggiungibili da questo host." + }, + "settings.ssh.remoteForward.label": { + "message": "Inoltro porte remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permette ai peer in connessione di esporre porte di questo host verso la loro macchina." + }, + "settings.ssh.jwt.label": { + "message": "Abilita autenticazione JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica ogni sessione SSH con il suo IdP per l'identità utente e l'audit. Disabiliti per basarsi solo sulle policy ACL di rete, utile quando non è disponibile un IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Per quanto tempo questo client memorizza un JWT prima di richiederlo di nuovo sulle connessioni SSH in uscita. Imposti 0 per disabilitare la cache e autenticarsi a ogni connessione." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "sec." + }, + "settings.advanced.section.interface": { + "message": "Interfaccia" + }, + "settings.advanced.section.security": { + "message": "Sicurezza" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Usi da 1 a 15 lettere, cifre, punti, trattini o trattini bassi." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve iniziare con \"utun\" seguito da un numero (es. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Inserisca una porta compresa tra {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se impostata su 0, verrà usata una porta libera casuale." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Inserisca un valore MTU compreso tra {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chiave pre-condivisa" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard opzionale per una crittografia simmetrica aggiuntiva. Non è la stessa cosa di una chiave di configurazione NetBird. Comunicherà solo con i peer che usano la stessa chiave pre-condivisa." + }, + "settings.troubleshooting.section.title": { + "message": "Pacchetto di debug" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizza informazioni sensibili" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Includi informazioni di sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, interfacce di rete e tabelle di routing." + }, + "settings.troubleshooting.upload.label": { + "message": "Carica il pacchetto sui server NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Restituisce una chiave di caricamento da condividere con il supporto NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Abilita log di traccia" + }, + "settings.troubleshooting.trace.help": { + "message": "Aumenta il livello di log a TRACE e lo ripristina al termine." + }, + "settings.troubleshooting.capture.label": { + "message": "Sessione di acquisizione" + }, + "settings.troubleshooting.capture.help": { + "message": "Si riconnette e attende per consentirle di riprodurre il problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Acquisisci pacchetti di rete" + }, + "settings.troubleshooting.packets.help": { + "message": "Salva un .pcap del traffico di rete durante la sessione di acquisizione." + }, + "settings.troubleshooting.duration.label": { + "message": "Durata acquisizione" + }, + "settings.troubleshooting.duration.help": { + "message": "Per quanto tempo viene eseguita la sessione di acquisizione." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min." + }, + "settings.troubleshooting.create": { + "message": "Crea pacchetto" + }, + "settings.troubleshooting.progress.description": { + "message": "Raccolta di log, dettagli di sistema e stato della connessione. Di solito richiede un momento: tenga aperta questa finestra fino al completamento." + }, + "settings.troubleshooting.cancelling": { + "message": "Annullamento…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacchetto di debug caricato con successo!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacchetto salvato" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Condivida la chiave di caricamento qui sotto con il supporto NetBird. Una copia locale è stata salvata anche sul suo dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Il pacchetto di debug è stato salvato localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copia chiave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Apri cartella" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Apri posizione file" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Caricamento non riuscito: {reason} Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Caricamento non riuscito. Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Riconnessione di NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Acquisizione log di debug" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generazione del pacchetto di debug…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Caricamento su NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annullamento…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tutti i diritti riservati." + }, + "settings.about.links.imprint": { + "message": "Note legali" + }, + "settings.about.links.privacy": { + "message": "Privacy" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termini di servizio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentazione" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} è pronto per l'installazione." + }, + "update.banner.later": { + "message": "Più tardi" + }, + "update.banner.installNow": { + "message": "Installa ora" + }, + "update.card.versionAvailableDownload": { + "message": "La versione {version} è disponibile per il download." + }, + "update.card.versionAvailableInstall": { + "message": "La versione {version} è disponibile per l'installazione." + }, + "update.card.whatsNew": { + "message": "Novità?" + }, + "update.card.installNow": { + "message": "Installa ora" + }, + "update.card.getInstaller": { + "message": "Scarica" + }, + "update.card.autoCheckInterval": { + "message": "NetBird verifica gli aggiornamenti in background." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sta usando l'ultima versione" + }, + "update.header.tooltip": { + "message": "Aggiornamento disponibile" + }, + "update.overlay.updatingVersion": { + "message": "Aggiornamento di NetBird alla v{version}" + }, + "update.overlay.updating": { + "message": "Aggiornamento di NetBird" + }, + "update.overlay.description": { + "message": "È disponibile una versione più recente ed è in corso l'installazione. NetBird si riavvierà automaticamente al termine dell'aggiornamento." + }, + "update.overlay.error.timeoutTitle": { + "message": "L'aggiornamento richiede troppo tempo" + }, + "update.overlay.error.timeoutDescription": { + "message": "L'installazione di {target} ha richiesto troppo tempo e non è stata completata." + }, + "update.overlay.error.canceledTitle": { + "message": "Aggiornamento interrotto" + }, + "update.overlay.error.canceledDescription": { + "message": "L'aggiornamento a {target} è stato annullato prima del completamento." + }, + "update.overlay.error.failTitle": { + "message": "Impossibile installare l'aggiornamento" + }, + "update.overlay.error.failDescription": { + "message": "Impossibile installare {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "errore sconosciuto" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nuova versione" + }, + "update.error.loadStateTitle": { + "message": "Caricamento stato aggiornamento non riuscito" + }, + "update.error.triggerTitle": { + "message": "Avvio aggiornamento non riuscito" + }, + "update.page.versionLine": { + "message": "Aggiornamento del client a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Aggiornamento del client." + }, + "update.page.outdated": { + "message": "La versione del client è precedente alla versione di aggiornamento automatico impostata in Management." + }, + "update.page.status.running": { + "message": "Aggiornamento in corso" + }, + "update.page.status.timeout": { + "message": "Aggiornamento scaduto. Riprovi." + }, + "update.page.status.canceled": { + "message": "Aggiornamento annullato." + }, + "update.page.status.failed": { + "message": "Aggiornamento non riuscito: {message}" + }, + "update.page.status.unknownError": { + "message": "errore di aggiornamento sconosciuto" + }, + "update.page.failedTitle": { + "message": "Aggiornamento non riuscito" + }, + "update.page.timeoutMessage": { + "message": "Aggiornamento scaduto." + }, + "update.page.dontClose": { + "message": "Non chiuda questa finestra." + }, + "update.page.updating": { + "message": "Aggiornamento…" + }, + "update.page.complete": { + "message": "Aggiornamento completato" + }, + "update.page.failed": { + "message": "Aggiornamento non riuscito" + }, + "window.title.settings": { + "message": "Impostazioni" + }, + "window.title.signIn": { + "message": "Accesso" + }, + "window.title.sessionExpiration": { + "message": "Sessione in scadenza" + }, + "window.title.updating": { + "message": "Aggiornamento" + }, + "window.title.welcome": { + "message": "Benvenuto in NetBird" + }, + "window.title.error": { + "message": "Errore" + }, + "welcome.title": { + "message": "Cerchi NetBird nella tray" + }, + "welcome.description": { + "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, + "welcome.continue": { + "message": "Continua" + }, + "welcome.back": { + "message": "Indietro" + }, + "welcome.management.title": { + "message": "Configura NetBird" + }, + "welcome.management.description": { + "message": "Clicchi su Continua per iniziare, oppure scelga Self-hosted se dispone di un proprio server NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Usi il nostro servizio gestito. Nessuna configurazione necessaria." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Si connetta al suo server di gestione." + }, + "welcome.management.urlLabel": { + "message": "URL del server di gestione" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL o la sua rete, poi prosegua se è certo che sia corretto." + }, + "welcome.management.checking": { + "message": "Verifica…" + }, + "browserLogin.title": { + "message": "Continui nel browser per completare l'accesso" + }, + "browserLogin.notSeeing": { + "message": "Non vede la scheda del browser?" + }, + "browserLogin.tryAgain": { + "message": "Riprova" + }, + "browserLogin.openFailedTitle": { + "message": "Apertura browser non riuscita" + }, + "sessionExpiration.title": { + "message": "La sessione sta per scadere" + }, + "sessionExpiration.titleLater": { + "message": "La sessione scadrà" + }, + "sessionExpiration.description": { + "message": "Questo dispositivo verrà disconnesso a breve. Rinnovi con un accesso dal browser." + }, + "sessionExpiration.descriptionLater": { + "message": "Un accesso dal browser mantiene questo dispositivo connesso alla sua rete." + }, + "sessionExpiration.stay": { + "message": "Rinnova sessione" + }, + "sessionExpiration.authenticate": { + "message": "Autenticati" + }, + "sessionExpiration.logout": { + "message": "Esci" + }, + "sessionExpiration.expired": { + "message": "Sessione scaduta" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo disconnesso. Si autentichi con un accesso dal browser per riconnettersi." + }, + "sessionExpiration.close": { + "message": "Chiudi" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Rinnovo sessione non riuscito" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Disconnessione non riuscita" + }, + "peers.search.placeholder": { + "message": "Cerca per nome o IP" + }, + "peers.filter.all": { + "message": "Tutti" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nessun peer disponibile" + }, + "peers.empty.description": { + "message": "Non ha peer disponibili oppure non ha accesso a nessuno di essi." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Chiave pubblica" + }, + "peers.details.connection": { + "message": "Connessione" + }, + "peers.details.latency": { + "message": "Latenza" + }, + "peers.details.lastHandshake": { + "message": "Ultimo handshake" + }, + "peers.details.statusSince": { + "message": "Ultimo aggiornamento connessione" + }, + "peers.details.bytes": { + "message": "Byte" + }, + "peers.details.bytesSent": { + "message": "Inviati" + }, + "peers.details.bytesReceived": { + "message": "Ricevuti" + }, + "peers.details.localIce": { + "message": "ICE locale" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Mai" + }, + "peers.details.justNow": { + "message": "Proprio ora" + }, + "peers.details.refresh": { + "message": "Aggiorna" + }, + "peers.status.connected": { + "message": "Connesso" + }, + "peers.status.connecting": { + "message": "Connessione" + }, + "peers.status.disconnected": { + "message": "Disconnesso" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Risorse" + }, + "peers.details.relayed": { + "message": "Tramite relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass abilitato" + }, + "networks.search.placeholder": { + "message": "Cerca per rete o dominio" + }, + "networks.filter.all": { + "message": "Tutte" + }, + "networks.filter.active": { + "message": "Attive" + }, + "networks.filter.overlapping": { + "message": "Sovrapposte" + }, + "networks.empty.title": { + "message": "Nessuna risorsa disponibile" + }, + "networks.empty.description": { + "message": "Non ha risorse di rete disponibili oppure non ha accesso a nessuna di esse." + }, + "networks.selected": { + "message": "Selezionata" + }, + "networks.unselected": { + "message": "Non selezionata" + }, + "networks.ips.heading": { + "message": "IP risolti" + }, + "networks.bulk.selectionCount": { + "message": "{selected} di {total} attive" + }, + "networks.bulk.enableAll": { + "message": "Abilita tutte" + }, + "networks.bulk.disableAll": { + "message": "Disabilita tutte" + }, + "exitNodes.search.placeholder": { + "message": "Cerca nodi di uscita" + }, + "exitNodes.none": { + "message": "Nessuno" + }, + "exitNodes.empty.title": { + "message": "Nessun nodo di uscita disponibile" + }, + "exitNodes.empty.description": { + "message": "Nessun nodo di uscita è stato condiviso con questo peer." + }, + "exitNodes.card.title": { + "message": "Nodo di uscita" + }, + "exitNodes.card.statusActive": { + "message": "Attivo" + }, + "exitNodes.card.statusInactive": { + "message": "Inattivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nessuno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connessione diretta senza nodo di uscita" + }, + "quickActions.connect": { + "message": "Connetti" + }, + "quickActions.disconnect": { + "message": "Disconnetti" + }, + "daemon.unavailable.title": { + "message": "Il servizio NetBird non è in esecuzione" + }, + "daemon.unavailable.description": { + "message": "L'app si riconnetterà automaticamente non appena il servizio sarà in esecuzione." + }, + "daemon.unavailable.docsLink": { + "message": "Documentazione" + }, + "daemon.outdated.title": { + "message": "Il servizio NetBird è obsoleto" + }, + "daemon.outdated.description": { + "message": "Aggiorna il servizio NetBird per usare questa app." + }, + "error.jwt_clock_skew": { + "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." + }, + "error.jwt_expired": { + "message": "Il token di accesso è scaduto. Effettui di nuovo l'accesso." + }, + "error.jwt_signature_invalid": { + "message": "Accesso non riuscito: la firma del token non è valida. Contatti il suo amministratore." + }, + "error.session_expired": { + "message": "La sessione è scaduta. Effettui di nuovo l'accesso." + }, + "error.invalid_setup_key": { + "message": "La chiave di configurazione è mancante o non valida." + }, + "error.permission_denied": { + "message": "L'accesso è stato rifiutato dal server." + }, + "error.daemon_unreachable": { + "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." + }, + "error.unknown": { + "message": "Operazione non riuscita." + } +} diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json new file mode 100644 index 000000000..2ed0a94c5 --- /dev/null +++ b/client/ui/i18n/locales/pt/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Não está em execução" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Login necessário" + }, + "tray.status.loginFailed": { + "message": "Falha no login" + }, + "tray.status.sessionExpired": { + "message": "Sessão expirada" + }, + "tray.session.expiresIn": { + "message": "A sessão expira em {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de um minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 dia" + }, + "tray.session.unit.days": { + "message": "{count} dias" + }, + "tray.menu.open": { + "message": "Abrir o NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nó de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfis" + }, + "tray.menu.manageProfiles": { + "message": "Gerenciar perfis" + }, + "tray.menu.settings": { + "message": "Configurações..." + }, + "tray.menu.debugBundle": { + "message": "Criar pacote de depuração" + }, + "tray.menu.about": { + "message": "Ajuda e suporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentação" + }, + "tray.menu.troubleshoot": { + "message": "Solução de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Baixar a versão mais recente" + }, + "tray.menu.installVersion": { + "message": "Instalar a versão {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Sair do NetBird" + }, + "notify.daemonOutdated.title": { + "message": "O serviço NetBird está desatualizado" + }, + "notify.daemonOutdated.body": { + "message": "Atualize o serviço NetBird para usar este aplicativo." + }, + "notify.update.title": { + "message": "Atualização do NetBird disponível" + }, + "notify.update.body": { + "message": "O NetBird {version} está disponível." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador exige esta atualização." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Falha ao conectar" + }, + "notify.error.disconnect": { + "message": "Falha ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Falha ao alternar para {profile}" + }, + "notify.error.exitNode": { + "message": "Falha ao atualizar o nó de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessão do NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "A sua sessão do NetBird expirou. Faça login novamente." + }, + "notify.sessionWarning.title": { + "message": "A sessão expira em breve" + }, + "notify.sessionWarning.body": { + "message": "A sua sessão do NetBird expira em {remaining}. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A sua sessão do NetBird está prestes a expirar. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.extend": { + "message": "Renovar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Dispensar" + }, + "notify.sessionWarning.failed": { + "message": "Falha ao renovar a sessão do NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessão do NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "A sua sessão foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Prazo da sessão rejeitado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou um prazo de sessão inválido. Faça login novamente." + }, + "notify.mdm.policyApplied.title": { + "message": "Definições do NetBird atualizadas" + }, + "notify.mdm.policyApplied.body": { + "message": "A sua configuração do NetBird foi atualizada pela política de TI." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Salvar" + }, + "common.saveChanges": { + "message": "Salvar alterações" + }, + "common.saving": { + "message": "Salvando…" + }, + "common.close": { + "message": "Fechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Alternar visibilidade da senha" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuir" + }, + "common.delete": { + "message": "Excluir" + }, + "common.create": { + "message": "Criar" + }, + "common.add": { + "message": "Adicionar" + }, + "common.remove": { + "message": "Remover" + }, + "common.refresh": { + "message": "Atualizar" + }, + "common.loading": { + "message": "Carregando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nenhum resultado encontrado" + }, + "common.noResults.description": { + "message": "Não encontramos nenhum resultado. Tente um termo de busca diferente ou altere os filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conecte-se ao NetBird primeiro para ver informações detalhadas sobre seus peers, recursos de rede e nós de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponível" + }, + "connect.status.loginRequired": { + "message": "Login necessário" + }, + "connect.error.loginTitle": { + "message": "Falha no login" + }, + "connect.error.connectTitle": { + "message": "Falha ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Falha ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} ativos" + }, + "nav.exitNode.title": { + "message": "Nós de saída" + }, + "nav.exitNode.none": { + "message": "Inativo" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Abrir configurações" + }, + "header.togglePanel": { + "message": "Alternar painel lateral" + }, + "profile.selector.loading": { + "message": "Carregando..." + }, + "profile.selector.noProfile": { + "message": "Nenhum perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nenhum perfil encontrado" + }, + "profile.selector.emptyDescription": { + "message": "Tente um termo de busca diferente ou crie um novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Mais opções" + }, + "profile.selector.deregister": { + "message": "Cancelar registro" + }, + "profile.selector.delete": { + "message": "Excluir" + }, + "profile.selector.switchTo": { + "message": "Alternar para este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Salvar alterações" + }, + "profile.dialog.title": { + "message": "Insira o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Defina um nome fácil de identificar para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "ex.: trabalho" + }, + "profile.dialog.submit": { + "message": "Adicionar perfil" + }, + "profile.dialog.required": { + "message": "Insira um nome de perfil, ex.: trabalho, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use o NetBird Cloud ou seu próprio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou adicione o perfil mesmo assim se tiver certeza de que está correta." + }, + "header.menu.settings": { + "message": "Configurações..." + }, + "header.menu.defaultView": { + "message": "Visualização padrão" + }, + "header.menu.advancedView": { + "message": "Visualização avançada" + }, + "header.menu.updateAvailable": { + "message": "Atualização disponível" + }, + "header.menu.open": { + "message": "Abrir menu" + }, + "header.profile.switch": { + "message": "Trocar perfil" + }, + "connect.toggle.label": { + "message": "Alternar conexão NetBird" + }, + "connect.localIp.label": { + "message": "Endereços IP locais" + }, + "common.search": { + "message": "Pesquisar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Selecionar nó de saída" + }, + "peers.row.label": { + "message": "Abrir detalhes de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalhes do par" + }, + "networks.row.toggle": { + "message": "Alternar {name}" + }, + "networks.bulk.label": { + "message": "Alternar todos os recursos visíveis" + }, + "settings.nav.label": { + "message": "Seções das configurações" + }, + "profile.switch.title": { + "message": "Alternar perfil para \"{name}\"?" + }, + "profile.switch.message": { + "message": "Tem certeza de que deseja alternar de perfil?\nO seu perfil atual será desconectado." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Cancelar registro do perfil \"{name}\"?" + }, + "profile.deregister.message": { + "message": "Tem certeza de que deseja cancelar o registro deste perfil?\nVocê precisará fazer login novamente para usá-lo." + }, + "profile.deregister.confirm": { + "message": "Cancelar registro" + }, + "profile.delete.title": { + "message": "Excluir o perfil \"{name}\"?" + }, + "profile.delete.message": { + "message": "Tem certeza de que deseja excluir este perfil?\nEsta ação não pode ser desfeita." + }, + "profile.delete.disabledActive": { + "message": "Perfis ativos não podem ser excluídos. Alterne para outro antes de excluir este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil padrão não pode ser excluído." + }, + "profile.error.switchTitle": { + "message": "Falha ao alternar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Falha ao cancelar registro do perfil" + }, + "profile.error.deleteTitle": { + "message": "Falha ao excluir o perfil" + }, + "profile.error.createTitle": { + "message": "Falha ao criar o perfil" + }, + "profile.error.editTitle": { + "message": "Falha ao editar o perfil" + }, + "profile.error.loadTitle": { + "message": "Falha ao carregar os perfis" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil ativo" + }, + "profile.dropdown.switchProfile": { + "message": "Alternar perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Adicionar perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gerenciar perfis" + }, + "profile.dropdown.settings": { + "message": "Configurações" + }, + "settings.profiles.section.profiles": { + "message": "Perfis" + }, + "settings.profiles.intro": { + "message": "Mantenha identidades separadas do NetBird lado a lado, por exemplo contas de trabalho e pessoais, ou diferentes servidores de gerenciamento. Adicione, cancele o registro ou exclua perfis abaixo." + }, + "settings.profiles.addProfile": { + "message": "Adicionar perfil" + }, + "settings.profiles.active": { + "message": "Ativo" + }, + "settings.profiles.emptyTitle": { + "message": "Nenhum perfil" + }, + "settings.profiles.emptyDescription": { + "message": "Crie um perfil para conectar a um servidor de gerenciamento do NetBird." + }, + "settings.error.loadTitle": { + "message": "Falha ao carregar as configurações" + }, + "settings.error.saveTitle": { + "message": "Falha ao salvar as configurações" + }, + "settings.error.debugBundleTitle": { + "message": "Falha no pacote de depuração" + }, + "settings.tabs.general": { + "message": "Geral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Segurança" + }, + "settings.tabs.profiles": { + "message": "Perfis" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avançado" + }, + "settings.tabs.troubleshooting": { + "message": "Solução de problemas" + }, + "settings.tabs.about": { + "message": "Sobre" + }, + "settings.tabs.updateAvailable": { + "message": "Atualização disponível" + }, + "settings.general.section.general": { + "message": "Geral" + }, + "settings.general.section.connection": { + "message": "Conexão" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Estabelecer uma conexão automaticamente quando o serviço iniciar." + }, + "settings.general.notifications.label": { + "message": "Notificações na área de trabalho" + }, + "settings.general.notifications.help": { + "message": "Mostrar notificações na área de trabalho para novas atualizações e eventos de conexão." + }, + "settings.general.autostart.label": { + "message": "Iniciar a interface do NetBird ao fazer login" + }, + "settings.general.autostart.help": { + "message": "Iniciar a interface do NetBird automaticamente quando você fizer login. Isto afeta apenas a interface gráfica, não o serviço em segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Falha ao alterar o início automático" + }, + "settings.general.language.label": { + "message": "Idioma de exibição" + }, + "settings.general.language.help": { + "message": "Escolha o idioma da interface do NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Nenhum idioma corresponde." + }, + "settings.general.management.label": { + "message": "Servidor de gerenciamento" + }, + "settings.general.management.help": { + "message": "Conecte ao NetBird Cloud ou ao seu próprio servidor de gerenciamento auto-hospedado. As alterações reconectarão o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hospedado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou salve mesmo assim se tiver certeza de que está correta." + }, + "settings.general.management.switchCloudTitle": { + "message": "Alternar para o NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor auto-hospedado.\nVocê pode precisar fazer login novamente." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Alternar para o Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Roteamento e DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar ao mudar de rede" + }, + "settings.network.monitor.help": { + "message": "Monitora a rede e reconecta automaticamente diante de mudanças como troca de Wi-Fi, alterações na Ethernet ou retorno do modo de suspensão." + }, + "settings.network.dns.label": { + "message": "Ativar DNS" + }, + "settings.network.dns.help": { + "message": "Aplicar as configurações de DNS gerenciadas pelo NetBird ao resolvedor do host." + }, + "settings.network.clientRoutes.label": { + "message": "Ativar rotas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Aceitar rotas de outros peers para alcançar as redes deles." + }, + "settings.network.serverRoutes.label": { + "message": "Ativar rotas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anunciar as rotas locais deste host para outros peers." + }, + "settings.network.ipv6.label": { + "message": "Ativar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usar endereçamento IPv6 para a rede de sobreposição do NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Criptografia" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfego de entrada" + }, + "settings.security.blockInbound.help": { + "message": "Rejeitar conexões não solicitadas de peers para este dispositivo e quaisquer redes que ele roteie. O tráfego de saída não é afetado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acesso à LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedir que peers alcancem a sua rede local ou os dispositivos dela quando este dispositivo roteia o tráfego deles." + }, + "settings.security.rosenpass.label": { + "message": "Ativar resistência quântica" + }, + "settings.security.rosenpass.help": { + "message": "Adicionar uma troca de chaves pós-quântica via Rosenpass sobre o WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Ativar modo permissivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permitir conexões com peers sem suporte a resistência quântica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Recursos" + }, + "settings.ssh.section.authentication": { + "message": "Autenticação" + }, + "settings.ssh.server.label": { + "message": "Ativar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executar o servidor SSH do NetBird neste host para que outros peers possam conectar a ele." + }, + "settings.ssh.root.label": { + "message": "Permitir login como root" + }, + "settings.ssh.root.help": { + "message": "Permitir que peers façam login como usuário root. Desative para exigir uma conta sem privilégios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transferir arquivos com segurança usando clientes SFTP ou SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Encaminhamento de porta local" + }, + "settings.ssh.localForward.help": { + "message": "Permitir que peers conectados encaminhem portas locais para serviços acessíveis a partir deste host." + }, + "settings.ssh.remoteForward.label": { + "message": "Encaminhamento de porta remota" + }, + "settings.ssh.remoteForward.help": { + "message": "Permitir que peers conectados exponham portas deste host de volta para a própria máquina deles." + }, + "settings.ssh.jwt.label": { + "message": "Ativar autenticação JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verificar cada sessão SSH no seu IdP para identidade do usuário e auditoria. Desative para depender apenas das políticas de ACL da rede, útil quando nenhum IdP está disponível." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL do cache de JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Por quanto tempo este cliente mantém um JWT em cache antes de solicitar novamente em conexões SSH de saída. Defina como 0 para desativar o cache e autenticar em cada conexão." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Segurança" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, pontos, hifens ou sublinhados." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve começar com \"utun\" seguido de um número (ex.: utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Insira uma porta entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se definida como 0, uma porta livre aleatória será usada." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Insira um valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave pré-compartilhada" + }, + "settings.advanced.psk.help": { + "message": "PSK opcional do WireGuard para criptografia simétrica adicional. Não é o mesmo que uma chave de configuração do NetBird. Você só se comunicará com peers que usem a mesma chave pré-compartilhada." + }, + "settings.troubleshooting.section.title": { + "message": "Pacote de depuração" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar informações sensíveis" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir informações do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluir o OS, o kernel, as interfaces de rede e as tabelas de roteamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Enviar pacote aos servidores do NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Retorna uma chave de upload para compartilhar com o suporte do NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Habilitar logs de trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nível de log para TRACE e o restaura em seguida." + }, + "settings.troubleshooting.capture.label": { + "message": "Sessão de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Reconecta e aguarda para que você possa reproduzir o problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar pacotes de rede" + }, + "settings.troubleshooting.packets.help": { + "message": "Salva um .pcap do tráfego de rede durante a sessão de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duração da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Por quanto tempo a sessão de captura é executada." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Criar pacote" + }, + "settings.troubleshooting.progress.description": { + "message": "Coletando logs, detalhes do sistema e estado da conexão. Isto costuma levar um instante — mantenha esta janela aberta até concluir." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacote de depuração enviado com sucesso!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacote salvo" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Compartilhe a chave de upload abaixo com o suporte do NetBird. Uma cópia local também foi salva no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu pacote de depuração foi salvo localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir pasta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir local do arquivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Falha no upload: {reason} O pacote continua salvo localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Falha no upload. O pacote continua salvo localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando o NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando logs de depuração" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Gerando o pacote de depuração…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Enviando para o NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os direitos reservados." + }, + "settings.about.links.imprint": { + "message": "Identificação legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos de serviço" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Documentação" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "O NetBird {version} está pronto para instalar." + }, + "update.banner.later": { + "message": "Mais tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versão {version} está disponível para download." + }, + "update.card.versionAvailableInstall": { + "message": "A versão {version} está disponível para instalação." + }, + "update.card.whatsNew": { + "message": "Novidades?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Baixar" + }, + "update.card.autoCheckInterval": { + "message": "O NetBird verifica atualizações em segundo plano." + }, + "update.card.changelog": { + "message": "Registro de alterações" + }, + "update.card.onLatestVersion": { + "message": "Você está na versão mais recente" + }, + "update.header.tooltip": { + "message": "Atualização disponível" + }, + "update.overlay.updatingVersion": { + "message": "Atualizando o NetBird para a v{version}" + }, + "update.overlay.updating": { + "message": "Atualizando o NetBird" + }, + "update.overlay.description": { + "message": "Uma versão mais recente está disponível e sendo instalada. O NetBird reiniciará automaticamente quando a atualização terminar." + }, + "update.overlay.error.timeoutTitle": { + "message": "A atualização está demorando demais" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalação de {target} demorou demais e não foi concluída." + }, + "update.overlay.error.canceledTitle": { + "message": "A atualização foi interrompida" + }, + "update.overlay.error.canceledDescription": { + "message": "A atualização para {target} foi cancelada antes de terminar." + }, + "update.overlay.error.failTitle": { + "message": "Não foi possível instalar a atualização" + }, + "update.overlay.error.failDescription": { + "message": "Não foi possível instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro desconhecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versão" + }, + "update.error.loadStateTitle": { + "message": "Falha ao carregar o estado da atualização" + }, + "update.error.triggerTitle": { + "message": "Falha ao iniciar a atualização" + }, + "update.page.versionLine": { + "message": "Atualizando o cliente para: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Atualizando o cliente." + }, + "update.page.outdated": { + "message": "A versão do seu cliente é anterior à versão de atualização automática definida no Management." + }, + "update.page.status.running": { + "message": "Atualizando" + }, + "update.page.status.timeout": { + "message": "A atualização expirou. Tente novamente." + }, + "update.page.status.canceled": { + "message": "Atualização cancelada." + }, + "update.page.status.failed": { + "message": "Falha na atualização: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de atualização desconhecido" + }, + "update.page.failedTitle": { + "message": "Falha na atualização" + }, + "update.page.timeoutMessage": { + "message": "A atualização expirou." + }, + "update.page.dontClose": { + "message": "Não feche esta janela." + }, + "update.page.updating": { + "message": "Atualizando…" + }, + "update.page.complete": { + "message": "Atualização concluída" + }, + "update.page.failed": { + "message": "Falha na atualização" + }, + "window.title.settings": { + "message": "Configurações" + }, + "window.title.signIn": { + "message": "Login" + }, + "window.title.sessionExpiration": { + "message": "Sessão expirando" + }, + "window.title.updating": { + "message": "Atualizando" + }, + "window.title.welcome": { + "message": "Bem-vindo ao NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Procure o NetBird na sua bandeja" + }, + "welcome.description": { + "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Voltar" + }, + "welcome.management.title": { + "message": "Configurar o NetBird" + }, + "welcome.management.description": { + "message": "Clique em Continuar para começar ou escolha Auto-hospedado se você tiver o seu próprio servidor NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o nosso serviço hospedado. Sem configuração necessária." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hospedado" + }, + "welcome.management.selfHosted.description": { + "message": "Conecte ao seu próprio servidor de gerenciamento." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de gerenciamento" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou a sua rede e continue se tiver certeza de que está correta." + }, + "welcome.management.checking": { + "message": "Verificando…" + }, + "browserLogin.title": { + "message": "Continue no seu navegador para concluir o login" + }, + "browserLogin.notSeeing": { + "message": "Não está vendo a aba do navegador?" + }, + "browserLogin.tryAgain": { + "message": "Tentar novamente" + }, + "browserLogin.openFailedTitle": { + "message": "Falha ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sessão expira em breve" + }, + "sessionExpiration.titleLater": { + "message": "A sua sessão vai expirar" + }, + "sessionExpiration.description": { + "message": "Este dispositivo será desconectado em breve. Renove com um login pelo navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Um login pelo navegador mantém este dispositivo conectado à sua rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sessão" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Sair" + }, + "sessionExpiration.expired": { + "message": "Sessão expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentique com um login pelo navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Fechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Falha ao renovar a sessão" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Falha ao sair" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nenhum peer disponível" + }, + "peers.empty.description": { + "message": "Você não tem nenhum peer disponível ou não tem acesso a nenhum deles." + }, + "peers.details.domain": { + "message": "Domínio" + }, + "peers.details.netbirdIp": { + "message": "IP do NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 do NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexão" + }, + "peers.details.latency": { + "message": "Latência" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última atualização da conexão" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recebidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Atualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Via relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass ativado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou domínio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Ativos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Nenhum recurso disponível" + }, + "networks.empty.description": { + "message": "Você não tem nenhum recurso de rede disponível ou não tem acesso a nenhum deles." + }, + "networks.selected": { + "message": "Selecionado" + }, + "networks.unselected": { + "message": "Não selecionado" + }, + "networks.ips.heading": { + "message": "IPs resolvidos" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} ativos" + }, + "networks.bulk.enableAll": { + "message": "Ativar todos" + }, + "networks.bulk.disableAll": { + "message": "Desativar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nós de saída" + }, + "exitNodes.none": { + "message": "Nenhum" + }, + "exitNodes.empty.title": { + "message": "Nenhum nó de saída disponível" + }, + "exitNodes.empty.description": { + "message": "Nenhum nó de saída foi compartilhado com este peer." + }, + "exitNodes.card.title": { + "message": "Nó de saída" + }, + "exitNodes.card.statusActive": { + "message": "Ativo" + }, + "exitNodes.card.statusInactive": { + "message": "Inativo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nenhum" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexão direta sem um nó de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O serviço do NetBird não está em execução" + }, + "daemon.unavailable.description": { + "message": "O aplicativo reconectará automaticamente assim que o serviço estiver em execução." + }, + "daemon.unavailable.docsLink": { + "message": "Documentação" + }, + "daemon.outdated.title": { + "message": "O serviço NetBird está desatualizado" + }, + "daemon.outdated.description": { + "message": "Atualize o serviço NetBird para usar este aplicativo." + }, + "error.jwt_clock_skew": { + "message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente." + }, + "error.jwt_expired": { + "message": "O seu token de login expirou. Faça login novamente." + }, + "error.jwt_signature_invalid": { + "message": "Falha no login: a assinatura do token é inválida. Entre em contato com o seu administrador." + }, + "error.session_expired": { + "message": "A sua sessão expirou. Faça login novamente." + }, + "error.invalid_setup_key": { + "message": "A chave de configuração está ausente ou é inválida." + }, + "error.permission_denied": { + "message": "O login foi rejeitado pelo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." + }, + "error.unknown": { + "message": "A operação falhou." + } +} diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json new file mode 100644 index 000000000..6ba7de8cc --- /dev/null +++ b/client/ui/i18n/locales/ru/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Отключено" + }, + "tray.status.daemonUnavailable": { + "message": "Не запущено" + }, + "tray.status.error": { + "message": "Ошибка" + }, + "tray.status.connected": { + "message": "Подключено" + }, + "tray.status.connecting": { + "message": "Подключение" + }, + "tray.status.needsLogin": { + "message": "Требуется вход" + }, + "tray.status.loginFailed": { + "message": "Ошибка входа" + }, + "tray.status.sessionExpired": { + "message": "Сеанс истёк" + }, + "tray.session.expiresIn": { + "message": "Сеанс истекает через {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "менее чем минуту" + }, + "tray.session.unit.minute": { + "message": "1 минуту" + }, + "tray.session.unit.minutes": { + "message": "{count} минут" + }, + "tray.session.unit.hour": { + "message": "1 час" + }, + "tray.session.unit.hours": { + "message": "{count} часов" + }, + "tray.session.unit.day": { + "message": "1 день" + }, + "tray.session.unit.days": { + "message": "{count} дней" + }, + "tray.menu.open": { + "message": "Открыть NetBird" + }, + "tray.menu.connect": { + "message": "Подключиться" + }, + "tray.menu.disconnect": { + "message": "Отключиться" + }, + "tray.menu.exitNode": { + "message": "Выходной узел" + }, + "tray.menu.networks": { + "message": "Ресурсы" + }, + "tray.menu.profiles": { + "message": "Профили" + }, + "tray.menu.manageProfiles": { + "message": "Управление профилями" + }, + "tray.menu.settings": { + "message": "Настройки…" + }, + "tray.menu.debugBundle": { + "message": "Создать отладочный пакет" + }, + "tray.menu.about": { + "message": "Помощь и поддержка" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Документация" + }, + "tray.menu.troubleshoot": { + "message": "Диагностика" + }, + "tray.menu.downloadLatest": { + "message": "Загрузить последнюю версию" + }, + "tray.menu.installVersion": { + "message": "Установить версию {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Демон: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Выйти из NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Служба NetBird устарела" + }, + "notify.daemonOutdated.body": { + "message": "Обновите службу NetBird, чтобы использовать это приложение." + }, + "notify.update.title": { + "message": "Доступно обновление NetBird" + }, + "notify.update.body": { + "message": "Доступна версия NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " Ваш администратор требует установить это обновление." + }, + "notify.error.title": { + "message": "Ошибка" + }, + "notify.error.connect": { + "message": "Не удалось подключиться" + }, + "notify.error.disconnect": { + "message": "Не удалось отключиться" + }, + "notify.error.switchProfile": { + "message": "Не удалось переключиться на {profile}" + }, + "notify.error.exitNode": { + "message": "Не удалось обновить выходной узел {name}" + }, + "notify.sessionExpired.title": { + "message": "Сеанс NetBird истёк" + }, + "notify.sessionExpired.body": { + "message": "Сеанс NetBird истёк. Пожалуйста, войдите снова." + }, + "notify.sessionWarning.title": { + "message": "Сеанс скоро истечёт" + }, + "notify.sessionWarning.body": { + "message": "Сеанс NetBird истекает через {remaining}. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Сеанс NetBird скоро истечёт. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.extend": { + "message": "Продлить сейчас" + }, + "notify.sessionWarning.dismiss": { + "message": "Закрыть" + }, + "notify.sessionWarning.failed": { + "message": "Не удалось продлить сеанс NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Сеанс NetBird продлён" + }, + "notify.sessionWarning.successBody": { + "message": "Сеанс обновлён." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Неверный срок действия сеанса" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова." + }, + "notify.mdm.policyApplied.title": { + "message": "Настройки NetBird обновлены" + }, + "notify.mdm.policyApplied.body": { + "message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой." + }, + "common.cancel": { + "message": "Отмена" + }, + "common.save": { + "message": "Сохранить" + }, + "common.saveChanges": { + "message": "Сохранить изменения" + }, + "common.saving": { + "message": "Сохранение…" + }, + "common.close": { + "message": "Закрыть" + }, + "common.copy": { + "message": "Копировать" + }, + "common.togglePasswordVisibility": { + "message": "Показать или скрыть пароль" + }, + "common.increase": { + "message": "Увеличить" + }, + "common.decrease": { + "message": "Уменьшить" + }, + "common.delete": { + "message": "Удалить" + }, + "common.create": { + "message": "Создать" + }, + "common.add": { + "message": "Добавить" + }, + "common.remove": { + "message": "Убрать" + }, + "common.refresh": { + "message": "Обновить" + }, + "common.loading": { + "message": "Загрузка…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Ничего не найдено" + }, + "common.noResults.description": { + "message": "Ничего не найдено. Попробуйте изменить поисковый запрос или фильтры." + }, + "notConnected.title": { + "message": "Отключено" + }, + "notConnected.description": { + "message": "Сначала подключитесь к NetBird, чтобы увидеть подробную информацию о пирах, сетевых ресурсах и выходных узлах." + }, + "connect.status.disconnected": { + "message": "Отключено" + }, + "connect.status.connecting": { + "message": "Подключение…" + }, + "connect.status.connected": { + "message": "Подключено" + }, + "connect.status.disconnecting": { + "message": "Отключение…" + }, + "connect.status.daemonUnavailable": { + "message": "Демон недоступен" + }, + "connect.status.loginRequired": { + "message": "Требуется вход" + }, + "connect.error.loginTitle": { + "message": "Не удалось войти" + }, + "connect.error.connectTitle": { + "message": "Не удалось подключиться" + }, + "connect.error.disconnectTitle": { + "message": "Не удалось отключиться" + }, + "nav.peers.title": { + "message": "Пиры" + }, + "nav.peers.description": { + "message": "{connected} из {total} подключено" + }, + "nav.resources.title": { + "message": "Ресурсы" + }, + "nav.resources.description": { + "message": "{active} из {total} активно" + }, + "nav.exitNode.title": { + "message": "Выходные узлы" + }, + "nav.exitNode.none": { + "message": "Не активен" + }, + "nav.exitNode.using": { + "message": "Через {name}" + }, + "header.openSettings": { + "message": "Открыть настройки" + }, + "header.togglePanel": { + "message": "Показать или скрыть боковую панель" + }, + "profile.selector.loading": { + "message": "Загрузка…" + }, + "profile.selector.noProfile": { + "message": "Нет профиля" + }, + "profile.selector.searchPlaceholder": { + "message": "Поиск профиля по имени…" + }, + "profile.selector.emptyTitle": { + "message": "Профили не найдены" + }, + "profile.selector.emptyDescription": { + "message": "Измените поисковый запрос или создайте новый профиль." + }, + "profile.selector.newProfile": { + "message": "Новый профиль" + }, + "profile.selector.moreOptions": { + "message": "Дополнительные параметры" + }, + "profile.selector.deregister": { + "message": "Отменить регистрацию" + }, + "profile.selector.delete": { + "message": "Удалить" + }, + "profile.selector.switchTo": { + "message": "Переключиться на этот профиль" + }, + "profile.selector.edit": { + "message": "Изменить" + }, + "profile.edit.title": { + "message": "Изменить профиль" + }, + "profile.edit.submit": { + "message": "Сохранить изменения" + }, + "profile.dialog.title": { + "message": "Введите имя профиля" + }, + "profile.dialog.nameLabel": { + "message": "Имя профиля" + }, + "profile.dialog.description": { + "message": "Задайте легко узнаваемое имя для профиля." + }, + "profile.dialog.placeholder": { + "message": "например, работа" + }, + "profile.dialog.submit": { + "message": "Добавить профиль" + }, + "profile.dialog.required": { + "message": "Введите имя профиля, например работа или дом" + }, + "profile.dialog.managementHelp": { + "message": "Используйте NetBird Cloud или собственный сервер." + }, + "profile.dialog.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно добавьте профиль, если уверены, что он правильный." + }, + "header.menu.settings": { + "message": "Настройки…" + }, + "header.menu.defaultView": { + "message": "Обычный вид" + }, + "header.menu.advancedView": { + "message": "Расширенный вид" + }, + "header.menu.updateAvailable": { + "message": "Доступно обновление" + }, + "header.menu.open": { + "message": "Открыть меню" + }, + "header.profile.switch": { + "message": "Сменить профиль" + }, + "connect.toggle.label": { + "message": "Переключить подключение NetBird" + }, + "connect.localIp.label": { + "message": "Локальные IP-адреса" + }, + "common.search": { + "message": "Поиск" + }, + "common.filter": { + "message": "Фильтр" + }, + "exitNodes.dropdown.trigger": { + "message": "Выбрать выходной узел" + }, + "peers.row.label": { + "message": "Открыть подробности для {name}, {status}" + }, + "peers.dialog.title": { + "message": "Сведения об узле" + }, + "networks.row.toggle": { + "message": "Переключить {name}" + }, + "networks.bulk.label": { + "message": "Переключить все видимые ресурсы" + }, + "settings.nav.label": { + "message": "Разделы настроек" + }, + "profile.switch.title": { + "message": "Переключиться на профиль «{name}»?" + }, + "profile.switch.message": { + "message": "Вы действительно хотите переключить профиль?\nТекущий профиль будет отключён." + }, + "profile.switch.confirm": { + "message": "Подтвердить" + }, + "profile.deregister.title": { + "message": "Отменить регистрацию профиля «{name}»?" + }, + "profile.deregister.message": { + "message": "Вы действительно хотите отменить регистрацию этого профиля?\nДля его использования потребуется войти снова." + }, + "profile.deregister.confirm": { + "message": "Отменить регистрацию" + }, + "profile.delete.title": { + "message": "Удалить профиль «{name}»?" + }, + "profile.delete.message": { + "message": "Вы действительно хотите удалить этот профиль?\nЭто действие нельзя отменить." + }, + "profile.delete.disabledActive": { + "message": "Активные профили нельзя удалить. Переключитесь на другой профиль, прежде чем удалять этот." + }, + "profile.delete.disabledDefault": { + "message": "Профиль по умолчанию нельзя удалить." + }, + "profile.error.switchTitle": { + "message": "Не удалось переключить профиль" + }, + "profile.error.deregisterTitle": { + "message": "Не удалось отменить регистрацию профиля" + }, + "profile.error.deleteTitle": { + "message": "Не удалось удалить профиль" + }, + "profile.error.createTitle": { + "message": "Не удалось создать профиль" + }, + "profile.error.editTitle": { + "message": "Не удалось изменить профиль" + }, + "profile.error.loadTitle": { + "message": "Не удалось загрузить профили" + }, + "profile.dropdown.activeProfile": { + "message": "Активный профиль" + }, + "profile.dropdown.switchProfile": { + "message": "Переключить профиль" + }, + "profile.dropdown.noEmail": { + "message": "Другое" + }, + "profile.dropdown.addProfile": { + "message": "Добавить профиль" + }, + "profile.dropdown.manageProfiles": { + "message": "Управление профилями" + }, + "profile.dropdown.settings": { + "message": "Настройки" + }, + "settings.profiles.section.profiles": { + "message": "Профили" + }, + "settings.profiles.intro": { + "message": "Управляйте несколькими профилями NetBird параллельно — например, рабочими и личными учётными записями или разными серверами управления. Ниже можно добавлять профили, отменять их регистрацию и удалять их." + }, + "settings.profiles.addProfile": { + "message": "Добавить профиль" + }, + "settings.profiles.active": { + "message": "Активен" + }, + "settings.profiles.emptyTitle": { + "message": "Нет профилей" + }, + "settings.profiles.emptyDescription": { + "message": "Создайте профиль, чтобы подключиться к серверу управления NetBird." + }, + "settings.error.loadTitle": { + "message": "Не удалось загрузить настройки" + }, + "settings.error.saveTitle": { + "message": "Не удалось сохранить настройки" + }, + "settings.error.debugBundleTitle": { + "message": "Не удалось создать отладочный пакет" + }, + "settings.tabs.general": { + "message": "Общие" + }, + "settings.tabs.network": { + "message": "Сеть" + }, + "settings.tabs.security": { + "message": "Безопасность" + }, + "settings.tabs.profiles": { + "message": "Профили" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Дополнительно" + }, + "settings.tabs.troubleshooting": { + "message": "Диагностика" + }, + "settings.tabs.about": { + "message": "О программе" + }, + "settings.tabs.updateAvailable": { + "message": "Доступно обновление" + }, + "settings.general.section.general": { + "message": "Общие" + }, + "settings.general.section.connection": { + "message": "Подключение" + }, + "settings.general.connectOnStartup.label": { + "message": "Подключаться при запуске" + }, + "settings.general.connectOnStartup.help": { + "message": "Автоматически устанавливать подключение при запуске службы." + }, + "settings.general.notifications.label": { + "message": "Уведомления на рабочем столе" + }, + "settings.general.notifications.help": { + "message": "Показывать уведомления о новых обновлениях и событиях подключения." + }, + "settings.general.autostart.label": { + "message": "Запускать интерфейс NetBird при входе" + }, + "settings.general.autostart.help": { + "message": "Автоматически запускать интерфейс NetBird при входе в систему. Это влияет только на графический интерфейс, но не на фоновую службу." + }, + "settings.general.autostart.errorTitle": { + "message": "Не удалось изменить автозапуск" + }, + "settings.general.language.label": { + "message": "Язык интерфейса" + }, + "settings.general.language.help": { + "message": "Выберите язык интерфейса NetBird." + }, + "settings.general.language.search": { + "message": "Поиск языка…" + }, + "settings.general.language.empty": { + "message": "Языки не найдены." + }, + "settings.general.management.label": { + "message": "Сервер управления" + }, + "settings.general.management.help": { + "message": "Подключайтесь к NetBird Cloud или к собственному серверу управления. Изменения вызовут переподключение клиента." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Собственный сервер" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно сохраните, если уверены, что он правильный." + }, + "settings.general.management.switchCloudTitle": { + "message": "Переключиться на NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Это отключит ваш собственный сервер.\nВозможно, потребуется войти снова." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Переключиться на Cloud" + }, + "settings.network.section.connectivity": { + "message": "Подключение" + }, + "settings.network.section.routingDns": { + "message": "Маршрутизация и DNS" + }, + "settings.network.monitor.label": { + "message": "Переподключаться при смене сети" + }, + "settings.network.monitor.help": { + "message": "Отслеживать сеть и автоматически переподключаться при изменениях, таких как смена Wi-Fi, изменения Ethernet или выход из спящего режима." + }, + "settings.network.dns.label": { + "message": "Включить DNS" + }, + "settings.network.dns.help": { + "message": "Применять управляемые NetBird настройки DNS к системному резолверу." + }, + "settings.network.clientRoutes.label": { + "message": "Включить клиентские маршруты" + }, + "settings.network.clientRoutes.help": { + "message": "Принимать маршруты от других пиров для доступа к их сетям." + }, + "settings.network.serverRoutes.label": { + "message": "Включить серверные маршруты" + }, + "settings.network.serverRoutes.help": { + "message": "Анонсировать локальные маршруты этого хоста другим пирам." + }, + "settings.network.ipv6.label": { + "message": "Включить IPv6" + }, + "settings.network.ipv6.help": { + "message": "Использовать IPv6-адресацию для оверлейной сети NetBird." + }, + "settings.security.section.firewall": { + "message": "Брандмауэр" + }, + "settings.security.section.encryption": { + "message": "Шифрование" + }, + "settings.security.blockInbound.label": { + "message": "Блокировать входящий трафик" + }, + "settings.security.blockInbound.help": { + "message": "Отклонять незапрошенные подключения от пиров к этому устройству и сетям, которые оно маршрутизирует. Исходящий трафик не затрагивается." + }, + "settings.security.blockLan.label": { + "message": "Блокировать доступ к LAN" + }, + "settings.security.blockLan.help": { + "message": "Запрещать пирам доступ к вашей локальной сети и её устройствам, когда это устройство маршрутизирует их трафик." + }, + "settings.security.rosenpass.label": { + "message": "Включить квантовую устойчивость" + }, + "settings.security.rosenpass.help": { + "message": "Добавить постквантовый обмен ключами через Rosenpass поверх WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Включить разрешающий режим" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Разрешать подключения к пирам без поддержки квантовой устойчивости." + }, + "settings.ssh.section.server": { + "message": "Сервер" + }, + "settings.ssh.section.capabilities": { + "message": "Возможности" + }, + "settings.ssh.section.authentication": { + "message": "Аутентификация" + }, + "settings.ssh.server.label": { + "message": "Включить SSH-сервер" + }, + "settings.ssh.server.help": { + "message": "Запускать SSH-сервер NetBird на этом хосте, чтобы другие пиры могли к нему подключаться." + }, + "settings.ssh.root.label": { + "message": "Разрешить вход под root" + }, + "settings.ssh.root.help": { + "message": "Разрешить пирам входить под пользователем root. Отключите, чтобы требовать непривилегированную учётную запись." + }, + "settings.ssh.sftp.label": { + "message": "Разрешить SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Безопасно передавать файлы через стандартные клиенты SFTP или SCP." + }, + "settings.ssh.localForward.label": { + "message": "Локальная переадресация портов" + }, + "settings.ssh.localForward.help": { + "message": "Разрешить подключающимся пирам туннелировать локальные порты к службам, доступным с этого хоста." + }, + "settings.ssh.remoteForward.label": { + "message": "Удалённая переадресация портов" + }, + "settings.ssh.remoteForward.help": { + "message": "Разрешить подключающимся пирам пробрасывать порты этого хоста на свою машину." + }, + "settings.ssh.jwt.label": { + "message": "Включить аутентификацию JWT" + }, + "settings.ssh.jwt.help": { + "message": "Проверять каждую сессию SSH через ваш IdP для идентификации пользователя и аудита. Отключите, чтобы полагаться только на сетевые политики ACL — полезно, когда IdP недоступен." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL кэша JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Как долго этот клиент кэширует JWT, прежде чем снова запрашивать его при исходящих SSH-подключениях. Установите 0, чтобы отключить кэширование и аутентифицироваться при каждом подключении." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "сек." + }, + "settings.advanced.section.interface": { + "message": "Интерфейс" + }, + "settings.advanced.section.security": { + "message": "Безопасность" + }, + "settings.advanced.interfaceName.label": { + "message": "Имя" + }, + "settings.advanced.interfaceName.error": { + "message": "Используйте от 1 до 15 букв, цифр, точек, дефисов или подчёркиваний." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Должно начинаться с «utun», за которым следует число (например, utun100)." + }, + "settings.advanced.port.label": { + "message": "Порт" + }, + "settings.advanced.port.error": { + "message": "Введите порт от {min} до {max}." + }, + "settings.advanced.port.help": { + "message": "Если задано 0, будет использован случайный свободный порт." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Введите значение MTU от {min} до {max}." + }, + "settings.advanced.psk.label": { + "message": "Общий ключ" + }, + "settings.advanced.psk.help": { + "message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ." + }, + "settings.troubleshooting.section.title": { + "message": "Отладочный пакет" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Анонимизировать конфиденциальную информацию" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Включить сведения о системе" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Включить ОС, ядро, сетевые интерфейсы и таблицы маршрутизации." + }, + "settings.troubleshooting.upload.label": { + "message": "Загрузить пакет на серверы NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Возвращает ключ загрузки, который можно передать поддержке NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Включить журналы TRACE" + }, + "settings.troubleshooting.trace.help": { + "message": "Повышает уровень журналирования до TRACE и затем восстанавливает прежний." + }, + "settings.troubleshooting.capture.label": { + "message": "Сеанс записи" + }, + "settings.troubleshooting.capture.help": { + "message": "Переподключается и ожидает, чтобы вы могли воспроизвести проблему." + }, + "settings.troubleshooting.packets.label": { + "message": "Записывать сетевые пакеты" + }, + "settings.troubleshooting.packets.help": { + "message": "Сохраняет .pcap сетевого трафика во время сеанса записи." + }, + "settings.troubleshooting.duration.label": { + "message": "Длительность записи" + }, + "settings.troubleshooting.duration.help": { + "message": "Как долго длится сеанс записи." + }, + "settings.troubleshooting.duration.suffix": { + "message": "мин." + }, + "settings.troubleshooting.create": { + "message": "Создать отладочный пакет" + }, + "settings.troubleshooting.progress.description": { + "message": "Сбор журналов, сведений о системе и состояния подключения. Обычно это занимает несколько секунд — не закрывайте это окно до завершения." + }, + "settings.troubleshooting.cancelling": { + "message": "Отмена…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Отладочный пакет успешно загружен!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Пакет сохранён" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Поделитесь ключом загрузки ниже с поддержкой NetBird. Локальная копия также сохранена на вашем устройстве." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Отладочный пакет сохранён локально." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Копировать ключ" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Открыть папку" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Открыть расположение файла" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Не удалось загрузить: {reason} Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Не удалось загрузить. Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Переподключение NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Сбор отладочных журналов" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Создание отладочного пакета…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Загрузка в NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Отмена…" + }, + "settings.about.client": { + "message": "Клиент NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Клиент NetBird" + }, + "settings.about.development": { + "message": "[Разработка]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Все права защищены." + }, + "settings.about.links.imprint": { + "message": "Правовая информация" + }, + "settings.about.links.privacy": { + "message": "Конфиденциальность" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Условия использования" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Форум" + }, + "settings.about.community.documentation": { + "message": "Документация" + }, + "settings.about.community.feedback": { + "message": "Обратная связь" + }, + "update.banner.message": { + "message": "NetBird {version} готов к установке." + }, + "update.banner.later": { + "message": "Позже" + }, + "update.banner.installNow": { + "message": "Установить сейчас" + }, + "update.card.versionAvailableDownload": { + "message": "Версия {version} доступна для загрузки." + }, + "update.card.versionAvailableInstall": { + "message": "Версия {version} доступна для установки." + }, + "update.card.whatsNew": { + "message": "Что нового?" + }, + "update.card.installNow": { + "message": "Установить сейчас" + }, + "update.card.getInstaller": { + "message": "Загрузить" + }, + "update.card.autoCheckInterval": { + "message": "NetBird проверяет обновления в фоновом режиме." + }, + "update.card.changelog": { + "message": "Список изменений" + }, + "update.card.onLatestVersion": { + "message": "У вас установлена последняя версия" + }, + "update.header.tooltip": { + "message": "Доступно обновление" + }, + "update.overlay.updatingVersion": { + "message": "Обновление NetBird до v{version}" + }, + "update.overlay.updating": { + "message": "Обновление NetBird" + }, + "update.overlay.description": { + "message": "Доступна более новая версия, идёт её установка. NetBird автоматически перезапустится после завершения обновления." + }, + "update.overlay.error.timeoutTitle": { + "message": "Обновление занимает слишком много времени" + }, + "update.overlay.error.timeoutDescription": { + "message": "Установка {target} заняла слишком много времени и не завершилась." + }, + "update.overlay.error.canceledTitle": { + "message": "Обновление остановлено" + }, + "update.overlay.error.canceledDescription": { + "message": "Обновление до {target} было отменено до завершения." + }, + "update.overlay.error.failTitle": { + "message": "Не удалось установить обновление" + }, + "update.overlay.error.failDescription": { + "message": "Не удалось установить обновление до {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "неизвестная ошибка" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "новой версии" + }, + "update.error.loadStateTitle": { + "message": "Не удалось загрузить состояние обновления" + }, + "update.error.triggerTitle": { + "message": "Не удалось запустить обновление" + }, + "update.page.versionLine": { + "message": "Обновление клиента до версии {version}." + }, + "update.page.versionLineGeneric": { + "message": "Обновление клиента." + }, + "update.page.outdated": { + "message": "Версия вашего клиента старше версии автообновления, заданной на сервере управления." + }, + "update.page.status.running": { + "message": "Обновление" + }, + "update.page.status.timeout": { + "message": "Время ожидания обновления истекло. Повторите попытку." + }, + "update.page.status.canceled": { + "message": "Обновление отменено." + }, + "update.page.status.failed": { + "message": "Не удалось обновить: {message}" + }, + "update.page.status.unknownError": { + "message": "неизвестная ошибка обновления" + }, + "update.page.failedTitle": { + "message": "Не удалось обновить" + }, + "update.page.timeoutMessage": { + "message": "Время ожидания обновления истекло." + }, + "update.page.dontClose": { + "message": "Пожалуйста, не закрывайте это окно." + }, + "update.page.updating": { + "message": "Обновление…" + }, + "update.page.complete": { + "message": "Обновление завершено" + }, + "update.page.failed": { + "message": "Обновление не удалось" + }, + "window.title.settings": { + "message": "Настройки" + }, + "window.title.signIn": { + "message": "Вход" + }, + "window.title.sessionExpiration": { + "message": "Истечение сеанса" + }, + "window.title.updating": { + "message": "Обновление" + }, + "window.title.welcome": { + "message": "Добро пожаловать в NetBird" + }, + "window.title.error": { + "message": "Ошибка" + }, + "welcome.title": { + "message": "Найдите NetBird в системном трее" + }, + "welcome.description": { + "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, + "welcome.continue": { + "message": "Продолжить" + }, + "welcome.back": { + "message": "Назад" + }, + "welcome.management.title": { + "message": "Настройка NetBird" + }, + "welcome.management.description": { + "message": "Нажмите «Продолжить», чтобы начать, или выберите «Собственный сервер», если у вас есть свой сервер NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Используйте наш облачный сервис. Настройка не требуется." + }, + "welcome.management.selfHosted.title": { + "message": "Собственный сервер" + }, + "welcome.management.selfHosted.description": { + "message": "Подключитесь к собственному серверу управления." + }, + "welcome.management.urlLabel": { + "message": "URL сервера управления" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или сеть, затем продолжите, если уверены, что он правильный." + }, + "welcome.management.checking": { + "message": "Проверка…" + }, + "browserLogin.title": { + "message": "Завершите вход в браузере" + }, + "browserLogin.notSeeing": { + "message": "Мы открыли вкладку браузера, чтобы вы могли войти в аккаунт. Не видите вкладку?" + }, + "browserLogin.tryAgain": { + "message": "Повторить" + }, + "browserLogin.openFailedTitle": { + "message": "Не удалось открыть браузер" + }, + "sessionExpiration.title": { + "message": "Сеанс скоро истечёт" + }, + "sessionExpiration.titleLater": { + "message": "Ваш сеанс истечёт" + }, + "sessionExpiration.description": { + "message": "Это устройство скоро будет отключено. Продлите сеанс через вход в браузере." + }, + "sessionExpiration.descriptionLater": { + "message": "Вход в браузере сохранит подключение этого устройства к вашей сети." + }, + "sessionExpiration.stay": { + "message": "Продлить сеанс" + }, + "sessionExpiration.authenticate": { + "message": "Войти" + }, + "sessionExpiration.logout": { + "message": "Выйти" + }, + "sessionExpiration.expired": { + "message": "Сеанс истёк" + }, + "sessionExpiration.expiredDescription": { + "message": "Устройство отключено. Войдите через браузер, чтобы переподключиться." + }, + "sessionExpiration.close": { + "message": "Закрыть" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Не удалось продлить сеанс" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Не удалось выйти" + }, + "peers.search.placeholder": { + "message": "Поиск по имени или IP" + }, + "peers.filter.all": { + "message": "Все" + }, + "peers.filter.online": { + "message": "В сети" + }, + "peers.filter.offline": { + "message": "Не в сети" + }, + "peers.empty.title": { + "message": "Нет доступных пиров" + }, + "peers.empty.description": { + "message": "У вас нет доступных пиров или нет доступа ни к одному из них." + }, + "peers.details.domain": { + "message": "Домен" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Открытый ключ" + }, + "peers.details.connection": { + "message": "Подключение" + }, + "peers.details.latency": { + "message": "Задержка" + }, + "peers.details.lastHandshake": { + "message": "Последнее рукопожатие" + }, + "peers.details.statusSince": { + "message": "Последнее обновление подключения" + }, + "peers.details.bytes": { + "message": "Байты" + }, + "peers.details.bytesSent": { + "message": "Отправлено" + }, + "peers.details.bytesReceived": { + "message": "Получено" + }, + "peers.details.localIce": { + "message": "Локальный ICE" + }, + "peers.details.remoteIce": { + "message": "Удалённый ICE" + }, + "peers.details.never": { + "message": "Никогда" + }, + "peers.details.justNow": { + "message": "Только что" + }, + "peers.details.refresh": { + "message": "Обновить" + }, + "peers.status.connected": { + "message": "Подключено" + }, + "peers.status.connecting": { + "message": "Подключение" + }, + "peers.status.disconnected": { + "message": "Отключено" + }, + "peers.details.relayAddress": { + "message": "Ретранслятор" + }, + "peers.details.networks": { + "message": "Ресурсы" + }, + "peers.details.relayed": { + "message": "Через ретранслятор" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass включён" + }, + "networks.search.placeholder": { + "message": "Поиск по сети или домену" + }, + "networks.filter.all": { + "message": "Все" + }, + "networks.filter.active": { + "message": "Активные" + }, + "networks.filter.overlapping": { + "message": "Пересекающиеся" + }, + "networks.empty.title": { + "message": "Нет доступных ресурсов" + }, + "networks.empty.description": { + "message": "У вас нет доступных сетевых ресурсов или нет доступа ни к одному из них." + }, + "networks.selected": { + "message": "Выбрано" + }, + "networks.unselected": { + "message": "Не выбрано" + }, + "networks.ips.heading": { + "message": "Распознанные IP-адреса" + }, + "networks.bulk.selectionCount": { + "message": "{selected} из {total} активно" + }, + "networks.bulk.enableAll": { + "message": "Включить все" + }, + "networks.bulk.disableAll": { + "message": "Отключить все" + }, + "exitNodes.search.placeholder": { + "message": "Поиск выходных узлов" + }, + "exitNodes.none": { + "message": "Нет" + }, + "exitNodes.empty.title": { + "message": "Нет доступных выходных узлов" + }, + "exitNodes.empty.description": { + "message": "Этому пиру не предоставлены выходные узлы." + }, + "exitNodes.card.title": { + "message": "Выходной узел" + }, + "exitNodes.card.statusActive": { + "message": "Активен" + }, + "exitNodes.card.statusInactive": { + "message": "Неактивен" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Нет" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Прямое подключение без выходного узла" + }, + "quickActions.connect": { + "message": "Подключиться" + }, + "quickActions.disconnect": { + "message": "Отключиться" + }, + "daemon.unavailable.title": { + "message": "Служба NetBird не запущена" + }, + "daemon.unavailable.description": { + "message": "Приложение автоматически переподключится, как только служба будет запущена." + }, + "daemon.unavailable.docsLink": { + "message": "Документация" + }, + "daemon.outdated.title": { + "message": "Служба NetBird устарела" + }, + "daemon.outdated.description": { + "message": "Обновите службу NetBird, чтобы использовать это приложение." + }, + "error.jwt_clock_skew": { + "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." + }, + "error.jwt_expired": { + "message": "Срок действия токена входа истёк. Войдите снова." + }, + "error.jwt_signature_invalid": { + "message": "Не удалось войти: недействительная подпись токена. Обратитесь к администратору." + }, + "error.session_expired": { + "message": "Ваш сеанс истёк. Войдите снова." + }, + "error.invalid_setup_key": { + "message": "Ключ установки отсутствует или недействителен." + }, + "error.permission_denied": { + "message": "Сервер отклонил вход." + }, + "error.daemon_unreachable": { + "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." + }, + "error.unknown": { + "message": "Не удалось выполнить операцию." + } +} diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json new file mode 100644 index 000000000..609344fc0 --- /dev/null +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "已断开连接" + }, + "tray.status.daemonUnavailable": { + "message": "未运行" + }, + "tray.status.error": { + "message": "错误" + }, + "tray.status.connected": { + "message": "已连接" + }, + "tray.status.connecting": { + "message": "正在连接" + }, + "tray.status.needsLogin": { + "message": "需要登录" + }, + "tray.status.loginFailed": { + "message": "登录失败" + }, + "tray.status.sessionExpired": { + "message": "会话已过期" + }, + "tray.session.expiresIn": { + "message": "会话将在 {remaining} 后过期" + }, + "tray.session.unit.lessThanMinute": { + "message": "不到一分钟" + }, + "tray.session.unit.minute": { + "message": "1 分钟" + }, + "tray.session.unit.minutes": { + "message": "{count} 分钟" + }, + "tray.session.unit.hour": { + "message": "1 小时" + }, + "tray.session.unit.hours": { + "message": "{count} 小时" + }, + "tray.session.unit.day": { + "message": "1 天" + }, + "tray.session.unit.days": { + "message": "{count} 天" + }, + "tray.menu.open": { + "message": "打开 NetBird" + }, + "tray.menu.connect": { + "message": "连接" + }, + "tray.menu.disconnect": { + "message": "断开连接" + }, + "tray.menu.exitNode": { + "message": "出口节点" + }, + "tray.menu.networks": { + "message": "资源" + }, + "tray.menu.profiles": { + "message": "配置文件" + }, + "tray.menu.manageProfiles": { + "message": "管理配置文件" + }, + "tray.menu.settings": { + "message": "设置…" + }, + "tray.menu.debugBundle": { + "message": "创建调试包" + }, + "tray.menu.about": { + "message": "帮助与支持" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "文档" + }, + "tray.menu.troubleshoot": { + "message": "故障排除" + }, + "tray.menu.downloadLatest": { + "message": "下载最新版本" + }, + "tray.menu.installVersion": { + "message": "安装 {version} 版本" + }, + "tray.menu.guiVersion": { + "message": "GUI:{version}" + }, + "tray.menu.daemonVersion": { + "message": "守护进程:{version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "退出 NetBird" + }, + "notify.daemonOutdated.title": { + "message": "NetBird 服务版本过旧" + }, + "notify.daemonOutdated.body": { + "message": "请更新 NetBird 服务以使用此应用。" + }, + "notify.update.title": { + "message": "NetBird 有可用更新" + }, + "notify.update.body": { + "message": "NetBird {version} 已可用。" + }, + "notify.update.enforcedSuffix": { + "message": " 您的管理员要求进行此次更新。" + }, + "notify.error.title": { + "message": "错误" + }, + "notify.error.connect": { + "message": "连接失败" + }, + "notify.error.disconnect": { + "message": "断开连接失败" + }, + "notify.error.switchProfile": { + "message": "切换到 {profile} 失败" + }, + "notify.error.exitNode": { + "message": "更新出口节点 {name} 失败" + }, + "notify.sessionExpired.title": { + "message": "NetBird 会话已过期" + }, + "notify.sessionExpired.body": { + "message": "您的 NetBird 会话已过期。请重新登录。" + }, + "notify.sessionWarning.title": { + "message": "会话即将过期" + }, + "notify.sessionWarning.body": { + "message": "您的 NetBird 会话将在 {remaining} 后过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "您的 NetBird 会话即将过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.extend": { + "message": "立即延长" + }, + "notify.sessionWarning.dismiss": { + "message": "忽略" + }, + "notify.sessionWarning.failed": { + "message": "延长 NetBird 会话失败" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird 会话已延长" + }, + "notify.sessionWarning.successBody": { + "message": "您的会话已刷新。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "会话截止时间被拒绝" + }, + "notify.sessionDeadlineRejected.body": { + "message": "服务器发送了无效的会话截止时间。请重新登录。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 设置已更新" + }, + "notify.mdm.policyApplied.body": { + "message": "您的 NetBird 配置已根据 IT 策略更新。" + }, + "common.cancel": { + "message": "取消" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "保存更改" + }, + "common.saving": { + "message": "正在保存…" + }, + "common.close": { + "message": "关闭" + }, + "common.copy": { + "message": "复制" + }, + "common.togglePasswordVisibility": { + "message": "切换密码可见性" + }, + "common.increase": { + "message": "增加" + }, + "common.decrease": { + "message": "减少" + }, + "common.delete": { + "message": "删除" + }, + "common.create": { + "message": "创建" + }, + "common.add": { + "message": "添加" + }, + "common.remove": { + "message": "移除" + }, + "common.refresh": { + "message": "刷新" + }, + "common.loading": { + "message": "正在加载…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "未找到任何结果" + }, + "common.noResults.description": { + "message": "我们未能找到任何结果。请尝试其他搜索词或更改筛选条件。" + }, + "notConnected.title": { + "message": "已断开连接" + }, + "notConnected.description": { + "message": "请先连接到 NetBird,以查看有关对等节点、网络资源和出口节点的详细信息。" + }, + "connect.status.disconnected": { + "message": "已断开连接" + }, + "connect.status.connecting": { + "message": "正在连接…" + }, + "connect.status.connected": { + "message": "已连接" + }, + "connect.status.disconnecting": { + "message": "正在断开连接…" + }, + "connect.status.daemonUnavailable": { + "message": "守护进程不可用" + }, + "connect.status.loginRequired": { + "message": "需要登录" + }, + "connect.error.loginTitle": { + "message": "登录失败" + }, + "connect.error.connectTitle": { + "message": "连接失败" + }, + "connect.error.disconnectTitle": { + "message": "断开连接失败" + }, + "nav.peers.title": { + "message": "对等节点" + }, + "nav.peers.description": { + "message": "{total} 个中已连接 {connected} 个" + }, + "nav.resources.title": { + "message": "资源" + }, + "nav.resources.description": { + "message": "{total} 个中已激活 {active} 个" + }, + "nav.exitNode.title": { + "message": "出口节点" + }, + "nav.exitNode.none": { + "message": "未激活" + }, + "nav.exitNode.using": { + "message": "经由 {name}" + }, + "header.openSettings": { + "message": "打开设置" + }, + "header.togglePanel": { + "message": "切换侧边栏" + }, + "profile.selector.loading": { + "message": "正在加载…" + }, + "profile.selector.noProfile": { + "message": "无配置文件" + }, + "profile.selector.searchPlaceholder": { + "message": "按名称搜索配置文件…" + }, + "profile.selector.emptyTitle": { + "message": "未找到配置文件" + }, + "profile.selector.emptyDescription": { + "message": "请尝试其他搜索词或创建新的配置文件。" + }, + "profile.selector.newProfile": { + "message": "新建配置文件" + }, + "profile.selector.moreOptions": { + "message": "更多选项" + }, + "profile.selector.deregister": { + "message": "注销" + }, + "profile.selector.delete": { + "message": "删除" + }, + "profile.selector.switchTo": { + "message": "切换到此配置文件" + }, + "profile.selector.edit": { + "message": "编辑" + }, + "profile.edit.title": { + "message": "编辑配置文件" + }, + "profile.edit.submit": { + "message": "保存更改" + }, + "profile.dialog.title": { + "message": "输入配置文件名称" + }, + "profile.dialog.nameLabel": { + "message": "配置文件名称" + }, + "profile.dialog.description": { + "message": "为您的配置文件设置一个易于识别的名称。" + }, + "profile.dialog.placeholder": { + "message": "例如:工作" + }, + "profile.dialog.submit": { + "message": "添加配置文件" + }, + "profile.dialog.required": { + "message": "请输入配置文件名称,例如:工作、家庭" + }, + "profile.dialog.managementHelp": { + "message": "使用 NetBird Cloud 或您自己的服务器。" + }, + "profile.dialog.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常添加该配置文件。" + }, + "header.menu.settings": { + "message": "设置…" + }, + "header.menu.defaultView": { + "message": "默认视图" + }, + "header.menu.advancedView": { + "message": "高级视图" + }, + "header.menu.updateAvailable": { + "message": "有可用更新" + }, + "header.menu.open": { + "message": "打开菜单" + }, + "header.profile.switch": { + "message": "切换配置文件" + }, + "connect.toggle.label": { + "message": "切换 NetBird 连接" + }, + "connect.localIp.label": { + "message": "本地 IP 地址" + }, + "common.search": { + "message": "搜索" + }, + "common.filter": { + "message": "筛选" + }, + "exitNodes.dropdown.trigger": { + "message": "选择出口节点" + }, + "peers.row.label": { + "message": "打开 {name} 的详情,{status}" + }, + "peers.dialog.title": { + "message": "对等节点详情" + }, + "networks.row.toggle": { + "message": "切换 {name}" + }, + "networks.bulk.label": { + "message": "切换所有可见资源" + }, + "settings.nav.label": { + "message": "设置部分" + }, + "profile.switch.title": { + "message": "切换到配置文件“{name}”?" + }, + "profile.switch.message": { + "message": "您确定要切换配置文件吗?\n您当前的配置文件将被断开连接。" + }, + "profile.switch.confirm": { + "message": "确认" + }, + "profile.deregister.title": { + "message": "注销配置文件“{name}”?" + }, + "profile.deregister.message": { + "message": "您确定要注销此配置文件吗?\n您将需要重新登录才能使用它。" + }, + "profile.deregister.confirm": { + "message": "注销" + }, + "profile.delete.title": { + "message": "删除配置文件“{name}”?" + }, + "profile.delete.message": { + "message": "您确定要删除此配置文件吗?\n此操作无法撤销。" + }, + "profile.delete.disabledActive": { + "message": "无法删除处于活动状态的配置文件。请先切换到其他配置文件,再删除此配置文件。" + }, + "profile.delete.disabledDefault": { + "message": "无法删除默认配置文件。" + }, + "profile.error.switchTitle": { + "message": "切换配置文件失败" + }, + "profile.error.deregisterTitle": { + "message": "注销配置文件失败" + }, + "profile.error.deleteTitle": { + "message": "删除配置文件失败" + }, + "profile.error.createTitle": { + "message": "创建配置文件失败" + }, + "profile.error.editTitle": { + "message": "编辑配置文件失败" + }, + "profile.error.loadTitle": { + "message": "加载配置文件失败" + }, + "profile.dropdown.activeProfile": { + "message": "当前配置文件" + }, + "profile.dropdown.switchProfile": { + "message": "切换配置文件" + }, + "profile.dropdown.noEmail": { + "message": "其他" + }, + "profile.dropdown.addProfile": { + "message": "添加配置文件" + }, + "profile.dropdown.manageProfiles": { + "message": "管理配置文件" + }, + "profile.dropdown.settings": { + "message": "设置" + }, + "settings.profiles.section.profiles": { + "message": "配置文件" + }, + "settings.profiles.intro": { + "message": "并行保留多个独立的 NetBird 身份,例如工作和个人账户,或不同的管理服务器。可在下方添加、注销或删除配置文件。" + }, + "settings.profiles.addProfile": { + "message": "添加配置文件" + }, + "settings.profiles.active": { + "message": "活动" + }, + "settings.profiles.emptyTitle": { + "message": "无配置文件" + }, + "settings.profiles.emptyDescription": { + "message": "创建一个配置文件以连接到 NetBird 管理服务器。" + }, + "settings.error.loadTitle": { + "message": "加载设置失败" + }, + "settings.error.saveTitle": { + "message": "保存设置失败" + }, + "settings.error.debugBundleTitle": { + "message": "创建调试包失败" + }, + "settings.tabs.general": { + "message": "常规" + }, + "settings.tabs.network": { + "message": "网络" + }, + "settings.tabs.security": { + "message": "安全" + }, + "settings.tabs.profiles": { + "message": "配置文件" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "高级" + }, + "settings.tabs.troubleshooting": { + "message": "故障排除" + }, + "settings.tabs.about": { + "message": "关于" + }, + "settings.tabs.updateAvailable": { + "message": "有可用更新" + }, + "settings.general.section.general": { + "message": "常规" + }, + "settings.general.section.connection": { + "message": "连接" + }, + "settings.general.connectOnStartup.label": { + "message": "启动时连接" + }, + "settings.general.connectOnStartup.help": { + "message": "在服务启动时自动建立连接。" + }, + "settings.general.notifications.label": { + "message": "桌面通知" + }, + "settings.general.notifications.help": { + "message": "显示有关新更新和连接事件的桌面通知。" + }, + "settings.general.autostart.label": { + "message": "登录时启动 NetBird 界面" + }, + "settings.general.autostart.help": { + "message": "在您登录时自动启动 NetBird 界面。此设置仅影响图形界面,不影响后台服务。" + }, + "settings.general.autostart.errorTitle": { + "message": "更改自启动设置失败" + }, + "settings.general.language.label": { + "message": "显示语言" + }, + "settings.general.language.help": { + "message": "选择 NetBird 界面的语言。" + }, + "settings.general.language.search": { + "message": "搜索语言…" + }, + "settings.general.language.empty": { + "message": "没有匹配的语言。" + }, + "settings.general.management.label": { + "message": "管理服务器" + }, + "settings.general.management.help": { + "message": "连接到 NetBird Cloud 或您自己的自托管管理服务器。更改将使客户端重新连接。" + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "自托管" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常保存。" + }, + "settings.general.management.switchCloudTitle": { + "message": "切换到 NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "这将断开您的自托管服务器。\n您可能需要重新登录。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "切换到 Cloud" + }, + "settings.network.section.connectivity": { + "message": "连接性" + }, + "settings.network.section.routingDns": { + "message": "路由与 DNS" + }, + "settings.network.monitor.label": { + "message": "网络变化时重新连接" + }, + "settings.network.monitor.help": { + "message": "监测网络,并在发生变化时自动重新连接,例如切换 Wi-Fi、以太网变化或从睡眠中恢复。" + }, + "settings.network.dns.label": { + "message": "启用 DNS" + }, + "settings.network.dns.help": { + "message": "将 NetBird 管理的 DNS 设置应用到主机解析器。" + }, + "settings.network.clientRoutes.label": { + "message": "启用客户端路由" + }, + "settings.network.clientRoutes.help": { + "message": "接受来自其他对等节点的路由,以访问它们的网络。" + }, + "settings.network.serverRoutes.label": { + "message": "启用服务器路由" + }, + "settings.network.serverRoutes.help": { + "message": "向其他对等节点通告此主机的本地路由。" + }, + "settings.network.ipv6.label": { + "message": "启用 IPv6" + }, + "settings.network.ipv6.help": { + "message": "为 NetBird 叠加网络使用 IPv6 寻址。" + }, + "settings.security.section.firewall": { + "message": "防火墙" + }, + "settings.security.section.encryption": { + "message": "加密" + }, + "settings.security.blockInbound.label": { + "message": "阻止入站流量" + }, + "settings.security.blockInbound.help": { + "message": "拒绝对等节点向本设备及其路由的任何网络发起的未经请求的连接。出站流量不受影响。" + }, + "settings.security.blockLan.label": { + "message": "阻止 LAN 访问" + }, + "settings.security.blockLan.help": { + "message": "当本设备为对等节点路由流量时,阻止它们访问您的本地网络或其设备。" + }, + "settings.security.rosenpass.label": { + "message": "启用抗量子加密" + }, + "settings.security.rosenpass.help": { + "message": "在 WireGuard® 之上通过 Rosenpass 添加后量子密钥交换。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "启用宽松模式" + }, + "settings.security.rosenpassPermissive.help": { + "message": "允许连接到不支持抗量子加密的对等节点。" + }, + "settings.ssh.section.server": { + "message": "服务器" + }, + "settings.ssh.section.capabilities": { + "message": "功能" + }, + "settings.ssh.section.authentication": { + "message": "身份验证" + }, + "settings.ssh.server.label": { + "message": "启用 SSH 服务器" + }, + "settings.ssh.server.help": { + "message": "在此主机上运行 NetBird SSH 服务器,以便其他对等节点可以连接到它。" + }, + "settings.ssh.root.label": { + "message": "允许 root 登录" + }, + "settings.ssh.root.help": { + "message": "允许对等节点以 root 用户身份登录。禁用后将要求使用非特权账户。" + }, + "settings.ssh.sftp.label": { + "message": "允许 SFTP" + }, + "settings.ssh.sftp.help": { + "message": "使用原生 SFTP 或 SCP 客户端安全地传输文件。" + }, + "settings.ssh.localForward.label": { + "message": "本地端口转发" + }, + "settings.ssh.localForward.help": { + "message": "允许连接的对等节点将本地端口隧道转发到此主机可访问的服务。" + }, + "settings.ssh.remoteForward.label": { + "message": "远程端口转发" + }, + "settings.ssh.remoteForward.help": { + "message": "允许连接的对等节点将此主机上的端口反向暴露到它们自己的机器。" + }, + "settings.ssh.jwt.label": { + "message": "启用 JWT 身份验证" + }, + "settings.ssh.jwt.help": { + "message": "针对您的 IdP 验证每个 SSH 会话,以确认用户身份并进行审计。禁用后将仅依赖网络 ACL 策略,在没有可用 IdP 时很有用。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT 缓存 TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "在出站 SSH 连接再次提示前,此客户端缓存 JWT 的时长。设为 0 可禁用缓存,每次连接都进行身份验证。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "接口" + }, + "settings.advanced.section.security": { + "message": "安全" + }, + "settings.advanced.interfaceName.label": { + "message": "名称" + }, + "settings.advanced.interfaceName.error": { + "message": "请使用 1-15 个字母、数字、点、连字符或下划线。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "必须以“utun”开头,后跟一个数字(例如 utun100)。" + }, + "settings.advanced.port.label": { + "message": "端口" + }, + "settings.advanced.port.error": { + "message": "请输入介于 {min} 和 {max} 之间的端口。" + }, + "settings.advanced.port.help": { + "message": "如果设为 0,将使用一个随机的空闲端口。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "请输入介于 {min} 和 {max} 之间的 MTU 值。" + }, + "settings.advanced.psk.label": { + "message": "预共享密钥" + }, + "settings.advanced.psk.help": { + "message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。" + }, + "settings.troubleshooting.section.title": { + "message": "调试包" + }, + "settings.troubleshooting.anonymize.label": { + "message": "匿名化敏感信息" + }, + "settings.troubleshooting.anonymize.help": { + "message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "包含系统信息" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "包含操作系统、内核、网络接口和路由表。" + }, + "settings.troubleshooting.upload.label": { + "message": "将调试包上传到 NetBird 服务器" + }, + "settings.troubleshooting.upload.help": { + "message": "返回一个上传密钥,供您分享给 NetBird 支持团队。" + }, + "settings.troubleshooting.trace.label": { + "message": "启用跟踪日志" + }, + "settings.troubleshooting.trace.help": { + "message": "将日志级别提升到 TRACE,之后再恢复原级别。" + }, + "settings.troubleshooting.capture.label": { + "message": "捕获会话" + }, + "settings.troubleshooting.capture.help": { + "message": "重新连接并等待,以便您复现问题。" + }, + "settings.troubleshooting.packets.label": { + "message": "捕获网络数据包" + }, + "settings.troubleshooting.packets.help": { + "message": "在捕获期间将网络流量保存为 .pcap 文件。" + }, + "settings.troubleshooting.duration.label": { + "message": "捕获时长" + }, + "settings.troubleshooting.duration.help": { + "message": "捕获会话运行的时长。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分钟" + }, + "settings.troubleshooting.create": { + "message": "创建调试包" + }, + "settings.troubleshooting.progress.description": { + "message": "正在收集日志、系统详情和连接状态。这通常只需片刻——请保持此窗口打开,直到完成。" + }, + "settings.troubleshooting.cancelling": { + "message": "正在取消…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "调试包已成功上传!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "调试包已保存" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "请将下方的上传密钥分享给 NetBird 支持团队。本地也已保存了一份副本。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "您的调试包已保存在本地。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "复制密钥" + }, + "settings.troubleshooting.done.openFolder": { + "message": "打开文件夹" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "打开文件位置" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "上传失败:{reason} 调试包仍已保存在本地。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "上传失败。调试包仍已保存在本地。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "正在重新连接 NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "正在捕获调试日志" + }, + "settings.troubleshooting.stage.bundling": { + "message": "正在生成调试包…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "正在上传到 NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "正在取消…" + }, + "settings.about.client": { + "message": "NetBird 客户端 v{version}" + }, + "settings.about.clientName": { + "message": "NetBird 客户端" + }, + "settings.about.development": { + "message": "[开发版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird。保留所有权利。" + }, + "settings.about.links.imprint": { + "message": "法律声明" + }, + "settings.about.links.privacy": { + "message": "隐私" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "服务条款" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "论坛" + }, + "settings.about.community.documentation": { + "message": "文档" + }, + "settings.about.community.feedback": { + "message": "反馈" + }, + "update.banner.message": { + "message": "NetBird {version} 已准备好安装。" + }, + "update.banner.later": { + "message": "稍后" + }, + "update.banner.installNow": { + "message": "立即安装" + }, + "update.card.versionAvailableDownload": { + "message": "{version} 版本可供下载。" + }, + "update.card.versionAvailableInstall": { + "message": "{version} 版本可供安装。" + }, + "update.card.whatsNew": { + "message": "更新内容?" + }, + "update.card.installNow": { + "message": "立即安装" + }, + "update.card.getInstaller": { + "message": "下载" + }, + "update.card.autoCheckInterval": { + "message": "NetBird 会在后台检查更新。" + }, + "update.card.changelog": { + "message": "更新日志" + }, + "update.card.onLatestVersion": { + "message": "您已是最新版本" + }, + "update.header.tooltip": { + "message": "有可用更新" + }, + "update.overlay.updatingVersion": { + "message": "正在将 NetBird 更新到 v{version}" + }, + "update.overlay.updating": { + "message": "正在更新 NetBird" + }, + "update.overlay.description": { + "message": "有更新的版本可用,正在安装。更新完成后,NetBird 将自动重启。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新耗时过长" + }, + "update.overlay.error.timeoutDescription": { + "message": "安装 {target} 耗时过长,未能完成。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新已停止" + }, + "update.overlay.error.canceledDescription": { + "message": "对 {target} 的更新在完成前已被取消。" + }, + "update.overlay.error.failTitle": { + "message": "无法安装更新" + }, + "update.overlay.error.failDescription": { + "message": "无法安装 {target}。" + }, + "update.overlay.error.unknownMessage": { + "message": "未知错误" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新版本" + }, + "update.error.loadStateTitle": { + "message": "加载更新状态失败" + }, + "update.error.triggerTitle": { + "message": "启动更新失败" + }, + "update.page.versionLine": { + "message": "正在将客户端更新到:{version}。" + }, + "update.page.versionLineGeneric": { + "message": "正在更新客户端。" + }, + "update.page.outdated": { + "message": "您的客户端版本早于管理服务器中设置的自动更新版本。" + }, + "update.page.status.running": { + "message": "正在更新" + }, + "update.page.status.timeout": { + "message": "更新超时。请重试。" + }, + "update.page.status.canceled": { + "message": "更新已取消。" + }, + "update.page.status.failed": { + "message": "更新失败:{message}" + }, + "update.page.status.unknownError": { + "message": "未知的更新错误" + }, + "update.page.failedTitle": { + "message": "更新失败" + }, + "update.page.timeoutMessage": { + "message": "更新超时。" + }, + "update.page.dontClose": { + "message": "请勿关闭此窗口。" + }, + "update.page.updating": { + "message": "正在更新…" + }, + "update.page.complete": { + "message": "更新完成" + }, + "update.page.failed": { + "message": "更新失败" + }, + "window.title.settings": { + "message": "设置" + }, + "window.title.signIn": { + "message": "登录" + }, + "window.title.sessionExpiration": { + "message": "会话即将过期" + }, + "window.title.updating": { + "message": "正在更新" + }, + "window.title.welcome": { + "message": "欢迎使用 NetBird" + }, + "window.title.error": { + "message": "错误" + }, + "welcome.title": { + "message": "在托盘中查找 NetBird" + }, + "welcome.description": { + "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" + }, + "welcome.continue": { + "message": "继续" + }, + "welcome.back": { + "message": "返回" + }, + "welcome.management.title": { + "message": "设置 NetBird" + }, + "welcome.management.description": { + "message": "点击“继续”即可开始;如果您有自己的 NetBird 服务器,请选择“自托管”。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "使用我们的托管服务。无需任何设置。" + }, + "welcome.management.selfHosted.title": { + "message": "自托管" + }, + "welcome.management.selfHosted.description": { + "message": "连接到您自己的管理服务器。" + }, + "welcome.management.urlLabel": { + "message": "管理服务器 URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL 或您的网络,如果确认无误,再继续。" + }, + "welcome.management.checking": { + "message": "正在检查…" + }, + "browserLogin.title": { + "message": "请在浏览器中继续以完成登录" + }, + "browserLogin.notSeeing": { + "message": "没看到浏览器标签页?" + }, + "browserLogin.tryAgain": { + "message": "重试" + }, + "browserLogin.openFailedTitle": { + "message": "打开浏览器失败" + }, + "sessionExpiration.title": { + "message": "会话即将过期" + }, + "sessionExpiration.titleLater": { + "message": "您的会话即将过期" + }, + "sessionExpiration.description": { + "message": "此设备即将断开连接。通过浏览器登录进行续期。" + }, + "sessionExpiration.descriptionLater": { + "message": "通过浏览器登录可让此设备保持连接到您的网络。" + }, + "sessionExpiration.stay": { + "message": "续期会话" + }, + "sessionExpiration.authenticate": { + "message": "进行身份验证" + }, + "sessionExpiration.logout": { + "message": "退出登录" + }, + "sessionExpiration.expired": { + "message": "会话已过期" + }, + "sessionExpiration.expiredDescription": { + "message": "设备已断开连接。通过浏览器登录进行身份验证以重新连接。" + }, + "sessionExpiration.close": { + "message": "关闭" + }, + "sessionExpiration.extendFailedTitle": { + "message": "延长会话失败" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "退出登录失败" + }, + "peers.search.placeholder": { + "message": "按名称或 IP 搜索" + }, + "peers.filter.all": { + "message": "全部" + }, + "peers.filter.online": { + "message": "在线" + }, + "peers.filter.offline": { + "message": "离线" + }, + "peers.empty.title": { + "message": "无可用的对等节点" + }, + "peers.empty.description": { + "message": "您可能没有任何可用的对等节点,或者无权访问其中任何一个。" + }, + "peers.details.domain": { + "message": "域名" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公钥" + }, + "peers.details.connection": { + "message": "连接" + }, + "peers.details.latency": { + "message": "延迟" + }, + "peers.details.lastHandshake": { + "message": "上次握手" + }, + "peers.details.statusSince": { + "message": "上次连接更新" + }, + "peers.details.bytes": { + "message": "字节" + }, + "peers.details.bytesSent": { + "message": "已发送" + }, + "peers.details.bytesReceived": { + "message": "已接收" + }, + "peers.details.localIce": { + "message": "本地 ICE" + }, + "peers.details.remoteIce": { + "message": "远程 ICE" + }, + "peers.details.never": { + "message": "从不" + }, + "peers.details.justNow": { + "message": "刚刚" + }, + "peers.details.refresh": { + "message": "刷新" + }, + "peers.status.connected": { + "message": "已连接" + }, + "peers.status.connecting": { + "message": "正在连接" + }, + "peers.status.disconnected": { + "message": "已断开连接" + }, + "peers.details.relayAddress": { + "message": "中继" + }, + "peers.details.networks": { + "message": "资源" + }, + "peers.details.relayed": { + "message": "经中继" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "已启用 Rosenpass" + }, + "networks.search.placeholder": { + "message": "按网络或域名搜索" + }, + "networks.filter.all": { + "message": "全部" + }, + "networks.filter.active": { + "message": "活动" + }, + "networks.filter.overlapping": { + "message": "重叠" + }, + "networks.empty.title": { + "message": "无可用资源" + }, + "networks.empty.description": { + "message": "您可能没有任何可用的网络资源,或者无权访问其中任何一个。" + }, + "networks.selected": { + "message": "已选择" + }, + "networks.unselected": { + "message": "未选择" + }, + "networks.ips.heading": { + "message": "已解析的 IP" + }, + "networks.bulk.selectionCount": { + "message": "{total} 个中已激活 {selected} 个" + }, + "networks.bulk.enableAll": { + "message": "全部启用" + }, + "networks.bulk.disableAll": { + "message": "全部禁用" + }, + "exitNodes.search.placeholder": { + "message": "搜索出口节点" + }, + "exitNodes.none": { + "message": "无" + }, + "exitNodes.empty.title": { + "message": "无可用的出口节点" + }, + "exitNodes.empty.description": { + "message": "尚未向此对等节点共享任何出口节点。" + }, + "exitNodes.card.title": { + "message": "出口节点" + }, + "exitNodes.card.statusActive": { + "message": "活动" + }, + "exitNodes.card.statusInactive": { + "message": "非活动" + }, + "exitNodes.dropdown.noneTitle": { + "message": "无" + }, + "exitNodes.dropdown.noneDescription": { + "message": "不使用出口节点的直接连接" + }, + "quickActions.connect": { + "message": "连接" + }, + "quickActions.disconnect": { + "message": "断开连接" + }, + "daemon.unavailable.title": { + "message": "NetBird 服务未运行" + }, + "daemon.unavailable.description": { + "message": "服务运行后,应用将自动重新连接。" + }, + "daemon.unavailable.docsLink": { + "message": "文档" + }, + "daemon.outdated.title": { + "message": "NetBird 服务版本过旧" + }, + "daemon.outdated.description": { + "message": "请更新 NetBird 服务以使用此应用。" + }, + "error.jwt_clock_skew": { + "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" + }, + "error.jwt_expired": { + "message": "您的登录令牌已过期。请重新登录。" + }, + "error.jwt_signature_invalid": { + "message": "登录失败:令牌签名无效。请联系您的管理员。" + }, + "error.session_expired": { + "message": "您的会话已过期。请重新登录。" + }, + "error.invalid_setup_key": { + "message": "设置密钥缺失或无效。" + }, + "error.permission_denied": { + "message": "登录被服务器拒绝。" + }, + "error.daemon_unreachable": { + "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" + }, + "error.unknown": { + "message": "操作失败。" + } +} diff --git a/client/ui/icons.go b/client/ui/icons.go index 874f24fdd..5ab19ca05 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -1,16 +1,10 @@ -//go:build !(linux && 386) && !windows +//go:build !android && !ios && !freebsd && !js package main -import ( - _ "embed" -) +import _ "embed" -//go:embed assets/netbird.png -var iconAbout []byte - -//go:embed assets/netbird-disconnected.png -var iconAboutDisconnected []byte +// Windows reuses these PNGs: multi-frame .ico never redrew under Wails3's NIM_MODIFY, single-frame PNG does. //go:embed assets/netbird-systemtray-connected.png var iconConnected []byte @@ -21,18 +15,6 @@ var iconConnectedDark []byte //go:embed assets/netbird-systemtray-disconnected.png var iconDisconnected []byte -//go:embed assets/netbird-systemtray-update-disconnected.png -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.png -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.png -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.png -var iconUpdateConnectedDark []byte - //go:embed assets/netbird-systemtray-connecting.png var iconConnecting []byte @@ -44,3 +26,91 @@ var iconError []byte //go:embed assets/netbird-systemtray-error-dark.png var iconErrorDark []byte + +//go:embed assets/netbird-systemtray-needs-login.png +var iconNeedsLogin []byte + +//go:embed assets/netbird-systemtray-update-connected.png +var iconUpdateConnected []byte + +//go:embed assets/netbird-systemtray-update-connected-dark.png +var iconUpdateConnectedDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected.png +var iconUpdateDisconnected []byte + +//go:embed assets/netbird-systemtray-update-disconnected-dark.png +var iconUpdateDisconnectedDark []byte + +//go:embed assets/netbird-systemtray-connected-macos.png +var iconConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-disconnected-macos.png +var iconDisconnectedMacOS []byte + +//go:embed assets/netbird-systemtray-connecting-macos.png +var iconConnectingMacOS []byte + +//go:embed assets/netbird-systemtray-error-macos.png +var iconErrorMacOS []byte + +//go:embed assets/netbird-systemtray-needs-login-macos.png +var iconNeedsLoginMacOS []byte + +//go:embed assets/netbird-systemtray-update-connected-macos.png +var iconUpdateConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-update-disconnected-macos.png +var iconUpdateDisconnectedMacOS []byte + +// SNI has no template recoloring, so ship an explicit pair: black (*-mono.png) +// for light panels, white (*-mono-dark.png) for dark panels. + +//go:embed assets/netbird-systemtray-connected-mono.png +var iconConnectedMono []byte + +//go:embed assets/netbird-systemtray-connected-mono-dark.png +var iconConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-connecting-mono.png +var iconConnectingMono []byte + +//go:embed assets/netbird-systemtray-connecting-mono-dark.png +var iconConnectingMonoDark []byte + +//go:embed assets/netbird-systemtray-disconnected-mono.png +var iconDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-disconnected-mono-dark.png +var iconDisconnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-error-mono.png +var iconErrorMono []byte + +//go:embed assets/netbird-systemtray-error-mono-dark.png +var iconErrorMonoDark []byte + +//go:embed assets/netbird-systemtray-needs-login-mono.png +var iconNeedsLoginMono []byte + +//go:embed assets/netbird-systemtray-needs-login-mono-dark.png +var iconNeedsLoginMonoDark []byte + +//go:embed assets/netbird-systemtray-update-connected-mono.png +var iconUpdateConnectedMono []byte + +//go:embed assets/netbird-systemtray-update-connected-mono-dark.png +var iconUpdateConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono.png +var iconUpdateDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono-dark.png +var iconUpdateDisconnectedMonoDark []byte + +//go:embed assets/netbird.png +var iconWindow []byte + +// Per-platform menu-row icons live in icons_menu_{windows,other}.go. Windows +// uses 16x16: they go into the Win32 check-mark slot (SM_CXMENUCHECK, ~16x16 at +// 100% DPI) which crops anything bigger; macOS/Linux use 24x24. diff --git a/client/ui/icons_menu_darwin.go b/client/ui/icons_menu_darwin.go new file mode 100644 index 000000000..c077a6d2d --- /dev/null +++ b/client/ui/icons_menu_darwin.go @@ -0,0 +1,23 @@ +//go:build darwin + +package main + +import _ "embed" + +// 22px matches the NSMenuItem row text weight (HIG's 18-22 range); +// Windows uses 16px and Linux 24px — see the sibling icons_menu_*.go. + +//go:embed assets/netbird-menu-dot-connected-22.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting-22.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error-22.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle-22.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline-22.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_menu_linux.go b/client/ui/icons_menu_linux.go new file mode 100644 index 000000000..54e0c743a --- /dev/null +++ b/client/ui/icons_menu_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package main + +import _ "embed" + +// 24x24: GTK4 menu rows render 22–48 px with no downscaling. + +//go:embed assets/netbird-menu-dot-connected.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_menu_windows.go b/client/ui/icons_menu_windows.go new file mode 100644 index 000000000..c08359902 --- /dev/null +++ b/client/ui/icons_menu_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package main + +import _ "embed" + +// SetMenuItemBitmaps sizes the HBITMAP to SM_CXMENUCHECK/SM_CYMENUCHECK (16x16 +// at 100% DPI); larger bitmaps overflow the row, hence this Windows-only set +// downscaled from the 24x24 originals. + +//go:embed assets/netbird-menu-dot-connected-16.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting-16.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error-16.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle-16.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline-16.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_windows.go b/client/ui/icons_windows.go deleted file mode 100644 index bd57b2690..000000000 --- a/client/ui/icons_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - _ "embed" -) - -//go:embed assets/netbird.ico -var iconAbout []byte - -//go:embed assets/netbird-disconnected.ico -var iconAboutDisconnected []byte - -//go:embed assets/netbird-systemtray-connected.ico -var iconConnected []byte - -//go:embed assets/netbird-systemtray-connected-dark.ico -var iconConnectedDark []byte - -//go:embed assets/netbird-systemtray-disconnected.ico -var iconDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected.ico -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.ico -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.ico -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.ico -var iconUpdateConnectedDark []byte - -//go:embed assets/netbird-systemtray-connecting.ico -var iconConnecting []byte - -//go:embed assets/netbird-systemtray-connecting-dark.ico -var iconConnectingDark []byte - -//go:embed assets/netbird-systemtray-error.ico -var iconError []byte - -//go:embed assets/netbird-systemtray-error-dark.ico -var iconErrorDark []byte diff --git a/client/ui/localizer.go b/client/ui/localizer.go new file mode 100644 index 000000000..33c1cf205 --- /dev/null +++ b/client/ui/localizer.go @@ -0,0 +1,130 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "strings" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// Localizer caches the active language so key lookups skip the preferences store. +// +// Kept in the main package (not i18n/) because StatusLabel maps daemon +// status enum strings to translations; moving it would invert the +// dependency direction. +type Localizer struct { + bundle *i18n.Bundle + store *preferences.Store + + mu sync.RWMutex + lang i18n.LanguageCode + + unsubscribe func() +} + +// NewLocalizer seeds the active language from the on-disk preference. Either +// argument may be nil (tests): T then returns the raw key and Watch is a no-op. +func NewLocalizer(bundle *i18n.Bundle, store *preferences.Store) *Localizer { + l := &Localizer{ + bundle: bundle, + store: store, + lang: i18n.DefaultLanguage, + } + if store != nil { + if p := store.Get(); p.Language != "" { + l.lang = p.Language + } + } + return l +} + +// Language returns the active language code. +func (l *Localizer) Language() i18n.LanguageCode { + l.mu.RLock() + defer l.mu.RUnlock() + return l.lang +} + +// T resolves key in the current language; args are {placeholder}/value pairs. +// With no bundle wired it returns key unchanged. +func (l *Localizer) T(key string, args ...string) string { + if l == nil || l.bundle == nil { + return key + } + l.mu.RLock() + lang := l.lang + l.mu.RUnlock() + return l.bundle.Translate(lang, key, args...) +} + +// Watch invokes cb on each language change, after the cached language is +// updated so cb may call l.T with the new locale. Replaces any prior subscription. +func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) { + if l.store == nil { + return + } + ch, unsubscribe := l.store.Subscribe() + l.mu.Lock() + if l.unsubscribe != nil { + l.unsubscribe() + } + l.unsubscribe = unsubscribe + l.mu.Unlock() + + go func() { + for p := range ch { + if p.Language == "" { + continue + } + l.mu.Lock() + if l.lang == p.Language { + l.mu.Unlock() + continue + } + l.lang = p.Language + l.mu.Unlock() + log.Infof("localizer: language switched to %s", p.Language) + if cb != nil { + cb(p.Language) + } + } + }() +} + +// Close cancels the preference subscription. +func (l *Localizer) Close() { + l.mu.Lock() + defer l.mu.Unlock() + if l.unsubscribe != nil { + l.unsubscribe() + l.unsubscribe = nil + } +} + +// StatusLabel maps a daemon status string to its tray label; unrecognised +// statuses pass through verbatim. +func (l *Localizer) StatusLabel(status string) string { + switch { + case status == "", strings.EqualFold(status, services.StatusIdle): + return l.T("tray.status.disconnected") + case strings.EqualFold(status, services.StatusDaemonUnavailable): + return l.T("tray.status.daemonUnavailable") + case strings.EqualFold(status, services.StatusConnected): + return l.T("tray.status.connected") + case strings.EqualFold(status, services.StatusConnecting): + return l.T("tray.status.connecting") + case strings.EqualFold(status, services.StatusNeedsLogin): + return l.T("tray.status.needsLogin") + case strings.EqualFold(status, services.StatusLoginFailed): + return l.T("tray.status.loginFailed") + case strings.EqualFold(status, services.StatusSessionExpired): + return l.T("tray.status.sessionExpired") + } + return status +} diff --git a/client/ui/main.go b/client/ui/main.go new file mode 100644 index 000000000..e6b77762c --- /dev/null +++ b/client/ui/main.go @@ -0,0 +1,387 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "embed" + "flag" + "io/fs" + "log" + "runtime" + "strings" + + "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/util" +) + +//go:embed all:frontend/dist +var assets embed.FS + +// localesRoot embeds the i18n bundles shared by the tray (Go) and the React +// UI (Vite imports the same files). The `all:` prefix is required so +// _index.json is included — //go:embed drops files starting with "_" or "." +// otherwise. +// +//go:embed all:i18n/locales +var localesRoot embed.FS + +// stringList collects repeated string flags. The first user-supplied value +// drops the seeded default; subsequent passes append. +type stringList struct { + values []string + userSet bool +} + +func (s *stringList) String() string { + return strings.Join(s.values, ",") +} + +func (s *stringList) Set(v string) error { + if !s.userSet { + s.values = nil + s.userSet = true + } + s.values = append(s.values, v) + return nil +} + +type registeredServices struct { + connection *services.Connection + authSession *authsession.Session + settings *services.Settings + networks *services.Networks + profiles *services.Profiles + update *services.Update + daemonFeed *services.DaemonFeed + notifier *notifications.NotificationService + compat *services.Compat + profileSwitcher *services.ProfileSwitcher + bundle *i18n.Bundle + prefStore *preferences.Store +} + +func init() { + application.RegisterEvent[services.Status](services.EventStatusSnapshot) + application.RegisterEvent[services.SystemEvent](services.EventDaemonNotification) + application.RegisterEvent[services.ProfileRef](services.EventProfileChanged) + application.RegisterEvent[authsession.Warning](services.EventSessionWarning) + application.RegisterEvent[updater.State](updater.EventStateChanged) + application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged) +} + +func main() { + daemonAddr, userSetLogFile := parseFlagsAndInitLog() + conn := NewConn(daemonAddr) + + // Without --log-file, the GUI manages a gui-client.log that follows the + // daemon's debug level and is collected in the debug bundle. It rides + // DaemonFeed's SubscribeEvents stream (see guilog.DebugLog). + debugLog := newDebugLog(userSetLogFile) + + // Declared before app.New so the SingleInstance callback closes over it. + var tray *Tray + app := newApplication(func() { + if tray != nil { + tray.ShowWindow() + } + }) + + settings := services.NewSettings(conn) + profiles := services.NewProfiles(conn) + // updater.Holder owns the typed update State; DaemonFeed feeds it and the + // Update service is a thin Wails-bound facade over it plus the install RPCs. + updaterHolder := updater.NewHolder(app.Event) + update := services.NewUpdate(conn, updaterHolder) + daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog) + notifier := notifications.New() + compat := services.NewCompat(conn) + // macOS shows no toast until permission is requested. Run it after + // ApplicationStarted so the notifier's Startup has initialised the + // notification-center delegate. No-op on Linux/Windows (stubs report + // authorized). + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go requestNotificationAuthorization(notifier) + initDockObserver() + }) + + bundle, prefStore, localizer := buildI18n(app) + + // After bundle + prefStore: both are used to localise daemon errors. + connection := services.NewConnection(conn, bundle, prefStore) + profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) + // authsession.Session owns the full extend + dismiss surface the tray + // drives directly; the Wails-bound services.Session wraps only the subset + // the React frontend calls, keeping the generated TS surface minimal. + authSession := authsession.NewSession(conn) + networks := services.NewNetworks(conn) + + registerServices(app, conn, registeredServices{ + connection: connection, + authSession: authSession, + settings: settings, + networks: networks, + profiles: profiles, + update: update, + daemonFeed: daemonFeed, + notifier: notifier, + compat: compat, + profileSwitcher: profileSwitcher, + bundle: bundle, + prefStore: prefStore, + }) + + window := newMainWindow(app, prefStore) + + // Settings is created eagerly (hidden) so the first gear click paints + // instantly and React keeps per-tab state across reopens. The other + // auxiliary windows stay lazy + destroy-on-close so Wails's macOS + // dock-reopen handler can't resurrect them. + windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) + // Minimal WMs (XEmbed-tray path) neither center small windows nor restore + // position across hide -> show, dropping them top-left. Gate Go-side + // re-centering on that environment; nil leaves placement to the WM on full + // desktops, macOS, and Windows. + windowManager.SetRecenterOnShow(recenterOnShowPredicate()) + app.RegisterService(application.NewService(windowManager)) + + // Welcome window, first launch only — Continue flips OnboardingCompleted + // so later launches skip it. ApplicationStarted hook so the Wails window + // machinery is fully up before the window is created. + if !prefStore.Get().OnboardingCompleted { + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + windowManager.OpenWelcome() + }) + } + + // In-process StatusNotifierWatcher so the tray works on minimal WMs that + // don't ship one (Fluxbox, i3, GNOME without AppIndicator). No-op off + // Linux. Must run before NewTray so the systray's + // RegisterStatusNotifierItem hits a watcher we control. + startStatusNotifierWatcher() + + tray = NewTray(app, window, TrayServices{ + Connection: connection, + Settings: settings, + Profiles: profiles, + Networks: networks, + DaemonFeed: daemonFeed, + Notifier: notifier, + Update: update, + ProfileSwitcher: profileSwitcher, + WindowManager: windowManager, + Session: authSession, + Localizer: localizer, + }) + listenForShowSignal(context.Background(), tray) + + // Start the feed only after every service's ServiceStartup has run. The + // first SubscribeEvents message replays cached state synchronously and can + // fire an OS notification; if Watch ran before app.Run it could beat the + // notifier's ServiceStartup, where the Linux notifier connects the session + // bus — its *dbus.Conn would still be nil and SendNotification would + // nil-deref (fatal panic on the dispatch goroutine, observed on Linux + // Mint). ApplicationStarted fires after the startup loop, so the bus is up. + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + daemonFeed.Watch(context.Background()) + // Probe daemon compatibility once the notifier bus is up; an outdated + // daemon may keep the main window from showing, so the OS toast is the + // only reliable signal the user gets. + go notifyIfDaemonOutdated(compat, notifier, localizer) + }) + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} + +// requestNotificationAuthorization prompts for macOS notification permission. +// The request blocks until the user responds (up to 3 minutes), so callers run +// it in a goroutine. No-op on Linux/Windows. +func requestNotificationAuthorization(notifier *notifications.NotificationService) { + authorized, err := notifier.CheckNotificationAuthorization() + if err != nil { + logrus.Debugf("check notification authorization: %v", err) + return + } + if authorized { + return + } + if _, err := notifier.RequestNotificationAuthorization(); err != nil { + logrus.Debugf("request notification authorization: %v", err) + } +} + +// parseFlagsAndInitLog returns the daemon gRPC address and userSetLogFile +// (true when --log-file was passed). userSetLogFile is the manual-override +// signal: true leaves logging alone, false lets the GUI manage a +// daemon-driven gui-client.log. The flag default is empty (not "console") so +// "no flag" and an explicit "--log-file console" stay distinguishable; empty +// falls back to console for InitLog. +func parseFlagsAndInitLog() (string, bool) { + daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port") + logFiles := &stringList{} + flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.") + logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.") + flag.Parse() + + userSetLogFile := len(logFiles.values) > 0 + targets := logFiles.values + if !userSetLogFile { + targets = []string{"console"} + } + + if err := util.InitLog(*logLevel, targets...); err != nil { + log.Fatalf("init log: %v", err) + } + return *daemonAddr, userSetLogFile +} + +// newApplication constructs the Wails application. onSecondInstance fires when +// a second process launches under the same SingleInstance UniqueID. +func newApplication(onSecondInstance func()) *application.App { + // On macOS, Options.Icon feeds NSApplication's setApplicationIconImage, + // overriding the bundle icon (Assets.car / icons.icns) the OS already + // picked. Suppress it on darwin to keep the bundle's squircle. + appIcon := iconWindow + if runtime.GOOS == "darwin" { + appIcon = nil + } + + return application.New(application.Options{ + // On Windows, Name is the AppUserModelID for toast notifications and + // the HKCU\Software\Classes\AppUserModelId\ registry path. It must + // match the System.AppUserModel.ID the MSI sets on the Start Menu + // shortcut (client/netbird.wxs) and the AppUserModelId key the + // installer pre-populates with the toast activator CLSID; otherwise + // toasts show under a different identity and the MSI's CustomActivator + // value is orphaned. + Name: "NetBird", + Description: "NetBird desktop client", + Icon: appIcon, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + ActivationPolicy: application.ActivationPolicyAccessory, + }, + Linux: application.LinuxOptions{ + ProgramName: "netbird", + }, + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "io.netbird.ui", + OnSecondInstanceLaunch: func(_ application.SecondInstanceData) { + onSecondInstance() + }, + }, + }) +} + +// buildI18n constructs the i18n bundle, preferences store, and tray localizer. +// The Bundle satisfies preferences.LanguageValidator so SetLanguage rejects +// codes that have no shipped translation. +func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localizer) { + // Reroot the embedded tree at the locales dir so the bundle sees + // _index.json and /common.json at top level (//go:embed roots at + // the package, not the leaf dir). + localesFS, err := fs.Sub(localesRoot, "i18n/locales") + if err != nil { + log.Fatalf("locate locales fs: %v", err) + } + bundle, err := i18n.NewBundle(localesFS) + if err != nil { + log.Fatalf("init i18n bundle: %v", err) + } + prefStore, err := preferences.NewStore(bundle, app.Event) + if err != nil { + log.Fatalf("init preferences store: %v", err) + } + return bundle, prefStore, NewLocalizer(bundle, prefStore) +} + +// registerServices binds every Wails-facing service onto the application. +// Services with no other caller are constructed inline; the rest arrive +// already built so the tray and feed loops share the same instances. +func registerServices(app *application.App, conn *Conn, s registeredServices) { + app.RegisterService(application.NewService(s.connection)) + app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore))) + app.RegisterService(application.NewService(s.settings)) + app.RegisterService(application.NewService(s.networks)) + app.RegisterService(application.NewService(services.NewForwarding(conn))) + app.RegisterService(application.NewService(s.profiles)) + app.RegisterService(application.NewService(services.NewDebug(conn))) + app.RegisterService(application.NewService(s.update)) + app.RegisterService(application.NewService(s.daemonFeed)) + app.RegisterService(application.NewService(s.notifier)) + app.RegisterService(application.NewService(s.profileSwitcher)) + app.RegisterService(application.NewService(services.NewI18n(s.bundle))) + app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) + app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) + app.RegisterService(application.NewService(services.NewVersion())) + app.RegisterService(application.NewService(services.NewUILog())) + app.RegisterService(application.NewService(s.compat)) +} + +// newMainWindow creates the hidden main window, sized to the user's last view +// mode, and installs the hide-on-close and macOS dock-reopen hooks. +func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow { + // Width matches the last view mode so Advanced-mode users don't see the + // window pop from 380px to 900px on launch. Height is mode-agnostic. + initialWidth := 380 + if prefStore.Get().ViewMode == preferences.ViewModeAdvanced { + initialWidth = 900 + } + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "main", + Title: "NetBird", + Width: initialWidth, + Height: services.WindowHeight, + // Center on first show; minimal WMs (fluxbox, the XEmbed tray path) + // drop new windows top-left unless asked. + InitialPosition: application.WindowCentered, + Hidden: true, + BackgroundColour: services.WindowBackgroundColour, + URL: "/", + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + Mac: services.AppleMacOSAppearanceOptions(), + Windows: services.MicrosoftWindowsAppearanceOptions(), + Linux: application.LinuxWindow{ + Icon: iconWindow, + }, + }) + + // Hide instead of quit on close; "really quit" is reached via tray -> Quit. + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + e.Cancel() + window.Hide() + }) + + // On macOS, Wails' default applicationShouldHandleReopen handler Show()s + // every hidden window on dock-icon click, resurrecting hide-on-close + // surfaces like Settings. Cancel it in a hook (hooks run before listeners) + // and show only the main window. No-op elsewhere — the event never fires. + if runtime.GOOS == "darwin" { + app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { + e.Cancel() + if e.Context().HasVisibleWindows() { + return + } + window.Show() + window.Focus() + }) + } + + return window +} diff --git a/client/ui/manifest.xml b/client/ui/manifest.xml deleted file mode 100644 index c71a407e5..000000000 --- a/client/ui/manifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - Netbird UI application - - - - - - - - \ No newline at end of file diff --git a/client/ui/network.go b/client/ui/network.go deleted file mode 100644 index cd5d23558..000000000 --- a/client/ui/network.go +++ /dev/null @@ -1,707 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "runtime" - "sort" - "strings" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -const ( - allNetworksText = "All networks" - overlappingNetworksText = "Overlapping networks" - exitNodeNetworksText = "Exit-node networks" - allNetworks filter = "all" - overlappingNetworks filter = "overlapping" - exitNodeNetworks filter = "exit-node" - getClientFMT = "get client: %v" -) - -type filter string - -func (s *serviceClient) showNetworksUI() { - s.wNetworks = s.app.NewWindow("Networks") - s.wNetworks.SetOnClosed(s.cancel) - - allGrid := container.New(layout.NewGridLayout(3)) - go s.updateNetworks(allGrid, allNetworks) - overlappingGrid := container.New(layout.NewGridLayout(3)) - exitNodeGrid := container.New(layout.NewGridLayout(3)) - routeCheckContainer := container.NewVBox() - tabs := container.NewAppTabs( - container.NewTabItem(allNetworksText, allGrid), - container.NewTabItem(overlappingNetworksText, overlappingGrid), - container.NewTabItem(exitNodeNetworksText, exitNodeGrid), - ) - tabs.OnSelected = func(item *container.TabItem) { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - } - tabs.OnUnselected = func(item *container.TabItem) { - grid, _ := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - grid.Objects = nil - } - - routeCheckContainer.Add(tabs) - scrollContainer := container.NewVScroll(routeCheckContainer) - scrollContainer.SetMinSize(fyne.NewSize(200, 300)) - - buttonBox := container.NewHBox( - layout.NewSpacer(), - widget.NewButton("Refresh", func() { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Select all", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.selectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Deselect All", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.deselectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - layout.NewSpacer(), - ) - - content := container.NewBorder(nil, buttonBox, nil, nil, scrollContainer) - - s.wNetworks.SetContent(content) - s.wNetworks.Show() - - s.startAutoRefresh(10*time.Second, tabs, allGrid, overlappingGrid, exitNodeGrid) -} - -func (s *serviceClient) updateNetworks(grid *fyne.Container, f filter) { - grid.Objects = nil - grid.Refresh() - idHeader := widget.NewLabelWithStyle(" ID", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - networkHeader := widget.NewLabelWithStyle("Range/Domains", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - resolvedIPsHeader := widget.NewLabelWithStyle("Resolved IPs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - - grid.Add(idHeader) - grid.Add(networkHeader) - grid.Add(resolvedIPsHeader) - - filteredRoutes, err := s.getFilteredNetworks(f) - if err != nil { - return - } - - sortNetworksByIDs(filteredRoutes) - - for _, route := range filteredRoutes { - r := route - - checkBox := widget.NewCheck(r.GetID(), func(checked bool) { - s.selectNetwork(r.ID, checked) - }) - checkBox.Checked = route.Selected - checkBox.Resize(fyne.NewSize(20, 20)) - checkBox.Refresh() - - grid.Add(checkBox) - network := r.GetRange() - domains := r.GetDomains() - - if len(domains) == 0 { - grid.Add(widget.NewLabel(network)) - grid.Add(widget.NewLabel("")) - continue - } - - // our selectors are only for display - noopFunc := func(_ string) { - // do nothing - } - - domainsSelector := widget.NewSelect(domains, noopFunc) - domainsSelector.Selected = domains[0] - grid.Add(domainsSelector) - - var resolvedIPsList []string - for domain, ipList := range r.GetResolvedIPs() { - resolvedIPsList = append(resolvedIPsList, fmt.Sprintf("%s: %s", domain, strings.Join(ipList.GetIps(), ", "))) - } - - if len(resolvedIPsList) == 0 { - grid.Add(widget.NewLabel("")) - continue - } - - // TODO: limit width within the selector display - resolvedIPsSelector := widget.NewSelect(resolvedIPsList, noopFunc) - resolvedIPsSelector.Selected = resolvedIPsList[0] - resolvedIPsSelector.Resize(fyne.NewSize(100, 100)) - grid.Add(resolvedIPsSelector) - } - - s.wNetworks.Content().Refresh() - grid.Refresh() -} - -func (s *serviceClient) getFilteredNetworks(f filter) ([]*proto.Network, error) { - routes, err := s.fetchNetworks() - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return nil, err - } - switch f { - case overlappingNetworks: - return getOverlappingNetworks(routes), nil - case exitNodeNetworks: - return getExitNodeNetworks(routes), nil - default: - } - return routes, nil -} - -func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - existingRange := make(map[string][]*proto.Network) - for _, route := range routes { - if len(route.Domains) > 0 { - continue - } - if r, exists := existingRange[route.GetRange()]; exists { - r = append(r, route) - existingRange[route.GetRange()] = r - } else { - existingRange[route.GetRange()] = []*proto.Network{route} - } - } - for _, r := range existingRange { - if len(r) > 1 { - filteredRoutes = append(filteredRoutes, r...) - } - } - return filteredRoutes -} - -func isDefaultRoute(routeRange string) bool { - // routeRange is the merged display string from the daemon, e.g. "0.0.0.0/0", - // "::/0", or "0.0.0.0/0, ::/0" when a v4 exit node has a paired v6 entry. - for _, part := range strings.Split(routeRange, ",") { - switch strings.TrimSpace(part) { - case "0.0.0.0/0", "::/0": - return true - } - } - return false -} - -func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - for _, route := range routes { - if isDefaultRoute(route.Range) { - filteredRoutes = append(filteredRoutes, route) - } - } - return filteredRoutes -} - -func sortNetworksByIDs(routes []*proto.Network) { - sort.Slice(routes, func(i, j int) bool { - return strings.ToLower(routes[i].GetID()) < strings.ToLower(routes[j].GetID()) - }) -} - -func (s *serviceClient) fetchNetworks() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - resp, err := conn.ListNetworks(s.ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to list routes: %v", err) - } - - return resp.Routes, nil -} - -func (s *serviceClient) selectNetwork(id string, checked bool) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return - } - - req := &proto.SelectNetworksRequest{ - NetworkIDs: []string{id}, - Append: checked, - } - - if checked { - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select network: %v", err) - s.showError(fmt.Errorf("failed to select network: %v", err)) - return - } - log.Infof("Network '%s' selected", id) - } else { - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect network: %v", err) - s.showError(fmt.Errorf("failed to deselect network: %v", err)) - return - } - log.Infof("Network '%s' deselected", id) - } -} - -func (s *serviceClient) selectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, true) - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select all networks: %v", err) - s.showError(fmt.Errorf("failed to select all networks: %v", err)) - return - } - - log.Debug("All networks selected") -} - -func (s *serviceClient) deselectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, false) - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect all networks: %v", err) - s.showError(fmt.Errorf("failed to deselect all networks: %v", err)) - return - } - - log.Debug("All networks deselected") -} - -func (s *serviceClient) getNetworksRequest(f filter, appendRoute bool) *proto.SelectNetworksRequest { - req := &proto.SelectNetworksRequest{} - if f == allNetworks { - req.All = true - } else { - routes, err := s.getFilteredNetworks(f) - if err != nil { - return nil - } - for _, route := range routes { - req.NetworkIDs = append(req.NetworkIDs, route.GetID()) - } - req.Append = appendRoute - } - return req -} - -func (s *serviceClient) showError(err error) { - wrappedMessage := wrapText(err.Error(), 50) - - dialog.ShowError(fmt.Errorf("%s", wrappedMessage), s.wNetworks) -} - -func (s *serviceClient) startAutoRefresh(interval time.Duration, tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - ticker := time.NewTicker(interval) - go func() { - for range ticker.C { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - } - }() - - s.wNetworks.SetOnClosed(func() { - ticker.Stop() - s.cancel() - }) -} - -func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - grid, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - s.wNetworks.Content().Refresh() - s.updateNetworks(grid, f) -} - -// startExitNodeRefresh initiates exit node menu refresh after connecting. -// On Windows, TrayOpenedCh is not supported by the systray library, so we use -// a background poller to keep exit nodes in sync while connected. -// On macOS/Linux, TrayOpenedCh handles refreshes on each tray open. -func (s *serviceClient) startExitNodeRefresh() { - s.cancelExitNodeRetry() - - if runtime.GOOS == "windows" { - ctx, cancel := context.WithCancel(s.ctx) - s.exitNodeMu.Lock() - s.exitNodeRetryCancel = cancel - s.exitNodeMu.Unlock() - - go s.pollExitNodes(ctx) - } else { - go s.updateExitNodes() - } -} - -func (s *serviceClient) cancelExitNodeRetry() { - s.exitNodeMu.Lock() - if s.exitNodeRetryCancel != nil { - s.exitNodeRetryCancel() - s.exitNodeRetryCancel = nil - } - s.exitNodeMu.Unlock() -} - -// pollExitNodes periodically refreshes exit nodes while connected. -// Uses a short initial interval to catch routes from the management sync, -// then switches to a longer interval for ongoing updates. -func (s *serviceClient) pollExitNodes(ctx context.Context) { - // Initial fast polling to catch routes as they appear after connect. - for i := 0; i < 5; i++ { - if s.updateExitNodes() { - break - } - select { - case <-ctx.Done(): - return - case <-time.After(2 * time.Second): - } - } - - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.updateExitNodes() - } - } -} - -// updateExitNodes fetches exit nodes from the daemon and recreates the menu. -// Returns true if exit nodes were found. -func (s *serviceClient) updateExitNodes() bool { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return false - } - exitNodes, err := s.getExitNodes(conn) - if err != nil { - log.Errorf("get exit nodes: %v", err) - return false - } - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - s.recreateExitNodeMenu(exitNodes) - - if len(s.mExitNodeItems) > 0 { - s.mExitNode.Enable() - return true - } - - s.mExitNode.Disable() - return false -} - -func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { - for _, node := range s.mExitNodeItems { - node.cancel() - node.Hide() - node.Remove() - } - s.mExitNodeItems = nil - if s.mExitNodeSeparator != nil { - s.mExitNodeSeparator.Remove() - s.mExitNodeSeparator = nil - } - if s.mExitNodeDeselectAll != nil { - s.mExitNodeDeselectAll.Remove() - s.mExitNodeDeselectAll = nil - } - - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.mExitNode.Remove() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - } - - var showDeselectAll bool - - for _, node := range exitNodes { - if node.Selected { - showDeselectAll = true - } - - menuItem := s.mExitNode.AddSubMenuItemCheckbox( - node.ID, - fmt.Sprintf("Use exit node %s", node.ID), - node.Selected, - ) - - ctx, cancel := context.WithCancel(s.ctx) - s.mExitNodeItems = append(s.mExitNodeItems, menuHandler{ - MenuItem: menuItem, - cancel: cancel, - }) - go s.handleChecked(ctx, node.ID, menuItem) - } - - if showDeselectAll { - s.addExitNodeDeselectAll() - } - -} - -func (s *serviceClient) addExitNodeDeselectAll() { - sep := s.mExitNode.AddSubMenuItem("───────────────", "") - sep.Disable() - s.mExitNodeSeparator = sep - - deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") - s.mExitNodeDeselectAll = deselectAllItem - - go func() { - for { - _, ok := <-deselectAllItem.ClickedCh - if !ok { - return - } - exitNodes, err := s.handleExitNodeMenuDeselectAll() - if err != nil { - log.Warnf("failed to handle deselect all exit nodes: %v", err) - } else { - s.exitNodeMu.Lock() - s.recreateExitNodeMenu(exitNodes) - s.exitNodeMu.Unlock() - } - } - }() -} - -func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) { - ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) - defer cancel() - - resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("list networks: %v", err) - } - - var exitNodes []*proto.Network - for _, network := range resp.Routes { - if isDefaultRoute(network.Range) { - exitNodes = append(exitNodes, network) - } - } - return exitNodes, nil -} - -func (s *serviceClient) handleChecked(ctx context.Context, id string, item *systray.MenuItem) { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-item.ClickedCh: - if !ok { - return - } - if err := s.toggleExitNode(id, item); err != nil { - log.Errorf("failed to toggle exit node: %v", err) - continue - } - } - } -} - -func (s *serviceClient) handleExitNodeMenuDeselectAll() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("get exit nodes: %v", err) - } - - var ids []string - for _, e := range exitNodes { - if e.Selected { - ids = append(ids, e.ID) - } - } - - // deselect selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return nil, err - } - - updatedExitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("re-fetch exit nodes: %v", err) - } - - return updatedExitNodes, nil -} - -// Add function to toggle exit node selection -func (s *serviceClient) toggleExitNode(nodeID string, item *systray.MenuItem) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get client: %v", err) - } - - log.Infof("Toggling exit node '%s'", nodeID) - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return fmt.Errorf("get exit nodes: %v", err) - } - - var exitNode *proto.Network - // find other selected nodes and ours - ids := make([]string, 0, len(exitNodes)) - for _, node := range exitNodes { - if node.ID == nodeID { - // preserve original state - cp := *node //nolint:govet - exitNode = &cp - - // set desired state for recreation - node.Selected = true - continue - } - if node.Selected { - ids = append(ids, node.ID) - - // set desired state for recreation - node.Selected = false - } - } - - // exit node is the only selected node, deselect it - deselectAll := item.Checked() && len(ids) == 0 - if deselectAll { - ids = append(ids, nodeID) - for _, node := range exitNodes { - if node.ID == nodeID { - // set desired state for recreation - node.Selected = false - } - } - } - - // deselect all other selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return err - } - - if !deselectAll { - if err := s.selectNewExitNode(conn, exitNode, nodeID, item); err != nil { - return err - } - } - - // linux/bsd doesn't handle Check/Uncheck well, so we recreate the menu - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.recreateExitNodeMenu(exitNodes) - } - - return nil -} - -func (s *serviceClient) deselectOtherExitNodes(conn proto.DaemonServiceClient, ids []string) error { - // deselect all other selected exit nodes - if len(ids) > 0 { - deselectReq := &proto.SelectNetworksRequest{ - NetworkIDs: ids, - } - if _, err := conn.DeselectNetworks(s.ctx, deselectReq); err != nil { - return fmt.Errorf("deselect networks: %v", err) - } - - log.Infof("Deselected exit nodes: %v", ids) - } - - // uncheck all other exit node menu items - for _, i := range s.mExitNodeItems { - i.Uncheck() - log.Infof("Unchecked exit node %v", i) - } - - return nil -} - -func (s *serviceClient) selectNewExitNode(conn proto.DaemonServiceClient, exitNode *proto.Network, nodeID string, item *systray.MenuItem) error { - if exitNode != nil && !exitNode.Selected { - selectReq := &proto.SelectNetworksRequest{ - NetworkIDs: []string{exitNode.ID}, - Append: true, - } - if _, err := conn.SelectNetworks(s.ctx, selectReq); err != nil { - return fmt.Errorf("select network: %v", err) - } - - log.Infof("Selected exit node '%s'", nodeID) - } - - item.Check() - log.Infof("Checked exit node '%s'", nodeID) - - return nil -} - -func getGridAndFilterFromTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) (*fyne.Container, filter) { - switch tabs.Selected().Text { - case overlappingNetworksText: - return overlappingGrid, overlappingNetworks - case exitNodeNetworksText: - return exitNodesGrid, exitNodeNetworks - default: - return allGrid, allNetworks - } -} - -// wrapText inserts newlines into the text to ensure that each line is -// no longer than 'lineLength' runes. -func wrapText(text string, lineLength int) string { - var sb strings.Builder - var currentLineLength int - - for _, runeValue := range text { - sb.WriteRune(runeValue) - currentLineLength++ - - if currentLineLength >= lineLength || runeValue == '\n' { - sb.WriteRune('\n') - currentLineLength = 0 - } - } - - return sb.String() -} diff --git a/client/ui/notifier/notifier.go b/client/ui/notifier/notifier.go deleted file mode 100644 index 8d1cbe4c4..000000000 --- a/client/ui/notifier/notifier.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package notifier sends desktop notifications. On Windows it uses the WinRT -// COM API directly via go-toast/v2 to avoid the PowerShell window flash that -// fyne's default implementation produces. On other platforms it delegates to -// fyne. -package notifier - -import "fyne.io/fyne/v2" - -// Notifier sends desktop notifications. -type Notifier interface { - Send(title, body string) -} - -// New returns a platform-specific Notifier. The fyne app is used as the -// fallback notifier on platforms where no native implementation is wired up, -// and on Windows when the COM path fails to initialize. -func New(app fyne.App) Notifier { - return newNotifier(app) -} - -type fyneNotifier struct { - app fyne.App -} - -func (f *fyneNotifier) Send(title, body string) { - f.app.SendNotification(fyne.NewNotification(title, body)) -} diff --git a/client/ui/notifier/notifier_other.go b/client/ui/notifier/notifier_other.go deleted file mode 100644 index 686d2885f..000000000 --- a/client/ui/notifier/notifier_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package notifier - -import "fyne.io/fyne/v2" - -func newNotifier(app fyne.App) Notifier { - return &fyneNotifier{app: app} -} diff --git a/client/ui/notifier/notifier_windows.go b/client/ui/notifier/notifier_windows.go deleted file mode 100644 index c7afb43ae..000000000 --- a/client/ui/notifier/notifier_windows.go +++ /dev/null @@ -1,88 +0,0 @@ -package notifier - -import ( - "os" - "path/filepath" - "sync" - - "fyne.io/fyne/v2" - toast "git.sr.ht/~jackmordaunt/go-toast/v2" - "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast" - log "github.com/sirupsen/logrus" -) - -const ( - // appID is the AppUserModelID shown in the Windows Action Center. It - // must match the System.AppUserModel.ID property set on the Start Menu - // shortcut by the MSI (see client/netbird.wxs); otherwise Windows - // groups toasts under a separate, unbranded entry. - appID = "NetBird" - - // appGUID identifies the COM activation callback class. Generated once - // for NetBird; do not change without coordinating an installer bump, - // since old registry entries pointing at the previous GUID would orphan. - appGUID = "{0E1B4DE7-E148-432B-9814-544F941826EC}" -) - -type comNotifier struct { - fallback *fyneNotifier - ready bool - iconPath string -} - -var ( - initOnce sync.Once - initErr error -) - -func newNotifier(app fyne.App) Notifier { - n := &comNotifier{ - fallback: &fyneNotifier{app: app}, - iconPath: resolveIcon(), - } - initOnce.Do(func() { - initErr = wintoast.SetAppData(wintoast.AppData{ - AppID: appID, - GUID: appGUID, - IconPath: n.iconPath, - }) - }) - if initErr != nil { - log.Warnf("toast: register app data failed, falling back to fyne notifications: %v", initErr) - return n.fallback - } - n.ready = true - return n -} - -func (n *comNotifier) Send(title, body string) { - if !n.ready { - n.fallback.Send(title, body) - return - } - notification := toast.Notification{ - AppID: appID, - Title: title, - Body: body, - Icon: n.iconPath, - } - if err := notification.Push(); err != nil { - log.Warnf("toast: push failed, using fyne fallback: %v", err) - n.fallback.Send(title, body) - } -} - -// resolveIcon returns an absolute path to the toast icon, or an empty string -// when no icon can be located. Windows requires a PNG/JPG for the -// AppUserModelId IconUri registry value; .ico is silently ignored. -func resolveIcon() string { - exe, err := os.Executable() - if err != nil { - return "" - } - candidate := filepath.Join(filepath.Dir(exe), "netbird.png") - if _, err := os.Stat(candidate); err == nil { - return candidate - } - return "" -} diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go new file mode 100644 index 000000000..afc854185 --- /dev/null +++ b/client/ui/preferences/store.go @@ -0,0 +1,267 @@ +//go:build !android && !ios && !freebsd && !js + +// Package preferences holds user-scope UI state, independent of the daemon +// profile and shared across all profiles. The Store persists to JSON under +// os.UserConfigDir() and broadcasts changes to in-process subscribers plus an +// optional emitter. +package preferences + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/util" +) + +// Lives under os.UserConfigDir()/netbird (OS-user writable, not the daemon's +// root-owned state). +const preferencesFileName = "ui-preferences.json" + +// EventPreferencesChanged fires on every persisted update, payload UIPreferences. +const EventPreferencesChanged = "netbird:preferences:changed" + +// ViewMode is the preferred Main-window layout: "default" (compact, 380-wide) +// or "advanced" (900-wide). +type ViewMode string + +const ( + ViewModeDefault ViewMode = "default" + ViewModeAdvanced ViewMode = "advanced" +) + +// DefaultViewMode applies when no file exists or its view-mode is empty. +const DefaultViewMode = ViewModeDefault + +var ErrUnsupportedViewMode = errors.New("unsupported view mode") + +func (v ViewMode) IsValid() bool { + switch v { + case ViewModeDefault, ViewModeAdvanced: + return true + } + return false +} + +// UIPreferences is rewritten in full on every change; there are no partial updates. +type UIPreferences struct { + Language i18n.LanguageCode `json:"language"` + ViewMode ViewMode `json:"viewMode"` + OnboardingCompleted bool `json:"onboardingCompleted"` +} + +// LanguageValidator rejects SetLanguage inputs with no shipped bundle. +// *i18n.Bundle satisfies it. +type LanguageValidator interface { + HasLanguage(code i18n.LanguageCode) bool +} + +// Emitter broadcasts changes to the frontend. Wails' +// *application.EventProcessor satisfies it; tests pass nil or a fake. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Store is the user-scope UI preferences store. +type Store struct { + path string + + mu sync.RWMutex + current UIPreferences + + subsMu sync.Mutex + subs []chan UIPreferences + + validator LanguageValidator + emitter Emitter +} + +// NewStore loads preferences from disk, falling back to defaults. A nil +// validator skips SetLanguage validation; a nil emitter skips broadcasting. +func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) { + path, err := preferencesPath() + if err != nil { + return nil, fmt.Errorf("resolve preferences path: %w", err) + } + + // Language starts empty: the frontend treats absence as the signal to + // detect the browser locale on first launch and call SetLanguage. + s := &Store{ + path: path, + validator: validator, + emitter: emitter, + current: UIPreferences{ViewMode: DefaultViewMode}, + } + + if err := s.load(); err != nil { + log.Warnf("load ui preferences from %s: %v (using defaults)", path, err) + } + + return s, nil +} + +// Get returns a copy of the current preferences. +func (s *Store) Get() UIPreferences { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current +} + +// SetViewMode validates, persists, and broadcasts. No-op if unchanged. +func (s *Store) SetViewMode(mode ViewMode) error { + if !mode.IsValid() { + return fmt.Errorf("%w: %q", ErrUnsupportedViewMode, mode) + } + + s.mu.Lock() + if s.current.ViewMode == mode { + s.mu.Unlock() + return nil + } + next := s.current + next.ViewMode = mode + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged. +func (s *Store) SetOnboardingCompleted(done bool) error { + s.mu.Lock() + if s.current.OnboardingCompleted == done { + s.mu.Unlock() + return nil + } + next := s.current + next.OnboardingCompleted = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetLanguage validates, persists, and broadcasts. No-op if unchanged. +func (s *Store) SetLanguage(lang i18n.LanguageCode) error { + if lang == "" { + return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage) + } + if s.validator != nil && !s.validator.HasLanguage(lang) { + return fmt.Errorf("%w: %q", i18n.ErrUnsupportedLanguage, lang) + } + + s.mu.Lock() + if s.current.Language == lang { + s.mu.Unlock() + return nil + } + next := s.current + next.Language = lang + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// Subscribe returns a channel of persisted changes and an unsubscribe func. +// The unsubscribe func closes the channel; callers must not close it themselves. +func (s *Store) Subscribe() (<-chan UIPreferences, func()) { + ch := make(chan UIPreferences, 4) + s.subsMu.Lock() + s.subs = append(s.subs, ch) + s.subsMu.Unlock() + + unsubscribe := func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + for i, c := range s.subs { + if c == ch { + s.subs = append(s.subs[:i], s.subs[i+1:]...) + close(ch) + return + } + } + } + return ch, unsubscribe +} + +// load reads the file into current. A missing file is not an error (the +// in-memory default stands); malformed contents return an error. +func (s *Store) load() error { + if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) { + return nil + } + + var loaded UIPreferences + if _, err := util.ReadJson(s.path, &loaded); err != nil { + return err + } + + if !loaded.ViewMode.IsValid() { + loaded.ViewMode = DefaultViewMode + } + + s.mu.Lock() + s.current = loaded + s.mu.Unlock() + return nil +} + +// persistLocked writes v to disk. Caller must hold s.mu and update in-memory +// state only after this returns nil. +func (s *Store) persistLocked(v UIPreferences) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err) + } + return util.WriteJson(context.Background(), s.path, v) +} + +// broadcast fans v out to subscribers and the emitter. Full-buffer subscribers +// are skipped: consumers only need the latest value, so dropping is safe. +func (s *Store) broadcast(v UIPreferences) { + s.subsMu.Lock() + subs := make([]chan UIPreferences, len(s.subs)) + copy(subs, s.subs) + s.subsMu.Unlock() + + for _, ch := range subs { + select { + case ch <- v: + default: + log.Debugf("preferences subscriber channel full; dropping update") + } + } + + if s.emitter != nil { + s.emitter.Emit(EventPreferencesChanged, v) + } +} + +func preferencesPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", preferencesFileName), nil +} diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go new file mode 100644 index 000000000..0d1cc7b54 --- /dev/null +++ b/client/ui/preferences/store_test.go @@ -0,0 +1,225 @@ +//go:build !android && !ios && !freebsd && !js + +package preferences + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/ui/i18n" +) + +// fakeValidator implements LanguageValidator for tests so we don't need a +// fully-loaded i18n.Bundle. +type fakeValidator struct{ ok map[i18n.LanguageCode]bool } + +func (f fakeValidator) HasLanguage(code i18n.LanguageCode) bool { return f.ok[code] } + +// recordingEmitter captures Emit calls so tests can assert the broadcast +// fired. +type recordingEmitter struct { + mu sync.Mutex + calls []emitCall +} + +type emitCall struct { + name string + data []any +} + +func (r *recordingEmitter) Emit(name string, data ...any) bool { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, emitCall{name: name, data: data}) + return true +} + +func (r *recordingEmitter) calledWith(name string) []emitCall { + r.mu.Lock() + defer r.mu.Unlock() + var out []emitCall + for _, c := range r.calls { + if c.name == name { + out = append(out, c) + } + } + return out +} + +// withTempConfigDir reroots os.UserConfigDir() at a temporary directory by +// pointing the OS-specific env vars there. Restored automatically by +// t.Setenv. +func withTempConfigDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + switch runtime.GOOS { + case "darwin": + t.Setenv("HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "Library", "Application Support"), 0o755)) + case "windows": + t.Setenv("AppData", tmp) + default: + t.Setenv("XDG_CONFIG_HOME", tmp) + } + return tmp +} + +func TestStore_DefaultsWhenFileMissing(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode(""), got.Language, "language must be empty when no file is on disk so the frontend can detect the browser locale") + assert.Equal(t, DefaultViewMode, got.ViewMode, "view-mode default should still apply") +} + +func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, emitter) + require.NoError(t, err) + + ch, unsubscribe := s.Subscribe() + defer unsubscribe() + + require.NoError(t, s.SetLanguage("hu")) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should reflect the SetLanguage value") + + select { + case v := <-ch: + assert.Equal(t, i18n.LanguageCode("hu"), v.Language, "subscriber should receive the new value") + case <-time.After(time.Second): + t.Fatal("subscriber timed out waiting for update") + } + + emits := emitter.calledWith(EventPreferencesChanged) + require.Len(t, emits, 1, "Emit should fire exactly once per SetLanguage") + payload, ok := emits[0].data[0].(UIPreferences) + require.True(t, ok, "emitter payload should be UIPreferences") + assert.Equal(t, i18n.LanguageCode("hu"), payload.Language) +} + +func TestStore_LoadFromDisk(t *testing.T) { + withTempConfigDir(t) + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"language":"hu"}`), 0o644)) + + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil) + require.NoError(t, err) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should load language from existing file") +} + +func TestStore_UnsupportedLanguageRejected(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err) + + err = s.SetLanguage("xx") + require.Error(t, err, "unknown language must be rejected") + assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage) + + err = s.SetLanguage("") + assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage, "empty language code must be rejected") +} + +func TestStore_NoValidatorAcceptsAnything(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(nil, nil) + require.NoError(t, err) + + require.NoError(t, s.SetLanguage("fr")) + got := s.Get() + assert.Equal(t, i18n.LanguageCode("fr"), got.Language) +} + +func TestStore_SetLanguageIdempotent(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter) + require.NoError(t, err) + + // First call goes from "" (unset) to "en" — real change, one broadcast. + require.NoError(t, s.SetLanguage("en")) + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, + "first SetLanguage from unset should broadcast") + + // Second call is a no-op — no disk write, no broadcast. Without this + // guard the tray would re-render the menu on every cosmetic re-save of + // the preferences file. + require.NoError(t, s.SetLanguage("en")) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, + "re-setting the current language should not broadcast again") +} + +func TestStore_CorruptFileFallsBackToDefault(t *testing.T) { + withTempConfigDir(t) + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o644)) + + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err, "corrupt file should not fail construction") + + got := s.Get() + assert.Equal(t, i18n.LanguageCode(""), got.Language, "corrupt JSON should leave the empty (unset) default in place so the frontend can re-detect") +} + +func TestStore_UnsubscribeStopsUpdates(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, nil) + require.NoError(t, err) + + ch, unsubscribe := s.Subscribe() + unsubscribe() + + require.NoError(t, s.SetLanguage("hu")) + + select { + case _, ok := <-ch: + assert.False(t, ok, "channel should be closed after unsubscribe") + case <-time.After(time.Second): + t.Fatal("expected closed channel, got nothing") + } +} + +func TestStore_FileShapeIsJSON(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil) + require.NoError(t, err) + require.NoError(t, s.SetLanguage("hu")) + + path, err := preferencesPath() + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed UIPreferences + require.NoError(t, json.Unmarshal(data, &parsed), "on-disk file must be valid JSON") + assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language) +} + +func TestStore_ErrUnsupportedSentinel(t *testing.T) { + // Verifies callers can match on the sentinel error rather than parsing + // strings — protects against accidental %v -> %w changes that would + // silently break errors.Is. + err := errors.New("inner") + wrapped := errors.Join(i18n.ErrUnsupportedLanguage, err) + assert.ErrorIs(t, wrapped, i18n.ErrUnsupportedLanguage) +} diff --git a/client/ui/process/process.go b/client/ui/process/process.go deleted file mode 100644 index 28276f416..000000000 --- a/client/ui/process/process.go +++ /dev/null @@ -1,38 +0,0 @@ -package process - -import ( - "os" - "path/filepath" - "strings" - - "github.com/shirou/gopsutil/v3/process" -) - -func IsAnotherProcessRunning() (int32, bool, error) { - processes, err := process.Processes() - if err != nil { - return 0, false, err - } - - pid := os.Getpid() - processName := strings.ToLower(filepath.Base(os.Args[0])) - - for _, p := range processes { - if int(p.Pid) == pid { - continue - } - - runningProcessPath, err := p.Exe() - // most errors are related to short-lived processes - if err != nil { - continue - } - - runningProcessName := strings.ToLower(filepath.Base(runningProcessPath)) - if runningProcessName == processName && isProcessOwnedByCurrentUser(p) { - return p.Pid, true, nil - } - } - - return 0, false, nil -} diff --git a/client/ui/process/process_nonwindows.go b/client/ui/process/process_nonwindows.go deleted file mode 100644 index cf9f6443d..000000000 --- a/client/ui/process/process_nonwindows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build !windows - -package process - -import ( - "os" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - currentUserID := os.Getuid() - uids, err := p.Uids() - if err != nil { - log.Errorf("get process uids: %v", err) - return false - } - for _, id := range uids { - log.Debugf("checking process uid: %d", id) - if int(id) == currentUserID { - return true - } - } - return false -} diff --git a/client/ui/process/process_windows.go b/client/ui/process/process_windows.go deleted file mode 100644 index 2d211d1a4..000000000 --- a/client/ui/process/process_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -package process - -import ( - "os/user" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - processUsername, err := p.Username() - if err != nil { - log.Errorf("get process username error: %v", err) - return false - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user error: %v", err) - return false - } - - return processUsername == currUser.Username -} diff --git a/client/ui/profile.go b/client/ui/profile.go deleted file mode 100644 index 83b0ec18b..000000000 --- a/client/ui/profile.go +++ /dev/null @@ -1,775 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os/user" - "slices" - "sort" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" -) - -// showProfilesUI creates and displays the Profiles window with a list of existing profiles, -// a button to add new profiles, allows removal, and lets the user switch the active profile. -func (s *serviceClient) showProfilesUI() { - - profiles, err := s.getProfiles() - if err != nil { - log.Errorf("get profiles: %v", err) - return - } - - var refresh func() - // List widget for profiles - list := widget.NewList( - func() int { return len(profiles) }, - func() fyne.CanvasObject { - // Each item: Selected indicator, Name, spacer, Select, Logout & Remove buttons - return container.NewHBox( - widget.NewLabel(""), // indicator - widget.NewLabel(""), // profile name - layout.NewSpacer(), - widget.NewButton("Select", nil), - widget.NewButton("Deregister", nil), - widget.NewButton("Remove", nil), - ) - }, - func(i widget.ListItemID, item fyne.CanvasObject) { - // Populate each row - row := item.(*fyne.Container) - indicator := row.Objects[0].(*widget.Label) - nameLabel := row.Objects[1].(*widget.Label) - selectBtn := row.Objects[3].(*widget.Button) - logoutBtn := row.Objects[4].(*widget.Button) - removeBtn := row.Objects[5].(*widget.Button) - - profile := profiles[i] - // Show a checkmark if selected - if profile.IsActive { - indicator.SetText("✓") - } else { - indicator.SetText("") - } - nameLabel.SetText(formatProfileLabel(profile, profiles)) - - // Configure Select/Active button - selectBtn.SetText(func() string { - if profile.IsActive { - return "Active" - } - return "Select" - }()) - selectBtn.OnTapped = func() { - if profile.IsActive { - return // already active - } - // confirm switch - dialog.ShowConfirm( - "Switch Profile", - fmt.Sprintf("Are you sure you want to switch to '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - // switch - err = s.switchProfile(profile.ID) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - dialog.ShowError(errors.New("failed to select profile"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Profile Switched", - fmt.Sprintf("Profile '%s' switched successfully", profile.Name), - s.wProfiles, - ) - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := s.menuDownClick(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to handle down click"), s.wProfiles) - return - } - } - // update slice flags - refresh() - }, - s.wProfiles, - ) - } - - logoutBtn.Show() - logoutBtn.SetText("Deregister") - logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile, refresh) - } - - // Remove profile - removeBtn.SetText("Remove") - removeBtn.OnTapped = func() { - dialog.ShowConfirm( - "Delete Profile", - fmt.Sprintf("Are you sure you want to delete '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - - err = s.removeProfile(profile.ID) - if err != nil { - log.Errorf("failed to remove profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Removed", - fmt.Sprintf("Profile '%s' removed successfully", profile.Name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - } - }, - ) - - refresh = func() { - newProfiles, err := s.getProfiles() - if err != nil { - dialog.ShowError(err, s.wProfiles) - return - } - profiles = newProfiles // update the slice - list.Refresh() // tell Fyne to re-call length/update on every visible row - } - - // Button to add a new profile - newBtn := widget.NewButton("New Profile", func() { - nameEntry := widget.NewEntry() - nameEntry.SetPlaceHolder("Enter Profile Name") - - formItems := []*widget.FormItem{{Text: "Name:", Widget: nameEntry}} - dlg := dialog.NewForm( - "New Profile", - "Create", - "Cancel", - formItems, - func(confirm bool) { - if !confirm { - return - } - name := nameEntry.Text - if name == "" { - dialog.ShowError(errors.New("profile name cannot be empty"), s.wProfiles) - return - } - - // add profile - err = s.addProfile(name) - if err != nil { - log.Errorf("failed to create profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to create profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Created", - fmt.Sprintf("Profile '%s' created successfully", name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - // make dialog wider - dlg.Resize(fyne.NewSize(350, 150)) - dlg.Show() - }) - - // Assemble window content - content := container.NewBorder(nil, newBtn, nil, nil, list) - s.wProfiles = s.app.NewWindow("NetBird Profiles") - s.wProfiles.SetContent(content) - s.wProfiles.Resize(fyne.NewSize(400, 300)) - s.wProfiles.SetOnClosed(s.cancel) - - s.wProfiles.Show() -} - -func (s *serviceClient) addProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.AddProfile(s.ctx, &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - - if err != nil { - return fmt.Errorf("add profile: %w", err) - } - - return nil -} - -func (s *serviceClient) switchProfile(handle string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &handle, - Username: &currUser.Username, - }) - if err != nil { - return fmt.Errorf("switch profile failed: %w", err) - } - - if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil { - return fmt.Errorf("switch profile: %w", err) - } - - return nil -} - -func (s *serviceClient) removeProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.RemoveProfile(s.ctx, &proto.RemoveProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - if err != nil { - return fmt.Errorf("remove profile: %w", err) - } - - return nil -} - -type Profile struct { - ID string - Name string - IsActive bool -} - -// formatProfileLabel returns the display label for a profile. Profiles can -// share the same Name, so when more than one profile in profiles carries this -// Name, a short form of the ID is appended to disambiguate the entries. -func formatProfileLabel(profile Profile, profiles []Profile) string { - count := 0 - for _, p := range profiles { - if p.Name == profile.Name { - count++ - } - } - if count <= 1 { - return profile.Name - } - return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID()) -} - -func (s *serviceClient) getProfiles() ([]Profile, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - profilesResp, err := conn.ListProfiles(s.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - ID: profile.Id, - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) { - dialog.ShowConfirm( - "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client: %v", err) - dialog.ShowError(fmt.Errorf("failed to connect to service"), s.wProfiles) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - dialog.ShowError(fmt.Errorf("failed to get current user"), s.wProfiles) - return - } - - username := currUser.Username - // ProfileName is treated as a handle; send the ID so the - // daemon resolves to exactly this profile. - _, err = conn.Logout(s.ctx, &proto.LogoutRequest{ - ProfileName: &profile.ID, - Username: &username, - }) - if err != nil { - log.Errorf("logout failed: %v", err) - dialog.ShowError(fmt.Errorf("deregister failed"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profile.Name), - s.wProfiles, - ) - - refreshCallback() - }, - s.wProfiles, - ) -} - -type subItem struct { - *systray.MenuItem - ctx context.Context - cancel context.CancelFunc -} - -type profileMenu struct { - mu sync.Mutex - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - profileSubItems []*subItem - manageProfilesSubItem *subItem - logoutSubItem *subItem - profilesState []Profile - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -type newProfileMenuArgs struct { - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -func newProfileMenu(args newProfileMenuArgs) *profileMenu { - p := profileMenu{ - ctx: args.ctx, - serviceClient: args.serviceClient, - profileManager: args.profileManager, - eventHandler: args.eventHandler, - profileMenuItem: args.profileMenuItem, - emailMenuItem: args.emailMenuItem, - downClickCallback: args.downClickCallback, - upClickCallback: args.upClickCallback, - getSrvClientCallback: args.getSrvClientCallback, - loadSettingsCallback: args.loadSettingsCallback, - app: args.app, - } - - p.emailMenuItem.Disable() - p.emailMenuItem.Hide() - p.refresh() - go p.updateMenu() - - return &p -} - -func (p *profileMenu) getProfiles() ([]Profile, error) { - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - profilesResp, err := conn.ListProfiles(p.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - ID: profile.Id, - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (p *profileMenu) refresh() { - p.mu.Lock() - defer p.mu.Unlock() - - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - return - } - - // Clear existing profile items - p.clear(profiles) - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - return - } - - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - activeProf, err := conn.GetActiveProfile(p.ctx, &proto.GetActiveProfileRequest{}) - if err != nil { - log.Errorf("failed to get active profile: %v", err) - return - } - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - activeProfState, err := p.profileManager.GetProfileState(profilemanager.ID(activeProf.Id)) - if err != nil { - log.Warnf("failed to get active profile state: %v", err) - p.emailMenuItem.Hide() - } else if activeProfState.Email != "" { - p.emailMenuItem.SetTitle(fmt.Sprintf("(%s)", activeProfState.Email)) - p.emailMenuItem.Show() - } - } - - for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "") - if profile.IsActive { - item.Check() - } - - ctx, cancel := context.WithCancel(context.Background()) - p.profileSubItems = append(p.profileSubItems, &subItem{item, ctx, cancel}) - - go func() { - for { - select { - case <-ctx.Done(): - return // context cancelled - case _, ok := <-item.ClickedCh: - if !ok { - return // channel closed - } - - // Handle profile selection - if profile.IsActive { - log.Infof("Profile '%s' is already active", profile.Name) - return - } - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.ID, - Username: &currUser.Username, - }) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - // show notification dialog - p.serviceClient.notifier.Send("Error", "Failed to switch profile") - return - } - - err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)) - if err != nil { - log.Errorf("failed to switch profile '%s': %v", profile.Name, err) - return - } - - log.Infof("Switched to profile '%s'", profile.Name) - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := p.downClickCallback(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - } - } - - if p.serviceClient.connectCancel != nil { - p.serviceClient.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(p.ctx) - p.serviceClient.connectCancel = connectCancel - - if err := p.upClickCallback(connectCtx); err != nil { - log.Errorf("failed to handle up click after switching profile: %v", err) - } - - connectCancel() - - p.refresh() - p.loadSettingsCallback() - } - } - }() - - } - ctx, cancel := context.WithCancel(context.Background()) - manageItem := p.profileMenuItem.AddSubMenuItem("Manage Profiles", "") - p.manageProfilesSubItem = &subItem{manageItem, ctx, cancel} - - go func() { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-manageItem.ClickedCh: - if !ok { - return - } - p.eventHandler.runSelfCommand(p.ctx, "profiles", "true") - p.refresh() - p.loadSettingsCallback() - } - } - }() - - // Add Logout menu item - ctx2, cancel2 := context.WithCancel(context.Background()) - logoutItem := p.profileMenuItem.AddSubMenuItem("Deregister", "") - p.logoutSubItem = &subItem{logoutItem, ctx2, cancel2} - - go func() { - for { - select { - case <-ctx2.Done(): - return - case _, ok := <-logoutItem.ClickedCh: - if !ok { - return - } - if err := p.eventHandler.logout(p.ctx); err != nil { - log.Errorf("logout failed: %v", err) - p.serviceClient.notifier.Send("Error", "Failed to deregister") - } else { - p.serviceClient.notifier.Send("Success", "Deregistered successfully") - } - } - } - }() - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - p.profileMenuItem.SetTitle(activeProf.ProfileName) - } else { - p.profileMenuItem.SetTitle(fmt.Sprintf("Profile: %s (User: %s)", activeProf.ProfileName, activeProf.Username)) - p.emailMenuItem.Hide() - } - -} - -func (p *profileMenu) clear(profiles []Profile) { - for _, item := range p.profileSubItems { - item.Remove() - item.cancel() - } - p.profileSubItems = make([]*subItem, 0, len(profiles)) - p.profilesState = profiles - - if p.manageProfilesSubItem != nil { - p.manageProfilesSubItem.Remove() - p.manageProfilesSubItem.cancel() - p.manageProfilesSubItem = nil - } - - if p.logoutSubItem != nil { - p.logoutSubItem.Remove() - p.logoutSubItem.cancel() - p.logoutSubItem = nil - } -} - -// setEnabled greys out (Disable) the profile menu and every existing -// sub-item when the daemon reports the kill switch active, so the user -// sees the menu but cannot enter "Manage Profiles" or switch profile. -// Previously this used Hide() on the parent, but Fyne's systray on -// Windows does not propagate Hide() to a parent that already has -// children — the submenu kept popping up and accepting clicks. Disable -// is the reliable visual lock. -func (p *profileMenu) setEnabled(enabled bool) { - if p.profileMenuItem == nil { - return - } - p.mu.Lock() - defer p.mu.Unlock() - - if enabled { - p.profileMenuItem.Enable() - p.profileMenuItem.SetTooltip("") - } else { - p.profileMenuItem.Disable() - p.profileMenuItem.SetTooltip("Profiles are disabled by daemon") - } - - apply := func(item *systray.MenuItem) { - if item == nil { - return - } - if enabled { - item.Enable() - } else { - item.Disable() - } - } - for _, sub := range p.profileSubItems { - if sub != nil { - apply(sub.MenuItem) - } - } - if p.manageProfilesSubItem != nil { - apply(p.manageProfilesSubItem.MenuItem) - } - if p.logoutSubItem != nil { - apply(p.logoutSubItem.MenuItem) - } -} - -func (p *profileMenu) updateMenu() { - // check every second - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // get profilesList - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - continue - } - - sort.Slice(profiles, func(i, j int) bool { - if profiles[i].Name != profiles[j].Name { - return profiles[i].Name < profiles[j].Name - } - return profiles[i].ID < profiles[j].ID - }) - - p.mu.Lock() - state := p.profilesState - p.mu.Unlock() - - sort.Slice(state, func(i, j int) bool { - return state[i].Name < state[j].Name - }) - - if slices.Equal(profiles, state) { - continue - } - - p.refresh() - case <-p.ctx.Done(): - return // context cancelled - - } - } -} diff --git a/client/ui/quickactions.go b/client/ui/quickactions.go deleted file mode 100644 index bf47ac434..000000000 --- a/client/ui/quickactions.go +++ /dev/null @@ -1,349 +0,0 @@ -//go:build !(linux && 386) - -//go:generate fyne bundle -o quickactions_assets.go assets/connected.png -//go:generate fyne bundle -o quickactions_assets.go -append assets/disconnected.png -package main - -import ( - "context" - _ "embed" - "fmt" - "runtime" - "sync/atomic" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" -) - -type quickActionsUiState struct { - connectionStatus string - isToggleButtonEnabled bool - isConnectionChanged bool - toggleAction func() -} - -func newQuickActionsUiState() quickActionsUiState { - return quickActionsUiState{ - connectionStatus: string(internal.StatusIdle), - isToggleButtonEnabled: false, - isConnectionChanged: false, - } -} - -type clientConnectionStatusProvider interface { - connectionStatus(ctx context.Context) (string, error) -} - -type daemonClientConnectionStatusProvider struct { - client proto.DaemonServiceClient -} - -func (d daemonClientConnectionStatusProvider) connectionStatus(ctx context.Context) (string, error) { - childCtx, cancel := context.WithTimeout(ctx, 400*time.Millisecond) - defer cancel() - status, err := d.client.Status(childCtx, &proto.StatusRequest{}) - if err != nil { - return "", err - } - - return status.Status, nil -} - -type clientCommand interface { - execute() error -} - -type connectCommand struct { - connectClient func() error -} - -func (c connectCommand) execute() error { - return c.connectClient() -} - -type disconnectCommand struct { - disconnectClient func() error -} - -func (c disconnectCommand) execute() error { - return c.disconnectClient() -} - -type quickActionsViewModel struct { - provider clientConnectionStatusProvider - connect clientCommand - disconnect clientCommand - uiChan chan quickActionsUiState - isWatchingConnectionStatus atomic.Bool -} - -func newQuickActionsViewModel(ctx context.Context, provider clientConnectionStatusProvider, connect, disconnect clientCommand, uiChan chan quickActionsUiState) { - viewModel := quickActionsViewModel{ - provider: provider, - connect: connect, - disconnect: disconnect, - uiChan: uiChan, - } - - viewModel.isWatchingConnectionStatus.Store(true) - - // base UI status - uiChan <- newQuickActionsUiState() - - // this retrieves the current connection status - // and pushes the UI state that reflects it via uiChan - go viewModel.watchConnectionStatus(ctx) -} - -func (q *quickActionsViewModel) updateUiState(ctx context.Context) { - uiState := newQuickActionsUiState() - connectionStatus, err := q.provider.connectionStatus(ctx) - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.uiChan <- uiState - return - } - - if connectionStatus == string(internal.StatusConnected) { - uiState.toggleAction = func() { - q.executeCommand(q.disconnect) - } - } else { - uiState.toggleAction = func() { - q.executeCommand(q.connect) - } - } - - uiState.isToggleButtonEnabled = true - uiState.connectionStatus = connectionStatus - q.uiChan <- uiState -} - -func (q *quickActionsViewModel) watchConnectionStatus(ctx context.Context) { - ticker := time.NewTicker(1000 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if q.isWatchingConnectionStatus.Load() { - q.updateUiState(ctx) - } - } - } -} - -func (q *quickActionsViewModel) executeCommand(command clientCommand) { - uiState := newQuickActionsUiState() - // newQuickActionsUiState starts with Idle connection status, - // and all that's necessary here is to just disable the toggle button. - uiState.connectionStatus = "" - - q.uiChan <- uiState - - q.isWatchingConnectionStatus.Store(false) - - err := command.execute() - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.isWatchingConnectionStatus.Store(true) - } else { - uiState = newQuickActionsUiState() - uiState.isConnectionChanged = true - q.uiChan <- uiState - } -} - -func getSystemTrayName() string { - os := runtime.GOOS - switch os { - case "darwin": - return "menu bar" - default: - return "system tray" - } -} - -func (s *serviceClient) getNetBirdImage(name string, content []byte) *canvas.Image { - imageSize := fyne.NewSize(64, 64) - - resource := fyne.NewStaticResource(name, content) - image := canvas.NewImageFromResource(resource) - image.FillMode = canvas.ImageFillContain - image.SetMinSize(imageSize) - image.Resize(imageSize) - - return image -} - -type quickActionsUiComponents struct { - content *fyne.Container - toggleConnectionButton *widget.Button - connectedLabelText, disconnectedLabelText string - connectedImage, disconnectedImage *canvas.Image - connectedCircleRes, disconnectedCircleRes fyne.Resource -} - -// applyQuickActionsUiState applies a single UI state to the quick actions window. -// It closes the window and returns true if the connection status has changed, -// in which case the caller should stop processing further states. -func (s *serviceClient) applyQuickActionsUiState( - uiState quickActionsUiState, - components quickActionsUiComponents, -) bool { - if uiState.isConnectionChanged { - fyne.DoAndWait(func() { - s.wQuickActions.Close() - }) - return true - } - - var logo *canvas.Image - var buttonText string - var buttonIcon fyne.Resource - - if uiState.connectionStatus == string(internal.StatusConnected) { - buttonText = components.connectedLabelText - buttonIcon = components.connectedCircleRes - logo = components.connectedImage - } else if uiState.connectionStatus == string(internal.StatusIdle) { - buttonText = components.disconnectedLabelText - buttonIcon = components.disconnectedCircleRes - logo = components.disconnectedImage - } - - fyne.DoAndWait(func() { - if buttonText != "" { - components.toggleConnectionButton.SetText(buttonText) - } - - if buttonIcon != nil { - components.toggleConnectionButton.SetIcon(buttonIcon) - } - - if uiState.isToggleButtonEnabled { - components.toggleConnectionButton.Enable() - } else { - components.toggleConnectionButton.Disable() - } - - components.toggleConnectionButton.OnTapped = func() { - if uiState.toggleAction != nil { - go uiState.toggleAction() - } - } - - components.toggleConnectionButton.Refresh() - - // the second position in the content's object array is the NetBird logo. - if logo != nil { - components.content.Objects[1] = logo - components.content.Refresh() - } - }) - - return false -} - -// showQuickActionsUI displays a simple window with the NetBird logo and a connection toggle button. -func (s *serviceClient) showQuickActionsUI() { - s.wQuickActions = s.app.NewWindow("NetBird") - vmCtx, vmCancel := context.WithCancel(s.ctx) - s.wQuickActions.SetOnClosed(vmCancel) - - client, err := s.getSrvClient(defaultFailTimeout) - - connCmd := connectCommand{ - connectClient: func() error { - return s.menuUpClick(s.ctx) - }, - } - - disConnCmd := disconnectCommand{ - disconnectClient: func() error { - return s.menuDownClick() - }, - } - - if err != nil { - log.Errorf("get service client: %v", err) - return - } - - uiChan := make(chan quickActionsUiState, 1) - newQuickActionsViewModel(vmCtx, daemonClientConnectionStatusProvider{client: client}, connCmd, disConnCmd, uiChan) - - connectedImage := s.getNetBirdImage("netbird.png", iconAbout) - disconnectedImage := s.getNetBirdImage("netbird-disconnected.png", iconAboutDisconnected) - - connectedCircle := canvas.NewImageFromResource(resourceConnectedPng) - disconnectedCircle := canvas.NewImageFromResource(resourceDisconnectedPng) - - connectedLabelText := "Disconnect" - disconnectedLabelText := "Connect" - - toggleConnectionButton := widget.NewButtonWithIcon(disconnectedLabelText, disconnectedCircle.Resource, func() { - // This button's tap function will be set when an ui state arrives via the uiChan channel. - }) - - // Button starts disabled until the first ui state arrives. - toggleConnectionButton.Disable() - - hintLabelText := fmt.Sprintf("You can always access NetBird from your %s.", getSystemTrayName()) - hintLabel := widget.NewLabel(hintLabelText) - - content := container.NewVBox( - layout.NewSpacer(), - disconnectedImage, - layout.NewSpacer(), - container.NewCenter(toggleConnectionButton), - layout.NewSpacer(), - container.NewCenter(hintLabel), - ) - - // this watches for ui state updates. - go func() { - - for { - select { - case <-vmCtx.Done(): - return - case uiState, ok := <-uiChan: - if !ok { - return - } - - closed := s.applyQuickActionsUiState( - uiState, - quickActionsUiComponents{ - content, - toggleConnectionButton, - connectedLabelText, disconnectedLabelText, - connectedImage, disconnectedImage, - connectedCircle.Resource, disconnectedCircle.Resource, - }, - ) - if closed { - return - } - } - } - }() - - s.wQuickActions.SetContent(content) - s.wQuickActions.Resize(fyne.NewSize(400, 200)) - s.wQuickActions.SetFixedSize(true) - s.wQuickActions.Show() -} diff --git a/client/ui/quickactions_assets.go b/client/ui/quickactions_assets.go deleted file mode 100644 index 9ff5e85a2..000000000 --- a/client/ui/quickactions_assets.go +++ /dev/null @@ -1,23 +0,0 @@ -// auto-generated -// Code generated by '$ fyne bundle'. DO NOT EDIT. - -package main - -import ( - _ "embed" - "fyne.io/fyne/v2" -) - -//go:embed assets/connected.png -var resourceConnectedPngData []byte -var resourceConnectedPng = &fyne.StaticResource{ - StaticName: "assets/connected.png", - StaticContent: resourceConnectedPngData, -} - -//go:embed assets/disconnected.png -var resourceDisconnectedPngData []byte -var resourceDisconnectedPng = &fyne.StaticResource{ - StaticName: "assets/disconnected.png", - StaticContent: resourceDisconnectedPngData, -} diff --git a/client/ui/recenter_linux.go b/client/ui/recenter_linux.go new file mode 100644 index 000000000..25c468d7f --- /dev/null +++ b/client/ui/recenter_linux.go @@ -0,0 +1,13 @@ +//go:build linux && !(linux && 386) + +package main + +// recenterOnShowPredicate returns a per-show predicate; re-centering is only +// needed under the minimal-WM / in-process-XEmbed-tray environment, which neither +// centers small windows nor restores position across hide -> show. Evaluated per +// show, not at startup, because the XEmbed tray can appear after the UI starts +// (panel and autostarted app race at login); xembedTrayAvailable is a cheap, +// side-effect-free probe safe to call repeatedly. +func recenterOnShowPredicate() func() bool { + return xembedTrayAvailable +} diff --git a/client/ui/recenter_other.go b/client/ui/recenter_other.go new file mode 100644 index 000000000..18ffe8730 --- /dev/null +++ b/client/ui/recenter_other.go @@ -0,0 +1,10 @@ +//go:build (!linux || (linux && 386)) && !freebsd && !android && !ios && !js + +package main + +// recenterOnShowPredicate returns nil off Linux: macOS and Windows WMs restore +// window position across hide -> show themselves, so Go-side re-centering would +// only fight a window the user moved. +func recenterOnShowPredicate() func() bool { + return nil +} diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go new file mode 100644 index 000000000..f7e3aeea0 --- /dev/null +++ b/client/ui/services/autostart.go @@ -0,0 +1,52 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Autostart facade over Wails' AutostartManager. The OS login-item registration +// is the single source of truth; nothing is mirrored to preferences. +type Autostart struct { + mgr *application.AutostartManager +} + +func NewAutostart(mgr *application.AutostartManager) *Autostart { + return &Autostart{mgr: mgr} +} + +func (a *Autostart) Supported(_ context.Context) bool { + _, err := a.mgr.Status() + return !errors.Is(err, application.ErrAutostartNotSupported) +} + +// IsEnabled returns false without error on unsupported platforms. +func (a *Autostart) IsEnabled(_ context.Context) (bool, error) { + enabled, err := a.mgr.IsEnabled() + if err != nil { + if errors.Is(err, application.ErrAutostartNotSupported) { + return false, nil + } + return false, fmt.Errorf("read autostart state: %w", err) + } + return enabled, nil +} + +// SetEnabled takes effect on the next login, not immediately. +func (a *Autostart) SetEnabled(_ context.Context, enabled bool) error { + if enabled { + if err := a.mgr.Enable(); err != nil { + return fmt.Errorf("enable autostart: %w", err) + } + return nil + } + if err := a.mgr.Disable(); err != nil { + return fmt.Errorf("disable autostart: %w", err) + } + return nil +} diff --git a/client/ui/services/compat.go b/client/ui/services/compat.go new file mode 100644 index 000000000..0cc6dd99f --- /dev/null +++ b/client/ui/services/compat.go @@ -0,0 +1,40 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +// Compat answers whether the running daemon is new enough to drive this UI. +type Compat struct { + conn DaemonConn +} + +func NewCompat(conn DaemonConn) *Compat { + return &Compat{conn: conn} +} + +// DaemonReady probes the WailsUIReady RPC once. A true result means the daemon +// implements it and is compatible. An Unimplemented response means the daemon +// predates this UI and is too old; the caller should surface an upgrade prompt. +// Any other error (daemon not running, transport failure) is returned so the +// frontend can tell "outdated" apart from "not reachable". +func (c *Compat) DaemonReady(ctx context.Context) (bool, error) { + client, err := c.conn.Client() + if err != nil { + return false, err + } + if _, err := client.WailsUIReady(ctx, &proto.WailsUIReadyRequest{}); err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/client/ui/services/conn.go b/client/ui/services/conn.go new file mode 100644 index 000000000..531abe7d9 --- /dev/null +++ b/client/ui/services/conn.go @@ -0,0 +1,13 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import "github.com/netbirdio/netbird/client/proto" + +// DaemonConn returns a lazy gRPC client to the NetBird daemon. +// All services receive a DaemonConn so they share a single connection. +type DaemonConn interface { + Client() (proto.DaemonServiceClient, error) +} + +func ptrStr(s string) *string { return &s } diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go new file mode 100644 index 000000000..a23a526e6 --- /dev/null +++ b/client/ui/services/connection.go @@ -0,0 +1,223 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// LoginParams are the inputs to Login. +type LoginParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl"` + SetupKey string `json:"setupKey"` + PreSharedKey string `json:"preSharedKey"` + Hostname string `json:"hostname"` + Hint string `json:"hint"` +} + +// LoginResult is the daemon's reply to Login. +type LoginResult struct { + NeedsSSOLogin bool `json:"needsSsoLogin"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` +} + +// WaitSSOParams are the inputs to WaitSSOLogin. +type WaitSSOParams struct { + UserCode string `json:"userCode"` + Hostname string `json:"hostname"` +} + +// UpParams selects the profile to bring up. +type UpParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// LogoutParams selects the profile to log out. +type LogoutParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// Connection groups the daemon RPCs that drive login / connect / disconnect. +type Connection struct { + conn DaemonConn + classifier errorClassifier +} + +// NewConnection wires up a Connection. translator or prefs may be nil, in which +// case classifyDaemonError falls back to the bare error key. +func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection { + return &Connection{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}} +} + +func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) { + cli, err := s.conn.Client() + if err != nil { + return LoginResult{}, err + } + + // No pre-Login Down: Login dislodges a pending WaitSSOLogin itself, and a + // defensive Down would only flash an Idle blink in the tray during handoff. + + // Fall back to the daemon's active profile and the current OS user. + profileName := p.ProfileName + username := p.Username + if profileName == "" { + if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil { + // Address the active profile by ID (the daemon resolves it as a + // handle); names can collide, the ID cannot. + profileName = active.GetId() + if username == "" { + username = active.GetUsername() + } + } + } + if username == "" { + if u, uerr := user.Current(); uerr == nil { + username = u.Username + } + } + + req := &proto.LoginRequest{ + ManagementUrl: p.ManagementURL, + SetupKey: p.SetupKey, + Hostname: p.Hostname, + IsUnixDesktopClient: runtime.GOOS == "linux", + } + if profileName != "" { + req.ProfileName = ptrStr(profileName) + } + if username != "" { + req.Username = ptrStr(username) + } + if p.PreSharedKey != "" { + req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) + } + if p.Hint != "" { + req.Hint = ptrStr(p.Hint) + } + + resp, err := cli.Login(ctx, req) + if err != nil { + return LoginResult{}, s.classifyDaemonError(err) + } + return LoginResult{ + NeedsSSOLogin: resp.GetNeedsSSOLogin(), + UserCode: resp.GetUserCode(), + VerificationURI: resp.GetVerificationURI(), + VerificationURIComplete: resp.GetVerificationURIComplete(), + }, nil +} + +func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ + UserCode: p.UserCode, + Hostname: p.Hostname, + }) + if err != nil { + return "", s.classifyDaemonError(err) + } + return resp.GetEmail(), nil +} + +func (s *Connection) Up(ctx context.Context, p UpParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + // Always async: status updates flow via SubscribeStatus. + req := &proto.UpRequest{Async: true} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + if _, err = cli.Up(ctx, req); err != nil { + return s.classifyDaemonError(err) + } + return nil +} + +func (s *Connection) Down(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil { + return s.classifyDaemonError(err) + } + return nil +} + +// OpenURL opens url in an external browser; the embedded webview blocks +// window.open, so the SSO verification page can't pop inline. Honors $BROWSER +// before the platform default. +func (s *Connection) OpenURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} + +func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.LogoutRequest{} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + if _, err = cli.Logout(ctx, req); err != nil { + return s.classifyDaemonError(err) + } + + // The daemon runs as root and can't reach the user-owned per-profile state + // file holding the account email (see Profiles.List), so clear the stale + // email here; the next SSO login recreates it. + if p.ProfileName != "" { + if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil { + // Non-fatal: the logout itself succeeded. + log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err) + } + } + + return nil +} + +// classifyDaemonError maps a gRPC error to a localised ClientError. +func (s *Connection) classifyDaemonError(err error) *ClientError { + return s.classifier.classify(err) +} diff --git a/client/ui/services/cursor_darwin.go b/client/ui/services/cursor_darwin.go new file mode 100644 index 000000000..a927ff719 --- /dev/null +++ b/client/ui/services/cursor_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package services + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework AppKit +#import +#import + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// NSEvent.mouseLocation is Y-up from primary's bottom-left; flip against +// the primary's frame height so the point matches Wails' Y-down Screen.Bounds. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + NSArray *screens = [NSScreen screens]; + if (screens == nil || screens.count == 0) return p; + NSScreen *primary = [screens firstObject]; + if (primary == nil) return p; + NSPoint loc = [NSEvent mouseLocation]; + p.x = (int)loc.x; + p.y = (int)(primary.frame.size.height - loc.y); + p.ok = 1; + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + return application.Point{X: int(res.x), Y: int(res.y)}, true +} diff --git a/client/ui/services/cursor_linux.go b/client/ui/services/cursor_linux.go new file mode 100644 index 000000000..760fd5b86 --- /dev/null +++ b/client/ui/services/cursor_linux.go @@ -0,0 +1,53 @@ +//go:build linux + +package services + +/* +#cgo pkg-config: x11 +#cgo LDFLAGS: -lX11 +#include +#include + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// XQueryPointer works on X11 and, via XWayland, on Wayland sessions. +// ok=0 when no X server is reachable. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + Display *dpy = XOpenDisplay(NULL); + if (!dpy) return p; + Window root = DefaultRootWindow(dpy); + if (root == 0) { XCloseDisplay(dpy); return p; } + Window root_return = 0, child_return = 0; + int root_x = 0, root_y = 0, win_x = 0, win_y = 0; + unsigned int mask_return = 0; + if (XQueryPointer(dpy, root, &root_return, &child_return, + &root_x, &root_y, &win_x, &win_y, &mask_return)) { + p.x = root_x; + p.y = root_y; + p.ok = 1; + } + XCloseDisplay(dpy); + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(app *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + p := application.Point{X: int(res.x), Y: int(res.y)} + // X11 root coords are physical pixels; Screen.Bounds is in DIPs. + if app == nil || app.Screen == nil { + return p, true + } + return app.Screen.PhysicalToDipPoint(p), true +} diff --git a/client/ui/services/cursor_other.go b/client/ui/services/cursor_other.go new file mode 100644 index 000000000..7f35ff438 --- /dev/null +++ b/client/ui/services/cursor_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !windows && !linux && !freebsd && !android && !ios && !js + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + return application.Point{}, false +} diff --git a/client/ui/services/cursor_windows.go b/client/ui/services/cursor_windows.go new file mode 100644 index 000000000..42ed32d74 --- /dev/null +++ b/client/ui/services/cursor_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package services + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +func getCursorPosition(app *application.App) (application.Point, bool) { + x, y, ok := w32.GetCursorPos() + if !ok || app == nil || app.Screen == nil { + return application.Point{}, false + } + // GetCursorPos is in physical pixels; Screen.Bounds is in DIPs. + return app.Screen.PhysicalToDipPoint(application.Point{X: x, Y: y}), true +} diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go new file mode 100644 index 000000000..632581fe9 --- /dev/null +++ b/client/ui/services/daemon_feed.go @@ -0,0 +1,590 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/updater" +) + +const ( + EventStatusSnapshot = "netbird:status" + // EventDaemonNotification carries each SubscribeEvents message. Auto-update + // SystemEvents are also forwarded to updater.Holder.OnSystemEvent so the typed + // update state needs no second daemon subscription. + EventDaemonNotification = "netbird:event" + // EventProfileChanged fires after a daemon-side switch (payload: the new + // ProfileRef). The daemon emits no profile event, so this is the only signal + // that lets a flip driven from one surface paint in the others. + EventProfileChanged = "netbird:profile:changed" + // EventSessionWarning is a typed sibling of EventDaemonNotification so + // subscribers needn't filter the notification firehose. Consumers branch on + // SessionWarning.Final to tell the T-10 event from the T-2 fallback. + EventSessionWarning = "netbird:session:warning" + + // StatusDaemonUnavailable is the synthetic Status emitted when the daemon's + // gRPC socket is unreachable. No internal.Status* collides with this label. + StatusDaemonUnavailable = "DaemonUnavailable" + + // Daemon connection status strings — mirror internal.Status* in + // client/internal/state.go. + StatusConnected = "Connected" + StatusConnecting = "Connecting" + StatusIdle = "Idle" + StatusNeedsLogin = "NeedsLogin" + StatusLoginFailed = "LoginFailed" + StatusSessionExpired = "SessionExpired" + + // SeverityCritical is the lower-cased proto SystemEvent_CRITICAL severity, as + // emitted by systemEventFromProto. Critical events bypass the notifications gate. + SeverityCritical = "critical" +) + +// Emitter sends a named payload to the frontend. Satisfied by Wails app.Event. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// SystemEvent is the frontend-facing shape of a daemon SystemEvent. +type SystemEvent struct { + ID string `json:"id"` + Severity string `json:"severity"` + Category string `json:"category"` + Message string `json:"message"` + UserMessage string `json:"userMessage"` + Timestamp int64 `json:"timestamp"` + Metadata map[string]string `json:"metadata"` +} + +// PeerStatus is the frontend-facing shape of a daemon PeerState. +type PeerStatus struct { + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + PubKey string `json:"pubKey"` + ConnStatus string `json:"connStatus"` + ConnStatusUpdateUnix int64 `json:"connStatusUpdateUnix"` + Relayed bool `json:"relayed"` + LocalIceCandidateType string `json:"localIceCandidateType"` + RemoteIceCandidateType string `json:"remoteIceCandidateType"` + LocalIceCandidateEndpoint string `json:"localIceCandidateEndpoint"` + RemoteIceCandidateEndpoint string `json:"remoteIceCandidateEndpoint"` + Fqdn string `json:"fqdn"` + BytesRx int64 `json:"bytesRx"` + BytesTx int64 `json:"bytesTx"` + LatencyMs int64 `json:"latencyMs"` + RelayAddress string `json:"relayAddress"` + LastHandshakeUnix int64 `json:"lastHandshakeUnix"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + Networks []string `json:"networks"` +} + +// PeerLink is this peer's connection to its mgmt or signal server. +type PeerLink struct { + URL string `json:"url"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` +} + +// LocalPeer mirrors LocalPeerState. +type LocalPeer struct { + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + PubKey string `json:"pubKey"` + Fqdn string `json:"fqdn"` + Networks []string `json:"networks"` +} + +// Status is the snapshot the frontend renders on the dashboard. +type Status struct { + Status string `json:"status"` + DaemonVersion string `json:"daemonVersion"` + Management PeerLink `json:"management"` + Signal PeerLink `json:"signal"` + Local LocalPeer `json:"local"` + Peers []PeerStatus `json:"peers"` + Events []SystemEvent `json:"events"` + // NetworksRevision bumps whenever the daemon's routed-networks set or their + // selected state changes, so consumers know when to re-fetch ListNetworks + // instead of polling every snapshot. + NetworksRevision uint64 `json:"networksRevision"` + // SessionExpiresAt is the absolute UTC instant the SSO session expires; nil + // when the peer is not SSO-tracked or login expiration is disabled. + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` +} + +// DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus, +// SubscribeEvents) out to the frontend and tray, and exposes a one-shot Status +// RPC for callers wanting the current snapshot without subscribing. +// +// Profile-switch suppression: BeginProfileSwitch makes statusStreamLoop swallow +// the transient stale Connected and Idle pushes the daemon emits during Down, so +// consumers see Connecting → new-profile-state instead of the full blink. +// +// Two flags govern the switch lifecycle, evaluated independently by +// consumeForSwitch on every push because their lifetimes differ: +// +// switchInProgress (suppression): clears on the first real push from the new +// Up. Daemon-side StatusConnecting comes BEFORE any NeedsLogin, so +// suppression must release here before the terminal arrives. +// switchLoginWatch (trigger): outlives suppression. Watches for NeedsLogin +// / LoginFailed / SessionExpired along the Up's retry loop and emits +// EventTriggerLogin so the React orchestrator opens browser-login. +// +// ┌────────────────────────────────────────────┬──────────────────────────────────┐ +// │ Incoming daemon status │ Action │ +// ├────────────────────────────────────────────┼──────────────────────────────────┤ +// │ Connected, Idle (while switchInProgress) │ Suppress (the blink we hide) │ +// │ Connecting │ Emit, clear switchInProgress │ +// │ NeedsLogin, LoginFailed, SessionExpired │ Emit, clear both flags, also │ +// │ │ emit EventTriggerLogin │ +// │ Connected, Idle (while only login-watch) │ Emit, clear switchLoginWatch │ +// │ DaemonUnavailable │ Emit, clear both flags │ +// │ (timeout elapsed) │ Clear flags, emit normally │ +// └────────────────────────────────────────────┴──────────────────────────────────┘ +type DaemonFeed struct { + conn DaemonConn + emitter Emitter + updater *updater.Holder + // logCtl attaches/detaches the GUI file log in response to the daemon's log + // level (a marked SystemEvent on the SubscribeEvents stream). nil when the GUI + // doesn't manage its log (server build / not wired), in which case the marker + // is ignored. + logCtl LogController + + mu sync.Mutex + cancel context.CancelFunc + streamWg sync.WaitGroup + + switchMu sync.Mutex + switchInProgress bool + switchInProgressUntil time.Time + switchLoginWatch bool + switchLoginWatchUntil time.Time +} + +// LogController is the subset of guilog.DebugLog that DaemonFeed drives: Apply +// turns the GUI file log on/off for a daemon level; Path is the gui-client.log +// path to register with the daemon (empty when the GUI doesn't own its log). +type LogController interface { + Apply(level string) + Path() string +} + +// NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not +// managed), in which case log-level markers on the event stream are ignored. +func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed { + return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl} +} + +// BeginProfileSwitch arms suppression for a switch from Connected/Connecting, +// where the daemon emits stale Connected updates during Down's teardown then an +// Idle before the new Up; statusStreamLoop drops those, and a synthetic +// Connecting snapshot is emitted so consumers paint optimistically. A 30s safety +// timeout clears the flag if no follow-up status arrives. +func (s *DaemonFeed) BeginProfileSwitch() { + now := time.Now() + s.switchMu.Lock() + s.switchInProgress = true + s.switchInProgressUntil = now.Add(30 * time.Second) + s.switchLoginWatch = true + s.switchLoginWatchUntil = now.Add(30 * time.Second) + s.switchMu.Unlock() + s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting}) +} + +// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting): +// clears suppression so the next daemon Idle paints through, and disarms the +// login-watch so the abort doesn't pop a browser-login after the user cancelled. +func (s *DaemonFeed) CancelProfileSwitch() { + s.switchMu.Lock() + s.switchInProgress = false + s.switchLoginWatch = false + s.switchMu.Unlock() +} + +// Watch starts the two background stream loops. Idempotent (a second call while +// running is a no-op); both loops self-restart via exponential backoff. +func (s *DaemonFeed) Watch(ctx context.Context) { + s.mu.Lock() + if s.cancel != nil { + s.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.mu.Unlock() + + s.streamWg.Add(2) + go s.statusStreamLoop(ctx) + go s.toastStreamLoop(ctx) +} + +// ServiceShutdown is the Wails service hook fired on app exit. +func (s *DaemonFeed) ServiceShutdown() error { + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + s.streamWg.Wait() + return nil +} + +// Get returns the current daemon status snapshot. An unreachable daemon socket +// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the +// frontend keys off a single status enum without a parallel "error" path. +func (s *DaemonFeed) Get(ctx context.Context) (Status, error) { + cli, err := s.conn.Client() + if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } + return Status{}, err + } + resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } + return Status{}, err + } + return statusFromProto(resp), nil +} + +// consumeForSwitch decides, for an incoming push during a profile switch, +// whether to suppress it (suppress) and whether the switch landed in a state +// needing the SSO flow (triggerLogin: NeedsLogin / SessionExpired / LoginFailed). +// +// The two flags have different lifetimes: suppression clears on Connecting, but +// the trigger watcher must survive past it to catch the eventual NeedsLogin — +// daemon-side StatusConnecting fires before loginToManagement, which is what may +// then set StatusNeedsLogin. +func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { + s.switchMu.Lock() + defer s.switchMu.Unlock() + + now := time.Now() + if s.switchInProgress && now.After(s.switchInProgressUntil) { + s.switchInProgress = false + } + if s.switchLoginWatch && now.After(s.switchLoginWatchUntil) { + s.switchLoginWatch = false + } + + if s.switchInProgress { + switch { + case strings.EqualFold(st.Status, StatusConnecting), + strings.EqualFold(st.Status, StatusNeedsLogin), + strings.EqualFold(st.Status, StatusLoginFailed), + strings.EqualFold(st.Status, StatusSessionExpired), + strings.EqualFold(st.Status, StatusDaemonUnavailable): + // New flow has begun (Up started, or daemon refused it). + s.switchInProgress = false + default: + // Stale Connected from teardown or transient Idle: suppress so the + // optimistic Connecting stays painted. Login-watch stays armed. + return true, false + } + } + + if s.switchLoginWatch { + switch { + case strings.EqualFold(st.Status, StatusNeedsLogin), + strings.EqualFold(st.Status, StatusLoginFailed), + strings.EqualFold(st.Status, StatusSessionExpired): + // SSO-needed terminal: trigger browser-login without a second click. + s.switchLoginWatch = false + return false, true + case strings.EqualFold(st.Status, StatusConnected), + strings.EqualFold(st.Status, StatusIdle), + strings.EqualFold(st.Status, StatusDaemonUnavailable): + // Terminal but not SSO — disarm without triggering. + s.switchLoginWatch = false + } + } + + return false, false +} + +// statusStreamLoop subscribes to SubscribeStatus and re-emits each snapshot on +// the Wails event bus. The first message is the current snapshot; later ones +// fire on connection-state changes only — no polling. +func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { + defer s.streamWg.Done() + + bo := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: time.Second, + RandomizationFactor: backoff.DefaultRandomizationFactor, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + + // unavailable fires the synthetic event once per outage, not on every retry. + unavailable := false + emitUnavailable := func() { + if unavailable { + return + } + unavailable = true + s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable}) + } + + op := func() error { + return s.subscribeAndStreamStatus(ctx, &unavailable, emitUnavailable) + } + + if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { + log.Errorf("status stream ended: %v", err) + } +} + +// subscribeAndStreamStatus is one attempt of the status backoff loop: open +// SubscribeStatus and re-emit every snapshot until it errors. A daemon- +// unreachable failure also flips the synthetic-unavailable signal. +func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error { + cli, err := s.conn.Client() + if err != nil { + emitUnavailable() + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("subscribe status: %w", err) + } + for { + resp, err := stream.Recv() + if err != nil { + return s.handleStatusRecvErr(ctx, err, emitUnavailable) + } + *unavailable = false + s.emitStatus(statusFromProto(resp)) + } +} + +// handleStatusRecvErr maps a SubscribeStatus Recv error into the backoff loop's +// return: ctx cancellation stops the loop, an unreachable socket flips the +// synthetic-unavailable signal, everything else is retryable. +func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUnavailable func()) error { + if ctx.Err() != nil { + return ctx.Err() + } + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("status stream recv: %w", err) +} + +// emitStatus pushes a snapshot to the frontend, dropping the transient +// stale-Connected / Idle pushes that occur mid profile switch. +func (s *DaemonFeed) emitStatus(st Status) { + log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) + suppress, triggerLogin := s.consumeForSwitch(st) + if suppress { + log.Debugf("suppressing status=%q during profile switch", st.Status) + return + } + s.emitter.Emit(EventStatusSnapshot, st) + if triggerLogin { + s.emitter.Emit(EventTriggerLogin) + } +} + +// toastStreamLoop subscribes to SubscribeEvents and re-emits every SystemEvent +// on the Wails event bus. Local name differs from the RPC so the file's two +// streams aren't both called streamLoop. +func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { + defer s.streamWg.Done() + + bo := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: time.Second, + RandomizationFactor: backoff.DefaultRandomizationFactor, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + + op := func() error { + return s.subscribeAndStreamEvents(ctx) + } + + if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { + log.Errorf("event stream ended: %v", err) + } +} + +// subscribeAndStreamEvents is one attempt of the event backoff loop: open +// SubscribeEvents and fan out every SystemEvent until it errors. +func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeEvents(ctx, &proto.SubscribeRequest{}) + if err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + // Re-register the GUI log path on every (re)connect so a daemon restart + // re-learns it. Best-effort — a failure must not abort the stream. Done even + // when file logging is off, so the path is known ahead of any debug toggle. + if s.logCtl != nil && s.logCtl.Path() != "" { + if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil { + log.Warnf("register UI log path: %v", err) + } + } + for { + ev, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("stream recv: %w", err) + } + s.dispatchSystemEvent(ev) + } +} + +// dispatchSystemEvent fans one daemon SystemEvent out to the frontend +// notification stream, the typed session-warning event (when the metadata +// carries one), and the updater holder (when present). +func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) { + se := systemEventFromProto(ev) + log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage) + // Internal refresh signal (CLI-driven profile add/remove), not a notification: + // translate and stop so it never reaches Recent Events or fires an OS toast. + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged { + s.emitter.Emit(EventProfileChanged, ProfileRef{}) + return + } + // Internal control signal driving the GUI file log on/off — handle and stop + // so it never reaches Recent Events or toasts. + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged { + if s.logCtl != nil { + s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey]) + } + return + } + s.emitter.Emit(EventDaemonNotification, se) + if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { + s.emitter.Emit(EventSessionWarning, warn) + } + if s.updater != nil { + s.updater.OnSystemEvent(ev) + } +} + +func statusFromProto(resp *proto.StatusResponse) Status { + full := resp.GetFullStatus() + mgmt := full.GetManagementState() + sig := full.GetSignalState() + local := full.GetLocalPeerState() + + st := Status{ + Status: resp.GetStatus(), + DaemonVersion: resp.GetDaemonVersion(), + NetworksRevision: full.GetNetworksRevision(), + Management: PeerLink{ + URL: mgmt.GetURL(), + Connected: mgmt.GetConnected(), + Error: mgmt.GetError(), + }, + Signal: PeerLink{ + URL: sig.GetURL(), + Connected: sig.GetConnected(), + Error: sig.GetError(), + }, + Local: LocalPeer{ + IP: local.GetIP(), + IPv6: local.GetIpv6(), + PubKey: local.GetPubKey(), + Fqdn: local.GetFqdn(), + Networks: append([]string{}, local.GetNetworks()...), + }, + } + + for _, p := range full.GetPeers() { + st.Peers = append(st.Peers, PeerStatus{ + IP: p.GetIP(), + IPv6: p.GetIpv6(), + PubKey: p.GetPubKey(), + ConnStatus: p.GetConnStatus(), + ConnStatusUpdateUnix: p.GetConnStatusUpdate().GetSeconds(), + Relayed: p.GetRelayed(), + LocalIceCandidateType: p.GetLocalIceCandidateType(), + RemoteIceCandidateType: p.GetRemoteIceCandidateType(), + LocalIceCandidateEndpoint: p.GetLocalIceCandidateEndpoint(), + RemoteIceCandidateEndpoint: p.GetRemoteIceCandidateEndpoint(), + Fqdn: p.GetFqdn(), + BytesRx: p.GetBytesRx(), + BytesTx: p.GetBytesTx(), + LatencyMs: p.GetLatency().AsDuration().Milliseconds(), + RelayAddress: p.GetRelayAddress(), + LastHandshakeUnix: p.GetLastWireguardHandshake().GetSeconds(), + RosenpassEnabled: p.GetRosenpassEnabled(), + Networks: append([]string{}, p.GetNetworks()...), + }) + } + for _, e := range full.GetEvents() { + st.Events = append(st.Events, systemEventFromProto(e)) + } + if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + t := ts.AsTime().UTC() + st.SessionExpiresAt = &t + } + return st +} + +func systemEventFromProto(e *proto.SystemEvent) SystemEvent { + out := SystemEvent{ + ID: e.GetId(), + Severity: strings.ToLower(strings.TrimPrefix(e.GetSeverity().String(), "SystemEvent_")), + Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")), + Message: e.GetMessage(), + UserMessage: e.GetUserMessage(), + Metadata: map[string]string{}, + } + if ts := e.GetTimestamp(); ts != nil { + out.Timestamp = ts.GetSeconds() + } + for k, v := range e.GetMetadata() { + out.Metadata[k] = v + } + return out +} + +// isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't +// answering, versus the daemon responding with an application-level code. Only +// the former should flip the tray to "Not running" — a daemon returning e.g. +// FailedPrecondition is alive and must not be reported as down. +func isDaemonUnreachable(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return true + } + return st.Code() == codes.Unavailable +} diff --git a/client/ui/services/debug.go b/client/ui/services/debug.go new file mode 100644 index 000000000..034086747 --- /dev/null +++ b/client/ui/services/debug.go @@ -0,0 +1,136 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/version" +) + +type DebugBundleParams struct { + Anonymize bool `json:"anonymize"` + SystemInfo bool `json:"systemInfo"` + UploadURL string `json:"uploadUrl"` + LogFileCount uint32 `json:"logFileCount"` +} + +// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload +// success, UploadFailureReason on upload failure. +type DebugBundleResult struct { + Path string `json:"path"` + UploadedKey string `json:"uploadedKey"` + UploadFailureReason string `json:"uploadFailureReason"` +} + +// LogLevel carries a logrus level name: "error", "warn", "info", "debug", "trace". +type LogLevel struct { + Level string `json:"level"` +} + +type Debug struct { + conn DaemonConn +} + +func NewDebug(conn DaemonConn) *Debug { + return &Debug{conn: conn} +} + +func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) { + cli, err := s.conn.Client() + if err != nil { + return DebugBundleResult{}, err + } + resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{ + Anonymize: p.Anonymize, + SystemInfo: p.SystemInfo, + UploadURL: p.UploadURL, + LogFileCount: p.LogFileCount, + CliVersion: version.NetbirdVersion(), + }) + if err != nil { + return DebugBundleResult{}, err + } + return DebugBundleResult{ + Path: resp.GetPath(), + UploadedKey: resp.GetUploadedKey(), + UploadFailureReason: resp.GetUploadFailureReason(), + }, nil +} + +func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) { + cli, err := s.conn.Client() + if err != nil { + return LogLevel{}, err + } + resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{}) + if err != nil { + return LogLevel{}, err + } + return LogLevel{Level: resp.GetLevel().String()}, nil +} + +// RevealFile opens the OS file manager focused on path. Needed because Wails' +// Browser.OpenURL refuses non-http(s) schemes like file://. +func (s *Debug) RevealFile(_ context.Context, path string) error { + if path == "" { + return fmt.Errorf("empty path") + } + return revealFile(path) +} + +// RegisterUILog reports the GUI log path to the daemon for bundle collection; +// the daemon runs as root and can't resolve the user's config dir. Called on +// each daemon (re)connect. +func (s *Debug) RegisterUILog(ctx context.Context, path string) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: path}) + return err +} + +func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.StartBundleCaptureRequest{} + if timeoutSeconds > 0 { + req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second) + } + _, err = cli.StartBundleCapture(ctx, req) + return err +} + +func (s *Debug) StopBundleCapture(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}) + return err +} + +func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + // proto.LogLevel_value keys are upper-case enum names; callers pass + // lowercase logrus names. Upper-case before lookup or a valid level + // silently falls through to INFO. + level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)] + if !ok { + level = int32(proto.LogLevel_INFO) + } + _, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}) + return err +} diff --git a/client/ui/services/debug_reveal_other.go b/client/ui/services/debug_reveal_other.go new file mode 100644 index 000000000..bef0a58bb --- /dev/null +++ b/client/ui/services/debug_reveal_other.go @@ -0,0 +1,20 @@ +//go:build !android && !ios && !freebsd && !js && !windows + +package services + +import ( + "os/exec" + "path/filepath" + "runtime" +) + +// revealFile opens the OS file manager focused on path. +func revealFile(path string) error { + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + cmd = exec.Command("open", "-R", path) + } else { + cmd = exec.Command("xdg-open", filepath.Dir(path)) + } + return cmd.Start() +} diff --git a/client/ui/services/debug_reveal_windows.go b/client/ui/services/debug_reveal_windows.go new file mode 100644 index 000000000..790d23fab --- /dev/null +++ b/client/ui/services/debug_reveal_windows.go @@ -0,0 +1,54 @@ +package services + +import ( + "fmt" + "os/exec" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// SW_SHOWNORMAL for ShellExecuteW's nShowCmd. +const swShowNormal = 1 + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecute = shell32.NewProc("ShellExecuteW") +) + +// revealFile opens Explorer focused on path. The debug bundle is written by the +// daemon (running as SYSTEM) into C:\Windows\SystemTemp, whose ACL denies the +// logged-in user. A plain "explorer /select" can't traverse it, so we elevate +// via the ShellExecuteW "runas" verb (UAC prompt) — the elevated Explorer can +// read the folder and highlight the file. +func revealFile(path string) error { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return fmt.Errorf("encode verb: %w", err) + } + file, err := windows.UTF16PtrFromString("explorer.exe") + if err != nil { + return fmt.Errorf("encode file: %w", err) + } + params, err := windows.UTF16PtrFromString("/select," + path) + if err != nil { + return fmt.Errorf("encode params: %w", err) + } + + // ShellExecuteW returns an HINSTANCE; a value <=32 is an error code. + ret, _, _ := procShellExecute.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(file)), + uintptr(unsafe.Pointer(params)), + 0, + swShowNormal, + ) + if ret <= 32 { + // Elevation declined or failed: fall back to an unelevated reveal of the + // parent directory so the user at least lands near the bundle. + return exec.Command("explorer", filepath.Dir(path)).Start() //nolint:gosec + } + return nil +} diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go new file mode 100644 index 000000000..f1679e764 --- /dev/null +++ b/client/ui/services/errors.go @@ -0,0 +1,133 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "encoding/json" + "strings" + + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle. +type ErrorTranslator interface { + Translate(lang i18n.LanguageCode, key string, args ...string) string +} + +// LanguagePreference reports the current UI language; runtime impl is *preferences.Store. +type LanguagePreference interface { + Get() preferences.UIPreferences +} + +// ClientError is a structured error returned to the frontend. The frontend +// translates Code via i18n; Short is an English fallback; Long carries the +// unwrapped daemon message. +type ClientError struct { + Code string `json:"code"` + Short string `json:"short"` + Long string `json:"long"` +} + +// Error returns the short message for plain Go callers. +func (e *ClientError) Error() string { + if e == nil { + return "" + } + return e.Short +} + +// MarshalJSON emits the struct so the Wails binding sends an object, not the +// default "error: ..." string. +func (e *ClientError) MarshalJSON() ([]byte, error) { + if e == nil { + return []byte("null"), nil + } + type alias ClientError + return json.Marshal((*alias)(e)) +} + +// errorClassifier maps gRPC errors to a localised ClientError. Shared by the +// daemon-facing services so the frontend gets a clean short message instead of +// the wrapped gRPC chain. +type errorClassifier struct { + translator ErrorTranslator + prefs LanguagePreference +} + +// classify maps a gRPC error to a ClientError by matching known substrings to a +// stable code. A missing locale entry surfaces as a visible "error." +// string — a deliberate fail-loud signal to update the bundle. +func (c errorClassifier) classify(err error) *ClientError { + if err == nil { + return nil + } + + msg := err.Error() + grpcCode := gcodes.Unknown + if st, ok := gstatus.FromError(err); ok { + msg = st.Message() + grpcCode = st.Code() + } + lower := strings.ToLower(msg) + + code := "unknown" + switch { + case strings.Contains(lower, "token used before issued"), + strings.Contains(lower, "token is not valid yet"): + code = "jwt_clock_skew" + case strings.Contains(lower, "token is expired"), + strings.Contains(lower, "token has expired"): + code = "jwt_expired" + case strings.Contains(lower, "token signature is invalid"): + code = "jwt_signature_invalid" + case strings.Contains(lower, "peer login has expired"): + code = "session_expired" + case strings.Contains(lower, "invalid setup-key"), + strings.Contains(lower, "invalid setup key"): + code = "invalid_setup_key" + case strings.Contains(lower, "permission denied"): + code = "permission_denied" + case strings.Contains(lower, "no connection could be made"), + strings.Contains(lower, "connection refused"), + strings.Contains(lower, "context deadline exceeded"): + code = "daemon_unreachable" + } + + // Fall back to the gRPC status code when the message didn't match a known + // substring — the daemon now forwards the innermost code with a clean desc + // that no longer contains the English marker text. + if code == "unknown" { + switch grpcCode { + case gcodes.PermissionDenied: + code = "permission_denied" + case gcodes.Unavailable, gcodes.DeadlineExceeded: + code = "daemon_unreachable" + } + } + + return &ClientError{ + Code: code, + Short: c.translateShort(code), + Long: msg, + } +} + +// translateShort resolves the localised short message for code, returning the +// bare "error." key when no translation is available so the gap stays visible. +func (c errorClassifier) translateShort(code string) string { + key := "error." + code + if c.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if c.prefs != nil { + if pref := c.prefs.Get().Language; pref != "" { + lang = pref + } + } + return c.translator.Translate(lang, key) +} diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go new file mode 100644 index 000000000..2f8f3d039 --- /dev/null +++ b/client/ui/services/errors_test.go @@ -0,0 +1,50 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +func TestErrorClassifier_Classify(t *testing.T) { + c := errorClassifier{} // nil translator → Short is the bare "error." key + + t.Run("permission denied by gRPC code with a clean desc", func(t *testing.T) { + // The daemon now forwards the innermost status: code + clean desc that + // no longer carries the English "permission denied" marker. + err := gstatus.Error(gcodes.PermissionDenied, "peer is already registered by a different User or a Setup Key") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "permission_denied", ce.Code) + require.Equal(t, "error.permission_denied", ce.Short) + require.Equal(t, "peer is already registered by a different User or a Setup Key", ce.Long) + }) + + t.Run("substring match still wins for unclassified codes", func(t *testing.T) { + err := gstatus.Error(gcodes.Unknown, "peer login has expired") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "session_expired", ce.Code) + }) + + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { + ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) + require.Equal(t, "daemon_unreachable", ce.Code) + }) + + t.Run("unmatched stays unknown", func(t *testing.T) { + ce := c.classify(errors.New("something odd")) + require.Equal(t, "unknown", ce.Code) + }) + + t.Run("nil error", func(t *testing.T) { + require.Nil(t, c.classify(nil)) + }) +} diff --git a/client/ui/services/foreground_other.go b/client/ui/services/foreground_other.go new file mode 100644 index 000000000..e6e16a21a --- /dev/null +++ b/client/ui/services/foreground_other.go @@ -0,0 +1,11 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func raiseToForeground(w *application.WebviewWindow) { + if w != nil { + w.Focus() + } +} diff --git a/client/ui/services/foreground_windows.go b/client/ui/services/foreground_windows.go new file mode 100644 index 000000000..215f30093 --- /dev/null +++ b/client/ui/services/foreground_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package services + +import ( + "syscall" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +var procAttachThreadInput = syscall.NewLazyDLL("user32.dll").NewProc("AttachThreadInput") + +func attachThreadInput(attach, attachTo w32.HANDLE, on bool) { + var flag uintptr + if on { + flag = 1 + } + _, _, _ = procAttachThreadInput.Call(uintptr(attach), uintptr(attachTo), flag) +} + +func raiseToForeground(w *application.WebviewWindow) { + if w == nil { + return + } + application.InvokeSync(func() { + ptr := w.NativeWindow() + if ptr == nil { + return + } + hwnd := w32.HWND(uintptr(ptr)) + + fgThread, _ := w32.GetWindowThreadProcessId(w32.GetForegroundWindow()) + appThread := w32.GetCurrentThreadId() + if fgThread != appThread { + attachThreadInput(fgThread, appThread, true) + defer attachThreadInput(fgThread, appThread, false) + } + w32.ShowWindow(hwnd, w32.SW_SHOW) + w32.BringWindowToTop(hwnd) + w32.SetForegroundWindow(hwnd) + }) +} diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go new file mode 100644 index 000000000..4ba979ad0 --- /dev/null +++ b/client/ui/services/forwarding.go @@ -0,0 +1,83 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +// PortRange is a port range; both ends are inclusive. +type PortRange struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` +} + +// PortInfo holds exactly one of Port or Range (the daemon's oneof). +type PortInfo struct { + Port *uint32 `json:"port,omitempty"` + Range *PortRange `json:"range,omitempty"` +} + +// ForwardingRule is one entry from the daemon's reverse-proxy table. +type ForwardingRule struct { + Protocol string `json:"protocol"` + DestinationPort PortInfo `json:"destinationPort"` + TranslatedAddress string `json:"translatedAddress"` + TranslatedHostname string `json:"translatedHostname"` + TranslatedPort PortInfo `json:"translatedPort"` +} + +// Forwarding groups the daemon RPCs that surface exposed/forwarded services. +type Forwarding struct { + conn DaemonConn +} + +func NewForwarding(conn DaemonConn) *Forwarding { + return &Forwarding{conn: conn} +} + +func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) + if err != nil { + return nil, err + } + out := make([]ForwardingRule, 0, len(resp.GetRules())) + for _, r := range resp.GetRules() { + out = append(out, forwardingRuleFromProto(r)) + } + return out, nil +} + +func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { + return ForwardingRule{ + Protocol: r.GetProtocol(), + DestinationPort: portInfoFromProto(r.GetDestinationPort()), + TranslatedAddress: r.GetTranslatedAddress(), + TranslatedHostname: r.GetTranslatedHostname(), + TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), + } +} + +func portInfoFromProto(p *proto.PortInfo) PortInfo { + if p == nil { + return PortInfo{} + } + switch sel := p.GetPortSelection().(type) { + case *proto.PortInfo_Port: + port := sel.Port + return PortInfo{Port: &port} + case *proto.PortInfo_Range_: + r := sel.Range + if r == nil { + return PortInfo{} + } + return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} + } + return PortInfo{} +} diff --git a/client/ui/services/i18n.go b/client/ui/services/i18n.go new file mode 100644 index 000000000..755bbe02d --- /dev/null +++ b/client/ui/services/i18n.go @@ -0,0 +1,30 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/i18n" +) + +// I18n is the Wails-bound facade over i18n.Bundle; the translation logic lives +// in client/ui/i18n. +type I18n struct { + bundle *i18n.Bundle +} + +func NewI18n(bundle *i18n.Bundle) *I18n { + return &I18n{bundle: bundle} +} + +// Languages returns the shipped locales. +func (s *I18n) Languages(_ context.Context) ([]i18n.Language, error) { + return s.bundle.Languages(), nil +} + +// Bundle returns the full key->text map so the React side can drive its own +// translation library off the same source bundles. +func (s *I18n) Bundle(_ context.Context, code i18n.LanguageCode) (map[string]string, error) { + return s.bundle.BundleFor(code) +} diff --git a/client/ui/services/network.go b/client/ui/services/network.go new file mode 100644 index 000000000..c2d127494 --- /dev/null +++ b/client/ui/services/network.go @@ -0,0 +1,88 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +type Network struct { + ID string `json:"id"` + Range string `json:"range"` + Selected bool `json:"selected"` + Domains []string `json:"domains"` + ResolvedIPs map[string][]string `json:"resolvedIps"` +} + +// SelectNetworksParams: All targets every available network; Append merges IDs into the existing selection. +type SelectNetworksParams struct { + NetworkIDs []string `json:"networkIds"` + Append bool `json:"append"` + All bool `json:"all"` +} + +type Networks struct { + conn DaemonConn +} + +func NewNetworks(conn DaemonConn) *Networks { + return &Networks{conn: conn} +} + +func (s *Networks) List(ctx context.Context) ([]Network, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ListNetworks(ctx, &proto.ListNetworksRequest{}) + if err != nil { + return nil, err + } + out := make([]Network, 0, len(resp.GetRoutes())) + for _, n := range resp.GetRoutes() { + out = append(out, networkFromProto(n)) + } + return out, nil +} + +func (s *Networks) Select(ctx context.Context, p SelectNetworksParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.SelectNetworks(ctx, &proto.SelectNetworksRequest{ + NetworkIDs: p.NetworkIDs, + Append: p.Append, + All: p.All, + }) + return err +} + +func (s *Networks) Deselect(ctx context.Context, p SelectNetworksParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.DeselectNetworks(ctx, &proto.SelectNetworksRequest{ + NetworkIDs: p.NetworkIDs, + Append: p.Append, + All: p.All, + }) + return err +} + +func networkFromProto(n *proto.Network) Network { + resolved := make(map[string][]string, len(n.GetResolvedIPs())) + for k, v := range n.GetResolvedIPs() { + resolved[k] = append([]string{}, v.GetIps()...) + } + return Network{ + ID: n.GetID(), + Range: n.GetRange(), + Selected: n.GetSelected(), + Domains: append([]string{}, n.GetDomains()...), + ResolvedIPs: resolved, + } +} diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go new file mode 100644 index 000000000..dae086de8 --- /dev/null +++ b/client/ui/services/preferences.go @@ -0,0 +1,36 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// Preferences is the Wails-bound facade over preferences.Store; the context.Context-first +// signatures are what the binding generator requires. +type Preferences struct { + store *preferences.Store +} + +func NewPreferences(store *preferences.Store) *Preferences { + return &Preferences{store: store} +} + +func (s *Preferences) Get(_ context.Context) (preferences.UIPreferences, error) { + return s.store.Get(), nil +} + +func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) error { + return s.store.SetLanguage(lang) +} + +func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error { + return s.store.SetViewMode(mode) +} + +func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error { + return s.store.SetOnboardingCompleted(done) +} diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go new file mode 100644 index 000000000..09468c9df --- /dev/null +++ b/client/ui/services/profile.go @@ -0,0 +1,179 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "os/user" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type Profile struct { + // ID is the daemon-generated on-disk identity of the profile. Display + // names can collide and be renamed, so the ID is the stable handle the + // daemon resolves switch/remove/logout requests against. + ID string `json:"id"` + Name string `json:"name"` + IsActive bool `json:"isActive"` + // Email is read from the user-owned per-profile state file (CLI writes it + // after SSO login), not via ListProfiles: the daemon runs as root and can't + // reach it, while the UI runs as the logged-in user. + Email string `json:"email"` +} + +type ProfileRef struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +type ActiveProfile struct { + // ID is the active profile's stable on-disk identity. Use it (not the + // display name) as the handle for daemon requests and active-profile + // comparisons, since names can collide. + ID string `json:"id"` + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// RenameProfileParams selects a profile by handle and carries its new display +// name. +type RenameProfileParams struct { + // Handle selects the profile to rename: an exact ID, a unique ID prefix, + // or a unique display name. The daemon resolves it server-side. + Handle string `json:"handle"` + // NewName is the new free-form display name. The daemon sanitizes it + // (strips control characters, trims, caps length) but keeps spaces, emoji, + // punctuation, and non-ASCII letters. + NewName string `json:"newName"` + + Username string `json:"username"` +} + +type Profiles struct { + conn DaemonConn +} + +func NewProfiles(conn DaemonConn) *Profiles { + return &Profiles{conn: conn} +} + +// Username returns the OS username the daemon expects for profile lookups. +func (s *Profiles) Username() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + return u.Username, nil +} + +func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return nil, err + } + pm := profilemanager.NewProfileManager() + out := make([]Profile, 0, len(resp.GetProfiles())) + for _, p := range resp.GetProfiles() { + prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()} + if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil { + prof.Email = state.Email + } + out = append(out, prof) + } + return out, nil +} + +func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) { + cli, err := s.conn.Client() + if err != nil { + return ActiveProfile{}, err + } + resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return ActiveProfile{}, err + } + return ActiveProfile{ + ID: resp.GetId(), + ProfileName: resp.GetProfileName(), + Username: resp.GetUsername(), + }, nil +} + +// Switch sends a profile switch to the daemon and returns the resolved +// on-disk ID of the now-active profile. ProfileName is treated as a handle +// (exact ID, unique ID prefix, or unique display name); the daemon resolves +// it server-side and echoes back the canonical ID. +func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + req := &proto.SwitchProfileRequest{} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + resp, err := cli.SwitchProfile(ctx, req) + if err != nil { + return "", err + } + return resp.GetId(), nil +} + +// Add creates a profile with the given display name and returns its +// daemon-generated on-disk ID, so callers can address the new profile by ID +// (e.g. to write config or switch to it) without re-resolving the name. +func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return "", err + } + return resp.GetId(), nil +} + +func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + return err +} + +// Rename changes a profile's display name. The on-disk ID is unaffected, so +// the active profile and any ID-based references stay valid (the default +// profile can be renamed too — only its display name changes). Returns the +// profile's previous display name as confirmation. +func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{ + Username: p.Username, + Handle: p.Handle, + NewProfileName: p.NewName, + }) + if err != nil { + return "", err + } + return resp.GetOldProfileName(), nil +} diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go new file mode 100644 index 000000000..c27b62d92 --- /dev/null +++ b/client/ui/services/profileswitcher.go @@ -0,0 +1,92 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ProfileSwitcher holds the reconnect policy shared by the tray and React +// frontend so both flip profiles identically. The policy keys off prevStatus +// from DaemonFeed.Get at SwitchActive entry: +// +// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint. +// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login. +// Idle → Switch only. +type ProfileSwitcher struct { + profiles *Profiles + connection *Connection + feed *DaemonFeed +} + +func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *DaemonFeed) *ProfileSwitcher { + return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed} +} + +// SwitchActive switches to the named profile applying the reconnect policy. +func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { + prevStatus := "" + if st, err := s.feed.Get(ctx); err == nil { + prevStatus = st.Status + } else { + log.Warnf("profileswitcher: get status: %v", err) + } + + wasActive := strings.EqualFold(prevStatus, StatusConnected) || + strings.EqualFold(prevStatus, StatusConnecting) + needsDown := wasActive || + strings.EqualFold(prevStatus, StatusNeedsLogin) || + strings.EqualFold(prevStatus, StatusLoginFailed) || + strings.EqualFold(prevStatus, StatusSessionExpired) + + log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v", + p.ProfileName, prevStatus, wasActive, needsDown) + + // Optimistic Connecting paint only when wasActive: those prevStatuses emit + // stale Connected + transient Idle pushes during Down that must be + // suppressed until Up resumes the stream (see DaemonFeed suppression table). + if wasActive { + s.feed.BeginProfileSwitch() + } + + resolvedID, err := s.profiles.Switch(ctx, p) + if err != nil { + return fmt.Errorf("switch profile %q: %w", p.ProfileName, err) + } + + // Mirror into the user-side ProfileManager state: the CLI's `netbird up` + // reads this file and sends the ID back in the Up RPC, so if it diverges + // the daemon reverts the UI switch on the next CLI `up`. Best-effort — the + // daemon is authoritative; a failure only leaves the CLI's view stale. + // Use the daemon-resolved ID rather than the handle we sent, since the + // on-disk state is keyed by ID, not display name. + if err := profilemanager.NewProfileManager().SwitchProfile(profilemanager.ID(resolvedID)); err != nil { + log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err) + } + + if needsDown { + if err := s.connection.Down(ctx); err != nil { + log.Errorf("profileswitcher: Down: %v", err) + } + } + + if wasActive { + if err := s.connection.Up(ctx, UpParams(p)); err != nil { + return fmt.Errorf("reconnect %q: %w", p.ProfileName, err) + } + } + + // The daemon emits no profile event, so fan out ourselves or the React + // ProfileContext stays on the old profile after a tray-initiated switch. + if s.feed != nil && s.feed.emitter != nil { + s.feed.emitter.Emit(EventProfileChanged, p) + } + + return nil +} diff --git a/client/ui/services/session.go b/client/ui/services/session.go new file mode 100644 index 000000000..facb8e898 --- /dev/null +++ b/client/ui/services/session.go @@ -0,0 +1,48 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/authsession" +) + +// Re-exports so generated bindings reference services.* without importing authsession. +type ( + ExtendStartParams = authsession.ExtendStartParams + ExtendStartResult = authsession.ExtendStartResult + ExtendWaitParams = authsession.ExtendWaitParams + ExtendResult = authsession.ExtendResult +) + +// Session wraps authsession.Session, exposing only the subset the React frontend +// calls; the tray uses authsession.Session directly, keeping the generated TS surface minimal. +type Session struct { + inner *authsession.Session + classifier errorClassifier +} + +// NewSession wraps inner; the caller retains ownership and may use it directly. +// translator or prefs may be nil, in which case errors fall back to the bare code key. +func NewSession(inner *authsession.Session, translator ErrorTranslator, prefs LanguagePreference) *Session { + return &Session{inner: inner, classifier: errorClassifier{translator: translator, prefs: prefs}} +} + +// RequestExtend starts the SSO session-extension flow; the result carries the verification URI to open. +func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { + res, err := s.inner.RequestExtend(ctx, p) + if err != nil { + return ExtendStartResult{}, s.classifier.classify(err) + } + return res, nil +} + +// WaitExtend blocks until the RequestExtend flow completes; the deadline is nil when the peer is ineligible. +func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { + res, err := s.inner.WaitExtend(ctx, p) + if err != nil { + return ExtendResult{}, s.classifier.classify(err) + } + return res, nil +} diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go new file mode 100644 index 000000000..1c16795ae --- /dev/null +++ b/client/ui/services/settings.go @@ -0,0 +1,256 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "reflect" + + "github.com/netbirdio/netbird/client/proto" +) + +type MDMFields struct { + ManagementURL string `json:"managementURL"` + PreSharedKey bool `json:"preSharedKey"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH *bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + DisableAdvancedView bool `json:"disableAdvancedView"` +} + +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +type Restrictions struct { + MDM MDMFields `json:"mdm"` + Features Features `json:"features"` +} + +type ConfigParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +type Config struct { + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + ConfigFile string `json:"configFile"` + LogFile string `json:"logFile"` + PreSharedKeySet bool `json:"preSharedKeySet"` + InterfaceName string `json:"interfaceName"` + WireguardPort int64 `json:"wireguardPort"` + MTU int64 `json:"mtu"` + DisableAutoConnect bool `json:"disableAutoConnect"` + ServerSSHAllowed bool `json:"serverSshAllowed"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableNotifications bool `json:"disableNotifications"` + BlockInbound bool `json:"blockInbound"` + NetworkMonitor bool `json:"networkMonitor"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + DisableDNS bool `json:"disableDns"` + DisableIPv6 bool `json:"disableIpv6"` + BlockLANAccess bool `json:"blockLanAccess"` + EnableSSHRoot bool `json:"enableSshRoot"` + EnableSSHSFTP bool `json:"enableSshSftp"` + EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"` + EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"` + DisableSSHAuth bool `json:"disableSshAuth"` + SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"` +} + +// SetConfigParams is a partial update — only non-nil pointer fields are sent +// to the daemon; nil fields are preserved. +type SetConfigParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + InterfaceName *string `json:"interfaceName,omitempty"` + WireguardPort *int64 `json:"wireguardPort,omitempty"` + MTU *int64 `json:"mtu,omitempty"` + PreSharedKey *string `json:"preSharedKey,omitempty"` + DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"` + ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"` + RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"` + RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"` + DisableNotifications *bool `json:"disableNotifications,omitempty"` + BlockInbound *bool `json:"blockInbound,omitempty"` + NetworkMonitor *bool `json:"networkMonitor,omitempty"` + DisableClientRoutes *bool `json:"disableClientRoutes,omitempty"` + DisableServerRoutes *bool `json:"disableServerRoutes,omitempty"` + DisableDNS *bool `json:"disableDns,omitempty"` + DisableIPv6 *bool `json:"disableIpv6,omitempty"` + DisableFirewall *bool `json:"disableFirewall,omitempty"` + BlockLANAccess *bool `json:"blockLanAccess,omitempty"` + EnableSSHRoot *bool `json:"enableSshRoot,omitempty"` + EnableSSHSFTP *bool `json:"enableSshSftp,omitempty"` + EnableSSHLocalPortForwarding *bool `json:"enableSshLocalPortForwarding,omitempty"` + EnableSSHRemotePortForwarding *bool `json:"enableSshRemotePortForwarding,omitempty"` + DisableSSHAuth *bool `json:"disableSshAuth,omitempty"` + SSHJWTCacheTTL *int32 `json:"sshJwtCacheTtl,omitempty"` +} + +type Settings struct { + conn DaemonConn +} + +func NewSettings(conn DaemonConn) *Settings { + return &Settings{conn: conn} +} + +func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) { + cli, err := s.conn.Client() + if err != nil { + return Config{}, err + } + resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return Config{}, err + } + return Config{ + ManagementURL: resp.GetManagementUrl(), + AdminURL: resp.GetAdminURL(), + ConfigFile: resp.GetConfigFile(), + LogFile: resp.GetLogFile(), + PreSharedKeySet: resp.GetPreSharedKey() != "", + InterfaceName: resp.GetInterfaceName(), + WireguardPort: resp.GetWireguardPort(), + MTU: resp.GetMtu(), + DisableAutoConnect: resp.GetDisableAutoConnect(), + ServerSSHAllowed: resp.GetServerSSHAllowed(), + RosenpassEnabled: resp.GetRosenpassEnabled(), + RosenpassPermissive: resp.GetRosenpassPermissive(), + DisableNotifications: resp.GetDisableNotifications(), + BlockInbound: resp.GetBlockInbound(), + NetworkMonitor: resp.GetNetworkMonitor(), + DisableClientRoutes: resp.GetDisableClientRoutes(), + DisableServerRoutes: resp.GetDisableServerRoutes(), + DisableDNS: resp.GetDisableDns(), + DisableIPv6: resp.GetDisableIpv6(), + BlockLANAccess: resp.GetBlockLanAccess(), + EnableSSHRoot: resp.GetEnableSSHRoot(), + EnableSSHSFTP: resp.GetEnableSSHSFTP(), + EnableSSHLocalPortForwarding: resp.GetEnableSSHLocalPortForwarding(), + EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(), + DisableSSHAuth: resp.GetDisableSSHAuth(), + SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(), + }, nil +} + +func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.SetConfigRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + ManagementUrl: p.ManagementURL, + AdminURL: p.AdminURL, + InterfaceName: p.InterfaceName, + WireguardPort: p.WireguardPort, + Mtu: p.MTU, + OptionalPreSharedKey: p.PreSharedKey, + DisableAutoConnect: p.DisableAutoConnect, + ServerSSHAllowed: p.ServerSSHAllowed, + RosenpassEnabled: p.RosenpassEnabled, + RosenpassPermissive: p.RosenpassPermissive, + DisableNotifications: p.DisableNotifications, + BlockInbound: p.BlockInbound, + NetworkMonitor: p.NetworkMonitor, + DisableClientRoutes: p.DisableClientRoutes, + DisableServerRoutes: p.DisableServerRoutes, + DisableDns: p.DisableDNS, + DisableIpv6: p.DisableIPv6, + DisableFirewall: p.DisableFirewall, + BlockLanAccess: p.BlockLANAccess, + EnableSSHRoot: p.EnableSSHRoot, + EnableSSHSFTP: p.EnableSSHSFTP, + EnableSSHLocalPortForwarding: p.EnableSSHLocalPortForwarding, + EnableSSHRemotePortForwarding: p.EnableSSHRemotePortForwarding, + DisableSSHAuth: p.DisableSSHAuth, + SshJWTCacheTTL: p.SSHJWTCacheTTL, + } + _, err = cli.SetConfig(ctx, req) + return err +} + +func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { + cli, err := s.conn.Client() + if err != nil { + return Restrictions{}, err + } + active, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return Restrictions{}, fmt.Errorf("get active profile: %w", err) + } + cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{ + ProfileName: active.GetId(), + Username: active.GetUsername(), + }) + if err != nil { + return Restrictions{}, err + } + featResp, err := cli.GetFeatures(ctx, &proto.GetFeaturesRequest{}) + if err != nil { + return Restrictions{}, err + } + r := Restrictions{ + Features: Features{ + DisableProfiles: featResp.GetDisableProfiles(), + DisableNetworks: featResp.GetDisableNetworks(), + DisableUpdateSettings: featResp.GetDisableUpdateSettings(), + }, + } + applyMDMRestrictions(&r.MDM, cfgResp) + r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView() + return r, nil +} + +func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { + managed := cfgResp.GetMDMManagedFields() + if len(managed) == 0 { + return + } + set := make(map[string]struct{}, len(managed)) + for _, k := range managed { + set[k] = struct{}{} + } + v := reflect.ValueOf(mdm).Elem() + t := v.Type() + for i := 0; i < t.NumField(); i++ { + if v.Field(i).Kind() != reflect.Bool { + continue + } + if t.Field(i).Name == "DisableAdvancedView" { + continue + } + if _, ok := set[t.Field(i).Tag.Get("json")]; ok { + v.Field(i).SetBool(true) + } + } + if _, ok := set["managementURL"]; ok { + mdm.ManagementURL = cfgResp.GetManagementUrl() + } + if _, ok := set["allowServerSSH"]; ok { + allowed := cfgResp.GetServerSSHAllowed() + mdm.AllowServerSSH = &allowed + } +} diff --git a/client/ui/services/uilog.go b/client/ui/services/uilog.go new file mode 100644 index 000000000..0d794c137 --- /dev/null +++ b/client/ui/services/uilog.go @@ -0,0 +1,36 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + log "github.com/sirupsen/logrus" +) + +// UILog forwards frontend console output into logrus, tagging the JS origin +// as the "ui" field to stay distinct from logrus's Go-caller source. +type UILog struct{} + +func NewUILog() *UILog { return &UILog{} } + +// Log maps an unrecognised level to info; empty source becomes "unknown". +func (s *UILog) Log(_ context.Context, level, source, msg string) { + origin := "unknown" + if source != "" { + origin = source + } + entry := log.WithField("ui", origin) + switch level { + case "trace": + entry.Trace(msg) + case "debug": + entry.Debug(msg) + case "warn", "warning": + entry.Warn(msg) + case "error": + entry.Error(msg) + default: + entry.Info(msg) + } +} diff --git a/client/ui/services/update.go b/client/ui/services/update.go new file mode 100644 index 000000000..753177d45 --- /dev/null +++ b/client/ui/services/update.go @@ -0,0 +1,73 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/updater" +) + +// UpdateResult mirrors TriggerUpdateResponse. +type UpdateResult struct { + Success bool `json:"success"` + ErrorMsg string `json:"errorMsg"` +} + +// Update is the Wails-bound facade over the daemon's update RPCs. The state +// machine and push event live in client/ui/updater. +type Update struct { + conn DaemonConn + holder *updater.Holder +} + +func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update { + return &Update{conn: conn, holder: holder} +} + +func (s *Update) GetState() updater.State { + return s.holder.Get() +} + +// Quit exits the app. Scheduled off the calling goroutine so the JS caller's +// response returns before the runtime tears down. +func (s *Update) Quit() { + go func() { + time.Sleep(100 * time.Millisecond) + application.Get().Quit() + }() +} + +func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) { + cli, err := s.conn.Client() + if err != nil { + return UpdateResult{}, err + } + resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{}) + if err != nil { + return UpdateResult{}, err + } + return UpdateResult{ + Success: resp.GetSuccess(), + ErrorMsg: resp.GetErrorMsg(), + }, nil +} + +func (s *Update) GetInstallerResult(ctx context.Context) (UpdateResult, error) { + cli, err := s.conn.Client() + if err != nil { + return UpdateResult{}, err + } + resp, err := cli.GetInstallerResult(ctx, &proto.InstallerResultRequest{}) + if err != nil { + return UpdateResult{}, err + } + return UpdateResult{ + Success: resp.GetSuccess(), + ErrorMsg: resp.GetErrorMsg(), + }, nil +} diff --git a/client/ui/services/version.go b/client/ui/services/version.go new file mode 100644 index 000000000..8caea9f72 --- /dev/null +++ b/client/ui/services/version.go @@ -0,0 +1,22 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/version" +) + +// Version reports only the GUI's own version; the daemon version comes from +// the status feed's DaemonVersion field. +type Version struct{} + +func NewVersion() *Version { + return &Version{} +} + +// GUI returns the UI binary's version, stamped via ldflags ("development" if un-stamped). +func (v *Version) GUI(_ context.Context) string { + return version.NetbirdVersion() +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go new file mode 100644 index 000000000..3316dadaa --- /dev/null +++ b/client/ui/services/windowmanager.go @@ -0,0 +1,576 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "net/url" + "strconv" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// LanguageSubscriber delivers UI preference changes so window titles follow the language. +type LanguageSubscriber interface { + Subscribe() (<-chan preferences.UIPreferences, func()) +} + +// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. +const EventTriggerLogin = "trigger-login" + +// EventBrowserLoginCancel signals the user dismissed the BrowserLogin popup. +const EventBrowserLoginCancel = "browser-login:cancel" + +// EventSettingsOpen tells the mounted settings window which tab to show. +const EventSettingsOpen = "netbird:settings:open" + +var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950 + +// WindowHeight is shared by the main and Settings windows. +const WindowHeight = 660 + +// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed). +var microsoftWindowsTheme = &application.WindowTheme{ + BorderColour: u32ptr(0x00211E1C), + TitleBarColour: u32ptr(0x00211E1C), + TitleTextColour: u32ptr(0x00E9E7E4), +} + +// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar). +func MicrosoftWindowsAppearanceOptions() application.WindowsWindow { + return application.WindowsWindow{ + BackdropType: application.Mica, + Theme: application.Dark, + CustomTheme: application.ThemeSettings{ + DarkModeActive: microsoftWindowsTheme, + DarkModeInactive: microsoftWindowsTheme, + LightModeActive: microsoftWindowsTheme, + LightModeInactive: microsoftWindowsTheme, + }, + } +} + +// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout. +func AppleMacOSAppearanceOptions() application.MacWindow { + return application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropNormal, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + } +} + +// LinuxAppearanceOptions is the shared Linux chrome; opaque so fake-translucency compositors paint it. +func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { + return application.LinuxWindow{ + Icon: icon, + WindowIsTranslucent: false, + } +} + +// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog. +func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions { + return application.WebviewWindowOptions{ + Name: name, + Title: title, + Width: 360, + Height: 320, + DisableResize: true, + AlwaysOnTop: true, + Hidden: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: WindowBackgroundColour, + URL: url, + Mac: AppleMacOSAppearanceOptions(), + Windows: MicrosoftWindowsAppearanceOptions(), + Linux: LinuxAppearanceOptions(linuxIcon), + } +} + +// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created +// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on +// close, so the macOS dock-reopen handler finds no hidden window to resurrect. +type WindowManager struct { + app *application.App + mainWindow *application.WebviewWindow + translator ErrorTranslator + prefs LanguagePreference + linuxIcon []byte + settings *application.WebviewWindow + browserLogin *application.WebviewWindow + sessionExpiration *application.WebviewWindow + installProgress *application.WebviewWindow + welcome *application.WebviewWindow + errorDialog *application.WebviewWindow + // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. + hiddenForLogin []application.Window + mu sync.Mutex + // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor + // restores position; nil on full desktops so re-centering can't fight a user-moved window. + recenterOnShow func() bool +} + +// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The +// Settings window is created here (hidden) so the first OpenSettings is instant. +func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager { + s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon} + // Re-title live windows on language flip. Wired internally so the binding generator + // doesn't try to expose the interface param. + if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { + ch, _ := sub.Subscribe() + go func() { + var last i18n.LanguageCode + for p := range ch { + if p.Language == "" || p.Language == last { + continue + } + last = p.Language + s.retitleAll() + } + }() + } + s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "settings", + Title: s.title("window.title.settings"), + Width: 900, + Height: WindowHeight, + Hidden: true, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: WindowBackgroundColour, + URL: "/#/settings", + Mac: AppleMacOSAppearanceOptions(), + Windows: MicrosoftWindowsAppearanceOptions(), + Linux: LinuxAppearanceOptions(linuxIcon), + }) + // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen. + s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + e.Cancel() + s.app.Event.Emit(EventSettingsOpen, "general") + s.settings.Hide() + }) + return s +} + +// OpenSettings shows the settings window on tab (empty → General), switching tab via +// EventSettingsOpen rather than SetURL (which would remount the provider tree). +func (s *WindowManager) OpenSettings(tab string) { + target := tab + if target == "" { + target = "general" + } + s.app.Event.Emit(EventSettingsOpen, target) + s.settings.Show() + s.settings.Focus() + // Re-center (minimal-WM only; see centerWhenReady). + s.centerWhenReady(s.settings) +} + +// OpenBrowserLogin shows the SSO popup, creating it on first use. +func (s *WindowManager) OpenBrowserLogin(uri string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.browserLogin == nil { + startURL := "/#/dialog/browser-login" + if uri != "" { + startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) + } + s.hideOtherWindowsLocked("browser-login") + // Prefer the main window's screen (multi-monitor); falls back to OS-default centering. + var screen *application.Screen + if s.mainWindow != nil { + if sc, err := s.mainWindow.GetScreen(); err == nil { + screen = sc + } + } + opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) + // Not always-on-top: it would obscure the browser tab the user logs in through. + opts.AlwaysOnTop = false + opts.InitialPosition = application.WindowCentered + opts.Screen = screen + s.browserLogin = s.app.Window.NewWithOptions(opts) + bl := s.browserLogin + // Red-X close means cancel: emit the event so startLogin() tears down the SSO wait. + bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.app.Event.Emit(EventBrowserLoginCancel) + s.mu.Lock() + s.browserLogin = nil + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + }) + s.centerWhenReady(s.browserLogin) + return + } + if uri != "" { + s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) + } + s.browserLogin.Show() + s.browserLogin.Focus() + s.centerWhenReady(s.browserLogin) +} + +// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the +// app's focal window: tray "Open" and dock activation hand off to it, not the main window. +func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + return s.browserLogin +} + +// InstallProgressWindow returns the live install-progress window, or nil. Same focal-window +// contract as BrowserLoginWindow; install supersedes everything, so check this first. +func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + return s.installProgress +} + +func (s *WindowManager) CloseBrowserLogin() { + s.mu.Lock() + w := s.browserLogin + s.browserLogin = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds +// the countdown. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if s.sessionExpiration == nil { + opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + s.sessionExpiration = s.app.Window.NewWithOptions(opts) + s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.sessionExpiration = nil + s.mu.Unlock() + }) + s.centerOnCursorScreen(s.sessionExpiration) + return + } + s.sessionExpiration.SetURL(startURL) + s.centerOnCursorScreen(s.sessionExpiration) + s.sessionExpiration.Show() + s.sessionExpiration.Focus() +} + +func (s *WindowManager) CloseSessionExpiration() { + s.mu.Lock() + w := s.sessionExpiration + s.sessionExpiration = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenInstallProgress shows the install-progress window and hides the rest for the duration +// (restored on close). It owns its own result polling since the daemon restarts mid-install. +func (s *WindowManager) OpenInstallProgress(version string) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/install-progress" + if version != "" { + startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) + } + if s.installProgress == nil { + s.hideOtherWindowsLocked("install-progress") + s.installProgress = s.app.Window.NewWithOptions( + DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon), + ) + s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.installProgress = nil + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + }) + s.centerWhenReady(s.installProgress) + return + } + s.installProgress.SetURL(startURL) + s.installProgress.Show() + s.installProgress.Focus() + s.centerWhenReady(s.installProgress) +} + +func (s *WindowManager) CloseInstallProgress() { + s.mu.Lock() + w := s.installProgress + s.installProgress = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. +func (s *WindowManager) OpenWelcome() { + s.mu.Lock() + defer s.mu.Unlock() + if s.welcome == nil { + opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts.Width = 420 + opts.InitialPosition = application.WindowCentered + s.welcome = s.app.Window.NewWithOptions(opts) + w := s.welcome + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.welcome = nil + s.mu.Unlock() + }) + s.centerWhenReady(s.welcome) + return + } + s.welcome.Show() + s.welcome.Focus() + s.centerWhenReady(s.welcome) +} + +func (s *WindowManager) CloseWelcome() { + s.mu.Lock() + w := s.welcome + s.welcome = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenError shows the custom error dialog; title/message are pre-localised and ride in the +// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close. +func (s *WindowManager) OpenError(title, message string) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := errorDialogURL(title, message) + if s.errorDialog == nil { + s.errorDialog = s.app.Window.NewWithOptions( + DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), + ) + s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.errorDialog = nil + s.mu.Unlock() + }) + s.centerWhenReady(s.errorDialog) + return + } + s.errorDialog.SetURL(startURL) + s.errorDialog.Show() + s.errorDialog.Focus() + s.centerWhenReady(s.errorDialog) +} + +func (s *WindowManager) CloseError() { + s.mu.Lock() + w := s.errorDialog + s.errorDialog = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenMain brings the main window forward; the welcome handoff uses it instead of the tray. +func (s *WindowManager) OpenMain() { + s.ShowMain() +} + +// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry +// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly. +func (s *WindowManager) ShowMain() { + if s.mainWindow == nil { + return + } + s.mainWindow.Show() + s.mainWindow.Focus() + // Re-center (minimal-WM only; see centerWhenReady). + s.centerWhenReady(s.mainWindow) +} + +// SetRecenterOnShow installs the recenterOnShow predicate (see the field). +func (s *WindowManager) SetRecenterOnShow(pred func() bool) { + s.recenterOnShow = pred +} + +// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it +// returns so it never fights a user-moved window. On GTK4 an inline Center() +// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync +// would deadlock, so a background goroutine retries until Position is non-zero, +// bounded so a window genuinely at the origin can't spin forever. +func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { + if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { // ~1s budget at 20ms steps + w.Center() + if x, y := w.Position(); x != 0 || y != 0 { + return // surface realized + } + time.Sleep(20 * time.Millisecond) + } + }() +} + +// centerOnCursorScreen centers w on the cursor's display; guards no-op on headless sessions. +// On minimal WMs it uses the same realize-detection retry loop as centerWhenReady. +func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) { + if w == nil { + return + } + place := func() { + screen := s.getScreenBasedOnCursorPosition() + if screen == nil { + return + } + width, height := w.Size() + if width <= 0 || height <= 0 { + return + } + wa := screen.WorkArea + if wa.Width <= 0 || wa.Height <= 0 { + return + } + w.SetPosition(wa.X+(wa.Width-width)/2, wa.Y+(wa.Height-height)/2) + } + place() + if s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { + place() + if x, y := w.Position(); x != 0 || y != 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + }() +} + +// title resolves a window-title i18n key in the current language, or the raw key if unavailable. +func (s *WindowManager) title(key string) string { + if s.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if s.prefs != nil { + if pref := s.prefs.Get().Language; pref != "" { + lang = pref + } + } + return s.translator.Translate(lang, key) +} + +// retitleAll re-applies the localised title to every live auxiliary window. Pointers are +// snapshotted under s.mu; SetTitle is then safe to call after releasing the lock. +func (s *WindowManager) retitleAll() { + s.mu.Lock() + type pair struct { + win *application.WebviewWindow + key string + } + wins := []pair{ + {s.settings, "window.title.settings"}, + {s.browserLogin, "window.title.signIn"}, + {s.sessionExpiration, "window.title.sessionExpiration"}, + {s.installProgress, "window.title.updating"}, + {s.welcome, "window.title.welcome"}, + {s.errorDialog, "window.title.error"}, + } + s.mu.Unlock() + for _, p := range wins { + if p.win != nil { + p.win.SetTitle(s.title(p.key)) + } + } +} + +// hideOtherWindowsLocked hides every visible window except keepName, recording +// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu. +func (s *WindowManager) hideOtherWindowsLocked(keepName string) { + for _, w := range s.app.Window.GetAll() { + if w == nil || w.Name() == keepName { + continue + } + if !w.IsVisible() { + continue + } + w.Hide() + s.hiddenForLogin = append(s.hiddenForLogin, w) + } +} + +// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked +// (caller holds s.mu). If the main window was among them, raiseToForeground +// lifts it above the SSO browser, which still owns the foreground — a plain +// Show/Focus would be demoted to a taskbar flash and leave it stranded behind. +func (s *WindowManager) restoreHiddenWindowsLocked() { + mainRestored := false + for _, w := range s.hiddenForLogin { + if w == nil { + continue + } + w.Show() + if w == s.mainWindow { + mainRestored = true + } + } + s.hiddenForLogin = nil + if mainRestored && s.mainWindow != nil { + raiseToForeground(s.mainWindow) + } +} + +// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the +// main-window screen, then nil (OS-default placement). +func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { + if s.app == nil || s.app.Screen == nil { + return nil + } + if p, ok := getCursorPosition(s.app); ok { + if sc := s.app.Screen.ScreenNearestDipPoint(p); sc != nil { + return sc + } + } + if s.mainWindow != nil { + if sc, err := s.mainWindow.GetScreen(); err == nil { + return sc + } + } + return nil +} + +// errorDialogURL builds the error window's start URL with title/message as escaped query params. +func errorDialogURL(title, message string) string { + q := url.Values{} + if title != "" { + q.Set("title", title) + } + if message != "" { + q.Set("message", message) + } + startURL := "/#/dialog/error" + if enc := q.Encode(); enc != "" { + startURL += "?" + enc + } + return startURL +} + +// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. +func u32ptr(v uint32) *uint32 { return &v } diff --git a/client/ui/signal_unix.go b/client/ui/signal_unix.go index 99de99f0f..876c5dd6e 100644 --- a/client/ui/signal_unix.go +++ b/client/ui/signal_unix.go @@ -1,76 +1,31 @@ -//go:build !windows && !(linux && 386) +//go:build !windows && !android && !ios && !freebsd && !js package main import ( "context" "os" - "os/exec" "os/signal" "syscall" log "github.com/sirupsen/logrus" ) -// setupSignalHandler sets up a signal handler to listen for SIGUSR1. -// When received, it opens the quick actions window. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGUSR1) +// listenForShowSignal lets external tools surface the running UI by signalling its pid (SIGUSR1). +func listenForShowSignal(ctx context.Context, tray *Tray) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) go func() { for { select { case <-ctx.Done(): + signal.Stop(sigCh) return - case <-sigChan: - log.Info("received SIGUSR1 signal, opening quick actions window") - s.openQuickActions() + case <-sigCh: + log.Debug("SIGUSR1 received, showing window") + tray.ShowWindow() } } }() } - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("start quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -// sendShowWindowSignal sends SIGUSR1 to the specified PID. -func sendShowWindowSignal(pid int32) error { - process, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - return process.Signal(syscall.SIGUSR1) -} diff --git a/client/ui/signal_windows.go b/client/ui/signal_windows.go index 58f46374f..86caa6d0c 100644 --- a/client/ui/signal_windows.go +++ b/client/ui/signal_windows.go @@ -5,9 +5,6 @@ package main import ( "context" "errors" - "fmt" - "os" - "os/exec" "time" log "github.com/sirupsen/logrus" @@ -17,155 +14,65 @@ import ( const ( quickActionsTriggerEventName = `Global\NetBirdQuickActionsTriggerEvent` waitTimeout = 5 * time.Second - // SYNCHRONIZE is needed for WaitForSingleObject, EVENT_MODIFY_STATE for ResetEvent. - desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + + // WAIT_TIMEOUT return code; not exposed by golang.org/x/sys/windows. + waitTimeoutCode uint32 = 0x00000102 ) -func getEventNameUint16Pointer() (*uint16, error) { - eventNamePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) - if err != nil { - log.Errorf("Failed to convert event name '%s' to UTF16: %v", quickActionsTriggerEventName, err) - return nil, err - } - - return eventNamePtr, nil -} - -// setupSignalHandler sets up signal handling for Windows. -// Windows doesn't support SIGUSR1, so this uses a similar approach using windows.Events. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - eventNamePtr, err := getEventNameUint16Pointer() +// listenForShowSignal shows the main window when an external process pulses the named event. +func listenForShowSignal(ctx context.Context, tray *Tray) { + namePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) if err != nil { + log.Errorf("trigger event name: %v", err) return } - eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr) - + handle, err := windows.CreateEvent(nil, 1, 0, namePtr) if err != nil { - if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - log.Warnf("Quick actions trigger event '%s' already exists. Attempting to open.", quickActionsTriggerEventName) - eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - log.Errorf("Failed to open existing quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) - return - } - log.Infof("Successfully opened existing quick actions trigger event '%s'.", quickActionsTriggerEventName) - } else { - log.Errorf("Failed to create quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) + if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + log.Errorf("create trigger event %q: %v", quickActionsTriggerEventName, err) + return + } + handle, err = windows.OpenEvent(desiredAccesses, false, namePtr) + if err != nil { + log.Errorf("open trigger event %q: %v", quickActionsTriggerEventName, err) return } } - if eventHandle == windows.InvalidHandle { - log.Errorf("Obtained an invalid handle for quick actions trigger event '%s'", quickActionsTriggerEventName) + if handle == windows.InvalidHandle { + log.Errorf("invalid handle for trigger event %q", quickActionsTriggerEventName) return } - log.Infof("Quick actions handler waiting for signal on event: %s", quickActionsTriggerEventName) - - go s.waitForEvent(ctx, eventHandle) + go waitForTrigger(ctx, handle, tray) } -func (s *serviceClient) waitForEvent(ctx context.Context, eventHandle windows.Handle) { +func waitForTrigger(ctx context.Context, handle windows.Handle, tray *Tray) { defer func() { - if err := windows.CloseHandle(eventHandle); err != nil { - log.Errorf("Failed to close quick actions event handle '%s': %v", quickActionsTriggerEventName, err) + if err := windows.CloseHandle(handle); err != nil { + log.Errorf("close trigger event handle: %v", err) } }() + timeoutMs := uint32(waitTimeout / time.Millisecond) for { if ctx.Err() != nil { return } - - status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds())) - - switch status { - case windows.WAIT_OBJECT_0: - log.Info("Received signal on quick actions event. Opening quick actions window.") - - // reset the event so it can be triggered again later (manual reset == 1) - if err := windows.ResetEvent(eventHandle); err != nil { - log.Errorf("Failed to reset quick actions event '%s': %v", quickActionsTriggerEventName, err) - } - - s.openQuickActions() - case uint32(windows.WAIT_TIMEOUT): - - default: - if isDone := logUnexpectedStatus(ctx, status, err); isDone { - return + ev, err := windows.WaitForSingleObject(handle, timeoutMs) + switch { + case err != nil: + log.Errorf("wait trigger event: %v", err) + return + case ev == waitTimeoutCode: + continue + case ev == windows.WAIT_OBJECT_0: + if err := windows.ResetEvent(handle); err != nil { + log.Errorf("reset trigger event: %v", err) } + tray.ShowWindow() } } } - -func logUnexpectedStatus(ctx context.Context, status uint32, err error) bool { - log.Errorf("Unexpected status %d from WaitForSingleObject for quick actions event '%s': %v", - status, quickActionsTriggerEventName, err) - select { - case <-time.After(5 * time.Second): - return false - case <-ctx.Done(): - return true - } -} - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("error starting quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -func sendShowWindowSignal(pid int32) error { - _, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - - eventNamePtr, err := getEventNameUint16Pointer() - if err != nil { - return err - } - - eventHandle, err := windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - return err - } - - err = windows.SetEvent(eventHandle) - if err != nil { - return fmt.Errorf("error setting event: %w", err) - } - - return nil -} diff --git a/client/ui/tray.go b/client/ui/tray.go new file mode 100644 index 000000000..700d94098 --- /dev/null +++ b/client/ui/tray.go @@ -0,0 +1,531 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "runtime" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/version" +) + +// Notification IDs are OS dedup keys that coalesce duplicate toasts; +// statusError is a tray-only sentinel for the error-icon state. +const ( + notifyIDUpdatePrefix = "netbird-update-" + notifyIDEvent = "netbird-event-" + notifyIDTrayError = "netbird-tray-error" + notifyIDMDMPolicy = "netbird-mdm-policy" + + statusError = "Error" + + urlGitHubRepo = "https://github.com/netbirdio/netbird" + urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" + urlDocs = "https://docs.netbird.io" +) + +// TrayServices bundles the services the tray menu needs, grouped so NewTray +// stays under the linter's parameter-count threshold. +type TrayServices struct { + Connection *services.Connection + Settings *services.Settings + Profiles *services.Profiles + Networks *services.Networks + DaemonFeed *services.DaemonFeed + Notifier *notifications.NotificationService + Update *services.Update + ProfileSwitcher *services.ProfileSwitcher + WindowManager *services.WindowManager + // Session is bound to authsession directly because the services wrapper + // only re-exposes the React subset. + Session *authsession.Session + Localizer *Localizer +} + +type Tray struct { + app *application.App + tray *application.SystemTray + window *application.WebviewWindow + svc TrayServices + // panelDark reports whether the desktop panel uses a dark scheme, so + // iconForState can pick the black vs white mono tray icon on Linux. Set + // by startTrayTheme (Linux only); nil elsewhere, where panelIsDark falls + // back to its default. + panelDark func() bool + loc *Localizer + + // menu and the *Item/*Submenu fields below are reassigned by buildMenu + // on every relayout — touch them only with menuMu held. Exceptions: + // the Connect/Disconnect OnClick closures capture their own item, and + // refreshSessionExpiresLabel snapshots its item under menuMu. + menu *application.Menu + statusItem *application.MenuItem + // sessionExpiresItem shows the SSO deadline as a remaining-time label, + // repainted by a 30s ticker. + sessionExpiresItem *application.MenuItem + upItem *application.MenuItem + downItem *application.MenuItem + exitNodeItem *application.MenuItem + exitNodeSubmenu *application.Menu + profileSubmenu *application.Menu + profileSubmenuItem *application.MenuItem + profileEmailItem *application.MenuItem + settingsItem *application.MenuItem + daemonVersionItem *application.MenuItem + + updater *trayUpdater + + // statusMu guards the daemon-status core mirrored on the tray. One mutex + // covers these fields because applyStatus writes them together on every + // Status push and the menu painters read them. + statusMu sync.Mutex + connected bool + lastStatus string + lastDaemonVersion string + // lastNetworksRevision is the daemon's routed-networks revision; a bump (or + // a connect/disconnect transition) gates the refreshExitNodes re-fetch so + // ListNetworks runs only when routes change. The peer-status route list + // can't substitute: it carries only actively-routed routes, not candidate + // exit nodes. + lastNetworksRevision uint64 + // pendingConnectLogin is set when handleConnect fires an Up on an idle + // daemon. The daemon flips to NeedsLogin if the peer is SSO-tracked with + // no cached token; applyStatus consumes the flag on that transition to + // open the browser-login flow, saving a second Connect click. + // Profile-switch reconnects are handled separately by + // DaemonFeed.statusStreamLoop. + pendingConnectLogin bool + + // sessionMu guards the cached SSO deadline used by the session row. + // Independent of statusMu so the 30s ticker reader and the Status-push + // writer don't block each other. + sessionMu sync.Mutex + sessionExpiresAt time.Time + + // profileMu guards the profile-domain state (active identity, the + // notifications gate, the in-flight switch cancel). Independent of + // statusMu so a long-running switch holding switchCancel doesn't block a + // Status-push reader of t.connected. + profileMu sync.Mutex + activeProfile string + activeUsername string + notificationsEnabled bool + switchCancel context.CancelFunc + + // profileLoadMu serializes loadProfiles so the applyStatus refresh can't + // race the ApplicationStarted seed or the post-switch reload — all + // manipulate profileSubmenu + SetMenu, which Wails isn't concurrency-safe + // against. + profileLoadMu sync.Mutex + + // profilesMu guards the cached profile rows that relayoutMenu repaints + // into a freshly built Profiles submenu, kept separate from the live + // submenu so a relayout always has a source to repaint from without + // re-hitting the daemon. + profilesMu sync.Mutex + profiles []services.Profile + profilesUser string + + // menuMu serialises relayoutMenu (buildMenu + SetMenu) and guards the + // menu/item-pointer fields above. relayoutMenu is the only post-startup + // SetMenu call site — a menu snapshot pushed outside the lock could + // reinstall a stale tree. + menuMu sync.Mutex + + // exitNodesMu guards the exitNodes row cache so relayoutMenu's read (and + // the Repaint copy) doesn't contend with status-push readers of statusMu. + exitNodesMu sync.Mutex + exitNodes []exitNodeEntry + // exitNodesRebuildMu serialises the ListNetworks fetch + submenu rebuild + + // SetMenu cycle so back-to-back Status pushes can't run it concurrently + // with itself. + exitNodesRebuildMu sync.Mutex + + // featureMu guards the daemon feature kill switches mirrored on the tray. + // Fetched at startup and refreshed on every config_changed event (the + // daemon re-applies MDM policy per engine spawn), so featuresDisabled can + // grey out menus without polling GetFeatures. + featureMu sync.Mutex + disableProfiles bool + disableNetworks bool +} + +func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { + t := &Tray{ + app: app, + window: window, + svc: svc, + notificationsEnabled: true, + // Localizer is constructed by main so the first menu render is already + // in the right locale — no English flash then re-paint. + loc: svc.Localizer, + } + t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) + t.tray = app.SystemTray.New() + // Seed panel-theme detection before the first paint so the initial icon + // matches the panel's light/dark scheme (Linux only). + t.startTrayTheme() + t.applyIcon() + t.tray.SetTooltip(t.loc.T("tray.tooltip")) + // On Linux the SNI hover tooltip rides on the systray label, not + // SetTooltip (a no-op there); without a label Wails shows the literal + // "Wails". macOS/Windows are skipped (label paints visible text on + // macOS; Windows uses SetTooltip above). + if runtime.GOOS == "linux" { + t.tray.SetLabel(t.loc.T("tray.tooltip")) + } + t.menu = t.buildMenu() + t.tray.SetMenu(t.menu) + // macOS/Linux give click→menu natively, so bindTrayClick is a no-op there + // (binding OnClick→OpenMenu on macOS would freeze the tray); Windows has no + // native left-click handler so it wires one to open the main window, leaving + // the menu on right-click (see tray_click_*.go). On Linux AttachWindow is + // skipped — with applySmartDefaults it would pop the window alongside the + // menu (e.g. GNOME Shell AppIndicator). + bindTrayClick(t) + + app.Event.On(services.EventStatusSnapshot, t.onStatusEvent) + app.Event.On(services.EventDaemonNotification, t.onSystemEvent) + // Refresh the Profiles submenu on ProfileSwitcher's change event. A + // switch on an idle daemon drives no status transition, so without this + // hook a React-initiated switch leaves the tray's submenu stale. + app.Event.On(services.EventProfileChanged, func(*application.CustomEvent) { + go t.loadProfiles() + }) + // Defer the first profile load until the menu impl is live — Menu.Update() + // short-circuits while app.running is false, and AppKit's main queue isn't + // ready earlier (see d23ef34 InvokeSync nil-deref). + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go t.loadProfiles() + go t.refreshRestrictions() + go t.runSessionExpiryTicker() + // Category registration must run after the notifications service + // Startup populates appName/registry path on Windows; before app.Run() + // the category lookup silently falls back to a plain notification. + t.registerSessionWarningCategory() + }) + + t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) + + go t.loadConfig() + return t +} + +// ShowWindow brings the main window forward — used by SIGUSR1 / Windows event. +// Show() alone is not enough on macOS (makeKeyAndOrderFront skips activation, +// so the window pops up behind the active app); Focus() additionally calls +// activateIgnoringOtherApps:YES on macOS and SetForegroundWindow on Windows. +func (t *Tray) ShowWindow() { + // An install supersedes every other flow, so check it before BrowserLogin. + if w := t.svc.WindowManager.InstallProgressWindow(); w != nil { + w.Show() + w.Focus() + return + } + if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil { + w.Show() + w.Focus() + return + } + if t.window == nil { + return + } + // Route through WindowManager so the main window is centered on first + // show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in + // the top-left corner. + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } + t.window.Show() + t.window.Focus() +} + +// applyLanguage re-renders every translated surface in the Localizer's current +// language. Wails dispatches menu/tray APIs onto the UI thread internally, so +// calling them from the Localizer's background goroutine is safe; profileLoadMu +// prevents loadProfiles from racing the rebuild. +func (t *Tray) applyLanguage() { + t.tray.SetTooltip(t.loc.T("tray.tooltip")) + // Mirror the Linux label fix from NewTray (the SNI tooltip rides on the + // label). + if runtime.GOOS == "linux" { + t.tray.SetLabel(t.loc.T("tray.tooltip")) + } + t.relayoutMenu() +} + +// relayoutMenu rebuilds the entire tray menu, repaints the cached +// status/session/profile/exit-node state into the fresh items, and pushes the +// whole tree with a single SetMenu. +// +// A full rebuild is required because on KDE/Plasma the StatusNotifierItem host +// caches a submenu's layout on first open (GetLayout for that submenu id) and +// never re-fetches it on a LayoutUpdated(parent=0) signal — so Clear()+Add() +// into the same container froze both the visible rows and the click→id mapping, +// and stale ids no-op'd. buildMenu allocates a fresh submenu container id each +// time, which Plasma treats as unseen and re-queries (confirmed via +// dbus-monitor). This also covers the darwin detached-NSMenu workaround, since +// it rebuilds the whole tree against the cached top-level pointer. +// +// Rows come from the profilesMu/exitNodes caches, so it never re-hits the +// daemon or recurses back into loadProfiles. +func (t *Tray) relayoutMenu() { + t.menuMu.Lock() + defer t.menuMu.Unlock() + + t.menu = t.buildMenu() + + t.statusMu.Lock() + connected := t.connected + lastStatus := t.lastStatus + daemonVersion := t.lastDaemonVersion + t.statusMu.Unlock() + + t.sessionMu.Lock() + sessionDeadline := t.sessionExpiresAt + t.sessionMu.Unlock() + + t.exitNodesMu.Lock() + exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...) + t.exitNodesMu.Unlock() + + disableProfiles, disableNetworks := t.featuresDisabled() + + daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) + connecting := strings.EqualFold(lastStatus, services.StatusConnecting) + + if t.statusItem != nil && lastStatus != "" { + t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus)) + t.statusItem.SetEnabled(statusRowEnabled()) + t.applyStatusIndicator(lastStatus) + } + if t.sessionExpiresItem != nil { + if sessionDeadline.IsZero() { + t.sessionExpiresItem.SetHidden(true) + } else { + remaining := t.formatSessionRemaining(time.Until(sessionDeadline)) + t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) + t.sessionExpiresItem.SetHidden(false) + } + } + if t.upItem != nil { + // Connect stays visible in the NeedsLogin states too — Up drives + // the SSO re-auth flow; hidden only when it would be a no-op. + t.upItem.SetHidden(connected || connecting || daemonUnavailable) + t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) + } + if t.downItem != nil { + // Disconnect doubles as the Connecting abort path. + t.downItem.SetHidden(!connected && !connecting) + t.downItem.SetEnabled(connected || connecting) + } + if t.exitNodeItem != nil { + t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0 && !disableNetworks) + } + if t.settingsItem != nil { + t.settingsItem.SetEnabled(!daemonUnavailable) + } + if t.profileSubmenuItem != nil { + t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles) + } + if daemonVersion != "" && t.daemonVersionItem != nil { + t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion)) + } + if t.updater != nil { + t.updater.applyLanguage() + } + // buildMenu recreated empty submenus, so repaint both from their caches + // before SetMenu. Neither fill re-fetches. Do NOT re-take + // exitNodesRebuildMu here — refreshExitNodes already holds it when it + // calls relayoutMenu. + t.fillExitNodeSubmenu(exitNodeEntries) + t.fillProfileSubmenu() + + // Single push of the whole tree: on Linux one LayoutUpdated with fresh + // container ids; on darwin an NSMenu rebuild against the cached pointer. + t.tray.SetMenu(t.menu) +} + +func (t *Tray) buildMenu() *application.Menu { + menu := application.NewMenu() + + // Enabled state is platform-dependent (see statusRowEnabled): Windows keeps + // it enabled because the disabled mask would desaturate the coloured status + // dot; macOS/Linux disable it so the greyed label signals it isn't + // clickable. + t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")). + SetEnabled(statusRowEnabled()). + SetBitmap(iconMenuDotIdle) + + menu.AddSeparator() + + // The OnClick closures capture the local item because t.upItem/t.downItem + // are menuMu-guarded and must not be read from the click goroutine. + upItem := menu.Add(t.loc.T("tray.menu.connect")) + upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) }) + t.upItem = upItem + downItem := menu.Add(t.loc.T("tray.menu.disconnect")) + downItem.OnClick(func(*application.Context) { t.handleDisconnect(downItem) }) + downItem.SetHidden(true) + t.downItem = downItem + + menu.AddSeparator() + + // Populated asynchronously once the app has started — Menu.Update() is a + // no-op before app.running is true, so the initial fill is gated on the + // ApplicationStarted hook. + profilesLabel := t.loc.T("tray.menu.profiles") + t.profileSubmenu = menu.AddSubmenu(profilesLabel) + // AddSubmenu returns the child *Menu, so retrieve the parent *MenuItem via + // FindByLabel. + t.profileSubmenuItem = menu.FindByLabel(profilesLabel) + t.profileEmailItem = menu.Add("").SetEnabled(false) + t.profileEmailItem.SetHidden(true) + // Click opens the SessionExpiration window so the user can extend ahead of + // the daemon's T-FinalWarningLead auto-prompt. + t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) + t.sessionExpiresItem.SetHidden(true) + + menu.AddSeparator() + // Accelerators on the Settings/Quit entries below are a no-op on Windows in + // Wails v3 alpha.95 (impl commented out in menuitem_windows.go); still set + // for forward compatibility. macOS/GTK render and fire them. + menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() }) + + menu.AddSeparator() + + // exitNodeSubmenu hosts one row per peer advertising a default route + // (0.0.0.0/0 or ::/0). FindByLabel grabs the parent so applyStatus can flip + // its enabled state independently of the children. + exitNodeLabel := t.loc.T("tray.menu.exitNode") + t.exitNodeSubmenu = menu.AddSubmenu(exitNodeLabel) + t.exitNodeItem = menu.FindByLabel(exitNodeLabel) + t.exitNodeItem.SetEnabled(false) + + menu.AddSeparator() + + // The label's trailing ellipsis follows the macOS HIG convention for items + // that open a window. + t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")). + SetAccelerator("CmdOrCtrl+,"). + OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) + + aboutLabel := menuLabel(t.loc.T("tray.menu.about")) + about := menu.AddSubmenu(aboutLabel) + about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlGitHubRepo) + }) + about.Add(t.loc.T("tray.menu.documentation")).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlDocs) + }) + about.Add(t.loc.T("tray.menu.troubleshoot")).OnClick(func(*application.Context) { + t.svc.WindowManager.OpenSettings("troubleshooting") + }) + about.AddSeparator() + about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false) + t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false) + // trayUpdater rewrites the label between downloadLatest (opt-in) and + // installVersion (enforced) and drives the click. + updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")). + OnClick(func(*application.Context) { t.updater.handleClick() }) + updateItem.SetHidden(true) + t.updater.attach(updateItem) + + menu.AddSeparator() + menu.Add(t.loc.T("tray.menu.quit")). + SetAccelerator("CmdOrCtrl+Q"). + OnClick(func(*application.Context) { t.app.Quit() }) + + return menu +} + +// handleConnect receives the clicked item from the buildMenu closure — +// t.upItem is menuMu-guarded and must not be read here. +func (t *Tray) handleConnect(upItem *application.MenuItem) { + // NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they + // need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so + // the React startLogin() (which owns the BrowserLogin popup) drives it; + // the hidden main webview is alive and subscribed, so only the popup shows. + t.statusMu.Lock() + needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || + strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || + strings.EqualFold(t.lastStatus, services.StatusLoginFailed) + t.statusMu.Unlock() + if needsLogin { + t.app.Event.Emit(services.EventTriggerLogin) + return + } + upItem.SetEnabled(false) + // Arm the SSO auto-handoff: Up() is async and the daemon may flip to + // NeedsLogin on an SSO peer with no cached token. applyStatus consumes the + // flag on that transition to trigger browser-login without a second Connect + // click, and clears it on any terminal state. + t.statusMu.Lock() + t.pendingConnectLogin = true + t.statusMu.Unlock() + go func() { + if err := t.svc.Connection.Up(context.Background(), services.UpParams{}); err != nil { + log.Errorf("connect: %v", err) + t.notifyError(t.loc.T("notify.error.connect")) + t.statusMu.Lock() + t.pendingConnectLogin = false + t.statusMu.Unlock() + upItem.SetEnabled(true) + } + }() +} + +// handleDisconnect aborts any in-flight profile switch before sending Down — +// otherwise the switcher's queued Up would reconnect right after, making the +// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's +// Idle push paints through instead of being swallowed by the suppression filter. +// Receives the clicked item from the buildMenu closure (see handleConnect). +func (t *Tray) handleDisconnect(downItem *application.MenuItem) { + downItem.SetEnabled(false) + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + go func() { + if err := t.svc.Connection.Down(context.Background()); err != nil { + log.Errorf("disconnect: %v", err) + t.notifyError(t.loc.T("notify.error.disconnect")) + downItem.SetEnabled(true) + } + }() +} + +// notify wraps the Wails notification service with the tray's standard +// id-prefix scheme and swallows errors (notifications are best-effort). +func (t *Tray) notify(title, body, id string) { + if t.svc.Notifier == nil { + return + } + _ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{ + ID: id, + Title: title, + Body: body, + }) +} + +// notifyError fires a generic "Error" notification for tray-driven action +// failures. Each tray click site already logs the underlying error; this +// adds the user-visible toast. +func (t *Tray) notifyError(message string) { + t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError) +} diff --git a/client/ui/tray_click_linux.go b/client/ui/tray_click_linux.go new file mode 100644 index 000000000..34a364fd9 --- /dev/null +++ b/client/ui/tray_click_linux.go @@ -0,0 +1,31 @@ +//go:build linux && !(linux && 386) + +package main + +// bindTrayClick wires the tray icon's left-click handler on Linux. +// +// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which +// fires the registered clickHandler: +// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke +// org.kde.StatusNotifierItem.Activate over D-Bus on left-click. +// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs +// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate +// call itself (xembed_host_linux.go), so it routes through the same hook. +// Registering OnClick here therefore covers both paths with one handler — no +// changes to the watcher or XEmbed C code are needed. Left-click now opens the +// main window; right-click still opens the menu via Wails' default +// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs). +// +// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it +// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's +// applySmartDefaults would pop the window alongside the menu on GNOME Shell +// with the AppIndicator extension (see the bindTrayClick comment in tray.go). +// +// ShowWindow() is the same dispatcher the explicit "Open NetBird" menu entry +// and SIGUSR1 use: it brings the install-progress / browser-login window +// forward when one of those flows is active, otherwise routes through +// WindowManager.ShowMain so the window re-centers on minimal WMs / the XEmbed +// path instead of landing in the top-left corner. +func bindTrayClick(t *Tray) { + t.tray.OnClick(func() { t.ShowWindow() }) +} diff --git a/client/ui/tray_click_other.go b/client/ui/tray_click_other.go new file mode 100644 index 000000000..e6a29e419 --- /dev/null +++ b/client/ui/tray_click_other.go @@ -0,0 +1,13 @@ +//go:build !windows && !android && !ios && !freebsd && !js && (!linux || (linux && 386)) + +package main + +func bindTrayClick(*Tray) { + // No-op: macOS's native NSStatusItem opens the menu on click itself, and + // binding OnClick→anything blocking there froze the tray historically + // (see tray_click_windows.go). Windows wires an explicit handler + // (tray_click_windows.go); Linux opens the window on left-click + // (tray_click_linux.go). The (linux && 386) arm keeps a no-op fallback for + // the i386 Linux build, which excludes the cgo XEmbed/SNI files that + // tray_click_linux.go's build tag matches. +} diff --git a/client/ui/tray_click_windows.go b/client/ui/tray_click_windows.go new file mode 100644 index 000000000..17a6dc5df --- /dev/null +++ b/client/ui/tray_click_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package main + +// Open application window on left click, right click opens the tray menu +func bindTrayClick(t *Tray) { + t.tray.OnClick(func() { t.ShowWindow() }) +} diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go new file mode 100644 index 000000000..12da68a5c --- /dev/null +++ b/client/ui/tray_events.go @@ -0,0 +1,137 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/services" +) + +// onSystemEvent fires an OS notification for daemon SystemEvents that carry a +// user-facing message. Gated by the "Notifications" toggle; critical events bypass it. +func (t *Tray) onSystemEvent(ev *application.CustomEvent) { + se, ok := ev.Data.(services.SystemEvent) + if !ok { + return + } + // config_changed carries no UserMessage, so handle it before the message gate below. + if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged { + log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey]) + go t.refreshRestrictions() + go t.loadConfig() + // MDM gets a localised toast here; the daemon's English "policy_applied" + // event is suppressed in shouldSkipSystemEvent. Other sources stay silent. + if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM { + t.profileMu.Lock() + enabled := t.notificationsEnabled + t.profileMu.Unlock() + if enabled { + t.notify( + t.loc.T("notify.mdm.policyApplied.title"), + t.loc.T("notify.mdm.policyApplied.body"), + notifyIDMDMPolicy, + ) + } + } + return + } + // Session-warning and deadline-rejected events build their body locally from + // metadata; every other event needs a UserMessage. + isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" + isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != "" + if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" { + return + } + if shouldSkipSystemEvent(se) { + return + } + + critical := strings.EqualFold(se.Severity, services.SeverityCritical) + t.profileMu.Lock() + enabled := t.notificationsEnabled + t.profileMu.Unlock() + if !enabled && !critical { + return + } + + // Session-warning events route via stable metadata flags rather than + // category/severity so a daemon-side reword still lands here. Final warning + // auto-opens the SessionExpiration dialog with no notification (the dialog is + // the last-chance reminder; doubling up would be noise). + if isDeadlineRejected { + t.notify( + t.loc.T("notify.sessionDeadlineRejected.title"), + t.loc.T("notify.sessionDeadlineRejected.body"), + notifyIDSessionExpired, + ) + return + } + + if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { + if se.Metadata[authsession.MetaFinal] == "true" { + t.openSessionExpiration() + return + } + t.notifySessionWarning( + t.loc.T("notify.sessionWarning.title"), + t.buildSessionWarningBody(se.Metadata), + ) + return + } + + body := se.UserMessage + if id := se.Metadata["id"]; id != "" { + body += fmt.Sprintf(" ID: %s", id) + } + t.notify(eventTitle(se), body, notifyIDEvent+se.ID) +} + +// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication". +func eventTitle(e services.SystemEvent) string { + prefix := titleCase(e.Severity) + if prefix == "" { + prefix = "Info" + } + category := titleCase(e.Category) + if category == "" { + category = "System" + } + return prefix + ": " + category +} + +func titleCase(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as +// a tray notification: +// - update-available announcements (trayUpdater emits its own) +// - install-progress signals (consumed by the install-progress window) +// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted) +func shouldSkipSystemEvent(se services.SystemEvent) bool { + // "policy_applied" carries a hardcoded English message; the localised toast + // fires on the paired config_changed (source=mdm) event instead. + if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied { + return true + } + if _, isUpdate := se.Metadata["new_version_available"]; isUpdate { + return true + } + if _, isProgress := se.Metadata["progress_window"]; isProgress { + return true + } + if se.Category == "network" && se.Metadata["network"] == "::/0" { + return true + } + return false +} diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go new file mode 100644 index 000000000..3e6f30842 --- /dev/null +++ b/client/ui/tray_exitnodes.go @@ -0,0 +1,148 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "net/netip" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/ui/services" +) + +// exitNodeEntry is one Exit Node submenu row; ID is the network's NetID, the Select/Deselect argument. +type exitNodeEntry struct { + ID string + Selected bool +} + +// fillExitNodeSubmenu uses a "✓ " prefix with plain Add, not AddCheckbox: Wails +// auto-toggles a checkbox on click before OnClick runs, so the deselect/select +// round-trip would briefly show two checked rows. Callers must hold exitNodesRebuildMu. +func (t *Tray) fillExitNodeSubmenu(nodes []exitNodeEntry) { + if t.exitNodeSubmenu == nil { + return + } + t.exitNodeSubmenu.Clear() + for _, n := range nodes { + id := n.ID + selected := n.Selected + label := id + if selected { + label = "✓ " + id + } + t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) { + t.toggleExitNode(id, selected) + }) + } +} + +// refreshExitNodes sources rows from Networks.List() rather than the Status stream +// because only ListNetworks carries the NetID + selected state Select/Deselect need. +// Serialized by exitNodesRebuildMu against overlapping Status pushes. +func (t *Tray) refreshExitNodes() { + t.exitNodesRebuildMu.Lock() + defer t.exitNodesRebuildMu.Unlock() + + t.statusMu.Lock() + connected := t.connected + t.statusMu.Unlock() + + var nodes []exitNodeEntry + if connected { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + list, err := t.svc.Networks.List(ctx) + cancel() + if err != nil { + log.Debugf("tray list networks: %v", err) + return + } + nodes = exitNodesFromNetworks(list) + } + + log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes)) + for _, n := range nodes { + log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected) + } + + t.exitNodesMu.Lock() + changed := !equalExitNodes(nodes, t.exitNodes) + t.exitNodes = nodes + t.exitNodesMu.Unlock() + + // relayoutMenu repaints from the cached entries, so the old exitNodeItem needs no poking here. + if changed { + t.relayoutMenu() + } +} + +// toggleExitNode uses append=true: append=false would drop the whole current +// selection (default-on semantics), turning off every other routed network the +// user had enabled. Mutual exclusion of exit nodes is enforced daemon-side. +func (t *Tray) toggleExitNode(id string, selected bool) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: true, All: false} + var err error + if selected { + err = t.svc.Networks.Deselect(ctx, params) + } else { + err = t.svc.Networks.Select(ctx, params) + } + if err != nil { + log.Errorf("tray toggle exit node %q: %v", id, err) + t.notifyError(t.loc.T("notify.error.exitNode", "name", id)) + return + } + t.refreshExitNodes() + }() +} + +// exitNodesFromNetworks keeps only networks whose range is a default route: those are the exit-node candidates. +func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry { + out := []exitNodeEntry{} + for _, n := range networks { + if !rangeIsDefaultRoute(n.Range) { + continue + } + out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected}) + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID) + }) + return out +} + +// rangeIsDefaultRoute reports whether r contains a default route. The daemon may +// comma-join a v4+v6 pair ("0.0.0.0/0, ::/0"), so each part is parsed rather than string-compared. +func rangeIsDefaultRoute(r string) bool { + for _, part := range strings.Split(r, ",") { + pref, err := netip.ParsePrefix(strings.TrimSpace(part)) + if err != nil { + continue + } + if pref.Bits() == 0 && pref.Addr().IsUnspecified() { + return true + } + } + return false +} + +func equalExitNodes(a, b []exitNodeEntry) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/client/ui/tray_features.go b/client/ui/tray_features.go new file mode 100644 index 000000000..2ff99e2be --- /dev/null +++ b/client/ui/tray_features.go @@ -0,0 +1,37 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + + log "github.com/sirupsen/logrus" +) + +// refreshRestrictions re-reads the operator-disabled UI flags and re-gates the +// menu. Must run on every config_changed event: the daemon re-applies its MDM +// policy on each engine spawn. +func (t *Tray) refreshRestrictions() { + r, err := t.svc.Settings.GetRestrictions(context.Background()) + if err != nil { + log.Debugf("get restrictions: %v", err) + return + } + t.featureMu.Lock() + changed := t.disableProfiles != r.Features.DisableProfiles || + t.disableNetworks != r.Features.DisableNetworks + t.disableProfiles = r.Features.DisableProfiles + t.disableNetworks = r.Features.DisableNetworks + t.featureMu.Unlock() + // relayoutMenu rebuilds the whole tree, so skip the no-op refresh (common case). + if changed { + t.relayoutMenu() + } +} + +// featuresDisabled returns the cached flags under featureMu. +func (t *Tray) featuresDisabled() (profiles, networks bool) { + t.featureMu.Lock() + defer t.featureMu.Unlock() + return t.disableProfiles, t.disableNetworks +} diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go new file mode 100644 index 000000000..6c7b85d79 --- /dev/null +++ b/client/ui/tray_icon.go @@ -0,0 +1,136 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +func (t *Tray) applyIcon() { + t.statusMu.Lock() + connected := t.connected + statusLabel := t.lastStatus + t.statusMu.Unlock() + hasUpdate := false + if t.updater != nil { + hasUpdate = t.updater.hasUpdate() + } + + log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s", + connected, hasUpdate, statusLabel, runtime.GOOS) + + icon, dark := t.iconForState() + if runtime.GOOS == "darwin" { + t.tray.SetTemplateIcon(icon) + return + } + if runtime.GOOS == "linux" { + // Wails' Linux SNI backend ignores SetDarkModeIcon (last write wins + // over SetIcon), so iconForState already picked the silhouette by + // panel theme; push that single icon. + t.tray.SetIcon(icon) + return + } + t.tray.SetIcon(icon) + if dark != nil { + t.tray.SetDarkModeIcon(dark) + } +} + +// panelIsDark defaults to true when no detector is wired (panelDark nil — +// non-Linux or portal unavailable), matching the common dark Linux panel. +func (t *Tray) panelIsDark() bool { + if t.panelDark == nil { + return true + } + return t.panelDark() +} + +func (t *Tray) iconForState() (icon, dark []byte) { + t.statusMu.Lock() + connected := t.connected + statusLabel := t.lastStatus + t.statusMu.Unlock() + hasUpdate := false + if t.updater != nil { + hasUpdate = t.updater.hasUpdate() + } + + connecting := strings.EqualFold(statusLabel, services.StatusConnecting) + errored := strings.EqualFold(statusLabel, statusError) || + strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) + needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || + strings.EqualFold(statusLabel, services.StatusSessionExpired) || + strings.EqualFold(statusLabel, services.StatusLoginFailed) + + if runtime.GOOS == "darwin" { + switch { + case connecting: + return iconConnectingMacOS, nil + case errored: + return iconErrorMacOS, nil + case needsLogin: + return iconNeedsLoginMacOS, nil + case connected && hasUpdate: + return iconUpdateConnectedMacOS, nil + case connected: + return iconConnectedMacOS, nil + case hasUpdate: + return iconUpdateDisconnectedMacOS, nil + default: + return iconDisconnectedMacOS, nil + } + } + + if runtime.GOOS == "linux" { + // Theme resolved here (black for light panel, white for dark) since + // the SNI backend can't switch per theme (see applyIcon); second + // return is unused on Linux. + dark := t.panelIsDark() + pick := func(black, white []byte) ([]byte, []byte) { + if dark { + return white, nil + } + return black, nil + } + switch { + case connecting: + return pick(iconConnectingMono, iconConnectingMonoDark) + case errored: + return pick(iconErrorMono, iconErrorMonoDark) + case needsLogin: + return pick(iconNeedsLoginMono, iconNeedsLoginMonoDark) + case connected && hasUpdate: + return pick(iconUpdateConnectedMono, iconUpdateConnectedMonoDark) + case connected: + return pick(iconConnectedMono, iconConnectedMonoDark) + case hasUpdate: + return pick(iconUpdateDisconnectedMono, iconUpdateDisconnectedMonoDark) + default: + return pick(iconDisconnectedMono, iconDisconnectedMonoDark) + } + } + + // Windows: colored PNGs. + switch { + case connecting: + return iconConnecting, iconConnectingDark + case errored: + return iconError, iconErrorDark + case needsLogin: + return iconNeedsLogin, iconNeedsLogin + case connected && hasUpdate: + return iconUpdateConnected, iconUpdateConnectedDark + case connected: + return iconConnected, iconConnectedDark + case hasUpdate: + return iconUpdateDisconnected, iconUpdateDisconnectedDark + default: + return iconDisconnected, iconDisconnected + } +} diff --git a/client/ui/tray_label_other.go b/client/ui/tray_label_other.go new file mode 100644 index 000000000..e104bc8e8 --- /dev/null +++ b/client/ui/tray_label_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +// menuLabel is the identity on macOS/Linux, which render "&" literally; +// Windows escapes it separately (tray_label_windows.go) to dodge the Win32 mnemonic. +func menuLabel(s string) string { return s } diff --git a/client/ui/tray_label_windows.go b/client/ui/tray_label_windows.go new file mode 100644 index 000000000..8c27ae9ea --- /dev/null +++ b/client/ui/tray_label_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +import "strings" + +// menuLabel doubles ampersands so Win32 draws a literal "&" instead of +// consuming it as the menu mnemonic prefix (Wails passes the label unescaped). +func menuLabel(s string) string { + return strings.ReplaceAll(s, "&", "&&") +} diff --git a/client/ui/tray_linux.go b/client/ui/tray_linux.go new file mode 100644 index 000000000..1d6e0a48b --- /dev/null +++ b/client/ui/tray_linux.go @@ -0,0 +1,71 @@ +//go:build linux && !386 + +package main + +import ( + "os" + "strings" +) + +// init runs before Wails' own init(), so the env vars are set in time. +func init() { + disableDMABUFRenderer() + disableCompositingMode() + disableWebKitSandboxIfNeeded() +} + +func disableDMABUFRenderer() { + if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" { + return + } + + // WebKitGTK's DMA-BUF renderer leaves a blank-white window on many setups + // (VMs, containers, minimal WMs). Wails only disables it for NVIDIA+Wayland, + // but the issue is broader; software rendering is fine for a small UI. + _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") +} + +func disableCompositingMode() { + if os.Getenv("WEBKIT_DISABLE_COMPOSITING_MODE") != "" { + return + } + // Disabling the DMA-BUF renderer alone isn't enough on some Intel setups: the + // GL compositor still hits Mesa's unimplemented DRM-format-modifier paths and + // SIGSEGVs inside g_application_run before the first frame. + _ = os.Setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1") +} + +// disableWebKitSandboxIfNeeded works around WebKitGTK crashing at startup when +// its bwrap sandbox can't create an unprivileged user namespace (containers/VMs, +// or Ubuntu 24.04+ AppArmor restrictions). +func disableWebKitSandboxIfNeeded() { + if _, set := os.LookupEnv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"); set { + return + } + if unprivilegedUsernsAllowed() { + return + } + _ = os.Setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") +} + +// unprivilegedUsernsAllowed reports whether the kernel permits unprivileged +// user namespaces (needed by WebKit's bwrap sandbox). Absent knobs are treated +// as allowed, to avoid needlessly weakening the sandbox. +func unprivilegedUsernsAllowed() bool { + // Debian/Ubuntu legacy switch: 0 disables unprivileged user namespaces. + if v, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil { + if strings.TrimSpace(string(v)) == "0" { + return false + } + } + // Ubuntu 24.04+ AppArmor restriction: non-zero restricts/blocks them. + if v, err := os.ReadFile("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"); err == nil { + if strings.TrimSpace(string(v)) != "0" { + return false + } + } + return true +} + +// Linux's tray provider needs the menu recreated rather than updated in place; +// tray.go's rebuildExitNodeMenu already does this, so no extra workaround here. diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go new file mode 100644 index 000000000..6c60e3d4b --- /dev/null +++ b/client/ui/tray_notify.go @@ -0,0 +1,58 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const notifyIDDaemonOutdated = "netbird-daemon-outdated" + +// sendFn fits both NotificationService.SendNotification and SendNotificationWithActions. +type sendFn func(notifications.NotificationOptions) error + +// safeSendNotification sends a best-effort OS notification, swallowing errors and panics. +// +// The panic guard is load-bearing on Linux: when Wails' notifier fails to +// connect the session bus at startup (headless, unreachable +// DBUS_SESSION_BUS_ADDRESS) it stays registered with a nil *dbus.Conn, so the +// next send nil-derefs inside godbus. Because sends run on a Wails +// event-dispatch goroutine that panic is fatal process-wide; recover() turns +// it into a logged no-op. +func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { + defer func() { + if r := recover(); r != nil { + log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r) + err = nil + } + }() + if err := send(opts); err != nil { + log.Errorf("notify %s: %v", what, err) + return err + } + return nil +} + +// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it +// is reachable but too old for this UI. A probe error means the daemon isn't +// reachable (not outdated), so it is left to the normal connection flow. +func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) { + ready, err := compat.DaemonReady(context.Background()) + if err != nil { + log.Debugf("daemon compatibility probe: %v", err) + return + } + if ready { + return + } + _ = safeSendNotification(notifier.SendNotification, "daemon-outdated", notifications.NotificationOptions{ + ID: notifyIDDaemonOutdated, + Title: loc.T("notify.daemonOutdated.title"), + Body: loc.T("notify.daemonOutdated.body"), + }) +} diff --git a/client/ui/tray_profiles.go b/client/ui/tray_profiles.go new file mode 100644 index 000000000..5251c138e --- /dev/null +++ b/client/ui/tray_profiles.go @@ -0,0 +1,194 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "fmt" + "sort" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/ui/services" +) + +// formatProfileLabel returns the display label for a profile. Profiles can +// share the same Name, so when more than one profile in profiles carries this +// Name, a short form of the ID is appended to disambiguate the entries. +func formatProfileLabel(profile services.Profile, profiles []services.Profile) string { + count := 0 + for _, p := range profiles { + if p.Name == profile.Name { + count++ + } + } + if count <= 1 { + return profile.Name + } + return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID()) +} + +// loadConfig caches the active-profile identity and the notifications gate. +// Runs in a startup goroutine so a slow daemon does not block menu construction. +func (t *Tray) loadConfig() { + ctx := context.Background() + + active, err := t.svc.Profiles.GetActive(ctx) + if err != nil { + log.Debugf("get active profile: %v", err) + return + } + // Address the active profile by ID (the daemon resolves it as a handle), + // since display names can collide. ConfigParams no longer matches + // ActiveProfile's shape for a struct conversion now that it carries an ID. + cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams{ + ProfileName: active.ID, + Username: active.Username, + }) + if err != nil { + log.Debugf("get config: %v", err) + return + } + + t.profileMu.Lock() + t.activeProfile = active.ProfileName + t.activeUsername = active.Username + t.notificationsEnabled = !cfg.DisableNotifications + t.profileMu.Unlock() +} + +// loadProfiles fetches the profile list and relayouts the menu. Also called +// from applyStatus to catch flips from another channel (CLI, autoconnect), +// since the daemon emits no active-profile event. Full relayout (not +// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment. +func (t *Tray) loadProfiles() { + t.profileLoadMu.Lock() + defer t.profileLoadMu.Unlock() + ctx := context.Background() + + username, err := t.svc.Profiles.Username() + if err != nil { + log.Debugf("get current user: %v", err) + return + } + profiles, err := t.svc.Profiles.List(ctx, username) + if err != nil { + log.Debugf("list profiles: %v", err) + return + } + + t.profilesMu.Lock() + t.profiles = profiles + t.profilesUser = username + t.profilesMu.Unlock() + + t.relayoutMenu() +} + +// fillProfileSubmenu paints cached profile rows into the freshly built submenu. +// Pure UI: never fetches, never calls SetMenu (relayoutMenu owns the SetMenu). +func (t *Tray) fillProfileSubmenu() { + if t.profileSubmenu == nil { + return + } + t.profilesMu.Lock() + profiles := append([]services.Profile(nil), t.profiles...) + username := t.profilesUser + t.profilesMu.Unlock() + + sort.Slice(profiles, func(i, j int) bool { + if profiles[i].Name != profiles[j].Name { + return profiles[i].Name < profiles[j].Name + } + return profiles[i].ID < profiles[j].ID + }) + + // Wails' systray does not reliably propagate a disabled parent to its + // children on every platform, so disable each row explicitly. + disableProfiles, _ := t.featuresDisabled() + + t.profileSubmenu.Clear() + var activeName, activeEmail string + for _, p := range profiles { + id := p.ID + // Display names can collide, so disambiguate with a short ID suffix. + display := formatProfileLabel(p, profiles) + active := p.IsActive + // Add, not AddCheckbox: Wails auto-toggles a checkbox on click before + // OnClick fires, so both old and new would briefly show checked during + // the switch. A plain item with a "✓ " prefix avoids the race. + label := display + if active { + label = "✓ " + display + } + item := t.profileSubmenu.Add(label) + item.OnClick(func(*application.Context) { + log.Infof("tray profile click: profile=%q id=%q wasActive=%v", display, id, active) + if active { + return + } + t.switchProfile(id, display) + }) + item.SetEnabled(!disableProfiles) + if active { + activeName = display + activeEmail = p.Email + } + } + t.profileSubmenu.AddSeparator() + manageProfiles := t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")) + manageProfiles.OnClick(func(*application.Context) { + t.svc.WindowManager.OpenSettings("profiles") + }) + manageProfiles.SetEnabled(!disableProfiles) + log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName) + if t.profileSubmenuItem != nil && activeName != "" { + t.profileSubmenuItem.SetLabel(activeName) + } + if t.profileEmailItem != nil { + if activeEmail != "" { + t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail)) + t.profileEmailItem.SetHidden(false) + } else { + t.profileEmailItem.SetHidden(true) + } + } +} + +// switchProfile cancels any in-flight switch before starting a new one, so +// rapid clicks converge to the last selected profile. Optimistic paint and +// event suppression live in ProfileSwitcher, shared with the React Status page. +// switchProfile sends handle (the profile's ID) to the daemon, which resolves +// it precisely even when display names collide. display is used only for the +// failure notification. +func (t *Tray) switchProfile(handle, display string) { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + t.switchCancel = cancel + t.profileMu.Unlock() + + go func() { + username, err := t.svc.Profiles.Username() + if err != nil { + log.Errorf("tray switchProfile: get current user: %v", err) + return + } + if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{ + ProfileName: handle, + Username: username, + }); err != nil { + if ctx.Err() != nil { + return + } + log.Errorf("tray switchProfile: %v", err) + t.notifyError(t.loc.T("notify.error.switchProfile", "profile", display)) + return + } + t.loadProfiles() + }() +} diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go new file mode 100644 index 000000000..6b73ddb49 --- /dev/null +++ b/client/ui/tray_session.go @@ -0,0 +1,271 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + nbstatus "github.com/netbirdio/netbird/client/status" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + notifyIDSessionExpired = "netbird-session-expired" + notifyIDSessionWarning = "netbird-session-warning" + + notifyCategorySessionWarning = "netbird-session-warning" + notifyActionExtendNow = "extend-now" + notifyActionDismiss = "dismiss" + + // finalWarningCountdownSeconds must stay in sync by hand with sessionwatch.FinalWarningLead. + finalWarningCountdownSeconds = 120 +) + +// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal. +func (t *Tray) handleSessionExpired() { + t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) + if t.window != nil { + t.window.SetURL("/#/login") + t.window.Show() + t.window.Focus() + } +} + +// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed. +// Cache-only; the caller relayouts when this returns true. +func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { + var d time.Time + if connected && deadline != nil { + d = *deadline + } + + t.sessionMu.Lock() + changed := !t.sessionExpiresAt.Equal(d) + t.sessionExpiresAt = d + t.sessionMu.Unlock() + + if changed { + switch { + case deadline == nil: + log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) + case deadline.IsZero(): + log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) + default: + log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v", + deadline.Format(time.RFC3339), time.Until(*deadline), connected) + } + } + return changed +} + +// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit. +func (t *Tray) runSessionExpiryTicker() { + tk := time.NewTicker(30 * time.Second) + for range tk.C { + t.refreshSessionExpiresLabel() + } +} + +// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu. +// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout. +func (t *Tray) refreshSessionExpiresLabel() { + t.menuMu.Lock() + item := t.sessionExpiresItem + t.menuMu.Unlock() + if item == nil { + return + } + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return + } + remaining := t.formatSessionRemaining(time.Until(deadline)) + item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) +} + +// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. +// Singular/plural keys are split per language for proper translation. +func (t *Tray) formatSessionRemaining(d time.Duration) string { + switch { + case d < time.Minute: + return t.loc.T("tray.session.unit.lessThanMinute") + case d < time.Hour: + m := int(d / time.Minute) + if m == 1 { + return t.loc.T("tray.session.unit.minute") + } + return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m)) + case d < 24*time.Hour: + h := int((d + 30*time.Minute) / time.Hour) + if h == 1 { + return t.loc.T("tray.session.unit.hour") + } + return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h)) + default: + days := int((d + 12*time.Hour) / (24 * time.Hour)) + if days == 1 { + return t.loc.T("tray.session.unit.day") + } + return t.loc.T("tray.session.unit.days", "count", strconv.Itoa(days)) + } +} + +// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning. +// Errors are swallowed since the worst case is a plain notification without buttons. +func (t *Tray) registerSessionWarningCategory() { + if t.svc.Notifier == nil { + return + } + if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{ + ID: notifyCategorySessionWarning, + Actions: []notifications.NotificationAction{ + {ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")}, + {ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")}, + }, + }); err != nil { + log.Debugf("register session-warning notification category: %v", err) + } + t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) { + if result.Error != nil { + log.Debugf("notification response error: %v", result.Error) + return + } + if result.Response.CategoryID != notifyCategorySessionWarning { + return + } + switch result.Response.ActionIdentifier { + case notifyActionExtendNow, notifications.DefaultActionIdentifier: + // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend. + go t.runExtendSession() + case notifyActionDismiss: + go t.dismissSessionWarning() + } + }) +} + +// buildSessionWarningBody composes the localised notification body from the daemon's metadata. +// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence. +// Falls back to a generic string when metadata is missing or unparsable. +func (t *Tray) buildSessionWarningBody(meta map[string]string) string { + if meta == nil { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + raw := meta[authsession.MetaExpiresAt] + if raw == "" { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + deadline, err := authsession.ParseExpiresAt(raw) + if err != nil { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + remaining := nbstatus.FormatRemainingDuration(time.Until(deadline)) + return t.loc.T("notify.sessionWarning.body", "remaining", remaining) +} + +// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the +// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests). +func (t *Tray) notifySessionWarning(title, body string) { + if t.svc.Notifier == nil { + return + } + err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{ + ID: notifyIDSessionWarning, + Title: title, + Body: body, + CategoryID: notifyCategorySessionWarning, + }) + if err != nil { + // A recovered panic returns nil err, so a dead bus correctly skips this fallback (it would panic too). + t.notify(title, body, notifyIDSessionWarning) + } +} + +// runExtendSession drives the daemon's RequestExtend + WaitExtend pair, opening the browser via Connection.OpenURL. +// Errors surface as notifyError rather than foreground UI, since the warning may fire while the window is closed. +func (t *Tray) runExtendSession() { + if t.svc.Session == nil || t.svc.Connection == nil { + log.Debugf("session-warning: extend requested but services not wired") + return + } + ctx := context.Background() + + start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{}) + if err != nil { + log.Warnf("session-warning: RequestExtend failed: %v", err) + t.notifyError(t.loc.T("notify.sessionWarning.failed")) + return + } + + uri := start.VerificationURIComplete + if uri == "" { + uri = start.VerificationURI + } + if uri != "" { + if err := t.svc.Connection.OpenURL(uri); err != nil { + log.Debugf("session-warning: opening verification URL: %v", err) + } + } + + result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ + DeviceCode: start.DeviceCode, + UserCode: start.UserCode, + }) + if err != nil { + log.Warnf("session-warning: WaitExtend failed: %v", err) + t.notifyError(t.loc.T("notify.sessionWarning.failed")) + return + } + if result.Preempted { + // Another UI surface owns the flow; stay silent so the user only sees the surviving flow's outcome. + log.Debugf("session-warning: WaitExtend preempted by a newer flow") + return + } + t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning) +} + +// dismissSessionWarning tells the daemon to silence the fallback dialog for the current deadline. +// Best-effort: a failure only means the dialog will still appear. +func (t *Tray) dismissSessionWarning() { + if t.svc.Session == nil { + return + } + if err := t.svc.Session.DismissWarning(context.Background()); err != nil { + log.Debugf("session-warning: DismissWarning failed: %v", err) + } +} + +// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. +// Idempotent on the WindowManager side. +func (t *Tray) openSessionExpiration() { + if t.svc.WindowManager == nil { + return + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) +} + +// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, +// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed. +func (t *Tray) openSessionExtendFlow() { + if t.svc.WindowManager == nil { + return + } + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return + } + seconds := int(time.Until(deadline).Seconds()) + if seconds <= 0 { + return + } + t.svc.WindowManager.OpenSessionExpiration(seconds) +} diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go new file mode 100644 index 000000000..793f1785a --- /dev/null +++ b/client/ui/tray_status.go @@ -0,0 +1,126 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "strings" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/ui/services" +) + +func (t *Tray) onStatusEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(services.Status) + if !ok { + return + } + t.applyStatus(st) +} + +// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped +// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus +// bursts during health probes that would otherwise spam Shell_NotifyIcon. +func (t *Tray) applyStatus(st services.Status) { + t.statusMu.Lock() + connected := strings.EqualFold(st.Status, services.StatusConnected) + iconChanged := connected != t.connected || st.Status != t.lastStatus + // The daemon re-emits SessionExpired on every snapshot while expired; act + // only on the transition into it so the notification fires once. + sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && + !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) + + triggerLogin := t.consumePendingConnectLogin(st.Status) + + daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion + t.connected = connected + t.lastStatus = st.Status + if daemonVersionChanged { + t.lastDaemonVersion = st.DaemonVersion + } + + revisionChanged := st.NetworksRevision != t.lastNetworksRevision + t.lastNetworksRevision = st.NetworksRevision + t.statusMu.Unlock() + + if triggerLogin { + t.app.Event.Emit(services.EventTriggerLogin) + } + + // Cache-only; the row is painted by the relayout below. + sessionChanged := t.applySessionExpiry(st.SessionExpiresAt, connected) + + if iconChanged { + t.applyIcon() + } + // All repainting goes through relayoutMenu (menuMu-serialised): applyStatus + // runs concurrently with itself and with relayouts, so in-place item + // mutation would race the buildMenu pointer swap. + if iconChanged || daemonVersionChanged || sessionChanged { + t.relayoutMenu() + } + // The revision is the only reliable signal: candidate routes never appear + // in the peer-status snapshot, so a removed exit node would go unnoticed. + if iconChanged || revisionChanged { + go t.refreshExitNodes() + } + // The daemon emits no active-profile event, so profile flips driven + // elsewhere (CLI, autoconnect) surface via status transitions. + if iconChanged { + go t.loadProfiles() + } + if sessionExpiredEnter { + t.handleSessionExpired() + } +} + +// consumePendingConnectLogin acts on the SSO auto-handoff flag armed by +// handleConnect. Returns true on NeedsLogin so the browser-login flow starts +// without a second Connect click; clears the flag on any terminal state so a +// stale flag can't fire on a later daemon flip. Must hold statusMu. +func (t *Tray) consumePendingConnectLogin(status string) bool { + if !t.pendingConnectLogin { + return false + } + switch { + case strings.EqualFold(status, services.StatusNeedsLogin): + t.pendingConnectLogin = false + return true + case strings.EqualFold(status, services.StatusConnected), + strings.EqualFold(status, services.StatusIdle), + strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, services.StatusSessionExpired), + strings.EqualFold(status, services.StatusDaemonUnavailable): + t.pendingConnectLogin = false + } + return false +} + +// applyStatusIndicator sets the status dot bitmap. Call only from relayoutMenu +// (menuMu held): on macOS the bitmap repaints via the relayout's trailing +// SetMenu, not here — the tree is half-built. +func (t *Tray) applyStatusIndicator(status string) { + if t.statusItem == nil { + return + } + t.statusItem.SetBitmap(statusIndicatorBitmap(status)) +} + +func statusIndicatorBitmap(status string) []byte { + switch { + case strings.EqualFold(status, services.StatusConnected): + return iconMenuDotConnected + case strings.EqualFold(status, services.StatusConnecting): + return iconMenuDotConnecting + case strings.EqualFold(status, services.StatusNeedsLogin), + strings.EqualFold(status, services.StatusSessionExpired): + return iconMenuDotConnecting + case strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, statusError): + return iconMenuDotError + case strings.EqualFold(status, services.StatusDaemonUnavailable): + return iconMenuDotOffline + default: + return iconMenuDotIdle + } +} diff --git a/client/ui/tray_status_enabled_linux.go b/client/ui/tray_status_enabled_linux.go new file mode 100644 index 000000000..ab7f869b8 --- /dev/null +++ b/client/ui/tray_status_enabled_linux.go @@ -0,0 +1,8 @@ +//go:build linux + +package main + +// statusRowEnabled keeps the top status row enabled on Linux: a disabled row +// paints greyed-out, washing out the status dot. The row has no OnClick, so +// enabling only affects drawing. +func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_status_enabled_other.go b/client/ui/tray_status_enabled_other.go new file mode 100644 index 000000000..606a7d702 --- /dev/null +++ b/client/ui/tray_status_enabled_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux && !android && !ios && !freebsd && !js + +package main + +// statusRowEnabled is false on macOS: disabling the row dims the label (signalling +// non-clickable) while keeping the bitmap opaque, so the coloured dot stays visible. +func statusRowEnabled() bool { return false } diff --git a/client/ui/tray_status_enabled_windows.go b/client/ui/tray_status_enabled_windows.go new file mode 100644 index 000000000..06750b7c0 --- /dev/null +++ b/client/ui/tray_status_enabled_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +// statusRowEnabled is always true on Windows: the Win32 disabled-state mask +// desaturates the row's HBITMAP, which would grey out the coloured status dot. +func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go new file mode 100644 index 000000000..a3249e57a --- /dev/null +++ b/client/ui/tray_theme_linux.go @@ -0,0 +1,134 @@ +//go:build linux && !(linux && 386) + +package main + +// Wails v3's Linux SNI backend ignores SetDarkModeIcon (it just calls setIcon, +// last write wins) and SNI carries no panel dark/light hint, so we detect the +// desktop colour scheme ourselves and pick the silhouette in iconForState. +// The live watcher is in tray_theme_watcher_linux.go. + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must +// run before the first applyIcon so the initial paint uses the right silhouette. +func (t *Tray) startTrayTheme() { + w := startThemeWatcher(func() { t.applyIcon() }) + t.panelDark = w.IsDark +} + +// isKDE reports whether the current desktop is KDE Plasma. XDG_CURRENT_DESKTOP +// is a colon-separated list (e.g. "ubuntu:KDE"), so match per token. +func isKDE() bool { + for _, d := range strings.Split(os.Getenv("XDG_CURRENT_DESKTOP"), ":") { + if strings.EqualFold(strings.TrimSpace(d), "KDE") { + return true + } + } + return false +} + +// kdeglobalsPath returns the user kdeglobals path. We read only this file, not +// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a +// missing Complementary group falls back to the portal. +func kdeglobalsPath() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "kdeglobals") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "kdeglobals") +} + +// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of +// its "Complementary" background (the colour Plasma paints the tray with). ok +// is false when this isn't KDE or the colour can't be read, so the caller falls +// through to the portal/GTK path. +func kdePanelIsDark() (dark, ok bool) { + if !isKDE() { + return false, false + } + path := kdeglobalsPath() + if path == "" { + return false, false + } + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + return false, false + } + return isDarkRGB(rgb[0], rgb[1], rgb[2]), true +} + +// readKdeComplementaryBackground parses kdeglobals for +// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255). +func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) { + f, err := os.Open(path) + if err != nil { + log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err) + return rgb, false + } + defer func() { _ = f.Close() }() + + const group = "[Colors:Complementary]" + inGroup := false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") { + inGroup = line == group + continue + } + if !inGroup { + continue + } + key, val, found := strings.Cut(line, "=") + if !found || strings.TrimSpace(key) != "BackgroundNormal" { + continue + } + return parseRGB(strings.TrimSpace(val)) + } + return rgb, false +} + +// parseRGB parses KDE's "r,g,b" colour triple into bytes. +func parseRGB(s string) (rgb [3]uint8, ok bool) { + parts := strings.Split(s, ",") + if len(parts) != 3 { + return rgb, false + } + for i, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil || n < 0 || n > 255 { + return rgb, false + } + rgb[i] = uint8(n) + } + return rgb, true +} + +// isDarkRGB reports whether a colour is dark via Rec. 601 luma, split at the +// 128 midpoint. +func isDarkRGB(r, g, b uint8) bool { + luma := (299*int(r) + 587*int(g) + 114*int(b)) / 1000 + return luma < 128 +} + +// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is treated +// as dark to match the default-dark fallback used elsewhere. +func gtkThemeIsDark() bool { + theme := os.Getenv("GTK_THEME") + if theme == "" { + return true + } + // GTK_THEME is "Name[:variant]"; the dark variant is ":dark". + return strings.Contains(strings.ToLower(theme), ":dark") +} diff --git a/client/ui/tray_theme_linux_test.go b/client/ui/tray_theme_linux_test.go new file mode 100644 index 000000000..f14f08d7f --- /dev/null +++ b/client/ui/tray_theme_linux_test.go @@ -0,0 +1,86 @@ +//go:build linux && !(linux && 386) + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadKdeComplementaryBackground(t *testing.T) { + // Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark. + // The tray sits on the panel, which Plasma paints from Complementary, so + // the panel is dark even though the global color-scheme is Light. + content := `[Colors:Window] +BackgroundNormal=239,240,241 + +[Colors:Complementary] +BackgroundAlternate=27,30,32 +BackgroundNormal=42,46,50 + +[General] +ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4 +` + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + t.Fatal("expected to find Complementary BackgroundNormal") + } + if rgb != [3]uint8{42, 46, 50} { + t.Fatalf("rgb = %v, want [42 46 50]", rgb) + } + if !isDarkRGB(rgb[0], rgb[1], rgb[2]) { + t.Fatal("panel colour 42,46,50 should be dark") + } + // The Window background (what color-scheme reflects) is light — the bug + // this fix addresses is picking the icon from that instead of the panel. + if isDarkRGB(239, 240, 241) { + t.Fatal("window colour 239,240,241 should be light") + } +} + +func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readKdeComplementaryBackground(path); ok { + t.Fatal("expected not-ok when Complementary group is absent") + } +} + +func TestParseRGB(t *testing.T) { + if _, ok := parseRGB("1,2"); ok { + t.Fatal("two components should fail") + } + if _, ok := parseRGB("300,0,0"); ok { + t.Fatal("out-of-range should fail") + } + if _, ok := parseRGB("a,b,c"); ok { + t.Fatal("non-numeric should fail") + } + rgb, ok := parseRGB(" 10 , 20 , 30 ") + if !ok || rgb != [3]uint8{10, 20, 30} { + t.Fatalf("parseRGB = %v ok=%v, want [10 20 30] true", rgb, ok) + } +} + +func TestIsDarkRGB(t *testing.T) { + if !isDarkRGB(0, 0, 0) { + t.Fatal("black is dark") + } + if isDarkRGB(255, 255, 255) { + t.Fatal("white is light") + } + if !isDarkRGB(42, 46, 50) { + t.Fatal("Breeze panel grey is dark") + } + if isDarkRGB(239, 240, 241) { + t.Fatal("Breeze window grey is light") + } +} diff --git a/client/ui/tray_theme_other.go b/client/ui/tray_theme_other.go new file mode 100644 index 000000000..3f85e1603 --- /dev/null +++ b/client/ui/tray_theme_other.go @@ -0,0 +1,7 @@ +//go:build (!linux || (linux && 386)) && !android && !ios && !freebsd && !js + +package main + +func (t *Tray) startTrayTheme() { + // No-op off Linux: leaves panelDark nil so panelIsDark uses its default. +} diff --git a/client/ui/tray_theme_watcher_linux.go b/client/ui/tray_theme_watcher_linux.go new file mode 100644 index 000000000..b9bafe30b --- /dev/null +++ b/client/ui/tray_theme_watcher_linux.go @@ -0,0 +1,246 @@ +//go:build linux && !(linux && 386) + +package main + +// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE +// the kdeglobals file (the portal's color-scheme doesn't track the panel's +// Complementary colour — see readDarkMode). The dark/light decision lives in +// tray_theme_linux.go; this file owns the session-bus connection and subscriptions. + +import ( + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + portalBusName = "org.freedesktop.portal.Desktop" + portalObjectPath = "/org/freedesktop/portal/desktop" + portalSettings = "org.freedesktop.portal.Settings" + + appearanceNamespace = "org.freedesktop.appearance" + colorSchemeKey = "color-scheme" + + colorSchemeNoPreference = 0 + colorSchemePreferDark = 1 + colorSchemePreferLight = 2 +) + +// themeWatcher owns a private session-bus connection so its signal subscription +// is isolated from the SNI watcher's. +type themeWatcher struct { + conn *dbus.Conn + onChange func() + + mu sync.Mutex + darkMode bool +} + +// startThemeWatcher returns nil if the session bus is unavailable; callers treat +// a nil watcher as "no preference", keeping the default-dark icon. +func startThemeWatcher(onChange func()) *themeWatcher { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err) + return nil + } + if err := conn.Auth(nil); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus auth failed: %v", err) + return nil + } + if err := conn.Hello(); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus hello failed: %v", err) + return nil + } + + w := &themeWatcher{conn: conn, onChange: onChange} + w.darkMode = w.readDarkMode() + + if err := w.subscribe(); err != nil { + log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err) + // Keep the connection: the seeded darkMode value is still useful. + } + + // The portal's signal doesn't track KDE's panel Complementary colour. + if isKDE() { + w.watchKdeglobals() + } + + log.Infof("tray theme: panel dark mode = %v", w.IsDark()) + return w +} + +// IsDark reports true for a nil watcher, so the icon defaults to the white +// silhouette suiting the common dark Linux panel. +func (w *themeWatcher) IsDark() bool { + if w == nil { + return true + } + w.mu.Lock() + defer w.mu.Unlock() + return w.darkMode +} + +// readDarkMode resolves whether the panel the tray icon sits on is dark. +// +// On KDE the freedesktop color-scheme is the application preference, not the +// panel's: Plasma paints its panel from the Breeze "Complementary" group, which +// stays dark even under a Light global scheme, so we read the panel background +// from kdeglobals first and decide by its luma. Off KDE the color-scheme portal +// is the source; on "no preference" (0) or when unavailable we fall back to +// GTK_THEME (":dark" suffix ⇒ dark), then default to dark. +func (w *themeWatcher) readDarkMode() bool { + if dark, ok := kdePanelIsDark(); ok { + return dark + } + switch w.readColorScheme() { + case colorSchemePreferDark: + return true + case colorSchemePreferLight: + return false + default: + return gtkThemeIsDark() + } +} + +// readColorScheme returns the raw freedesktop color-scheme value, or +// colorSchemeNoPreference when the portal can't be reached. +func (w *themeWatcher) readColorScheme() uint32 { + obj := w.conn.Object(portalBusName, portalObjectPath) + call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey) + if call.Err != nil { + log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err) + return colorSchemeNoPreference + } + + var v dbus.Variant + if err := call.Store(&v); err != nil { + log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err) + return colorSchemeNoPreference + } + + return variantToColorScheme(v) +} + +func (w *themeWatcher) subscribe() error { + if err := w.conn.AddMatchSignal( + dbus.WithMatchObjectPath(portalObjectPath), + dbus.WithMatchInterface(portalSettings), + dbus.WithMatchMember("SettingChanged"), + ); err != nil { + return err + } + + sigs := make(chan *dbus.Signal, 8) + w.conn.Signal(sigs) + go w.loop(sigs) + return nil +} + +func (w *themeWatcher) loop(sigs chan *dbus.Signal) { + for sig := range sigs { + if sig.Name != portalSettings+".SettingChanged" { + continue + } + // Signal body: (namespace string, key string, value variant). + if len(sig.Body) < 3 { + continue + } + namespace, _ := sig.Body[0].(string) + key, _ := sig.Body[1].(string) + if namespace != appearanceNamespace || key != colorSchemeKey { + continue + } + if _, ok := sig.Body[2].(dbus.Variant); !ok { + continue + } + + // Re-resolve via readDarkMode, not the signal value: under KDE the panel + // colour comes from kdeglobals, so the signal value would be wrong. + w.update() + } +} + +func (w *themeWatcher) update() { + dark := w.readDarkMode() + w.mu.Lock() + changed := dark != w.darkMode + w.darkMode = dark + w.mu.Unlock() + + if changed && w.onChange != nil { + log.Infof("tray theme: panel dark mode changed to %v", dark) + w.onChange() + } +} + +// watchKdeglobals watches the parent directory, not the file: KDE rewrites +// kdeglobals atomically (write-temp + rename), which would drop an inotify watch +// on the original inode. Filtering by name re-arms implicitly. +func (w *themeWatcher) watchKdeglobals() { + path := kdeglobalsPath() + if path == "" { + return + } + dir, name := filepath.Split(path) + + fw, err := fsnotify.NewWatcher() + if err != nil { + log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err) + return + } + if err := fw.Add(filepath.Clean(dir)); err != nil { + log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err) + _ = fw.Close() + return + } + + go func() { + defer func() { _ = fw.Close() }() + for { + select { + case event, ok := <-fw.Events: + if !ok { + return + } + if filepath.Base(event.Name) != name { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + w.update() + case err, ok := <-fw.Errors: + if !ok { + return + } + log.Debugf("tray theme: kdeglobals watch error: %v", err) + } + } + }() +} + +// variantToColorScheme unwraps the color-scheme variant; the portal nests it one level. +func variantToColorScheme(v dbus.Variant) uint32 { + inner := v.Value() + if nested, ok := inner.(dbus.Variant); ok { + inner = nested.Value() + } + + switch n := inner.(type) { + case uint32: + return n + case int32: + return uint32(n) + case uint8: + return uint32(n) + default: + log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner) + return colorSchemeNoPreference + } +} diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go new file mode 100644 index 000000000..2d79cff05 --- /dev/null +++ b/client/ui/tray_update.go @@ -0,0 +1,197 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" +) + +// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. +type trayUpdater struct { + app *application.App + window *application.WebviewWindow + update *services.Update + notifier *notifications.NotificationService + loc *Localizer + onIconChange func() + // onMenuChange drives a full tray relayout: the update row lives in the + // About submenu, which KDE/Plasma caches on first open and never re-fetches + // on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints. + onMenuChange func() + + mu sync.Mutex + item *application.MenuItem + state updater.State + notifiedVersion string + progressWindowOpen bool +} + +func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { + u := &trayUpdater{ + app: app, + window: window, + update: update, + notifier: notifier, + loc: loc, + onIconChange: onIconChange, + onMenuChange: onMenuChange, + } + app.Event.On(updater.EventStateChanged, u.onStateEvent) + // Seed from cached state to cover an event that fired before wiring completed. + u.state = update.GetState() + return u +} + +// attach (re)binds the menu item on each Tray.buildMenu run. The caller owns the +// item's OnClick handler. +func (u *trayUpdater) attach(item *application.MenuItem) { + u.mu.Lock() + u.item = item + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// hasUpdate reports whether the tray should paint the "update available" icon. +func (u *trayUpdater) hasUpdate() bool { + u.mu.Lock() + defer u.mu.Unlock() + return u.state.Available +} + +// applyLanguage re-renders the menu item label after a locale switch. +func (u *trayUpdater) applyLanguage() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// handleClick opens the GitHub releases page when not Enforced, otherwise shows +// the progress page and asks the daemon to start the installer. +func (u *trayUpdater) handleClick() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + + if !state.Enforced { + _ = u.app.Browser.OpenURL(urlGitHubReleases) + return + } + + u.openProgressWindow(state.Version) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := u.update.Trigger(ctx); err != nil { + log.Errorf("trigger update: %v", err) + } + }() +} + +func (u *trayUpdater) onStateEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(updater.State) + if !ok { + log.Warnf("update state event payload not UpdateState: %T", ev.Data) + return + } + u.applyState(st) +} + +// applyState diffs st against the cached state and drives the resulting side +// effects: icon repaint, menu refresh, new-version notification, progress window. +func (u *trayUpdater) applyState(st updater.State) { + u.mu.Lock() + prev := u.state + u.state = st + + sendNotify := st.Available && st.Version != "" && st.Version != u.notifiedVersion + if sendNotify { + u.notifiedVersion = st.Version + } + + showWindow := st.Installing && !u.progressWindowOpen + if showWindow { + u.progressWindowOpen = true + } else if !st.Installing { + u.progressWindowOpen = false + } + u.mu.Unlock() + + // Full relayout rather than in-place: KDE layout-caches the About submenu, so + // a direct SetLabel/SetHidden wouldn't paint. Fall back if no hook was wired. + if u.onMenuChange != nil { + u.onMenuChange() + } else { + u.refreshMenuItem(st) + } + if prev.Available != st.Available && u.onIconChange != nil { + u.onIconChange() + } + if sendNotify { + u.sendUpdateNotification(st) + } + if showWindow { + u.openProgressWindow(st.Version) + } +} + +func (u *trayUpdater) refreshMenuItem(st updater.State) { + u.mu.Lock() + item := u.item + u.mu.Unlock() + if item == nil { + return + } + + if !st.Available { + item.SetHidden(true) + return + } + if st.Enforced { + item.SetLabel(u.loc.T("tray.menu.installVersion", "version", st.Version)) + } else { + item.SetLabel(u.loc.T("tray.menu.downloadLatest")) + } + item.SetHidden(false) +} + +func (u *trayUpdater) sendUpdateNotification(st updater.State) { + if u.notifier == nil { + return + } + body := u.loc.T("notify.update.body", "version", st.Version) + if st.Enforced { + body += u.loc.T("notify.update.enforcedSuffix") + } + _ = safeSendNotification(u.notifier.SendNotification, "update", notifications.NotificationOptions{ + ID: notifyIDUpdatePrefix + st.Version, + Title: u.loc.T("notify.update.title"), + Body: body, + }) +} + +// openProgressWindow points the main window at the /update progress page and +// brings it forward. +func (u *trayUpdater) openProgressWindow(version string) { + if u.window == nil { + return + } + url := "/#/update" + if version != "" { + url += "?version=" + version + } + u.window.SetURL(url) + u.window.Show() + u.window.Focus() +} diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go new file mode 100644 index 000000000..38476179a --- /dev/null +++ b/client/ui/tray_watcher_linux.go @@ -0,0 +1,176 @@ +//go:build linux && !(linux && 386) + +package main + +// In-process org.kde.StatusNotifierWatcher for minimal WMs (Fluxbox, OpenBox, +// i3) that ship no watcher. When an XEmbed tray exists (_NET_SYSTEM_TRAY_S0), +// an in-process XEmbed host bridges the SNI icon into it. + +import ( + "sync" + "time" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + watcherName = "org.kde.StatusNotifierWatcher" + watcherPath = "/StatusNotifierWatcher" + watcherIface = "org.kde.StatusNotifierWatcher" + + // The UI is often autostarted before the panel on minimal WMs, so a single + // startup probe would miss a tray that appears a second later. + watcherProbeInterval = 500 * time.Millisecond + watcherProbeTimeout = 10 * time.Second +) + +type statusNotifierWatcher struct { + conn *dbus.Conn + items []string + hosts map[string]*xembedHost + hostsMu sync.Mutex +} + +// RegisterStatusNotifierItem is the D-Bus method called by tray clients. +// sender is injected by godbus and is not part of the D-Bus signature. +func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error { + for _, s := range w.items { + if s == service { + return nil + } + } + w.items = append(w.items, service) + log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender) + + go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service)) + return nil +} + +// RegisterStatusNotifierHost is required by the protocol but unused here. +func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error { + log.Debugf("StatusNotifierWatcher: host registered %q", service) + return nil +} + +// tryStartXembedHost is a no-op when no XEmbed tray manager is available. +func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) { + w.hostsMu.Lock() + defer w.hostsMu.Unlock() + + if _, exists := w.hosts[busName]; exists { + return + } + + // Private session bus so our signal subscriptions don't reach Wails' + // signal handler, which panics on unexpected signals. + sessionConn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err) + return + } + if err := sessionConn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err) + closeBus(sessionConn) + return + } + if err := sessionConn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err) + closeBus(sessionConn) + return + } + + host, err := newXembedHost(sessionConn, busName, objPath) + if err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err) + closeBus(sessionConn) + return + } + + w.hosts[busName] = host + go host.run() + log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName) +} + +// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher only as a +// bridge to an XEmbed tray on minimal WMs. The watcher is a stub that never +// relays items to a real StatusNotifierHost, so claiming the name on a desktop +// with a real host (e.g. Hyprland + Waybar) would dead-end every other tray +// app's icon. It gates on the actual presence of an XEmbed tray rather than +// GetNameOwner, which can't win a login-order race; without one it stays off +// the bus so the real watcher owns the name. The XEmbed tray may come up after +// the UI, so it re-probes for a grace period rather than deciding once. +// Safe to call unconditionally. +func startStatusNotifierWatcher() { + go func() { + deadline := time.Now().Add(watcherProbeTimeout) + for { + if xembedTrayAvailable() { + claimStatusNotifierWatcher() + return + } + if time.Now().After(deadline) { + log.Debugf("StatusNotifierWatcher: no XEmbed tray appeared within %s, leaving the watcher to the desktop", watcherProbeTimeout) + return + } + time.Sleep(watcherProbeInterval) + } + }() +} + +// claimStatusNotifierWatcher takes ownership of org.kde.StatusNotifierWatcher +// on a private session bus and exports the stub watcher. The GetNameOwner / +// DoNotQueue guards back off if a real watcher already holds the name. +func claimStatusNotifierWatcher() { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err) + return + } + if err := conn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: auth failed: %v", err) + closeBus(conn) + return + } + if err := conn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: Hello failed: %v", err) + closeBus(conn) + return + } + + var owner string + callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner) + if callErr == nil && owner != "" { + log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner) + closeBus(conn) + return + } + + reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err) + closeBus(conn) + return + } + + w := &statusNotifierWatcher{ + conn: conn, + hosts: make(map[string]*xembedHost), + } + if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil { + log.Errorf("StatusNotifierWatcher: export failed: %v", err) + closeBus(conn) + return + } + + log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)") + // Connection kept open for the process lifetime. +} + +// closeBus closes a private session bus opened on a back-off path, logging a +// warning rather than swallowing the error. +func closeBus(conn *dbus.Conn) { + if err := conn.Close(); err != nil { + log.Warnf("StatusNotifierWatcher: closing session bus failed: %v", err) + } +} diff --git a/client/ui/tray_watcher_other.go b/client/ui/tray_watcher_other.go new file mode 100644 index 000000000..ef8a1bb52 --- /dev/null +++ b/client/ui/tray_watcher_other.go @@ -0,0 +1,9 @@ +//go:build (!linux || (linux && 386)) && !freebsd && !android && !ios && !js + +package main + +// startStatusNotifierWatcher is a no-op stub so main.go can call it across all +// build targets; only minimal Linux WMs need the real watcher (tray_watcher_linux.go). +func startStatusNotifierWatcher() { + // Intentionally empty: only minimal Linux WMs need the real SNI watcher. +} diff --git a/client/ui/uilogpath.go b/client/ui/uilogpath.go new file mode 100644 index 000000000..96fbb9637 --- /dev/null +++ b/client/ui/uilogpath.go @@ -0,0 +1,37 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/guilog" +) + +// uiLogFileName must stay in sync with the daemon's "gui-client*.log.*" glob +// for rotated siblings (addUILog in client/internal/debug). +const uiLogFileName = "gui-client.log" + +// uiLogPath returns the GUI log path with native separators, since the daemon +// opens it directly for debug-bundle collection. +func uiLogPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", uiLogFileName), nil +} + +// newDebugLog builds the GUI debug log, disabled when userSetLogFile is set +// (manual --log-file override) or the config dir can't be resolved. +func newDebugLog(userSetLogFile bool) *guilog.DebugLog { + path, err := uiLogPath() + if err != nil { + log.Warnf("resolve GUI log path: %v; GUI file logging disabled", err) + return guilog.NewDebugLog("", false) + } + return guilog.NewDebugLog(path, !userSetLogFile) +} diff --git a/client/ui/update.go b/client/ui/update.go deleted file mode 100644 index 25c317bdf..000000000 --- a/client/ui/update.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -func (s *serviceClient) showUpdateProgress(ctx context.Context, version string) { - log.Infof("show installer progress window: %s", version) - s.wUpdateProgress = s.app.NewWindow("Automatically updating client") - - statusLabel := widget.NewLabel("Updating...") - infoLabel := widget.NewLabel(fmt.Sprintf("Your client version is older than the auto-update version set in Management.\nUpdating client to: %s.", version)) - content := container.NewVBox(infoLabel, statusLabel) - s.wUpdateProgress.SetContent(content) - s.wUpdateProgress.CenterOnScreen() - s.wUpdateProgress.SetFixedSize(true) - s.wUpdateProgress.SetCloseIntercept(func() { - // this is empty to lock window until result known - }) - s.wUpdateProgress.RequestFocus() - s.wUpdateProgress.Show() - - updateWindowCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) - - // Initialize dot updater - updateText := dotUpdater() - - // Channel to receive the result from RPC call - resultErrCh := make(chan error, 1) - resultOkCh := make(chan struct{}, 1) - - // Start RPC call in background - go func() { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Infof("backend not reachable, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - resp, err := conn.GetInstallerResult(updateWindowCtx, &proto.InstallerResultRequest{}) - if err != nil { - log.Infof("backend stopped responding, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - if !resp.Success { - resultErrCh <- mapInstallError(resp.ErrorMsg) - return - } - - // Success - close(resultOkCh) - }() - - // Update UI with dots and wait for result - go func() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - defer cancel() - - // allow closing update window after 10 sec - timerResetCloseInterceptor := time.NewTimer(10 * time.Second) - defer timerResetCloseInterceptor.Stop() - - for { - select { - case <-updateWindowCtx.Done(): - s.showInstallerResult(statusLabel, updateWindowCtx.Err()) - return - case err := <-resultErrCh: - s.showInstallerResult(statusLabel, err) - return - case <-resultOkCh: - log.Info("backend exited, upgrade in progress, closing all UI") - killParentUIProcess() - s.app.Quit() - return - case <-ticker.C: - statusLabel.SetText(updateText()) - case <-timerResetCloseInterceptor.C: - s.wUpdateProgress.SetCloseIntercept(nil) - } - } - }() -} - -func (s *serviceClient) showInstallerResult(statusLabel *widget.Label, err error) { - s.wUpdateProgress.SetCloseIntercept(nil) - switch { - case errors.Is(err, context.DeadlineExceeded): - log.Warn("update watcher timed out") - statusLabel.SetText("Update timed out. Please try again.") - case errors.Is(err, context.Canceled): - log.Info("update watcher canceled") - statusLabel.SetText("Update canceled.") - case err != nil: - log.Errorf("update failed: %v", err) - statusLabel.SetText("Update failed: " + err.Error()) - default: - s.wUpdateProgress.Close() - } -} - -// dotUpdater returns a closure that cycles through dots for a loading animation. -func dotUpdater() func() string { - dotCount := 0 - return func() string { - dotCount = (dotCount + 1) % 4 - return fmt.Sprintf("%s%s", "Updating", strings.Repeat(".", dotCount)) - } -} - -func mapInstallError(msg string) error { - msg = strings.ToLower(strings.TrimSpace(msg)) - - switch { - case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"): - return context.DeadlineExceeded - case strings.Contains(msg, "canceled"), strings.Contains(msg, "cancelled"): - return context.Canceled - case msg == "": - return errors.New("unknown update error") - default: - return errors.New(msg) - } -} diff --git a/client/ui/update_notwindows.go b/client/ui/update_notwindows.go deleted file mode 100644 index 5766f18f7..000000000 --- a/client/ui/update_notwindows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !(linux && 386) - -package main - -func killParentUIProcess() { - // No-op on non-Windows platforms -} diff --git a/client/ui/update_windows.go b/client/ui/update_windows.go deleted file mode 100644 index 1b03936f9..000000000 --- a/client/ui/update_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build windows - -package main - -import ( - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" - - nbprocess "github.com/netbirdio/netbird/client/ui/process" -) - -// killParentUIProcess finds and kills the parent systray UI process on Windows. -// This is a workaround in case the MSI installer fails to properly terminate the UI process. -// The installer should handle this via util:CloseApplication with TerminateProcess, but this -// provides an additional safety mechanism to ensure the UI is closed before the upgrade proceeds. -func killParentUIProcess() { - pid, running, err := nbprocess.IsAnotherProcessRunning() - if err != nil { - log.Warnf("failed to check for parent UI process: %v", err) - return - } - - if !running { - log.Debug("no parent UI process found to kill") - return - } - - log.Infof("killing parent UI process (PID: %d)", pid) - - // Open the process with terminate rights - handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) - if err != nil { - log.Warnf("failed to open parent process %d: %v", pid, err) - return - } - defer func() { - _ = windows.CloseHandle(handle) - }() - - // Terminate the process with exit code 0 - if err := windows.TerminateProcess(handle, 0); err != nil { - log.Warnf("failed to terminate parent process %d: %v", pid, err) - } -} diff --git a/client/ui/updater/state.go b/client/ui/updater/state.go new file mode 100644 index 000000000..606f8b401 --- /dev/null +++ b/client/ui/updater/state.go @@ -0,0 +1,97 @@ +//go:build !android && !ios && !freebsd && !js + +// Package updater holds the auto-update domain: the typed State, the +// daemon-SystemEvent metadata schema, and the Holder that caches the latest +// state and broadcasts changes. No Wails dependency. +package updater + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// EventStateChanged carries the full State snapshot as payload. +const EventStateChanged = "netbird:update:state" + +// State is the typed snapshot of the daemon's update situation. Installing is +// driven only by the daemon's progress_window:show event; a UI-side +// Update.Trigger() does not flip it. +type State struct { + Available bool `json:"available"` + Version string `json:"version"` + Enforced bool `json:"enforced"` + Installing bool `json:"installing"` +} + +// Emitter is the broadcast dependency Holder needs; the Wails app.Event +// processor satisfies it. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Holder caches the latest update State and broadcasts changes. +type Holder struct { + emitter Emitter + + mu sync.Mutex + state State +} + +// NewHolder constructs an empty-state Holder. A nil emitter skips the broadcast. +func NewHolder(emitter Emitter) *Holder { + return &Holder{emitter: emitter} +} + +// Get returns a copy of the cached State. +func (h *Holder) Get() State { + h.mu.Lock() + defer h.mu.Unlock() + return h.state +} + +// OnSystemEvent folds update-related metadata into the cached state, emitting +// EventStateChanged only on an actual change so repeated daemon snapshots +// don't produce redundant pushes. +func (h *Holder) OnSystemEvent(ev *proto.SystemEvent) { + md := ev.GetMetadata() + if len(md) == 0 { + return + } + + h.mu.Lock() + changed := false + if v, ok := md["new_version_available"]; ok { + _, enforced := md["enforced"] + if !h.state.Available || h.state.Version != v || h.state.Enforced != enforced { + h.state.Available = true + h.state.Version = v + h.state.Enforced = enforced + changed = true + } + } + if md["progress_window"] == "show" { + if !h.state.Installing { + h.state.Installing = true + changed = true + } + if v, ok := md["version"]; ok && v != "" && h.state.Version != v { + h.state.Version = v + h.state.Available = true + changed = true + } + } + snap := h.state + h.mu.Unlock() + + if !changed { + return + } + log.Infof("update state: available=%v version=%q enforced=%v installing=%v", + snap.Available, snap.Version, snap.Enforced, snap.Installing) + if h.emitter != nil { + h.emitter.Emit(EventStateChanged, snap) + } +} diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go new file mode 100644 index 000000000..ba7a5d07c --- /dev/null +++ b/client/ui/xembed_host_linux.go @@ -0,0 +1,443 @@ +//go:build linux && !(linux && 386) + +package main + +/* +#cgo pkg-config: x11 gtk4 gtk4-x11 cairo cairo-xlib +#cgo LDFLAGS: -lX11 +#include "xembed_tray_linux.h" +#include +#include +#include +*/ +import "C" + +import ( + "errors" + "sync" + "time" + "unsafe" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +// activeMenuHost holds the popup owner; C callbacks cannot carry Go pointers. +var ( + activeMenuHost *xembedHost + activeMenuHostMu sync.Mutex +) + +// menuItemInfo is a dbusMenuLayout entry flattened for the C popup builder. +type menuItemInfo struct { + id int32 + label string + enabled bool + isCheck bool + checked bool + isSeparator bool + children []menuItemInfo +} + +// dbusMenuLayout mirrors the (ia{sv}av) result of com.canonical.dbusmenu.GetLayout. +// Each Children variant wraps a nested dbusMenuLayout, decoded in flattenMenu. +type dbusMenuLayout struct { + ID int32 + Properties map[string]dbus.Variant + Children []dbus.Variant +} + +// xembedHost manages one XEmbed tray icon for an SNI item. +type xembedHost struct { + conn *dbus.Conn + busName string + objPath dbus.ObjectPath + + dpy *C.Display + trayMgr C.Window + iconWin C.Window + iconSize int + + mu sync.Mutex + iconData []byte + iconW int + iconH int + + stopCh chan struct{} +} + +// newXembedHost creates an XEmbed tray icon for the given SNI item. +// Errors when no XEmbed tray manager is available, so callers can fall back. +func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return nil, errors.New("cannot open X display") + } + C.xembed_install_error_handlers() + + screen := C.xembed_default_screen(dpy) + trayMgr := C.xembed_find_tray(dpy, screen) + if trayMgr == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("no XEmbed system tray found") + } + + iconSize := int(C.xembed_get_icon_size(dpy, trayMgr)) + if iconSize <= 0 { + iconSize = 24 // fallback + } + + iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr) + if iconWin == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("failed to create icon window") + } + + if C.xembed_dock(dpy, trayMgr, iconWin) != 0 { + C.xembed_destroy_icon(dpy, iconWin) + C.XCloseDisplay(dpy) + return nil, errors.New("failed to dock icon") + } + + h := &xembedHost{ + conn: conn, + busName: busName, + objPath: objPath, + dpy: dpy, + trayMgr: trayMgr, + iconWin: iconWin, + iconSize: iconSize, + stopCh: make(chan struct{}), + } + + h.fetchAndDrawIcon() + return h, nil +} + +func (h *xembedHost) fetchAndDrawIcon() { + obj := h.conn.Object(h.busName, h.objPath) + variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap") + if err != nil { + log.Debugf("xembed: failed to get IconPixmap: %v", err) + return + } + + // IconPixmap has D-Bus signature a(iiay). + type px struct { + W int32 + H int32 + Pix []byte + } + + var icons []px + if err := variant.Store(&icons); err != nil { + log.Debugf("xembed: failed to decode IconPixmap: %v", err) + return + } + + if len(icons) == 0 { + log.Debug("xembed: IconPixmap is empty") + return + } + + icon := icons[0] + if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) { + log.Debug("xembed: invalid IconPixmap data") + return + } + + h.mu.Lock() + h.iconData = icon.Pix + h.iconW = int(icon.W) + h.iconH = int(icon.H) + h.mu.Unlock() + + h.drawIcon() +} + +func (h *xembedHost) drawIcon() { + h.mu.Lock() + data := h.iconData + w := h.iconW + ht := h.iconH + h.mu.Unlock() + + if data == nil || w <= 0 || ht <= 0 { + return + } + + cData := C.CBytes(data) + defer C.free(cData) + + C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize), + (*C.uchar)(cData), C.int(w), C.int(ht)) +} + +// run is the event loop: polls X11 events and D-Bus NewIcon signals until stopped. +func (h *xembedHost) run() { + matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'" + if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { + log.Debugf("xembed: failed to add signal match: %v", err) + } + + sigCh := make(chan *dbus.Signal, 16) + h.conn.Signal(sigCh) + defer h.conn.RemoveSignal(sigCh) + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-h.stopCh: + return + + case sig := <-sigCh: + if sig == nil { + continue + } + if sig.Name == "org.kde.StatusNotifierItem.NewIcon" { + h.fetchAndDrawIcon() + } + + case <-ticker.C: + var outX, outY C.int + result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY) + + switch result { + case 1: // left click + go h.activate(int32(outX), int32(outY)) + case 2: // right click + go h.contextMenu(int32(outX), int32(outY)) + case 3: // expose + h.drawIcon() + case 4: // configure (resize) + newSize := int(outX) + if newSize > 0 && newSize != h.iconSize { + h.iconSize = newSize + h.drawIcon() + } + case -1: // tray died + log.Info("xembed: tray manager destroyed, cleaning up") + return + } + } + } +} + +func (h *xembedHost) activate(x, y int32) { + obj := h.conn.Object(h.busName, h.objPath) + if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil { + log.Debugf("xembed: Activate call failed: %v", err) + } +} + +func (h *xembedHost) contextMenu(x, y int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + + menuObj := h.conn.Object(h.busName, menuPath) + var revision uint32 + var layout dbusMenuLayout + err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0, + int32(0), // parentId (root) + int32(-1), // recursionDepth (all) + []string{}, // propertyNames (all) + ).Store(&revision, &layout) + if err != nil { + log.Debugf("xembed: GetLayout failed: %v", err) + return + } + + items := h.flattenMenu(layout) + log.Debugf("xembed: menu has %d items (revision %d)", len(items), revision) + if len(items) == 0 { + return + } + + var allocs []unsafe.Pointer + cItems := buildCItems(items, &allocs) + defer func() { + for _, p := range allocs { + C.free(p) + } + }() + + // C callback reaches us through this global. + activeMenuHostMu.Lock() + activeMenuHost = h + activeMenuHostMu.Unlock() + + C.xembed_show_popup_menu(cItems, C.int(len(items)), + nil, C.int(x), C.int(y)) +} + +func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { + var items []menuItemInfo + + for _, childVar := range layout.Children { + var child dbusMenuLayout + if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil { + continue + } + if mi, ok := h.menuItemFromLayout(child); ok { + items = append(items, mi) + } + } + + return items +} + +// menuItemFromLayout decodes one dbusmenu child; ok is false for hidden items (drop them). +func (h *xembedHost) menuItemFromLayout(child dbusMenuLayout) (menuItemInfo, bool) { + mi := menuItemInfo{id: child.ID, enabled: true} + + if propString(child.Properties, "type") == "separator" { + mi.isSeparator = true + return mi, true + } + + if vis, ok := propBool(child.Properties, "visible"); ok && !vis { + return menuItemInfo{}, false + } + + mi.label = propString(child.Properties, "label") + if en, ok := propBool(child.Properties, "enabled"); ok { + mi.enabled = en + } + if propString(child.Properties, "toggle-type") == "checkmark" { + mi.isCheck = true + } + if n, ok := propInt32(child.Properties, "toggle-state"); ok && n == 1 { + mi.checked = true + } + + // children are already present from the recursionDepth=-1 GetLayout. + if propString(child.Properties, "children-display") == "submenu" { + mi.children = h.flattenMenu(child) + } + + return mi, true +} + +func (h *xembedHost) sendMenuEvent(id int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + menuObj := h.conn.Object(h.busName, menuPath) + data := dbus.MakeVariant("") + err := menuObj.Call("com.canonical.dbusmenu.Event", 0, + id, "clicked", data, uint32(0)).Err + if err != nil { + log.Debugf("xembed: menu Event call failed: %v", err) + } +} + +func (h *xembedHost) stop() { + select { + case <-h.stopCh: + return + default: + close(h.stopCh) + } + + C.xembed_destroy_icon(h.dpy, h.iconWin) + C.XCloseDisplay(h.dpy) +} + +// buildCItems builds a C-allocated xembed_menu_item tree. Every malloc is +// appended to *allocs for the caller to free once the C side has deep-copied it. +func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_item { + if len(items) == 0 { + return nil + } + size := C.size_t(len(items)) * C.size_t(unsafe.Sizeof(C.xembed_menu_item{})) + arr := C.malloc(size) + *allocs = append(*allocs, arr) + C.memset(arr, 0, size) + + slice := (*[1 << 16]C.xembed_menu_item)(arr)[:len(items):len(items)] + for i, mi := range items { + slice[i].id = C.int(mi.id) + slice[i].enabled = boolToInt(mi.enabled) + slice[i].is_check = boolToInt(mi.isCheck) + slice[i].checked = boolToInt(mi.checked) + slice[i].is_separator = boolToInt(mi.isSeparator) + if mi.label != "" { + cstr := C.CString(mi.label) + *allocs = append(*allocs, unsafe.Pointer(cstr)) + slice[i].label = cstr + } + if len(mi.children) > 0 { + slice[i].children = buildCItems(mi.children, allocs) + slice[i].child_count = C.int(len(mi.children)) + } + } + + return (*C.xembed_menu_item)(arr) +} + +// xembedTrayAvailable reports whether an XEmbed tray manager (_NET_SYSTEM_TRAY_S0) +// owns the default screen. Side-effect-free probe. Gates the in-process +// StatusNotifierWatcher: when a real SNI host already owns the tray (e.g. Waybar +// on Wayland) we must not claim org.kde.StatusNotifierWatcher and shadow it. +// Returns false when there is no X display (pure Wayland). +func xembedTrayAvailable() bool { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return false + } + C.xembed_install_error_handlers() + defer C.XCloseDisplay(dpy) + screen := C.xembed_default_screen(dpy) + return C.xembed_find_tray(dpy, screen) != 0 +} + +// goMenuItemClicked is the C callback fired from the GTK main thread on popup +// activation. The host is looked up via activeMenuHost since C callbacks can't +// carry Go pointers; //export requires this to live in package main. +// +//export goMenuItemClicked +func goMenuItemClicked(id C.int) { + activeMenuHostMu.Lock() + h := activeMenuHost + activeMenuHostMu.Unlock() + + if h != nil { + go h.sendMenuEvent(int32(id)) + } +} + +func boolToInt(b bool) C.int { + if b { + return 1 + } + return 0 +} + +// propString returns the property's string value, or "" if absent or not a string. +func propString(props map[string]dbus.Variant, key string) string { + if v, ok := props[key]; ok { + if s, ok := v.Value().(string); ok { + return s + } + } + return "" +} + +// propBool returns the property's bool value; ok is false if absent or not a bool. +func propBool(props map[string]dbus.Variant, key string) (value, ok bool) { + if v, present := props[key]; present { + if b, isBool := v.Value().(bool); isBool { + return b, true + } + } + return false, false +} + +// propInt32 returns the property's int32 value; ok is false if absent or not an int32. +func propInt32(props map[string]dbus.Variant, key string) (value int32, ok bool) { + if v, present := props[key]; present { + if n, isInt := v.Value().(int32); isInt { + return n, true + } + } + return 0, false +} diff --git a/client/ui/xembed_tray_linux.c b/client/ui/xembed_tray_linux.c new file mode 100644 index 000000000..07ec74bb2 --- /dev/null +++ b/client/ui/xembed_tray_linux.c @@ -0,0 +1,708 @@ +#include "xembed_tray_linux.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SYSTEM_TRAY_REQUEST_DOCK 0 +#define XEMBED_MAPPED (1 << 0) + +/* Xlib's default protocol-error handler calls exit() on any async X error, + killing the whole UI process. Handlers are process-global (not + per-Display), so a single install covers our raw tray Display and GDK's. + Tray work is full of races the X server reports asynchronously — the tray + manager window dying between xembed_find_tray and xembed_dock (BadWindow + on XSendEvent), or x11_move_window touching a popup the WM already + destroyed — and the default handler would take us down for any of them. + Returning 0 here makes the error a logged no-op instead. */ +static int xembed_x_error_handler(Display *dpy, XErrorEvent *ev) { + char buf[256]; + XGetErrorText(dpy, ev->error_code, buf, sizeof(buf)); + fprintf(stderr, + "xembed: X error (ignored): %s (code=%d, request=%d.%d, resource=0x%lx)\n", + buf, ev->error_code, ev->request_code, ev->minor_code, + ev->resourceid); + return 0; +} + +/* The I/O error handler fires when the X connection itself drops (server + gone, socket closed). Xlib treats this as fatal and exits even if we + return, so this can't keep the process alive — it only logs a clearer + line than Xlib's terse default before the unavoidable exit. */ +static int xembed_x_io_error_handler(Display *dpy) { + (void)dpy; + fprintf(stderr, "xembed: X I/O error (connection lost)\n"); + return 0; +} + +/* Install the process-global handlers. Idempotent and cheap, so callers may + invoke it after every XOpenDisplay without tracking prior installs. */ +void xembed_install_error_handlers(void) { + XSetErrorHandler(xembed_x_error_handler); + XSetIOErrorHandler(xembed_x_io_error_handler); +} + +Window xembed_find_tray(Display *dpy, int screen) { + char atom_name[64]; + snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen); + Atom sel = XInternAtom(dpy, atom_name, False); + return XGetSelectionOwner(dpy, sel); +} + +int xembed_get_icon_size(Display *dpy, Window tray_mgr) { + Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *prop = NULL; + int size = 0; + + if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False, + XA_CARDINAL, &actual_type, &actual_format, + &nitems, &bytes_after, &prop) == Success) { + if (prop && nitems == 1 && actual_format == 32) { + size = (int)(*(unsigned long *)prop); + } + if (prop) + XFree(prop); + } + return size; +} + +Window xembed_create_icon(Display *dpy, int screen, int size, + Window tray_mgr) { + (void)tray_mgr; /* unused; kept in signature for caller symmetry */ + Window root = RootWindow(dpy, screen); + + /* Inherit visual & depth from the parent (tray manager / root) so + ParentRelative background works on every tray. Many minimal + toolbars (Fluxbox slit, OpenBox, etc.) only offer a 24-bit + default visual and do not composite alpha; ParentRelative makes + the X server texture this window's background from the parent, + so transparent pixels in the icon show the toolbar beneath + instead of solid black. ARGB-aware trays still work because the + cairo OVER blend in xembed_draw_icon honours per-pixel alpha + against whatever base the X server painted underneath. */ + XSetWindowAttributes attrs; + memset(&attrs, 0, sizeof(attrs)); + attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask; + attrs.background_pixmap = ParentRelative; + unsigned long mask = CWEventMask | CWBackPixmap; + + Window win = XCreateWindow( + dpy, root, + 0, 0, size, size, + 0, /* border width */ + CopyFromParent, /* depth */ + InputOutput, + CopyFromParent, /* visual */ + mask, + &attrs + ); + + /* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */ + Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False); + unsigned long info[2] = { 0, XEMBED_MAPPED }; + XChangeProperty(dpy, win, xembed_info, xembed_info, + 32, PropModeReplace, (unsigned char *)info, 2); + + return win; +} + +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) { + Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False); + + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = tray_mgr; + ev.message_type = opcode; + ev.format = 32; + ev.data.l[0] = CurrentTime; + ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK; + ev.data.l[2] = (long)icon_win; + + XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev); + XFlush(dpy); + return 0; +} + +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h) { + if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0) + return; + + /* Query the window's actual visual and depth so cairo composites + through the matching ARGB pipeline. */ + XWindowAttributes wa; + if (!XGetWindowAttributes(dpy, icon_win, &wa)) + return; + + /* Build a CAIRO_FORMAT_ARGB32 source surface from the SNI IconPixmap + bytes. SNI ships the pixels as [A,R,G,B,...] in network byte + order; cairo's ARGB32 stores native uint32 with B in the lowest + byte on little-endian hosts. Repack into native order with + pre-multiplied alpha so cairo can composite without tonemapping. */ + int stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, img_w); + unsigned char *buf = (unsigned char *)calloc(stride * img_h, 1); + if (!buf) + return; + + for (int y = 0; y < img_h; y++) { + unsigned int *row = (unsigned int *)(buf + y * stride); + for (int x = 0; x < img_w; x++) { + int idx = (y * img_w + x) * 4; + unsigned int a = data[idx + 0]; + unsigned int r = data[idx + 1]; + unsigned int g = data[idx + 2]; + unsigned int b = data[idx + 3]; + + if (a == 0) { + row[x] = 0; + } else if (a == 255) { + row[x] = (a << 24) | (r << 16) | (g << 8) | b; + } else { + unsigned int pr = r * a / 255; + unsigned int pg = g * a / 255; + unsigned int pb = b * a / 255; + row[x] = (a << 24) | (pr << 16) | (pg << 8) | pb; + } + } + } + + cairo_surface_t *src = cairo_image_surface_create_for_data( + buf, CAIRO_FORMAT_ARGB32, img_w, img_h, stride); + if (cairo_surface_status(src) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Wrap the X11 window in a cairo XLib surface using its real visual. */ + cairo_surface_t *dst = cairo_xlib_surface_create( + dpy, icon_win, wa.visual, win_size, win_size); + if (cairo_surface_status(dst) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Repaint the ParentRelative background first — without this the + window keeps the previously-drawn icon underneath when an icon + update arrives, and cairo's OVER blend would composite the new + icon on top of the stale one. XClearWindow forces the X server + to retexture from the parent (tray toolbar), giving us a clean + opaque base. */ + XClearWindow(dpy, icon_win); + + cairo_t *cr = cairo_create(dst); + + /* Scale the source onto the window with alpha compositing (default + OPERATOR_OVER). Transparent pixels keep the toolbar's pixels + visible underneath. */ + double sx = (double)win_size / img_w; + double sy = (double)win_size / img_h; + cairo_scale(cr, sx, sy); + cairo_set_source_surface(cr, src, 0, 0); + cairo_paint(cr); + + cairo_destroy(cr); + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + XFlush(dpy); +} + +void xembed_destroy_icon(Display *dpy, Window icon_win) { + if (icon_win) + XDestroyWindow(dpy, icon_win); + XFlush(dpy); +} + +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y) { + *out_x = 0; + *out_y = 0; + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + + switch (ev.type) { + case ButtonPress: + if (ev.xbutton.window == icon_win) { + *out_x = ev.xbutton.x_root; + *out_y = ev.xbutton.y_root; + if (ev.xbutton.button == Button1) + return 1; + if (ev.xbutton.button == Button3) + return 2; + } + break; + + case Expose: + if (ev.xexpose.window == icon_win && ev.xexpose.count == 0) + return 3; + break; + + case DestroyNotify: + if (ev.xdestroywindow.window == icon_win) + return -1; + break; + + case ConfigureNotify: + if (ev.xconfigure.window == icon_win) { + *out_x = ev.xconfigure.width; + *out_y = ev.xconfigure.height; + return 4; + } + break; + + case ReparentNotify: + /* Tray manager reparented us — this is expected after docking. */ + break; + + default: + break; + } + } + + return 0; +} + +/* --- GTK4 popup window menu support --- */ + +/* Implemented in Go via //export */ +extern void goMenuItemClicked(int id); + +/* The top-level popup window, reused across invocations. Submenu + popups are tracked in a separate list so they all close when the + top-level closes. */ +static GtkWidget *popup_win = NULL; +static GList *submenu_popups = NULL; /* list of GtkWidget* */ + +typedef struct { + xembed_menu_item *items; + int count; + int x, y; +} popup_data; + +/* Deep-free a heap-owned xembed_menu_item array (label + children). */ +static void free_items(xembed_menu_item *items, int count) { + if (!items) return; + for (int i = 0; i < count; i++) { + free((void *)items[i].label); + free_items(items[i].children, items[i].child_count); + } + free(items); +} + +static void free_popup_data(popup_data *pd) { + if (!pd) return; + free_items(pd->items, pd->count); + free(pd); +} + + +/* Close every popup window — top-level plus any open submenus. + Called when the user clicks an actionable item or focus leaves the + menu tree. */ +static void close_all_popups(void) { + for (GList *l = submenu_popups; l; l = l->next) { + gtk_window_destroy(GTK_WINDOW(l->data)); + } + g_list_free(submenu_popups); + submenu_popups = NULL; + + if (popup_win) { + gtk_widget_set_visible(popup_win, FALSE); + } +} + +static void on_button_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); + goMenuItemClicked(id); +} + +static void on_check_toggled(GtkCheckButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); + goMenuItemClicked(id); +} + +/* The popup is a regular WM-managed window (not override-redirect), + so the WM hands keyboard focus to it on map. When focus moves + elsewhere — the user clicked somewhere else, switched apps, etc. — + the focus controller's "leave" signal fires and we tear down the + menu tree. Submenus open from inside the top-level popup, so we + defer the actual close to an idle callback: that gives the new + submenu a chance to take focus first, and we only close if none of + our windows still has it. */ +static gboolean any_popup_has_focus(void) { + if (popup_win && gtk_window_is_active(GTK_WINDOW(popup_win))) + return TRUE; + for (GList *l = submenu_popups; l; l = l->next) { + if (gtk_window_is_active(GTK_WINDOW(l->data))) + return TRUE; + } + return FALSE; +} + +static gboolean focus_out_recheck(gpointer user_data) { + (void)user_data; + if (!any_popup_has_focus()) + close_all_popups(); + return G_SOURCE_REMOVE; +} + +static void on_popup_focus_leave(GtkEventControllerFocus *ctrl, + gpointer user_data) { + (void)ctrl; (void)user_data; + g_idle_add(focus_out_recheck, NULL); +} + +/* Attach a focus controller that fires close_all_popups on focus loss. */ +static void attach_outside_click_close(GtkWidget *win) { + GtkEventController *focus = gtk_event_controller_focus_new(); + g_signal_connect(focus, "leave", + G_CALLBACK(on_popup_focus_leave), NULL); + gtk_widget_add_controller(win, focus); +} + +/* Move a GtkWindow at the X11 level. GTK4 removed gtk_window_move(); the + GdkSurface is mapped to a real X11 Window we can reposition with + XMoveWindow. Must be called after the window has been realized (i.e. + after gtk_widget_set_visible TRUE). + + The popup is **not** override-redirect — the WM keeps managing it so + focus tracking still works (focus-out fires when the user clicks + elsewhere). We tag the window with a stack of EWMH hints that make + sane WMs (fluxbox, openbox, i3, kwin, mutter) render it like a + floating menu: above the tray panel, skipped from taskbar/pager, + no decorations. */ +static void x11_move_window(GtkWidget *win, int x, int y) { + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win)); + if (!surface || !GDK_IS_X11_SURFACE(surface)) + return; + Window xid = gdk_x11_surface_get_xid(surface); + GdkDisplay *display = gdk_surface_get_display(surface); + Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); + + /* These calls poke a window the WM may have already destroyed (a popup + torn down between scheduling and this idle callback). On GDK's Display + use GDK's own error trap rather than our global handler — push/pop is + the spec-correct way to make untrapped BadWindow/BadMatch from these + raw Xlib calls non-fatal, independent of whichever process-global + handler happens to be installed. */ + gdk_x11_display_error_trap_push(display); + + /* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc + render the window above panels and skip decorations. Must be + set before the window is mapped to be honoured by some WMs; + on already-mapped windows it works for most modern WMs but a + few need an unmap/map cycle to re-read the property. */ + Atom wm_type = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE", False); + Atom wm_type_popup = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False); + XChangeProperty(xdpy, xid, wm_type, XA_ATOM, 32, + PropModeReplace, (unsigned char *)&wm_type_popup, 1); + + /* _NET_WM_STATE_ABOVE + SKIP_TASKBAR + SKIP_PAGER. Bundled into + one property write. */ + Atom wm_state = XInternAtom(xdpy, "_NET_WM_STATE", False); + Atom state_above = XInternAtom(xdpy, "_NET_WM_STATE_ABOVE", False); + Atom state_skip_tb = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_TASKBAR", False); + Atom state_skip_pg = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_PAGER", False); + Atom states[3] = { state_above, state_skip_tb, state_skip_pg }; + XChangeProperty(xdpy, xid, wm_state, XA_ATOM, 32, + PropModeReplace, (unsigned char *)states, 3); + + XMoveWindow(xdpy, xid, x, y); + XRaiseWindow(xdpy, xid); + + /* POPUP_MENU windows aren't given keyboard focus by most WMs (the + spec says they're "menus", which traditionally use a grab rather + than focus). Without focus GtkEventControllerFocus's leave signal + never fires, so we'd have no way to notice the user clicking + elsewhere. Ask the WM to activate us via _NET_ACTIVE_WINDOW + (source=2 means "pager / pseudo-user request" which most WMs + honour without timestamp checks). This is safer than calling + XSetInputFocus directly — that races the X server with the + not-yet-fully-mapped window and trips BadMatch. */ + Atom net_active = XInternAtom(xdpy, "_NET_ACTIVE_WINDOW", False); + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = xid; + ev.message_type = net_active; + ev.format = 32; + ev.data.l[0] = 2; /* source: pager */ + ev.data.l[1] = CurrentTime; + XSendEvent(xdpy, DefaultRootWindow(xdpy), False, + SubstructureRedirectMask | SubstructureNotifyMask, + (XEvent *)&ev); + + XFlush(xdpy); + gdk_x11_display_error_trap_pop_ignored(display); +} + +/* Forward declaration — submenu buttons need to schedule a child popup. */ +static GtkWidget *build_menu_box(xembed_menu_item *items, int count); + +typedef struct { + xembed_menu_item *items; + int count; + GtkWidget *anchor; /* the submenu button — used to position the popup */ +} submenu_open_data; + +static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) { + submenu_open_data *sd = (submenu_open_data *)user_data; + + GtkWidget *win = gtk_window_new(); + gtk_window_set_decorated(GTK_WINDOW(win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(win), FALSE); + + attach_outside_click_close(win); + + GtkWidget *vbox = build_menu_box(sd->items, sd->count); + gtk_window_set_child(GTK_WINDOW(win), vbox); + + /* Need the anchor button's position in root coordinates. GTK4 + removed gtk_widget_translate_coordinates(); compute via the + button's bounds within its native widget plus the native + surface's screen origin via X11. */ + graphene_rect_t bounds; + if (!gtk_widget_compute_bounds(GTK_WIDGET(btn), + GTK_WIDGET(gtk_widget_get_native(GTK_WIDGET(btn))), + &bounds)) { + bounds.origin.x = 0; + bounds.origin.y = 0; + bounds.size.width = 0; + bounds.size.height = 0; + } + GdkSurface *anchor_surface = + gtk_native_get_surface(gtk_widget_get_native(GTK_WIDGET(btn))); + int ox = 0, oy = 0; + if (anchor_surface && GDK_IS_X11_SURFACE(anchor_surface)) { + Window axid = gdk_x11_surface_get_xid(anchor_surface); + GdkDisplay *display = gdk_surface_get_display(anchor_surface); + Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); + Window child; + /* Trap BadWindow in case the anchor's surface is torn down between + the click and this handler running. */ + gdk_x11_display_error_trap_push(display); + XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy), + 0, 0, &ox, &oy, &child); + gdk_x11_display_error_trap_pop_ignored(display); + } + int ax = ox + (int)bounds.origin.x; + int ay = oy + (int)bounds.origin.y; + + gtk_widget_set_visible(win, TRUE); + + int sw, sh; + gtk_window_get_default_size(GTK_WINDOW(win), &sw, &sh); + if (sw <= 0 || sh <= 0) { + /* default_size returns -1,-1 if never explicitly set; fall back + to the measured preferred size. */ + GtkRequisition req; + gtk_widget_get_preferred_size(win, NULL, &req); + sw = req.width; + sh = req.height; + } + + /* The parent popup grows upward from the tray, so submenu items + sit closer to the bottom of the screen than to the top. Align + the submenu's BOTTOM to the anchor button's bottom: the popup + grows upward, level with the row that opened it. */ + int final_x = ax + (int)bounds.size.width; + int final_y = ay + (int)bounds.size.height - sh; + + /* Horizontal flip against the monitor under the anchor button. */ + GdkDisplay *display = gtk_widget_get_display(win); + GListModel *monitors = gdk_display_get_monitors(display); + guint n = g_list_model_get_n_items(monitors); + for (guint i = 0; i < n; i++) { + GdkMonitor *m = (GdkMonitor *)g_list_model_get_item(monitors, i); + GdkRectangle geom; + gdk_monitor_get_geometry(m, &geom); + if (ax >= geom.x && ax < geom.x + geom.width && + ay >= geom.y && ay < geom.y + geom.height) { + if (final_x + sw > geom.x + geom.width) + final_x = ax - sw; /* flip to the left */ + g_object_unref(m); + break; + } + g_object_unref(m); + } + + x11_move_window(win, final_x, final_y); + gtk_window_present(GTK_WINDOW(win)); + + submenu_popups = g_list_prepend(submenu_popups, win); +} + +/* Build a vbox of GtkWidgets for the supplied items. Used for both the + top-level popup and each submenu popup. The submenu_open_data attached + to submenu buttons is freed when the button is destroyed. */ +static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) { + (void)widget; + free(user_data); +} + +static GtkWidget *build_menu_box(xembed_menu_item *items, int count) { + GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + for (int i = 0; i < count; i++) { + xembed_menu_item *mi = &items[i]; + + if (mi->is_separator) { + GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_margin_top(sep, 2); + gtk_widget_set_margin_bottom(sep, 2); + gtk_box_append(GTK_BOX(vbox), sep); + continue; + } + + if (mi->is_check) { + GtkWidget *chk = gtk_check_button_new_with_label( + mi->label ? mi->label : ""); + gtk_check_button_set_active(GTK_CHECK_BUTTON(chk), mi->checked); + gtk_widget_set_sensitive(chk, mi->enabled); + g_signal_connect(chk, "toggled", + G_CALLBACK(on_check_toggled), + GINT_TO_POINTER(mi->id)); + gtk_box_append(GTK_BOX(vbox), chk); + continue; + } + + /* Plain button (leaf) or submenu opener. Show "Label ▸" for + submenu folders so users see they're nested. */ + const char *label_text = mi->label ? mi->label : ""; + char *display_label = NULL; + if (mi->child_count > 0 && mi->children) { + /* Compose "label ▸" (BLACK RIGHT-POINTING SMALL TRIANGLE). */ + size_t n = strlen(label_text) + 8; /* ascii + " ▸" + NUL */ + display_label = (char *)malloc(n); + snprintf(display_label, n, "%s \xE2\x96\xB8", label_text); + label_text = display_label; + } + + GtkWidget *btn = gtk_button_new_with_label(label_text); + gtk_widget_set_sensitive(btn, mi->enabled); + gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE); + GtkWidget *lbl = gtk_button_get_child(GTK_BUTTON(btn)); + if (GTK_IS_LABEL(lbl)) + gtk_label_set_xalign(GTK_LABEL(lbl), 0.0); + + free(display_label); + + if (mi->child_count > 0 && mi->children) { + submenu_open_data *sd = + (submenu_open_data *)calloc(1, sizeof(submenu_open_data)); + sd->items = mi->children; + sd->count = mi->child_count; + sd->anchor = btn; + g_signal_connect(btn, "clicked", + G_CALLBACK(on_submenu_button_clicked), sd); + g_signal_connect(btn, "destroy", + G_CALLBACK(on_button_destroy_free_data), sd); + } else { + g_signal_connect(btn, "clicked", + G_CALLBACK(on_button_clicked), + GINT_TO_POINTER(mi->id)); + } + gtk_box_append(GTK_BOX(vbox), btn); + } + + return vbox; +} + +static gboolean popup_menu_idle(gpointer user_data) { + popup_data *pd = (popup_data *)user_data; + + /* Destroy old top-level (and orphan submenus) before rebuilding. */ + close_all_popups(); + if (popup_win) { + gtk_window_destroy(GTK_WINDOW(popup_win)); + popup_win = NULL; + } + + popup_win = gtk_window_new(); + gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE); + + attach_outside_click_close(popup_win); + + GtkWidget *vbox = build_menu_box(pd->items, pd->count); + gtk_window_set_child(GTK_WINDOW(popup_win), vbox); + + gtk_widget_set_visible(popup_win, TRUE); + + /* Position the window above the click point (menu grows upward + from tray). Use measured preferred size — default_size is -1 + until set. */ + GtkRequisition req; + gtk_widget_get_preferred_size(popup_win, NULL, &req); + int win_w = req.width; + int win_h = req.height; + + int final_x = pd->x - win_w / 2; + int final_y = pd->y - win_h; + if (final_x < 0) final_x = 0; + if (final_y < 0) final_y = pd->y; /* fallback: below click */ + x11_move_window(popup_win, final_x, final_y); + + gtk_window_present(GTK_WINDOW(popup_win)); + + /* The vbox+children retain pointers into pd->items (via submenu + click handlers). free_popup_data() walks the array recursively + to release labels and children buffers — but we need to keep + the items alive while the popup is open. Defer the free until + the popup window is destroyed. */ + g_object_set_data_full(G_OBJECT(popup_win), "popup_data", pd, + (GDestroyNotify)free_popup_data); + return G_SOURCE_REMOVE; +} + +/* Recursively deep-copy a Go-supplied items array into freshly-allocated + C memory. Each label is strdup'd, each children array is calloc'd. */ +static xembed_menu_item *copy_items(xembed_menu_item *src, int count) { + if (count <= 0 || !src) return NULL; + xembed_menu_item *dst = + (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item)); + for (int i = 0; i < count; i++) { + dst[i] = src[i]; + if (src[i].label) + dst[i].label = strdup(src[i].label); + if (src[i].child_count > 0 && src[i].children) { + dst[i].children = copy_items(src[i].children, src[i].child_count); + dst[i].child_count = src[i].child_count; + } else { + dst[i].children = NULL; + dst[i].child_count = 0; + } + } + return dst; +} + +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y) { + (void)cb; + popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data)); + pd->items = copy_items(items, count); + pd->count = count; + pd->x = x; + pd->y = y; + + g_idle_add(popup_menu_idle, pd); +} diff --git a/client/ui/xembed_tray_linux.h b/client/ui/xembed_tray_linux.h new file mode 100644 index 000000000..18a77c4c0 --- /dev/null +++ b/client/ui/xembed_tray_linux.h @@ -0,0 +1,81 @@ +#ifndef XEMBED_TRAY_H +#define XEMBED_TRAY_H + +#include + +// xembed_default_screen wraps the DefaultScreen macro for CGo. +static inline int xembed_default_screen(Display *dpy) { + return DefaultScreen(dpy); +} + +// xembed_install_error_handlers replaces Xlib's default protocol- and +// I/O-error handlers (which call exit()) with logging handlers, so an async +// X error from a tray race doesn't kill the UI process. Process-global and +// idempotent; safe to call after every XOpenDisplay. +void xembed_install_error_handlers(void); + +// xembed_find_tray returns the selection owner window for +// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists. +Window xembed_find_tray(Display *dpy, int screen); + +// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray +// manager window. Returns the size in pixels, or 0 if not set. +int xembed_get_icon_size(Display *dpy, Window tray_mgr); + +// xembed_create_icon creates a tray icon window of the given size, +// sets _XEMBED_INFO, and returns the window ID. +// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL +// property is queried to obtain a 32-bit ARGB visual for transparency. +Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr); + +// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK +// to the tray manager to embed our icon window. +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win); + +// xembed_draw_icon draws ARGB pixel data onto the icon window. +// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format). +// img_w, img_h are the source image dimensions. +// win_size is the target window dimension (square). +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h); + +// xembed_destroy_icon destroys the icon window. +void xembed_destroy_icon(Display *dpy, Window icon_win); + +// xembed_poll_event processes pending X11 events. Returns: +// 0 = no actionable event +// 1 = left button press (out_x, out_y filled) +// 2 = right button press (out_x, out_y filled) +// 3 = expose (needs redraw) +// 4 = configure (resize; out_x=width, out_y=height) +// -1 = DestroyNotify on icon window (tray died) +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y); + +// Callback type for menu item clicks. Called with the item's dbusmenu ID. +typedef void (*xembed_menu_click_cb)(int id); + +// xembed_popup_menu builds and shows a GTK3 popup menu. +// items is an array of menu item descriptors, count is the number of items. +// cb is called (from the GTK main thread) when an item is clicked. +// x, y are root coordinates for positioning the popup. +// This must be called from the GTK main thread (use g_idle_add). + +typedef struct xembed_menu_item { + int id; // dbusmenu item ID + const char *label; // display label (NULL for separator) + int enabled; // whether the item is clickable + int is_check; // whether this is a checkbox item + int checked; // checkbox state (0 or 1) + int is_separator;// 1 if this is a separator + // children + child_count populate when this item is a submenu folder + // (dbusmenu's children-display=="submenu"). NULL/0 means leaf item. + struct xembed_menu_item *children; + int child_count; +} xembed_menu_item; + +// Schedule a GTK popup menu on the main thread. +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y); + +#endif diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index f42b6b3d2..800ecead1 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -96,6 +96,10 @@ disableProfiles : hide the profile menu, reject profile CRUD. disableNetworks : hide the Networks / Exit Node menus, reject the related RPCs. + disableAdvancedView : hide the advanced-view section of the new + UI. Tristate at the daemon: set to true to + hide, false to explicitly show, omit the + key to let the UI apply its own default. disableMetricsCollection: opt out of anonymous usage telemetry. --> diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig index 53453db5c..9bf616094 100644 --- a/docs/netbird-macos.mobileconfig +++ b/docs/netbird-macos.mobileconfig @@ -144,6 +144,8 @@ disableNetworks + disableAdvancedView + disableMetricsCollection --> diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh index a2f5ff5e8..2777b407c 100644 --- a/docs/netbird-macos.sh +++ b/docs/netbird-macos.sh @@ -64,6 +64,7 @@ disableMetricsCollection="$NULL" disableUpdateSettings="$NULL" disableProfiles="$NULL" disableNetworks="$NULL" +disableAdvancedView="$NULL" # tristate at the daemon rosenpassEnabled="$NULL" rosenpassPermissive="$NULL" wireguardPort='51820' @@ -160,6 +161,7 @@ main() { is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings" is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles" is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks" + is_set "$disableAdvancedView" && emit_bool disableAdvancedView "$disableAdvancedView" is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled" is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive" is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort" diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg index ba4402e50f956facd45f368b214ee6fbfc6dcf39..83d07c9b00404570e9aa8eac3b4ab71e4b149240 100644 GIT binary patch delta 40 tcmeC;zQnyjfLYv;A%&rgA(0`EA(Disable networks
    When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks. + Disable advanced view + When enabled, the client UI hides the advanced-view section of the new UI revision. Tristate at the daemon: 1 (enabled) hides the section; 0 (disabled) explicitly shows it; not configured leaves the UI's default behavior in place. MDM is the sole source — no equivalent CLI flag exists. + Disable metrics collection When enabled, the client does not collect or report local usage metrics. diff --git a/docs/netbird.admx b/docs/netbird.admx index 2f7645d63..1a53a3b64 100644 --- a/docs/netbird.admx +++ b/docs/netbird.admx @@ -207,6 +207,18 @@ + + + + + + + Date: Mon, 6 Jul 2026 16:04:26 +0200 Subject: [PATCH 35/36] [client] introduce client-side event aggregation (#6627) * added an implementation of aggregating memory store Signed-off-by: Dmitri * initial support for aggregation of events Signed-off-by: Dmitri * added tcp-aggregation test Signed-off-by: Dmitri * added manager integration test Signed-off-by: Dmitri * added tracking of the number of start-, drop, and end-events in an aggregation window Signed-off-by: Dmitri * fixes based on sonarcube checks Signed-off-by: Dmitri * regenerated proto files Signed-off-by: Dmitri * removed inadvertenly added google proto files Signed-off-by: Dmitri * pacifying linter Signed-off-by: Dmitri * update test to validate event aggregation over tcp, udp, icmp, and icmpv6 Signed-off-by: Dmitri * updated event aggregation test Signed-off-by: Dmitri * regenerate protobufs with expected versions of protoc and protoc-gen-go Signed-off-by: Dmitri * remove protoc/protoc-gen headers from flow_grpc.pb.go Signed-off-by: Dmitri * updated openapi spec Signed-off-by: Dmitri * updated openapi NetworkTrafficEvent spec, regenerated types Signed-off-by: Dmitri * respond to feedback Signed-off-by: Dmitri Dolguikh * fixed an issue with how we track events that shouldn't be aggregated Signed-off-by: Dmitri Dolguikh * fixed mapping of events to protobuf Signed-off-by: Dmitri Dolguikh * icmp code values in aggregated events do not matter Signed-off-by: Dmitri Dolguikh * regenerate openapi types Signed-off-by: Dmitri Dolguikh * added a comment re: unbounded unacked events Signed-off-by: Dmitri Dolguikh * reset aggregated event type to unknown Signed-off-by: Dmitri Dolguikh * fix event aggregation test Signed-off-by: Dmitri Dolguikh * used the source port of the earliest event Signed-off-by: Dmitri Dolguikh * add tracking of window starts and ends Signed-off-by: Dmitri Dolguikh * updated openapi spec Signed-off-by: Dmitri Dolguikh * reverted changes to generate.sh Signed-off-by: Dmitri Dolguikh * cleanup handling of not-aggregated events + test Signed-off-by: Dmitri Dolguikh * responded to feedback + small fixes Signed-off-by: Dmitri Dolguikh * small fix in a test Signed-off-by: Dmitri Dolguikh * another test Signed-off-by: Dmitri Dolguikh * force setting non-empty rule id on aggregated events Signed-off-by: Dmitri Dolguikh * fixed a couple of issues flagged by coderabbit Signed-off-by: Dmitri Dolguikh * fix spelling Signed-off-by: Dmitri Dolguikh * handle exhausted retry backoffs Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Signed-off-by: Dmitri Dolguikh --- client/internal/netflow/logger/logger.go | 8 +- client/internal/netflow/manager.go | 115 ++++-- .../netflow/manager_integration_test.go | 291 ++++++++++++++ .../netflow/store/event_aggregation_test.go | 362 ++++++++++++++++++ client/internal/netflow/store/memory.go | 106 ++++- client/internal/netflow/types/types.go | 33 +- flow/client/client.go | 2 +- flow/proto/flow.pb.go | 165 +++++--- flow/proto/flow.proto | 6 + flow/proto/flow_grpc.pb.go | 94 ++--- shared/management/http/api/openapi.yml | 27 ++ shared/management/http/api/types.gen.go | 21 +- 12 files changed, 1081 insertions(+), 149 deletions(-) create mode 100644 client/internal/netflow/manager_integration_test.go create mode 100644 client/internal/netflow/store/event_aggregation_test.go diff --git a/client/internal/netflow/logger/logger.go b/client/internal/netflow/logger/logger.go index 8f8e68784..deb38bc4d 100644 --- a/client/internal/netflow/logger/logger.go +++ b/client/internal/netflow/logger/logger.go @@ -27,7 +27,7 @@ type Logger struct { wgIfaceNetV6 netip.Prefix dnsCollection atomic.Bool exitNodeCollection atomic.Bool - Store types.Store + Store types.AggregatingStore } func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger { @@ -35,7 +35,7 @@ func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) statusRecorder: statusRecorder, wgIfaceNet: wgIfaceIPNet, wgIfaceNetV6: wgIfaceIPNetV6, - Store: store.NewMemoryStore(), + Store: store.NewAggregatingMemoryStore(), } } @@ -125,6 +125,10 @@ func (l *Logger) stop() { l.mux.Unlock() } +func (l *Logger) ResetAggregationWindow() types.FlowEventAggregator { + return l.Store.ResetAggregationWindow() +} + func (l *Logger) GetEvents() []*types.Event { return l.Store.GetEvents() } diff --git a/client/internal/netflow/manager.go b/client/internal/netflow/manager.go index eff083dbf..43d61b771 100644 --- a/client/internal/netflow/manager.go +++ b/client/internal/netflow/manager.go @@ -9,12 +9,14 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v4" "github.com/google/uuid" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/internal/netflow/conntrack" "github.com/netbirdio/netbird/client/internal/netflow/logger" + "github.com/netbirdio/netbird/client/internal/netflow/store" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/flow/client" @@ -23,14 +25,16 @@ import ( // Manager handles netflow tracking and logging type Manager struct { - mux sync.Mutex - shutdownWg sync.WaitGroup - logger nftypes.FlowLogger - flowConfig *nftypes.FlowConfig - conntrack nftypes.ConnTracker - receiverClient *client.GRPCClient - publicKey []byte - cancel context.CancelFunc + mux sync.Mutex + shutdownWg sync.WaitGroup + logger nftypes.FlowLogger + flowConfig *nftypes.FlowConfig + conntrack nftypes.ConnTracker + receiverClient *client.GRPCClient + eventsWithoutAcks nftypes.Store + publicKey []byte + cancel context.CancelFunc + retryInterval time.Duration } // NewManager creates a new netflow manager @@ -48,9 +52,11 @@ func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *pee } return &Manager{ - logger: flowLogger, - conntrack: ct, - publicKey: publicKey, + logger: flowLogger, + conntrack: ct, + publicKey: publicKey, + retryInterval: time.Second, + eventsWithoutAcks: store.NewMemoryStore(), } } @@ -66,6 +72,7 @@ func (m *Manager) needsNewClient(previous *nftypes.FlowConfig) bool { } // enableFlow starts components for flow tracking +// must be called under m.mux lock func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error { // first make sender ready so events don't pile up if m.needsNewClient(previous) { @@ -85,6 +92,7 @@ func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error { return nil } +// must be called under m.mux lock func (m *Manager) resetClient() error { if m.receiverClient != nil { if err := m.receiverClient.Close(); err != nil { @@ -107,14 +115,19 @@ func (m *Manager) resetClient() error { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel - m.shutdownWg.Add(2) + m.shutdownWg.Add(3) + flowConfigInterval := m.flowConfig.Interval go func() { defer m.shutdownWg.Done() - m.receiveACKs(ctx, flowClient) + m.receiveACKs(ctx, flowClient, flowConfigInterval) }() go func() { defer m.shutdownWg.Done() - m.startSender(ctx) + m.startSender(ctx, flowConfigInterval) + }() + go func() { + defer m.shutdownWg.Done() + m.startRetries(ctx, flowConfigInterval) }() return nil @@ -198,8 +211,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger { return m.logger } -func (m *Manager) startSender(ctx context.Context) { - ticker := time.NewTicker(m.flowConfig.Interval) +func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) { + ticker := time.NewTicker(flowConfigInterval) defer ticker.Stop() for { @@ -207,27 +220,29 @@ func (m *Manager) startSender(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - events := m.logger.GetEvents() + collectedEvents := m.logger.ResetAggregationWindow() + events := collectedEvents.GetAggregatedEvents() for _, event := range events { + m.eventsWithoutAcks.StoreEvent(event) if err := m.send(event); err != nil { log.Errorf("failed to send flow event to server: %v", err) - continue + } else { + log.Tracef("sent flow event: %s", event.ID) } - log.Tracef("sent flow event: %s", event.ID) } } } } -func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) { - err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error { +func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, flowConfigInterval time.Duration) { + err := client.Receive(ctx, flowConfigInterval, func(ack *proto.FlowEventAck) error { id, err := uuid.FromBytes(ack.EventId) if err != nil { log.Warnf("failed to convert ack event id to uuid: %v", err) return nil } log.Tracef("received flow event ack: %s", id) - m.logger.DeleteEvents([]uuid.UUID{id}) + m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id}) return nil }) @@ -236,6 +251,51 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) { } } +// We effectively never drop events (see MaxInterval), which makes eventsWithoutAcks unbounded. +// We may want to limit the max size of the store, and start dropping oldest events when the threshold is reached. +func (m *Manager) startRetries(ctx context.Context, flowConfigInterval time.Duration) { + timer := time.NewTimer(m.retryInterval) + retryBackoff := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: 1 * time.Second, + RandomizationFactor: 0.5, + Multiplier: 1.7, + MaxInterval: flowConfigInterval / 2, + MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + resetBackoff := true + for _, e := range m.eventsWithoutAcks.GetEvents() { + if e.Timestamp.Add(time.Second).After(time.Now()) { + // grace period on retries to avoid early retries + // do not retry if the event is less than 1 sec old + continue + } + if err := m.send(e); err != nil { + if nextBackoff := retryBackoff.NextBackOff(); nextBackoff != backoff.Stop { + timer = time.NewTimer(nextBackoff) + resetBackoff = false + } else { + resetBackoff = true // we exhausted retries, reset retry loop + } + break + } + } + if resetBackoff { // use regular retry interval in absence of network errors + retryBackoff.Reset() + timer = time.NewTimer(m.retryInterval) + } + } + } +} + func (m *Manager) send(event *nftypes.Event) error { m.mux.Lock() client := m.receiverClient @@ -250,9 +310,11 @@ func (m *Manager) send(event *nftypes.Event) error { func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { protoEvent := &proto.FlowEvent{ - EventId: event.ID[:], - Timestamp: timestamppb.New(event.Timestamp), - PublicKey: publicKey, + EventId: event.ID[:], + Timestamp: timestamppb.New(event.Timestamp), + PublicKey: publicKey, + WindowStart: timestamppb.New(event.WindowStart), + WindowEnd: timestamppb.New(event.WindowEnd), FlowFields: &proto.FlowFields{ FlowId: event.FlowID[:], RuleId: event.RuleID, @@ -267,6 +329,9 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { TxBytes: event.TxBytes, SourceResourceId: event.SourceResourceID, DestResourceId: event.DestResourceID, + NumOfStarts: event.NumOfStarts, + NumOfEnds: event.NumOfEnds, + NumOfDrops: event.NumOfDrops, }, } diff --git a/client/internal/netflow/manager_integration_test.go b/client/internal/netflow/manager_integration_test.go new file mode 100644 index 000000000..9029bdda2 --- /dev/null +++ b/client/internal/netflow/manager_integration_test.go @@ -0,0 +1,291 @@ +package netflow + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "slices" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/netbirdio/netbird/flow/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "google.golang.org/grpc" +) + +type testServer struct { + proto.UnimplementedFlowServiceServer + events chan *proto.FlowEvent + acks chan *proto.FlowEventAck + grpcSrv *grpc.Server + addr string + handlerDone chan struct{} // signaled each time Events() exits + handlerStarted chan struct{} // signaled each time Events() begins +} + +func newTestServer(t *testing.T) *testServer { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := &testServer{ + events: make(chan *proto.FlowEvent, 100), + acks: make(chan *proto.FlowEventAck, 100), + grpcSrv: grpc.NewServer(), + addr: listener.Addr().String(), + handlerDone: make(chan struct{}, 10), + handlerStarted: make(chan struct{}, 10), + } + + proto.RegisterFlowServiceServer(s.grpcSrv, s) + + go func() { + if err := s.grpcSrv.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + t.Logf("server error: %v", err) + } + }() + + t.Cleanup(func() { + s.grpcSrv.Stop() + }) + + return s +} + +func (s *testServer) Events(stream proto.FlowService_EventsServer) error { + defer func() { + select { + case s.handlerDone <- struct{}{}: + default: + } + }() + + err := stream.Send(&proto.FlowEventAck{IsInitiator: true}) + if err != nil { + return err + } + + select { + case s.handlerStarted <- struct{}{}: + default: + } + + ctx, cancel := context.WithCancel(stream.Context()) + defer cancel() + + go func() { + defer cancel() + for { + event, err := stream.Recv() + if err != nil { + return + } + + if !event.IsInitiator { + select { + case s.events <- event: + case <-ctx.Done(): + return + } + } + } + }() + + for { + select { + case ack := <-s.acks: + if err := stream.Send(ack); err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func TestSendEventReceiveAck(t *testing.T) { + _, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + server := newTestServer(t) + manager := createManager(t, server.addr, 60*time.Second) // set high to prevent retries in this test + defer manager.Close() + + assert.Eventually(t, func() bool { + select { + case <-server.handlerStarted: + return true + default: + return false + } + }, 3*time.Second, 100*time.Millisecond) + + event1 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.2"), + DestPort: 2345, + Protocol: 6, + } + manager.logger.StoreEvent(event1) + event2 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.1"), + DestPort: 1234, + Protocol: 6, + } + manager.logger.StoreEvent(event2) + + // verify the server received logged events + serverSideEvents := make([]*proto.FlowEvent, 0) + assert.Eventually(t, func() bool { + select { + case event := <-server.events: + serverSideEvents = append(serverSideEvents, event) + if len(serverSideEvents) == 2 { + return true + } + default: + if len(serverSideEvents) == 2 { + return true + } + } + return false + }, 5*time.Second, 100*time.Millisecond) + + serverSideFlowIds := make([]uuid.UUID, 0, 2) + slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool { + id, err := uuid.FromBytes(e.FlowFields.FlowId) + assert.NoError(t, err) + serverSideFlowIds = append(serverSideFlowIds, id) + return true + }) + assert.ElementsMatch(t, []uuid.UUID{event1.FlowID, event2.FlowID}, serverSideFlowIds) + + // verify the manager tracks un-acked events + unackedEvents := manager.eventsWithoutAcks.GetEvents() + assert.Len(t, unackedEvents, 2) + flowIds := make([]uuid.UUID, 0) + slices.Values(unackedEvents)(func(e *types.Event) bool { + flowIds = append(flowIds, e.FlowID) + return true + }) + assert.ElementsMatch(t, flowIds, []uuid.UUID{event1.FlowID, event2.FlowID}) +} + +// verify handling of retries: +// - unacked events are retried +// - when acks arrive, events are removed from the un-acked event tracker +func TestRetryEvents(t *testing.T) { + _, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + server := newTestServer(t) + manager := createManager(t, server.addr, time.Second) // set low to start retries sooner + defer manager.Close() + + assert.Eventually(t, func() bool { + select { + case <-server.handlerStarted: + return true + default: + return false + } + }, 3*time.Second, 100*time.Millisecond) + + event1 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.2"), + DestPort: 2345, + Protocol: 6, + } + manager.logger.StoreEvent(event1) + event2 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.1"), + DestPort: 1234, + Protocol: 6, + } + manager.logger.StoreEvent(event2) + + // verify the server received retries of logged events + serverSideEvents := make([]*proto.FlowEvent, 0) + func() { + c := time.After(2500 * time.Millisecond) + for { + select { + case event := <-server.events: + serverSideEvents = append(serverSideEvents, event) + case <-c: + return + } + } + }() + assert.True(t, len(serverSideEvents) > 2) // must see retries + + uniqueServerSideEvents := make(map[uuid.UUID]*proto.FlowEvent) + slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool { + id, err := uuid.FromBytes(e.FlowFields.FlowId) + assert.NoError(t, err) + uniqueServerSideEvents[id] = e + return true + }) + assert.Contains(t, uniqueServerSideEvents, event1.FlowID) + assert.Contains(t, uniqueServerSideEvents, event2.FlowID) + + // ack events + server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event1.FlowID].EventId} + server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event2.FlowID].EventId} + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + unackedEvents := manager.eventsWithoutAcks.GetEvents() + assert.Empty(c, unackedEvents) + + }, 3*time.Second, 100*time.Millisecond) +} + +func createManager(t *testing.T, serverAddr string, retryInterval time.Duration) *Manager { + t.Helper() + + mockIFace := &mockIFaceMapper{ + address: wgaddr.Address{ + Network: netip.MustParsePrefix("192.168.1.1/32"), + }, + isUserspaceBind: true, + } + + publicKey := []byte("test-public-key") + manager := NewManager(mockIFace, publicKey, nil) + manager.retryInterval = retryInterval + + initialConfig := &types.FlowConfig{ + Enabled: true, + URL: fmt.Sprintf("http://%s", serverAddr), + TokenPayload: "initial-payload", + TokenSignature: "initial-signature", + Interval: 500 * time.Millisecond, + } + + err := manager.Update(initialConfig) + require.NoError(t, err) + + return manager +} + +func ipAddr(a string) netip.Addr { + addr, _ := netip.ParseAddr(a) + return addr +} diff --git a/client/internal/netflow/store/event_aggregation_test.go b/client/internal/netflow/store/event_aggregation_test.go new file mode 100644 index 000000000..c0422e8b7 --- /dev/null +++ b/client/internal/netflow/store/event_aggregation_test.go @@ -0,0 +1,362 @@ +package store + +import ( + "math/rand" + "net/netip" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/stretchr/testify/assert" +) + +var random = rand.New(rand.NewSource(time.Now().UnixNano())) + +func TestFlowAggregation(t *testing.T) { + var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP} + var tests = []struct { + description string + addresses [][]netip.Addr + dstPort uint16 + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, protocol := range protocols { + for _, tt := range tests { + t.Run(tt.description+" "+protocol.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + + for _, srcAndDst := range tt.addresses { + inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd) + for _, e := range inEvents { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected) + } + + events := store.GetAggregatedEvents() + assert.ElementsMatch(t, events, allExpected) + }) + } + } +} + +func TestIcmpEventAggregation(t *testing.T) { + var protocols = []types.Protocol{types.ICMP, types.ICMPv6} + var icmpTypes = []uint8{1, 2, 3} + + var tests = []struct { + description string + addresses [][]netip.Addr + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, protocol := range protocols { + for _, tt := range tests { + t.Run(tt.description+" "+protocol.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + for _, icmpType := range icmpTypes { + events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd) + for _, e := range events { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected) + } + aggregatedEvents := store.GetAggregatedEvents() + assert.Len(t, aggregatedEvents, len(allExpected)) + assert.ElementsMatch(t, aggregatedEvents, allExpected) + }) + } + } +} + +func TestFlowAggregationOfUnknownProtocols(t *testing.T) { + var tests = []struct { + description string + addresses [][]netip.Addr + dstPort uint16 + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, tt := range tests { + t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + + for _, srcAndDst := range tt.addresses { + inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd) + for _, e := range inEvents { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected...) + } + + events := store.GetAggregatedEvents() + assert.ElementsMatch(t, events, allExpected) + }) + } +} + +func TestResetAggregationWindow(t *testing.T) { + store := NewAggregatingMemoryStore() + store.StoreEvent(&types.Event{ + ID: uuid.New(), + Timestamp: time.Now(), + EventFields: types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Protocol: types.TCP, + RuleID: []byte("rule-id-1"), + Direction: types.Ingress, + SourceIP: netip.MustParseAddr("1.1.1.1"), + SourcePort: 1234, + DestIP: netip.MustParseAddr("2.2.2.2"), + DestPort: 5678, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }, + }) + + reset := store.ResetAggregationWindow() + previousEvents, ok := reset.(*AggregatingMemory) + assert.True(t, ok) + assert.NotEqual(t, previousEvents.WindowStart, store.WindowStart) + assert.Equal(t, previousEvents.WindowEnd, store.WindowStart) + assert.NotEmpty(t, previousEvents.events) + assert.Empty(t, store.events) +} + +func generateEvents(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol, + direction types.Direction, icmpType uint8, windowStart, windowEnd time.Time) ([]*types.Event, *types.Event) { + var rxPackets, txPackets, rxBytes, txBytes uint64 + inEvents := make([]*types.Event, 0) + ts := time.Now() + flowId := uuid.New() + srcPort := uint16(random.Uint32() >> 16) + + for idx, eventType := range eventTypes { + e := &types.Event{ + ID: uuid.New(), + Timestamp: ts.Add(time.Duration(idx) * time.Second), + EventFields: types.EventFields{ + FlowID: flowId, + Type: eventType, + Protocol: protocol, + RuleID: []byte("rule-id-1"), + Direction: direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }} + rxBytes += e.RxBytes + txBytes += e.TxBytes + rxPackets += e.RxPackets + txPackets += e.TxPackets + inEvents = append(inEvents, e) + if protocol == types.ICMP || protocol == types.ICMPv6 { + e.ICMPType = icmpType + } + } + + var start, end, drop uint64 + for _, eventType := range eventTypes { + switch eventType { + case types.TypeStart: + start += 1 + case types.TypeDrop: + drop += 1 + case types.TypeEnd: + end += 1 + } + } + aggregatedEvent := &types.Event{ + ID: inEvents[0].ID, + Timestamp: inEvents[0].Timestamp, + WindowStart: windowStart, + WindowEnd: windowEnd, + EventFields: types.EventFields{ + FlowID: flowId, + Type: types.TypeUnknown, + Protocol: inEvents[0].Protocol, + RuleID: []byte("rule-id-1"), + Direction: inEvents[0].Direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: rxPackets, + TxPackets: txPackets, + RxBytes: rxBytes, + TxBytes: txBytes, + NumOfStarts: start, + NumOfEnds: end, + NumOfDrops: drop, + }} + if protocol == types.ICMP || protocol == types.ICMPv6 { + aggregatedEvent.ICMPType = icmpType + } + + return inEvents, aggregatedEvent +} + +func generateEventsForUnknownProtocol(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol, + direction types.Direction, windowStart, windowEnd time.Time) ([]*types.Event, []*types.Event) { + inEvents := make([]*types.Event, 0) + expectedEvents := make([]*types.Event, 0) + + ts := time.Now() + flowId := uuid.New() + srcPort := uint16(random.Uint32() >> 16) + + for idx, eventType := range eventTypes { + e := &types.Event{ + ID: uuid.New(), + Timestamp: ts.Add(time.Duration(idx) * time.Second), + EventFields: types.EventFields{ + FlowID: flowId, + Type: eventType, + Protocol: protocol, + RuleID: []byte("rule-id-1"), + Direction: direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }} + inEvents = append(inEvents, e) + + var start, end, drop uint64 + switch eventType { + case types.TypeStart: + start = 1 + case types.TypeDrop: + drop = 1 + case types.TypeEnd: + end = 1 + } + + expectedEvents = append(expectedEvents, &types.Event{ + ID: e.ID, + Timestamp: e.Timestamp, + WindowStart: windowStart, + WindowEnd: windowEnd, + EventFields: types.EventFields{ + FlowID: flowId, + Type: types.TypeUnknown, + Protocol: e.Protocol, + RuleID: []byte("rule-id-1"), + Direction: e.Direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: e.RxPackets, + TxPackets: e.TxPackets, + RxBytes: e.RxBytes, + TxBytes: e.TxBytes, + NumOfStarts: start, + NumOfEnds: end, + NumOfDrops: drop, + }}) + } + + return inEvents, expectedEvents +} diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go index a44505e96..a34e4be63 100644 --- a/client/internal/netflow/store/memory.go +++ b/client/internal/netflow/store/memory.go @@ -1,10 +1,15 @@ package store import ( + "maps" + "math/rand" + v2 "math/rand/v2" + "net/netip" + "slices" "sync" + "time" "github.com/google/uuid" - "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -19,6 +24,13 @@ type Memory struct { events map[uuid.UUID]*types.Event } +type AggregatingMemory struct { + Memory + WindowStart time.Time + WindowEnd time.Time + rnd *v2.PCG +} + func (m *Memory) StoreEvent(event *types.Event) { m.mux.Lock() defer m.mux.Unlock() @@ -48,3 +60,95 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) { delete(m.events, id) } } + +func NewAggregatingMemoryStore() *AggregatingMemory { + return &AggregatingMemory{WindowStart: time.Now(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} +} + +func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator { + am.mux.Lock() + defer am.mux.Unlock() + + now := time.Now() + toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} + + am.events = make(map[uuid.UUID]*types.Event) + am.WindowStart = now + + return &toret +} + +type aggregationKey struct { + srcAddr netip.Addr + destAddr netip.Addr + destPort uint16 + direction int + protocol uint8 + icmpType uint8 + unique uint64 // used to prevent aggregation on non icmp/udp/tcp events +} + +func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event { + am.mux.Lock() + defer am.mux.Unlock() + + aggregated := make(map[aggregationKey]*types.Event) + for _, v := range am.events { + lookupKey := aggregationKey{srcAddr: v.SourceIP, destAddr: v.DestIP, destPort: v.DestPort, direction: int(v.Direction), protocol: uint8(v.Protocol), icmpType: v.ICMPType} + if _, ok := aggregated[lookupKey]; !ok { + event := v.Clone() + + switch event.Type { + case types.TypeStart: + event.NumOfStarts += 1 + case types.TypeDrop: + event.NumOfDrops += 1 + case types.TypeEnd: + event.NumOfEnds += 1 + } + event.Type = types.TypeUnknown + + // Please note that ICMPCode field isn't propagated by the manager (see flow/proto/flow.pb.go, FlowFields struct) + // so the field value in an icmp event in the "aggregated" doesn't matter + + event.WindowStart = am.WindowStart + event.WindowEnd = am.WindowEnd + + if event.Protocol != types.ICMP && event.Protocol != types.ICMPv6 && event.Protocol != types.UDP && event.Protocol != types.TCP { + lookupKey.unique = am.rnd.Uint64() // to make the lookup key unique so we don't aggregate on it + } + + aggregated[lookupKey] = event + continue + } + + aggregatedEvent := aggregated[lookupKey] + if aggregatedEvent.Protocol != types.ICMP && aggregatedEvent.Protocol != types.ICMPv6 && aggregatedEvent.Protocol != types.UDP && aggregatedEvent.Protocol != types.TCP { + continue // we don't aggregate this type of events; shouldn't ever get here + } + + // track the number of connections, duration?, open and close events? + aggregatedEvent.RxBytes += v.RxBytes + aggregatedEvent.RxPackets += v.RxPackets + aggregatedEvent.TxBytes += v.TxBytes + aggregatedEvent.TxPackets += v.TxPackets + switch v.Type { + case types.TypeStart: + aggregatedEvent.NumOfStarts += 1 + case types.TypeDrop: + aggregatedEvent.NumOfDrops += 1 + case types.TypeEnd: + aggregatedEvent.NumOfEnds += 1 + } + if aggregatedEvent.Timestamp.Compare(v.Timestamp) > 0 { + aggregatedEvent.Timestamp = v.Timestamp + aggregatedEvent.ID = v.ID + aggregatedEvent.SourcePort = v.SourcePort + } + if len(aggregatedEvent.RuleID) == 0 && len(v.RuleID) != 0 { + aggregatedEvent.RuleID = slices.Clone(v.RuleID) + } + } + + return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here +} diff --git a/client/internal/netflow/types/types.go b/client/internal/netflow/types/types.go index 3f7d0d0ad..ccb2da66b 100644 --- a/client/internal/netflow/types/types.go +++ b/client/internal/netflow/types/types.go @@ -2,6 +2,7 @@ package types import ( "net/netip" + "slices" "strconv" "time" @@ -69,8 +70,10 @@ const ( ) type Event struct { - ID uuid.UUID - Timestamp time.Time + ID uuid.UUID + Timestamp time.Time + WindowStart time.Time + WindowEnd time.Time EventFields } @@ -92,6 +95,17 @@ type EventFields struct { TxPackets uint64 RxBytes uint64 TxBytes uint64 + NumOfStarts uint64 + NumOfEnds uint64 + NumOfDrops uint64 +} + +func (e *Event) Clone() *Event { + toret := *e + toret.RuleID = slices.Clone(e.RuleID) + toret.SourceResourceID = slices.Clone(e.SourceResourceID) + toret.DestResourceID = slices.Clone(e.DestResourceID) + return &toret } type FlowConfig struct { @@ -114,13 +128,15 @@ type FlowManager interface { GetLogger() FlowLogger } +type FlowEventAggregator interface { + ResetAggregationWindow() FlowEventAggregator + GetAggregatedEvents() []*Event +} + type FlowLogger interface { + ResetAggregationWindow() FlowEventAggregator // StoreEvent stores a flow event StoreEvent(flowEvent EventFields) - // GetEvents returns all stored events - GetEvents() []*Event - // DeleteEvents deletes events from the store - DeleteEvents([]uuid.UUID) // Close closes the logger Close() // Enable enables the flow logger receiver @@ -140,6 +156,11 @@ type Store interface { Close() } +type AggregatingStore interface { + FlowEventAggregator + Store +} + // ConnTracker defines the interface for connection tracking functionality type ConnTracker interface { // Start begins tracking connections by listening for conntrack events. diff --git a/flow/client/client.go b/flow/client/client.go index 180a4b441..3f31c2464 100644 --- a/flow/client/client.go +++ b/flow/client/client.go @@ -109,7 +109,7 @@ func (c *GRPCClient) Close() error { func (c *GRPCClient) Send(event *proto.FlowEvent) error { c.mu.Lock() stream := c.stream - c.mu.Unlock() + defer c.mu.Unlock() // stream.Send() is not safe to call concurrently from multiple goroutines if stream == nil { return errors.New("stream not initialized") diff --git a/flow/proto/flow.pb.go b/flow/proto/flow.pb.go index 04e6e3792..710024f0e 100644 --- a/flow/proto/flow.pb.go +++ b/flow/proto/flow.pb.go @@ -134,9 +134,11 @@ type FlowEvent struct { // When the event occurred Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Public key of the sending peer - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` - FlowFields *FlowFields `protobuf:"bytes,4,opt,name=flow_fields,json=flowFields,proto3" json:"flow_fields,omitempty"` - IsInitiator bool `protobuf:"varint,5,opt,name=isInitiator,proto3" json:"isInitiator,omitempty"` + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + FlowFields *FlowFields `protobuf:"bytes,4,opt,name=flow_fields,json=flowFields,proto3" json:"flow_fields,omitempty"` + IsInitiator bool `protobuf:"varint,5,opt,name=isInitiator,proto3" json:"isInitiator,omitempty"` + WindowStart *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=window_start,json=windowStart,proto3" json:"window_start,omitempty"` + WindowEnd *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=window_end,json=windowEnd,proto3" json:"window_end,omitempty"` } func (x *FlowEvent) Reset() { @@ -206,6 +208,20 @@ func (x *FlowEvent) GetIsInitiator() bool { return false } +func (x *FlowEvent) GetWindowStart() *timestamppb.Timestamp { + if x != nil { + return x.WindowStart + } + return nil +} + +func (x *FlowEvent) GetWindowEnd() *timestamppb.Timestamp { + if x != nil { + return x.WindowEnd + } + return nil +} + type FlowEventAck struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -284,7 +300,6 @@ type FlowFields struct { // Layer 4 -specific information // // Types that are assignable to ConnectionInfo: - // // *FlowFields_PortInfo // *FlowFields_IcmpInfo ConnectionInfo isFlowFields_ConnectionInfo `protobuf_oneof:"connection_info"` @@ -297,6 +312,9 @@ type FlowFields struct { // Resource ID SourceResourceId []byte `protobuf:"bytes,14,opt,name=source_resource_id,json=sourceResourceId,proto3" json:"source_resource_id,omitempty"` DestResourceId []byte `protobuf:"bytes,15,opt,name=dest_resource_id,json=destResourceId,proto3" json:"dest_resource_id,omitempty"` + NumOfStarts uint64 `protobuf:"varint,16,opt,name=num_of_starts,json=numOfStarts,proto3" json:"num_of_starts,omitempty"` + NumOfEnds uint64 `protobuf:"varint,17,opt,name=num_of_ends,json=numOfEnds,proto3" json:"num_of_ends,omitempty"` + NumOfDrops uint64 `protobuf:"varint,18,opt,name=num_of_drops,json=numOfDrops,proto3" json:"num_of_drops,omitempty"` } func (x *FlowFields) Reset() { @@ -443,6 +461,27 @@ func (x *FlowFields) GetDestResourceId() []byte { return nil } +func (x *FlowFields) GetNumOfStarts() uint64 { + if x != nil { + return x.NumOfStarts + } + return 0 +} + +func (x *FlowFields) GetNumOfEnds() uint64 { + if x != nil { + return x.NumOfEnds + } + return 0 +} + +func (x *FlowFields) GetNumOfDrops() uint64 { + if x != nil { + return x.NumOfDrops + } + return 0 +} + type isFlowFields_ConnectionInfo interface { isFlowFields_ConnectionInfo() } @@ -579,7 +618,7 @@ var file_flow_proto_rawDesc = []byte{ 0x0a, 0x0a, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0xd4, 0x01, 0x0a, 0x09, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, + 0x6f, 0x74, 0x6f, 0x22, 0xce, 0x02, 0x0a, 0x09, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, @@ -592,45 +631,59 @@ var file_flow_proto_rawDesc = []byte{ 0x77, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x52, 0x0a, 0x66, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, - 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x4b, 0x0a, 0x0c, 0x46, 0x6c, - 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, - 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x49, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x9c, 0x04, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, - 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x66, 0x6c, 0x6f, 0x77, 0x49, 0x64, 0x12, - 0x1e, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, - 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x17, 0x0a, 0x07, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x06, 0x72, 0x75, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x66, 0x6c, - 0x6f, 0x77, 0x2e, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, - 0x12, 0x17, 0x0a, 0x07, 0x64, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x06, 0x64, 0x65, 0x73, 0x74, 0x49, 0x70, 0x12, 0x2d, 0x0a, 0x09, 0x70, 0x6f, 0x72, - 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, - 0x6c, 0x6f, 0x77, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, - 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, - 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, - 0x6f, 0x77, 0x2e, 0x49, 0x43, 0x4d, 0x50, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x69, - 0x63, 0x6d, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, 0x5f, 0x70, 0x61, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x72, 0x78, 0x50, - 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, - 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, - 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x72, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, - 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x04, 0x52, 0x07, 0x74, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, - 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x64, 0x65, 0x73, - 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x64, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x49, 0x64, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x3d, 0x0a, 0x0c, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x5f, 0x65, 0x6e, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x77, 0x69, 0x6e, 0x64, 0x6f, + 0x77, 0x45, 0x6e, 0x64, 0x22, 0x4b, 0x0a, 0x0c, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x41, 0x63, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, + 0x72, 0x22, 0x82, 0x05, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, + 0x12, 0x17, 0x0a, 0x07, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x06, 0x66, 0x6c, 0x6f, 0x77, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x04, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x75, 0x6c, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x75, 0x6c, 0x65, + 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x65, + 0x73, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x64, 0x65, 0x73, + 0x74, 0x49, 0x70, 0x12, 0x2d, 0x0a, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x50, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x49, 0x43, 0x4d, + 0x50, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x49, 0x6e, 0x66, + 0x6f, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x72, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, + 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, + 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x07, 0x72, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x74, 0x78, + 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x64, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x64, + 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x22, 0x0a, + 0x0d, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x73, 0x18, 0x10, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x53, 0x74, 0x61, 0x72, 0x74, + 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x65, 0x6e, 0x64, 0x73, + 0x18, 0x11, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x45, 0x6e, 0x64, + 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x64, 0x72, 0x6f, 0x70, + 0x73, 0x18, 0x12, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x44, 0x72, + 0x6f, 0x70, 0x73, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x22, 0x48, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, @@ -683,17 +736,19 @@ var file_flow_proto_goTypes = []interface{}{ var file_flow_proto_depIdxs = []int32{ 7, // 0: flow.FlowEvent.timestamp:type_name -> google.protobuf.Timestamp 4, // 1: flow.FlowEvent.flow_fields:type_name -> flow.FlowFields - 0, // 2: flow.FlowFields.type:type_name -> flow.Type - 1, // 3: flow.FlowFields.direction:type_name -> flow.Direction - 5, // 4: flow.FlowFields.port_info:type_name -> flow.PortInfo - 6, // 5: flow.FlowFields.icmp_info:type_name -> flow.ICMPInfo - 2, // 6: flow.FlowService.Events:input_type -> flow.FlowEvent - 3, // 7: flow.FlowService.Events:output_type -> flow.FlowEventAck - 7, // [7:8] is the sub-list for method output_type - 6, // [6:7] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 7, // 2: flow.FlowEvent.window_start:type_name -> google.protobuf.Timestamp + 7, // 3: flow.FlowEvent.window_end:type_name -> google.protobuf.Timestamp + 0, // 4: flow.FlowFields.type:type_name -> flow.Type + 1, // 5: flow.FlowFields.direction:type_name -> flow.Direction + 5, // 6: flow.FlowFields.port_info:type_name -> flow.PortInfo + 6, // 7: flow.FlowFields.icmp_info:type_name -> flow.ICMPInfo + 2, // 8: flow.FlowService.Events:input_type -> flow.FlowEvent + 3, // 9: flow.FlowService.Events:output_type -> flow.FlowEventAck + 9, // [9:10] is the sub-list for method output_type + 8, // [8:9] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_flow_proto_init() } diff --git a/flow/proto/flow.proto b/flow/proto/flow.proto index ff5c50282..1c9e728d2 100644 --- a/flow/proto/flow.proto +++ b/flow/proto/flow.proto @@ -24,6 +24,9 @@ message FlowEvent { FlowFields flow_fields = 4; bool isInitiator = 5; + + google.protobuf.Timestamp window_start = 6; + google.protobuf.Timestamp window_end = 7; } message FlowEventAck { @@ -75,6 +78,9 @@ message FlowFields { bytes source_resource_id = 14; bytes dest_resource_id = 15; + uint64 num_of_starts = 16; + uint64 num_of_ends = 17; + uint64 num_of_drops = 18; } // Flow event types diff --git a/flow/proto/flow_grpc.pb.go b/flow/proto/flow_grpc.pb.go index b790f86a2..9ae9702a5 100644 --- a/flow/proto/flow_grpc.pb.go +++ b/flow/proto/flow_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.21.9 +// source: flow.proto package proto @@ -11,15 +15,19 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + FlowService_Events_FullMethodName = "/flow.FlowService/Events" +) // FlowServiceClient is the client API for FlowService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type FlowServiceClient interface { // Client to receiver streams of events and acknowledgements - Events(ctx context.Context, opts ...grpc.CallOption) (FlowService_EventsClient, error) + Events(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FlowEvent, FlowEventAck], error) } type flowServiceClient struct { @@ -30,54 +38,40 @@ func NewFlowServiceClient(cc grpc.ClientConnInterface) FlowServiceClient { return &flowServiceClient{cc} } -func (c *flowServiceClient) Events(ctx context.Context, opts ...grpc.CallOption) (FlowService_EventsClient, error) { - stream, err := c.cc.NewStream(ctx, &FlowService_ServiceDesc.Streams[0], "/flow.FlowService/Events", opts...) +func (c *flowServiceClient) Events(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FlowEvent, FlowEventAck], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FlowService_ServiceDesc.Streams[0], FlowService_Events_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &flowServiceEventsClient{stream} + x := &grpc.GenericClientStream[FlowEvent, FlowEventAck]{ClientStream: stream} return x, nil } -type FlowService_EventsClient interface { - Send(*FlowEvent) error - Recv() (*FlowEventAck, error) - grpc.ClientStream -} - -type flowServiceEventsClient struct { - grpc.ClientStream -} - -func (x *flowServiceEventsClient) Send(m *FlowEvent) error { - return x.ClientStream.SendMsg(m) -} - -func (x *flowServiceEventsClient) Recv() (*FlowEventAck, error) { - m := new(FlowEventAck) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FlowService_EventsClient = grpc.BidiStreamingClient[FlowEvent, FlowEventAck] // FlowServiceServer is the server API for FlowService service. // All implementations must embed UnimplementedFlowServiceServer -// for forward compatibility +// for forward compatibility. type FlowServiceServer interface { // Client to receiver streams of events and acknowledgements - Events(FlowService_EventsServer) error + Events(grpc.BidiStreamingServer[FlowEvent, FlowEventAck]) error mustEmbedUnimplementedFlowServiceServer() } -// UnimplementedFlowServiceServer must be embedded to have forward compatible implementations. -type UnimplementedFlowServiceServer struct { -} +// UnimplementedFlowServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedFlowServiceServer struct{} -func (UnimplementedFlowServiceServer) Events(FlowService_EventsServer) error { - return status.Errorf(codes.Unimplemented, "method Events not implemented") +func (UnimplementedFlowServiceServer) Events(grpc.BidiStreamingServer[FlowEvent, FlowEventAck]) error { + return status.Error(codes.Unimplemented, "method Events not implemented") } func (UnimplementedFlowServiceServer) mustEmbedUnimplementedFlowServiceServer() {} +func (UnimplementedFlowServiceServer) testEmbeddedByValue() {} // UnsafeFlowServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to FlowServiceServer will @@ -87,34 +81,22 @@ type UnsafeFlowServiceServer interface { } func RegisterFlowServiceServer(s grpc.ServiceRegistrar, srv FlowServiceServer) { + // If the following call panics, it indicates UnimplementedFlowServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&FlowService_ServiceDesc, srv) } func _FlowService_Events_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(FlowServiceServer).Events(&flowServiceEventsServer{stream}) + return srv.(FlowServiceServer).Events(&grpc.GenericServerStream[FlowEvent, FlowEventAck]{ServerStream: stream}) } -type FlowService_EventsServer interface { - Send(*FlowEventAck) error - Recv() (*FlowEvent, error) - grpc.ServerStream -} - -type flowServiceEventsServer struct { - grpc.ServerStream -} - -func (x *flowServiceEventsServer) Send(m *FlowEventAck) error { - return x.ServerStream.SendMsg(m) -} - -func (x *flowServiceEventsServer) Recv() (*FlowEvent, error) { - m := new(FlowEvent) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FlowService_EventsServer = grpc.BidiStreamingServer[FlowEvent, FlowEventAck] // FlowService_ServiceDesc is the grpc.ServiceDesc for FlowService service. // It's only intended for direct use with grpc.RegisterService, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index f746b31f4..c5492eb79 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -2769,6 +2769,28 @@ components: type: integer description: "Number of packets transmitted." example: 5 + num_of_starts: + type: integer + description: "Number of start events." + example: 3 + num_of_ends: + type: integer + description: "Number of end events." + example: 4 + num_of_drops: + type: integer + description: "Number of drop events." + example: 5 + window_start: + type: string + format: date-time + description: Timestamp of the start of the aggregation window. + example: 2025-03-20T16:23:58.125397Z + window_end: + type: string + format: date-time + description: Timestamp of the end of the aggregation window. + example: 2025-03-20T16:23:58.125397Z events: type: array description: "List of events that are correlated to this flow (e.g., start, end)." @@ -2790,6 +2812,11 @@ components: - rx_packets - tx_bytes - tx_packets + - num_of_starts + - num_of_ends + - num_of_drops + - window_start + - window_end - events NetworkTrafficEventsResponse: type: object diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 3b587c4bf..50bcd3487 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3709,9 +3709,18 @@ type NetworkTrafficEvent struct { Events []NetworkTrafficSubEvent `json:"events"` // FlowId FlowID is the ID of the connection flow. Not unique because it can be the same for multiple events (e.g., start and end of the connection). - FlowId string `json:"flow_id"` - Icmp NetworkTrafficICMP `json:"icmp"` - Policy NetworkTrafficPolicy `json:"policy"` + FlowId string `json:"flow_id"` + Icmp NetworkTrafficICMP `json:"icmp"` + + // NumOfDrops Number of drop events. + NumOfDrops int `json:"num_of_drops"` + + // NumOfEnds Number of end events. + NumOfEnds int `json:"num_of_ends"` + + // NumOfStarts Number of start events. + NumOfStarts int `json:"num_of_starts"` + Policy NetworkTrafficPolicy `json:"policy"` // Protocol Protocol is the protocol of the traffic (e.g. 1 = ICMP, 6 = TCP, 17 = UDP, etc.). Protocol int `json:"protocol"` @@ -3732,6 +3741,12 @@ type NetworkTrafficEvent struct { // TxPackets Number of packets transmitted. TxPackets int `json:"tx_packets"` User NetworkTrafficUser `json:"user"` + + // WindowEnd Timestamp of the end of the aggregation window. + WindowEnd time.Time `json:"window_end"` + + // WindowStart Timestamp of the start of the aggregation window. + WindowStart time.Time `json:"window_start"` } // NetworkTrafficEventsResponse defines model for NetworkTrafficEventsResponse. From d0d6dd4b0c9f8fb53a541c9bfcccea319d85f2d2 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 7 Jul 2026 16:58:48 +0200 Subject: [PATCH 36/36] [client] add json gateway for netbird daemon (#6272) * add json gateway for netbird daemon * client: add optional daemon JSON socket gateway --- client/cmd/service.go | 13 +- client/cmd/service_controller.go | 95 +- client/cmd/service_installer.go | 8 + client/cmd/service_json_gateway.go | 52 + client/cmd/service_json_socket_test.go | 176 ++ client/cmd/service_params.go | 17 +- client/cmd/service_params_test.go | 28 + client/cmd/service_socket.go | 111 + client/proto/daemon.pb.gw.go | 2560 ++++++++++++++++++++++++ client/proto/daemon_gateway_test.go | 80 + client/proto/generate.sh | 8 +- client/test/json-socket-docker.sh | 223 +++ go.mod | 2 + 13 files changed, 3341 insertions(+), 32 deletions(-) create mode 100644 client/cmd/service_json_gateway.go create mode 100644 client/cmd/service_json_socket_test.go create mode 100644 client/cmd/service_socket.go create mode 100644 client/proto/daemon.pb.gw.go create mode 100644 client/proto/daemon_gateway_test.go create mode 100755 client/test/json-socket-docker.sh diff --git a/client/cmd/service.go b/client/cmd/service.go index 56d8a8726..b0a56c71a 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "net/http" "runtime" "strings" "sync" @@ -22,15 +23,21 @@ var serviceCmd = &cobra.Command{ Short: "Manage the NetBird daemon service", } +const defaultJSONSocket = "unix:///var/run/netbird-http.sock" + var ( - serviceName string - serviceEnvVars []string + serviceName string + serviceEnvVars []string + jsonSocket string + enableJSONSocket bool ) type program struct { ctx context.Context cancel context.CancelFunc serv *grpc.Server + jsonServ *http.Server + jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex } @@ -46,6 +53,8 @@ func init() { serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture") serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks") + serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket") + serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket") rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") serviceEnvDesc := `Sets extra environment variables for the service. ` + diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 8de147946..5ef13a0a6 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -5,9 +5,6 @@ package cmd import ( "context" "fmt" - "net" - "os" - "strings" "time" "github.com/kardianos/service" @@ -22,41 +19,56 @@ import ( "github.com/netbirdio/netbird/util" ) +func validateJSONSocketFlags() error { + if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket { + return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway") + } + return nil +} + func (p *program) Start(svc service.Service) error { // Start should not block. Do the actual work async. log.Info("starting NetBird service") //nolint + if err := validateJSONSocketFlags(); err != nil { + return err + } + // Collect static system and platform information system.UpdateStaticInfoAsync() // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. p.serv = grpc.NewServer() - split := strings.Split(daemonAddr, "://") - switch split[0] { - case "unix": - // cleanup failed close - stat, err := os.Stat(split[1]) - if err == nil && !stat.IsDir() { - if err := os.Remove(split[1]); err != nil { - log.Debugf("remove socket file: %v", err) - } - } - case "tcp": - default: - return fmt.Errorf("unsupported daemon address protocol: %v", split[0]) - } - - listen, err := net.Listen(split[0], split[1]) + daemonListener, err := listenOnAddress(daemonAddr) if err != nil { return fmt.Errorf("listen daemon interface: %w", err) } - go func() { - defer listen.Close() - if split[0] == "unix" { - if err := os.Chmod(split[1], 0666); err != nil { - log.Errorf("failed setting daemon permissions: %v", split[1]) + var jsonListener *socketListener + if enableJSONSocket { + jsonListener, err = listenOnAddress(jsonSocket) + if err != nil { + _ = daemonListener.Close() + return fmt.Errorf("listen daemon JSON interface: %w", err) + } + } else { + removeStaleUnixSocketForAddress(jsonSocket) + } + + go func() { + defer daemonListener.Close() + if jsonListener != nil { + defer jsonListener.Close() + } + + if err := daemonListener.chmodUnixSocket("daemon"); err != nil { + log.Error(err) + return + } + if jsonListener != nil { + if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { + log.Error(err) return } } @@ -71,8 +83,16 @@ func (p *program) Start(svc service.Service) error { p.serverInstance = serverInstance p.serverInstanceMu.Unlock() - log.Printf("started daemon server: %v", split[1]) - if err := p.serv.Serve(listen); err != nil { + if jsonListener != nil { + if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { + log.Fatalf("failed to start daemon JSON server: %v", err) + } + } else { + log.Debug("daemon JSON socket disabled") + } + + log.Printf("started daemon server: %v", daemonListener.address) + if err := p.serv.Serve(daemonListener.Listener); err != nil { log.Errorf("failed to serve daemon requests: %v", err) } }() @@ -92,6 +112,20 @@ func (p *program) Stop(srv service.Service) error { p.cancel() + p.jsonServMu.Lock() + jsonServ := p.jsonServ + p.jsonServMu.Unlock() + if jsonServ != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := jsonServ.Shutdown(shutdownCtx); err != nil { + log.Errorf("failed to stop daemon JSON server gracefully: %v", err) + if err := jsonServ.Close(); err != nil { + log.Errorf("failed to close daemon JSON server: %v", err) + } + } + shutdownCancel() + } + if p.serv != nil { p.serv.Stop() } @@ -148,6 +182,9 @@ var runCmd = &cobra.Command{ if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } return s.Run() }, @@ -162,6 +199,9 @@ var startCmd = &cobra.Command{ if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } if err := s.Start(); err != nil { return fmt.Errorf("start service: %w", err) @@ -198,6 +238,9 @@ var restartCmd = &cobra.Command{ if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } if err := s.Restart(); err != nil { return fmt.Errorf("restart service: %w", err) diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index 2d45fa063..ae2dfb9fa 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -67,6 +67,10 @@ func buildServiceArguments() []string { args = append(args, "--disable-networks") } + if enableJSONSocket { + args = append(args, "--enable-json-socket", "--json-socket", jsonSocket) + } + return args } @@ -106,6 +110,10 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error { // Create fully configured service config for install/reconfigure func createServiceConfigForInstall() (*service.Config, error) { + if err := validateJSONSocketFlags(); err != nil { + return nil, err + } + svcConfig, err := newSVCConfig() if err != nil { return nil, fmt.Errorf("create service config: %w", err) diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go new file mode 100644 index 000000000..29c1a6456 --- /dev/null +++ b/client/cmd/service_json_gateway.go @@ -0,0 +1,52 @@ +//go:build !ios && !android + +package cmd + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/client/proto" +) + +func grpcGatewayEndpoint(addr string) string { + return strings.TrimPrefix(addr, "tcp://") +} + +func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { + mux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil { + return err + } + + jsonServer := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + BaseContext: func(net.Listener) context.Context { + return p.ctx + }, + } + + p.jsonServMu.Lock() + p.jsonServ = jsonServer + p.jsonServMu.Unlock() + + go func() { + log.Printf("started daemon JSON server: %v", jsonListener.address) + if err := jsonServer.Serve(jsonListener.Listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("failed to serve daemon JSON requests: %v", err) + } + }() + + return nil +} diff --git a/client/cmd/service_json_socket_test.go b/client/cmd/service_json_socket_test.go new file mode 100644 index 000000000..4b39794d7 --- /dev/null +++ b/client/cmd/service_json_socket_test.go @@ -0,0 +1,176 @@ +//go:build !ios && !android + +package cmd + +import ( + "net" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func preserveJSONSocketTestState(t *testing.T) { + t.Helper() + + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket + origChanged := map[string]bool{} + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + origChanged[flag.Name] = flag.Changed + }) + + t.Cleanup(func() { + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = origChanged[flag.Name] + }) + }) +} + +func TestJSONSocketFlagsArePositiveEnableOnly(t *testing.T) { + assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("enable-json-socket")) + assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("json-socket")) + assert.Nil(t, serviceCmd.PersistentFlags().Lookup("disable-json-socket")) + assert.Equal(t, "false", serviceCmd.PersistentFlags().Lookup("enable-json-socket").DefValue) +} + +func TestBuildServiceArgumentsDefaultDisablesJSONSocket(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = false + jsonSocket = "tcp://127.0.0.1:8080" + + args := buildServiceArguments() + + assert.NotContains(t, args, "--enable-json-socket") + assert.NotContains(t, args, "--json-socket") +} + +func TestBuildServiceArgumentsIncludesJSONSocketWhenEnabled(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = true + jsonSocket = "tcp://127.0.0.1:8080" + + args := buildServiceArguments() + + enableIndex := indexOfArg(args, "--enable-json-socket") + jsonIndex := indexOfArg(args, "--json-socket") + require.NotEqual(t, -1, enableIndex) + require.NotEqual(t, -1, jsonIndex) + require.Less(t, enableIndex, jsonIndex) + require.Less(t, jsonIndex+1, len(args)) + assert.Equal(t, "tcp://127.0.0.1:8080", args[jsonIndex+1]) +} + +func TestJSONSocketWithoutEnableValidation(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = false + require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080")) + + err := validateJSONSocketFlags() + + require.Error(t, err) + assert.Contains(t, err.Error(), "--enable-json-socket") +} + +func TestJSONSocketWithEnableValidation(t *testing.T) { + preserveJSONSocketTestState(t) + + require.NoError(t, serviceCmd.PersistentFlags().Set("enable-json-socket", "true")) + require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080")) + + assert.NoError(t, validateJSONSocketFlags()) +} + +func TestJSONSocketServiceParamsPersistEnableAndAddress(t *testing.T) { + preserveJSONSocketTestState(t) + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = false + }) + + enableJSONSocket = true + jsonSocket = "tcp://127.0.0.1:8080" + + params := currentServiceParams() + require.True(t, params.EnableJSONSocket) + require.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket) + + enableJSONSocket = false + jsonSocket = defaultJSONSocket + applyServiceParams(testServiceEnvCommand(), params) + + assert.True(t, enableJSONSocket) + assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket) +} + +func TestRemoveStaleUnixSocketDoesNotRemoveRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird-http.sock") + require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0600)) + + removeStaleUnixSocket(path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, []byte("not a socket"), data) +} + +func TestRemoveStaleUnixSocketRemovesSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not available on Windows") + } + + path := filepath.Join(t.TempDir(), "netbird-http.sock") + addr := &net.UnixAddr{Name: path, Net: "unix"} + listener, err := net.ListenUnix("unix", addr) + require.NoError(t, err) + listener.SetUnlinkOnClose(false) + require.NoError(t, listener.Close()) + + _, err = os.Lstat(path) + require.NoError(t, err, "test setup must leave a stale Unix socket path") + + removeStaleUnixSocket(path) + + _, err = os.Lstat(path) + assert.True(t, os.IsNotExist(err), "expected stale Unix socket to be removed, got %v", err) +} + +func TestRemoveStaleUnixSocketDoesNotRemoveLiveSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not available on Windows") + } + + path := filepath.Join(t.TempDir(), "netbird-http.sock") + listener, err := net.Listen("unix", path) + require.NoError(t, err) + defer listener.Close() + + removeStaleUnixSocket(path) + + _, err = os.Lstat(path) + assert.NoError(t, err, "expected live Unix socket to be preserved") +} + +func testServiceEnvCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + return cmd +} + +func indexOfArg(args []string, arg string) int { + for i, candidate := range args { + if candidate == arg { + return i + } + } + return -1 +} diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index 192e0ac60..f25087a69 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -23,6 +23,7 @@ const serviceParamsFile = "service.json" type serviceParams struct { LogLevel string `json:"log_level"` DaemonAddr string `json:"daemon_addr"` + JSONSocket string `json:"json_socket"` ManagementURL string `json:"management_url,omitempty"` ConfigPath string `json:"config_path,omitempty"` LogFiles []string `json:"log_files,omitempty"` @@ -30,6 +31,7 @@ type serviceParams struct { DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` EnableCapture bool `json:"enable_capture,omitempty"` DisableNetworks bool `json:"disable_networks,omitempty"` + EnableJSONSocket bool `json:"enable_json_socket,omitempty"` ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` } @@ -75,6 +77,7 @@ func currentServiceParams() *serviceParams { params := &serviceParams{ LogLevel: logLevel, DaemonAddr: daemonAddr, + JSONSocket: jsonSocket, ManagementURL: managementURL, ConfigPath: configPath, LogFiles: logFiles, @@ -82,6 +85,7 @@ func currentServiceParams() *serviceParams { DisableUpdateSettings: updateSettingsDisabled, EnableCapture: captureEnabled, DisableNetworks: networksDisabled, + EnableJSONSocket: enableJSONSocket, } if len(serviceEnvVars) > 0 { @@ -113,9 +117,8 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { return } - // For fields with non-empty defaults (log-level, daemon-addr), keep the - // != "" guard so that an older service.json missing the field doesn't - // clobber the default with an empty string. + // For fields with non-empty defaults, keep the != "" guard so that an older + // service.json missing the field doesn't clobber the default with an empty string. if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" { logLevel = params.LogLevel } @@ -124,6 +127,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { daemonAddr = params.DaemonAddr } + if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" { + jsonSocket = params.JSONSocket + } + + if !serviceCmd.PersistentFlags().Changed("enable-json-socket") { + enableJSONSocket = params.EnableJSONSocket + } + // For optional fields where empty means "use default", always apply so // that an explicit clear (--management-url "") persists across reinstalls. if !rootCmd.PersistentFlags().Changed("management-url") { diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go index f338c12f4..94f98a0ce 100644 --- a/client/cmd/service_params_test.go +++ b/client/cmd/service_params_test.go @@ -41,6 +41,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) { params := &serviceParams{ LogLevel: "debug", DaemonAddr: "unix:///var/run/netbird.sock", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, ManagementURL: "https://my.server.com", ConfigPath: "/etc/netbird/config.json", LogFiles: []string{"/var/log/netbird/client.log", "console"}, @@ -63,6 +65,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) { assert.Equal(t, params.LogLevel, loaded.LogLevel) assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr) + assert.Equal(t, params.JSONSocket, loaded.JSONSocket) + assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket) assert.Equal(t, params.ManagementURL, loaded.ManagementURL) assert.Equal(t, params.ConfigPath, loaded.ConfigPath) assert.Equal(t, params.LogFiles, loaded.LogFiles) @@ -101,6 +105,8 @@ func TestLoadServiceParams_InvalidJSON(t *testing.T) { func TestCurrentServiceParams(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles @@ -110,6 +116,8 @@ func TestCurrentServiceParams(t *testing.T) { t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles @@ -120,6 +128,8 @@ func TestCurrentServiceParams(t *testing.T) { logLevel = "trace" daemonAddr = "tcp://127.0.0.1:9999" + jsonSocket = "tcp://127.0.0.1:8080" + enableJSONSocket = true managementURL = "https://mgmt.example.com" configPath = "/tmp/test-config.json" logFiles = []string{"/tmp/test.log"} @@ -131,6 +141,8 @@ func TestCurrentServiceParams(t *testing.T) { assert.Equal(t, "trace", params.LogLevel) assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr) + assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket) + assert.True(t, params.EnableJSONSocket) assert.Equal(t, "https://mgmt.example.com", params.ManagementURL) assert.Equal(t, "/tmp/test-config.json", params.ConfigPath) assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles) @@ -142,6 +154,8 @@ func TestCurrentServiceParams(t *testing.T) { func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles @@ -151,6 +165,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles @@ -162,6 +178,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { // Reset all flags to defaults. logLevel = "info" daemonAddr = "unix:///var/run/netbird.sock" + jsonSocket = defaultJSONSocket + enableJSONSocket = false managementURL = "" configPath = "/etc/netbird/config.json" logFiles = []string{"/var/log/netbird/client.log"} @@ -184,6 +202,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { saved := &serviceParams{ LogLevel: "debug", DaemonAddr: "tcp://127.0.0.1:5555", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, ManagementURL: "https://saved.example.com", ConfigPath: "/saved/config.json", LogFiles: []string{"/saved/client.log"}, @@ -201,6 +221,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { // All other fields were not Changed, so they should use saved values. assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr) + assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket) + assert.True(t, enableJSONSocket) assert.Equal(t, "https://saved.example.com", managementURL) assert.Equal(t, "/saved/config.json", configPath) assert.Equal(t, []string{"/saved/client.log"}, logFiles) @@ -212,14 +234,17 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { origProfilesDisabled := profilesDisabled origUpdateSettingsDisabled := updateSettingsDisabled + origEnableJSONSocket := enableJSONSocket t.Cleanup(func() { profilesDisabled = origProfilesDisabled updateSettingsDisabled = origUpdateSettingsDisabled + enableJSONSocket = origEnableJSONSocket }) // Simulate current state where booleans are true (e.g. set by previous install). profilesDisabled = true updateSettingsDisabled = true + enableJSONSocket = true // Reset Changed state so flags appear unset. serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { @@ -238,6 +263,7 @@ func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { assert.False(t, profilesDisabled, "saved false should override current true") assert.False(t, updateSettingsDisabled, "saved false should override current true") + assert.False(t, enableJSONSocket, "saved false should override current true") } func TestApplyServiceParams_ClearManagementURL(t *testing.T) { @@ -530,6 +556,7 @@ func fieldToGlobalVar(field string) string { m := map[string]string{ "LogLevel": "logLevel", "DaemonAddr": "daemonAddr", + "JSONSocket": "jsonSocket", "ManagementURL": "managementURL", "ConfigPath": "configPath", "LogFiles": "logFiles", @@ -537,6 +564,7 @@ func fieldToGlobalVar(field string) string { "DisableUpdateSettings": "updateSettingsDisabled", "EnableCapture": "captureEnabled", "DisableNetworks": "networksDisabled", + "EnableJSONSocket": "enableJSONSocket", "ServiceEnvVars": "serviceEnvVars", } if v, ok := m[field]; ok { diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go new file mode 100644 index 000000000..f825a4062 --- /dev/null +++ b/client/cmd/service_socket.go @@ -0,0 +1,111 @@ +//go:build !ios && !android + +package cmd + +import ( + "errors" + "fmt" + "net" + "os" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +type socketListener struct { + net.Listener + network string + address string +} + +func listenOnAddress(addr string) (*socketListener, error) { + network, address, err := parseListenAddress(addr) + if err != nil { + return nil, err + } + + if network == "unix" { + removeStaleUnixSocket(address) + } + + listener, err := net.Listen(network, address) + if err != nil { + return nil, err + } + + return &socketListener{Listener: listener, network: network, address: address}, nil +} + +func parseListenAddress(addr string) (string, string, error) { + network, address, ok := strings.Cut(addr, "://") + if !ok || network == "" || address == "" { + return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr) + } + + switch network { + case "unix", "tcp": + return network, address, nil + default: + return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network) + } +} + +func removeStaleUnixSocket(path string) { + stat, err := os.Lstat(path) + if err != nil { + if !os.IsNotExist(err) { + log.Debugf("stat socket file: %v", err) + } + return + } + + if stat.Mode()&os.ModeSocket == 0 { + return + } + + if !isStaleUnixSocket(path) { + return + } + + if err := os.Remove(path); err != nil { + log.Debugf("remove socket file: %v", err) + } +} + +func isStaleUnixSocket(path string) bool { + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err == nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close unix socket probe: %v", closeErr) + } + return false + } + + if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) { + log.Debugf("not removing unix socket %s after probe error: %v", path, err) + return false + } + + return errors.Is(err, syscall.ECONNREFUSED) +} + +func removeStaleUnixSocketForAddress(addr string) { + network, address, err := parseListenAddress(addr) + if err != nil || network != "unix" { + return + } + removeStaleUnixSocket(address) +} + +func (l *socketListener) chmodUnixSocket(description string) error { + if l == nil || l.network != "unix" { + return nil + } + + if err := os.Chmod(l.address, 0666); err != nil { + return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err) + } + return nil +} diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go new file mode 100644 index 000000000..a0a9690f0 --- /dev/null +++ b/client/proto/daemon.pb.gw.go @@ -0,0 +1,2560 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: daemon.proto + +/* +Package proto is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package proto + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Login(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Login(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitSSOLoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitSSOLogin(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitSSOLoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitSSOLogin(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Up(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Up(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Status(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Status(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DownRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Down(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DownRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Down(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SelectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SelectNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DeselectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DeselectNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EmptyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ForwardingRules(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EmptyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ForwardingRules(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DebugBundleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DebugBundle(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DebugBundleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DebugBundle(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetLogLevel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetLogLevel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListStatesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListStates(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListStatesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListStates(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CleanStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.CleanState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CleanStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CleanState(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DeleteState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DeleteState(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetSyncResponsePersistenceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetSyncResponsePersistence(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetSyncResponsePersistenceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetSyncResponsePersistence(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TracePacketRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.TracePacket(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TracePacketRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TracePacket(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StartCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_StartCaptureClient, runtime.ServerMetadata, error) { + var ( + protoReq StartCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.StartCapture(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StartBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StartBundleCapture(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StopBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StopBundleCapture(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SubscribeEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeEventsClient, runtime.ServerMetadata, error) { + var ( + protoReq SubscribeRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.SubscribeEvents(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetEventsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetEvents(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetEventsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetEvents(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SwitchProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SwitchProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SwitchProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SwitchProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.AddProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.AddProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RenameProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RenameProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RenameProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RenameProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RemoveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RemoveProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListProfilesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListProfiles(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListProfilesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListProfiles(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetActiveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetActiveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetActiveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetActiveProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LogoutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Logout(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LogoutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Logout(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetFeaturesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetFeatures(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetFeaturesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetFeatures(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerUpdateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.TriggerUpdate(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerUpdateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TriggerUpdate(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPeerSSHHostKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetPeerSSHHostKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPeerSSHHostKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetPeerSSHHostKey(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestJWTAuthRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RequestJWTAuth(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestJWTAuthRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RequestJWTAuth(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitJWTTokenRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitJWTToken(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitJWTTokenRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitJWTToken(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StartCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StartCPUProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StopCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StopCPUProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallerResultRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetInstallerResult(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallerResultRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetInstallerResult(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ExposeService_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_ExposeServiceClient, runtime.ServerMetadata, error) { + var ( + protoReq ExposeServiceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.ExposeService(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux". +// UnaryRPC :call DaemonServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterDaemonServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server DaemonServiceServer) error { + mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Login_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Up_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Status_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Down_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + + return nil +} + +// RegisterDaemonServiceHandlerFromEndpoint is same as RegisterDaemonServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterDaemonServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterDaemonServiceHandler(ctx, mux, conn) +} + +// RegisterDaemonServiceHandler registers the http handlers for service DaemonService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterDaemonServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterDaemonServiceHandlerClient(ctx, mux, NewDaemonServiceClient(conn)) +} + +// RegisterDaemonServiceHandlerClient registers the http handlers for service DaemonService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "DaemonServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "DaemonServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "DaemonServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client DaemonServiceClient) error { + mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Login_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Up_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Status_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Down_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCapture_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SubscribeEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SubscribeEvents_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ExposeService", runtime.WithHTTPPathPattern("/daemon.DaemonService/ExposeService")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ExposeService_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ExposeService_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_DaemonService_Login_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Login"}, "")) + pattern_DaemonService_WaitSSOLogin_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitSSOLogin"}, "")) + pattern_DaemonService_Up_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Up"}, "")) + pattern_DaemonService_Status_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Status"}, "")) + pattern_DaemonService_Down_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Down"}, "")) + pattern_DaemonService_GetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetConfig"}, "")) + pattern_DaemonService_ListNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListNetworks"}, "")) + pattern_DaemonService_SelectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SelectNetworks"}, "")) + pattern_DaemonService_DeselectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeselectNetworks"}, "")) + pattern_DaemonService_ForwardingRules_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ForwardingRules"}, "")) + pattern_DaemonService_DebugBundle_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DebugBundle"}, "")) + pattern_DaemonService_GetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetLogLevel"}, "")) + pattern_DaemonService_SetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetLogLevel"}, "")) + pattern_DaemonService_ListStates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListStates"}, "")) + pattern_DaemonService_CleanState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "CleanState"}, "")) + pattern_DaemonService_DeleteState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeleteState"}, "")) + pattern_DaemonService_SetSyncResponsePersistence_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetSyncResponsePersistence"}, "")) + pattern_DaemonService_TracePacket_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TracePacket"}, "")) + pattern_DaemonService_StartCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCapture"}, "")) + pattern_DaemonService_StartBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartBundleCapture"}, "")) + pattern_DaemonService_StopBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopBundleCapture"}, "")) + pattern_DaemonService_SubscribeEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeEvents"}, "")) + pattern_DaemonService_GetEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetEvents"}, "")) + pattern_DaemonService_SwitchProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SwitchProfile"}, "")) + pattern_DaemonService_SetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetConfig"}, "")) + pattern_DaemonService_AddProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddProfile"}, "")) + pattern_DaemonService_RenameProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RenameProfile"}, "")) + pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, "")) + pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, "")) + pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, "")) + pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, "")) + pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, "")) + pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, "")) + pattern_DaemonService_GetPeerSSHHostKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetPeerSSHHostKey"}, "")) + pattern_DaemonService_RequestJWTAuth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestJWTAuth"}, "")) + pattern_DaemonService_WaitJWTToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitJWTToken"}, "")) + pattern_DaemonService_StartCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCPUProfile"}, "")) + pattern_DaemonService_StopCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopCPUProfile"}, "")) + pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, "")) + pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, "")) +) + +var ( + forward_DaemonService_Login_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitSSOLogin_0 = runtime.ForwardResponseMessage + forward_DaemonService_Up_0 = runtime.ForwardResponseMessage + forward_DaemonService_Status_0 = runtime.ForwardResponseMessage + forward_DaemonService_Down_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetConfig_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_SelectNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_DeselectNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_ForwardingRules_0 = runtime.ForwardResponseMessage + forward_DaemonService_DebugBundle_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetLogLevel_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetLogLevel_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListStates_0 = runtime.ForwardResponseMessage + forward_DaemonService_CleanState_0 = runtime.ForwardResponseMessage + forward_DaemonService_DeleteState_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetSyncResponsePersistence_0 = runtime.ForwardResponseMessage + forward_DaemonService_TracePacket_0 = runtime.ForwardResponseMessage + forward_DaemonService_StartCapture_0 = runtime.ForwardResponseStream + forward_DaemonService_StartBundleCapture_0 = runtime.ForwardResponseMessage + forward_DaemonService_StopBundleCapture_0 = runtime.ForwardResponseMessage + forward_DaemonService_SubscribeEvents_0 = runtime.ForwardResponseStream + forward_DaemonService_GetEvents_0 = runtime.ForwardResponseMessage + forward_DaemonService_SwitchProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetConfig_0 = runtime.ForwardResponseMessage + forward_DaemonService_AddProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_RenameProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage + forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetPeerSSHHostKey_0 = runtime.ForwardResponseMessage + forward_DaemonService_RequestJWTAuth_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitJWTToken_0 = runtime.ForwardResponseMessage + forward_DaemonService_StartCPUProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage + forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream +) diff --git a/client/proto/daemon_gateway_test.go b/client/proto/daemon_gateway_test.go new file mode 100644 index 000000000..20031e9d9 --- /dev/null +++ b/client/proto/daemon_gateway_test.go @@ -0,0 +1,80 @@ +package proto + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) { + mux := gatewayruntime.NewServeMux() + if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil { + t.Fatalf("register daemon gateway server handlers: %v", err) + } + + assertAllDaemonGatewayRoutesRegistered(t, mux) +} + +func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{}) + go func() { + if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped { + t.Errorf("serve bufconn gRPC server: %v", err) + } + }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mux := gatewayruntime.NewServeMux() + opts := []grpc.DialOption{ + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + } + if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil { + t.Fatalf("register daemon gateway client handlers: %v", err) + } + + assertAllDaemonGatewayRoutesRegistered(t, mux) +} + +func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) { + t.Helper() + for _, method := range DaemonService_ServiceDesc.Methods { + assertGatewayRouteRegistered(t, mux, method.MethodName) + } + for _, stream := range DaemonService_ServiceDesc.Streams { + assertGatewayRouteRegistered(t, mux, stream.StreamName) + } +} + +func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) { + t.Helper() + + path := "/daemon.DaemonService/" + methodName + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + mux.ServeHTTP(res, req) + + if res.Code == http.StatusNotFound { + t.Fatalf("gateway route for %s is not registered", methodName) + } +} diff --git a/client/proto/generate.sh b/client/proto/generate.sh index 1ae55e380..cea8ae912 100755 --- a/client/proto/generate.sh +++ b/client/proto/generate.sh @@ -12,5 +12,11 @@ script_path=$(dirname "$(realpath "$0")") cd "$script_path" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1 -protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional +go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26.3 +protoc -I ./ ./daemon.proto \ + --go_out=../ \ + --go-grpc_out=../ \ + --grpc-gateway_out=../ \ + --grpc-gateway_opt=generate_unbound_methods=true \ + --experimental_allow_proto3_optional cd "$old_pwd" diff --git a/client/test/json-socket-docker.sh b/client/test/json-socket-docker.sh new file mode 100755 index 000000000..a878f13d6 --- /dev/null +++ b/client/test/json-socket-docker.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +set -eEuo pipefail + +usage() { + cat <<'EOF' +Usage: client/test/json-socket-docker.sh [tcp|unix|both] + +Builds the NetBird client Docker image from the local source tree, starts +`netbird service run` in a container with --enable-json-socket, and verifies +that the HTTP/JSON daemon gateway responds to Status requests. + +Modes: + tcp Validate tcp://0.0.0.0:8080 via a published localhost port (default) + unix Validate unix:///sock/netbird-http.sock via a bind-mounted socket dir + both Run both validations + +Environment: + CONTAINER_RUNTIME docker or podman. Auto-detected if unset. + IMAGE Image tag to build. Default: netbird-json-socket-test:local + TARGETARCH Go/Docker target arch. Default: `go env GOARCH` + PLATFORM Docker platform. Default: linux/$TARGETARCH + WAIT_TIMEOUT Seconds to wait for the JSON socket. Default: 30 +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +MODE="${1:-tcp}" +case "${MODE}" in + tcp|unix|both) ;; + *) + usage >&2 + echo "invalid mode: ${MODE}" >&2 + exit 2 + ;; +esac + +RUNTIME="${CONTAINER_RUNTIME:-}" +if [[ -z "${RUNTIME}" ]]; then + if command -v docker >/dev/null 2>&1; then + RUNTIME=docker + elif command -v podman >/dev/null 2>&1; then + RUNTIME=podman + else + echo "docker or podman is required" >&2 + exit 127 + fi +fi +if ! command -v "${RUNTIME}" >/dev/null 2>&1; then + echo "container runtime not found: ${RUNTIME}" >&2 + exit 127 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required" >&2 + exit 127 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${IMAGE:-netbird-json-socket-test:local}" +TARGETARCH="${TARGETARCH:-$(go env GOARCH)}" +PLATFORM="${PLATFORM:-linux/${TARGETARCH}}" +WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}" +TMP_DIR="$(mktemp -d)" +CONTAINERS=() + +cleanup() { + local status=$? + for container in "${CONTAINERS[@]:-}"; do + "${RUNTIME}" rm -f "${container}" >/dev/null 2>&1 || true + done + rm -rf "${TMP_DIR}" + exit "${status}" +} +trap cleanup EXIT + +build_image() { + echo "==> Building Linux ${TARGETARCH} netbird binary" + mkdir -p "${TMP_DIR}/context/client" + cp "${ROOT_DIR}/client/Dockerfile" "${TMP_DIR}/context/Dockerfile" + cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh" + + (cd "${ROOT_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -o "${TMP_DIR}/context/netbird" ./client) + + echo "==> Building ${IMAGE} for ${PLATFORM}" + "${RUNTIME}" build \ + --platform "${PLATFORM}" \ + --build-arg NETBIRD_BINARY=netbird \ + -t "${IMAGE}" \ + -f "${TMP_DIR}/context/Dockerfile" \ + "${TMP_DIR}/context" +} + +pick_port() { + python3 - <<'PY' +import socket +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +assert_status_json() { + local response_file="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "${response_file}" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as fh: + data = json.load(fh) +if not data.get("status"): + raise SystemExit("missing non-empty status field") +if "daemonVersion" not in data: + raise SystemExit("missing daemonVersion field") +print(f"status={data['status']} daemonVersion={data['daemonVersion']}") +PY + else + grep -q '"status"' "${response_file}" + grep -q '"daemonVersion"' "${response_file}" + cat "${response_file}" + fi +} + +container_logs() { + local container="$1" + echo "---- ${container} logs ----" >&2 + "${RUNTIME}" logs "${container}" >&2 || true + echo "--------------------------" >&2 +} + +wait_for_http_status() { + local container="$1" + local response="${TMP_DIR}/${container}.json" + local curl_err="${TMP_DIR}/${container}.curl.err" + shift + local deadline=$((SECONDS + WAIT_TIMEOUT)) + + while (( SECONDS < deadline )); do + if curl -fsS "$@" \ + -X POST \ + -H 'Content-Type: application/json' \ + -d '{}' \ + -o "${response}" \ + 2>"${curl_err}"; then + assert_status_json "${response}" + return 0 + fi + + if ! "${RUNTIME}" ps --format '{{.Names}}' | grep -Fxq "${container}"; then + echo "container exited before JSON socket became ready" >&2 + container_logs "${container}" + return 1 + fi + sleep 1 + done + + echo "timed out waiting for JSON socket after ${WAIT_TIMEOUT}s" >&2 + cat "${curl_err}" >&2 || true + container_logs "${container}" + return 1 +} + +run_netbird_container() { + local container="$1" + local json_socket="$2" + shift 2 + + CONTAINERS+=("${container}") + "${RUNTIME}" run --rm -d \ + --name "${container}" \ + -e NB_STATE_DIR=/tmp/netbird-state \ + --entrypoint /usr/local/bin/netbird \ + "$@" \ + "${IMAGE}" \ + --log-file console \ + --daemon-addr unix:///tmp/netbird.sock \ + service run \ + --enable-json-socket \ + --json-socket "${json_socket}" >/dev/null +} + +run_tcp_test() { + local port container + port="$(pick_port)" + container="nb-json-socket-tcp-$RANDOM-$RANDOM" + + echo "==> Validating TCP JSON socket on 127.0.0.1:${port}" + run_netbird_container "${container}" "tcp://0.0.0.0:8080" -p "127.0.0.1:${port}:8080" + wait_for_http_status "${container}" "http://127.0.0.1:${port}/daemon.DaemonService/Status" +} + +run_unix_test() { + local sock_dir sock_path container + sock_dir="${TMP_DIR}/sock" + sock_path="${sock_dir}/netbird-http.sock" + container="nb-json-socket-unix-$RANDOM-$RANDOM" + mkdir -p "${sock_dir}" + + echo "==> Validating Unix JSON socket at ${sock_path}" + run_netbird_container "${container}" "unix:///sock/netbird-http.sock" -v "${sock_dir}:/sock" + wait_for_http_status "${container}" --unix-socket "${sock_path}" "http://unix/daemon.DaemonService/Status" +} + +build_image + +case "${MODE}" in + tcp) + run_tcp_test + ;; + unix) + run_unix_test + ;; + both) + run_tcp_test + run_unix_test + ;; +esac + +echo "==> Docker JSON socket validation passed (${MODE})" diff --git a/go.mod b/go.mod index 3564891a3..3f1f8e414 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( github.com/google/nftables v0.3.0 github.com/gopacket/gopacket v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 @@ -318,6 +319,7 @@ require ( golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect

    ;$Y7|`GmdsUGKN|{m7r8 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 fvks?6@<`I3J33Bf@Z{MP-A)! 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�@c$i|I-1BZcDq4#G3!BYm%Qfh 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
    >I4eNfT#ckCRoHiLZReBWlTv9 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
    Y}Bz51e`F7Z<5P4^1 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ðR|%AfxE5$QeZwVssQ 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 bsqOy + + + + + + + + + + + + + + + + + + diff --git a/client/ui/frontend/src/assets/logos/netbird.svg b/client/ui/frontend/src/assets/logos/netbird.svg new file mode 100644 index 000000000..6254931c6 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx new file mode 100644 index 000000000..c5e2b5f22 --- /dev/null +++ b/client/ui/frontend/src/components/Badge.tsx @@ -0,0 +1,43 @@ +import { forwardRef, type ComponentType, type HTMLAttributes } from "react"; +import type { LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger"; + +type Props = HTMLAttributes & { + variant?: BadgeVariant; + icon?: ComponentType; + iconSize?: number; +}; + +const VARIANT_CLASSES: Record = { + info: "bg-sky-900 border border-sky-700 text-sky-200", + neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird", + success: "bg-green-900 border border-green-700 text-green-200", + warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", + danger: "bg-red-900 border border-red-700 text-red-200", +}; + +export const Badge = forwardRef(function Badge( + { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest }, + ref, +) { + return ( + + {Icon && } + {children} + + ); +}); + +export default Badge; diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx new file mode 100644 index 000000000..1b2a87da4 --- /dev/null +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Copy } from "lucide-react"; +import { cn } from "@/lib/cn"; + +const VARIANT_HOVER = { + default: "group-hover/copy:[&_*]:text-nb-gray-300", + bright: "group-hover/copy:[&_*]:text-nb-gray-200", +} as const; + +type CopyToClipboardVariant = keyof typeof VARIANT_HOVER; + +type CopyToClipboardProps = { + children: ReactNode; + message?: string; + size?: number; + iconAlignment?: "left" | "right"; + className?: string; + iconClassName?: string; + alwaysShowIcon?: boolean; + variant?: CopyToClipboardVariant; + "aria-label"?: string; + tabIndex?: number; + onKeyDown?: (e: KeyboardEvent) => void; +}; + +export const CopyToClipboard = ({ + children, + message, + size = 10, + iconAlignment = "right", + className, + iconClassName, + alwaysShowIcon = false, + variant = "default", + "aria-label": ariaLabel, + tabIndex = 0, + onKeyDown, +}: CopyToClipboardProps) => { + const { t } = useTranslation(); + const wrapperRef = useRef(null); + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const text = message ?? wrapperRef.current?.innerText ?? ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy to clipboard failed", e); + } + }; + + const resolvedLabel = + ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy")); + + return ( + + ); +}; diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx new file mode 100644 index 000000000..d43c37e1b --- /dev/null +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -0,0 +1,233 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cva } from "class-variance-authority"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const menuItemVariants = cva("", { + variants: { + variant: { + default: + "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50", + danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500", + }, + }, + defaultVariants: { variant: "default" }, +}); + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + } +>(({ className, inset, children, variant, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + href?: string; + target?: string; + rel?: string; + } +>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => ( + { + if (href) return; + e.preventDefault(); + e.stopPropagation(); + onClick?.(e); + }} + {...props} + > + {href ? ( + + {children} + + ) : ( + children + )} + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx new file mode 100644 index 000000000..7a30f8b33 --- /dev/null +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react"; +import { Preferences } from "@bindings/services"; +import { type LanguageCode, type Language } from "@bindings/i18n/models.js"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { loadLanguages } from "@/lib/i18n"; +import { cn } from "@/lib/cn"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ + +const labelFor = (lang: Language): string => + lang.englishName && lang.englishName !== lang.displayName + ? `${lang.displayName} (${lang.englishName})` + : lang.displayName; + +export function LanguagePicker() { + const { t, i18n } = useTranslation(); + const [languages, setLanguages] = useState([]); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const isFocusVisible = useFocusVisible(); + + useEffect(() => { + let cancelled = false; + loadLanguages() + .then((list) => { + if (!cancelled) setLanguages(list); + }) + .catch((err: unknown) => console.error("load languages failed", err)); + return () => { + cancelled = true; + }; + }, []); + + const sorted = useMemo( + () => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)), + [languages], + ); + + const current = useMemo( + () => + languages.find((l) => l.code === i18n.language) ?? + languages.find((l) => l.code === "en"), + [languages, i18n.language], + ); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const select = async (code: string) => { + setOpen(false); + if (busy || code === i18n.language) return; + setBusy(true); + try { + await Preferences.SetLanguage(code as LanguageCode); + } catch (e) { + await errorDialog({ + Title: t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +

    p_r)^;F^SB&oAwAUvat>AaH*WM-R9KsutQD*d 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%0D_|qoNNhGUnvG#&**I3n#=L$~UCJ(F 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 Z%h#_b*~^!E-WSq^XhsVQb2wuQ{{qd0QA+>- literal 0 HcmV?d00001 diff --git a/client/ui/frontend/src/assets/img/tray-darwin.png b/client/ui/frontend/src/assets/img/tray-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..75df803d82744a9b328bb5ff1985bec49932c968 GIT binary patch literal 196162 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%0D5D3-WVk1Ic=&A5S41dfB5!h;xWA?-O%o2h% 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