Add ASN to logtail, collector, aggregator, frontend and CLI

This commit is contained in:
2026-03-24 02:28:29 +01:00
parent a798bb1d1d
commit 30c8c40157
17 changed files with 566 additions and 157 deletions

View File

@@ -126,8 +126,20 @@ func applyTerm(term string, fs *filterState) error {
} else {
fs.IsTor = "0"
}
case "asn":
if op == "~=" {
return fmt.Errorf("asn does not support ~=; use =, !=, >=, >, <=, <")
}
expr := op + value
if op == "=" {
expr = value
}
if _, _, ok := st.ParseStatusExpr(expr); !ok {
return fmt.Errorf("invalid asn expression %q", expr)
}
fs.ASN = expr
default:
return fmt.Errorf("unknown field %q; valid: status, website, uri, prefix, is_tor", field)
return fmt.Errorf("unknown field %q; valid: status, website, uri, prefix, is_tor, asn", field)
}
return nil
}
@@ -167,9 +179,24 @@ func FilterExprString(f filterState) string {
if f.IsTor != "" {
parts = append(parts, "is_tor="+f.IsTor)
}
if f.ASN != "" {
parts = append(parts, asnTermStr(f.ASN))
}
return strings.Join(parts, " AND ")
}
// asnTermStr converts a stored ASN expression (">=1000", "12345") to a
// full filter term ("asn>=1000", "asn=12345").
func asnTermStr(expr string) string {
if expr == "" {
return ""
}
if len(expr) > 0 && (expr[0] == '!' || expr[0] == '>' || expr[0] == '<') {
return "asn" + expr
}
return "asn=" + expr
}
// statusTermStr converts a stored status expression (">=400", "200") to a
// full filter term ("status>=400", "status=200").
func statusTermStr(expr string) string {

View File

@@ -258,3 +258,77 @@ func TestFilterExprRoundTrip(t *testing.T) {
}
}
}
func TestParseAsnEQ(t *testing.T) {
fs, err := ParseFilterExpr("asn=12345")
if err != nil || fs.ASN != "12345" {
t.Fatalf("got err=%v fs=%+v", err, fs)
}
}
func TestParseAsnNE(t *testing.T) {
fs, err := ParseFilterExpr("asn!=65000")
if err != nil || fs.ASN != "!=65000" {
t.Fatalf("got err=%v fs=%+v", err, fs)
}
}
func TestParseAsnGE(t *testing.T) {
fs, err := ParseFilterExpr("asn>=1000")
if err != nil || fs.ASN != ">=1000" {
t.Fatalf("got err=%v fs=%+v", err, fs)
}
}
func TestParseAsnLT(t *testing.T) {
fs, err := ParseFilterExpr("asn<64512")
if err != nil || fs.ASN != "<64512" {
t.Fatalf("got err=%v fs=%+v", err, fs)
}
}
func TestParseAsnRegexRejected(t *testing.T) {
_, err := ParseFilterExpr("asn~=123")
if err == nil {
t.Fatal("expected error for asn~=")
}
}
func TestParseAsnInvalidExpr(t *testing.T) {
_, err := ParseFilterExpr("asn=notanumber")
if err == nil {
t.Fatal("expected error for non-numeric ASN")
}
}
func TestFilterExprStringASN(t *testing.T) {
s := FilterExprString(filterState{ASN: "12345"})
if s != "asn=12345" {
t.Fatalf("got %q", s)
}
s = FilterExprString(filterState{ASN: ">=1000"})
if s != "asn>=1000" {
t.Fatalf("got %q", s)
}
}
func TestFilterExprRoundTripASN(t *testing.T) {
cases := []filterState{
{ASN: "12345"},
{ASN: "!=65000"},
{ASN: ">=1000"},
{ASN: "<64512"},
{Status: ">=400", ASN: "12345"},
}
for _, fs := range cases {
expr := FilterExprString(fs)
fs2, err := ParseFilterExpr(expr)
if err != nil {
t.Errorf("round-trip parse error for %+v → %q: %v", fs, expr, err)
continue
}
if fs2 != fs {
t.Errorf("round-trip mismatch: %+v → %q → %+v", fs, expr, fs2)
}
}
}

View File

@@ -220,8 +220,17 @@ func TestDrillURL(t *testing.T) {
if !strings.Contains(u, "f_status=429") {
t.Errorf("drill from status: missing f_status in %q", u)
}
if !strings.Contains(u, "by=asn") {
t.Errorf("drill from status: expected next by=asn in %q", u)
}
p.GroupByS = "asn"
u = p.drillURL("12345")
if !strings.Contains(u, "f_asn=12345") {
t.Errorf("drill from asn: missing f_asn in %q", u)
}
if !strings.Contains(u, "by=website") {
t.Errorf("drill from status: expected cycle back to by=website in %q", u)
t.Errorf("drill from asn: expected cycle back to by=website in %q", u)
}
}

View File

@@ -54,6 +54,7 @@ type filterState struct {
WebsiteRe string // RE2 regex against website
URIRe string // RE2 regex against request URI
IsTor string // "", "1" (TOR only), "0" (non-TOR only)
ASN string // expression: "12345", "!=65000", ">=1000", etc.
}
// QueryParams holds all parsed URL parameters for one page request.
@@ -91,7 +92,7 @@ var windowSpecs = []struct{ s, label string }{
}
var groupBySpecs = []struct{ s, label string }{
{"website", "website"}, {"prefix", "prefix"}, {"uri", "uri"}, {"status", "status"},
{"website", "website"}, {"asn", "asn"}, {"prefix", "prefix"}, {"status", "status"}, {"uri", "uri"},
}
func parseWindowString(s string) (pb.Window, string) {
@@ -121,6 +122,8 @@ func parseGroupByString(s string) (pb.GroupBy, string) {
return pb.GroupBy_REQUEST_URI, "uri"
case "status":
return pb.GroupBy_HTTP_RESPONSE, "status"
case "asn":
return pb.GroupBy_ASN_NUMBER, "asn"
default:
return pb.GroupBy_WEBSITE, "website"
}
@@ -159,12 +162,13 @@ func (h *Handler) parseParams(r *http.Request) QueryParams {
WebsiteRe: q.Get("f_website_re"),
URIRe: q.Get("f_uri_re"),
IsTor: q.Get("f_is_tor"),
ASN: q.Get("f_asn"),
},
}
}
func buildFilter(f filterState) *pb.Filter {
if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" && f.WebsiteRe == "" && f.URIRe == "" && f.IsTor == "" {
if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" && f.WebsiteRe == "" && f.URIRe == "" && f.IsTor == "" && f.ASN == "" {
return nil
}
out := &pb.Filter{}
@@ -195,6 +199,12 @@ func buildFilter(f filterState) *pb.Filter {
case "0":
out.Tor = pb.TorFilter_TOR_NO
}
if f.ASN != "" {
if n, op, ok := st.ParseStatusExpr(f.ASN); ok {
out.AsnNumber = &n
out.AsnOp = op
}
}
return out
}
@@ -226,6 +236,9 @@ func (p QueryParams) toValues() url.Values {
if p.Filter.IsTor != "" {
v.Set("f_is_tor", p.Filter.IsTor)
}
if p.Filter.ASN != "" {
v.Set("f_asn", p.Filter.ASN)
}
return v
}
@@ -247,7 +260,7 @@ func (p QueryParams) buildURL(overrides map[string]string) string {
func (p QueryParams) clearFilterURL() string {
return p.buildURL(map[string]string{
"f_website": "", "f_prefix": "", "f_uri": "", "f_status": "",
"f_website_re": "", "f_uri_re": "",
"f_website_re": "", "f_uri_re": "", "f_is_tor": "", "f_asn": "",
})
}
@@ -260,7 +273,9 @@ func nextGroupBy(s string) string {
return "uri"
case "uri":
return "status"
default: // status → back to website
case "status":
return "asn"
default: // asn → back to website
return "website"
}
}
@@ -276,6 +291,8 @@ func groupByFilterKey(s string) string {
return "f_uri"
case "status":
return "f_status"
case "asn":
return "f_asn"
default:
return "f_website"
}
@@ -338,6 +355,12 @@ func buildCrumbs(p QueryParams) []Crumb {
RemoveURL: p.buildURL(map[string]string{"f_is_tor": ""}),
})
}
if p.Filter.ASN != "" {
crumbs = append(crumbs, Crumb{
Text: asnTermStr(p.Filter.ASN),
RemoveURL: p.buildURL(map[string]string{"f_asn": ""}),
})
}
return crumbs
}

View File

@@ -32,7 +32,7 @@
<input type="hidden" name="w" value="{{.Params.WindowS}}">
<input type="hidden" name="by" value="{{.Params.GroupByS}}">
<input type="hidden" name="n" value="{{.Params.N}}">
<input class="filter-input" type="text" name="q" value="{{.FilterExpr}}" placeholder="status>=400 AND website~=gouda.* AND uri~=^/api/ AND is_tor=1">
<input class="filter-input" type="text" name="q" value="{{.FilterExpr}}" placeholder="status>=400 AND website~=gouda.* AND uri~=^/ct/v1/ AND is_tor=0 AND asn=8298">
<button type="submit">filter</button>
{{- if .FilterExpr}} <a class="clear" href="{{.ClearFilterURL}}">× clear</a>{{end}}
</form>