From a32900066d545386f65651d04361e828afee3e78 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 19:25:01 -0500 Subject: [PATCH 01/37] serve: honour Forwarded / X-Forwarded-* and --external-url for download_url; accept many flows per request and merge them per workload; ?name=; chart externalURL/extraArgs/extraEnv; UI summary, copy, download download_url was built from r.TLS and r.Host. Behind a TLS-terminating ingress or Gateway the listener has no TLS, so the JSON answer said http:///download/ for a host that only serves https (measured behind Cilium Gateway API: Envoy forwards X-Forwarded-Proto: https, the answer said http). baseURL() now takes, in order: --external-url (env CF2CNP_EXTERNAL_URL), RFC 7239 Forwarded (proto=, host=), X-Forwarded-Proto / X-Forwarded-Host, then r.TLS and r.Host. /generate accepts one flow, a JSON array, or one flow per line (hubble observe -o json), and policies that select the same workload are merged into one object with one rule per peer: two flows into the same service from two peers used to produce two objects with the same metadata.name, so the second kubectl apply replaced the first; in file mode the second file overwrote the first. ?name= names the (single) resulting policy. The JSON answer carries flows, policies and the yaml itself. Chart 0.5.0: externalURL, extraArgs, extraEnv; the container args are rendered so a flag can be set. The page: a summary of the pasted flows before generating, a policy-name field, Copy YAML / Download YAML / Load example / Clear, and the apply hint. Tests: internal/flow (single, NDJSON, array, errors, directory), internal/policy (merge, dedupe, distinct workloads, name override, one file per workload, multi-document), internal/server (every scheme case, the Grafana path and its cache id, many flows, ?name=, bad inputs, YOLO). Fixtures are real Hubble 1.20 flows. --- README.md | 24 +- cmd/cf2cnp/main.go | 16 +- helm/cf2cnp/Chart.yaml | 2 +- helm/cf2cnp/README.md | 3 + helm/cf2cnp/templates/deployment.yaml | 15 + helm/cf2cnp/values.yaml | 12 + internal/flow/parser.go | 56 ++- internal/flow/parser_test.go | 81 ++++ internal/policy/generator.go | 221 +++++++-- internal/policy/generator_test.go | 124 +++++ internal/server/server.go | 436 +++++++++--------- internal/server/server_test.go | 159 +++++++ internal/testdata/egress-pos-to-world.json | 1 + internal/testdata/ingress-pos-to-shop.json | 1 + .../testdata/ingress-stranger-to-shop.json | 1 + 15 files changed, 875 insertions(+), 277 deletions(-) create mode 100644 internal/flow/parser_test.go create mode 100644 internal/policy/generator_test.go create mode 100644 internal/server/server_test.go create mode 100644 internal/testdata/egress-pos-to-world.json create mode 100644 internal/testdata/ingress-pos-to-shop.json create mode 100644 internal/testdata/ingress-stranger-to-shop.json diff --git a/README.md b/README.md index a8b7aee..d54ada4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A CLI tool that generates CiliumNetworkPolicies from Hubble flow data. This tool - **Smart Label Filtering**: Extracts only relevant app labels (see [label Selection](#label-selection)) - **Cross-Namespace Support**: Automatically adds namespace labels for cross-namespace traffic - **FQDN Egress Support**: Generates proper `toFQDNs` rules with required DNS resolution rules for external traffic -- **Flow Aggregation**: Combines multiple flows with the same source/destination into a single policy with multiple ports +- **Flow Aggregation**: Combines multiple flows with the same source/destination into a single policy with multiple ports, and flows that select the same workload into one policy with one rule per peer (so two peers of one service are one object, not two objects with the same name) +- **Many Flows at Once**: A file, a request body or a pasted text may hold one flow, a JSON array of flows, or one flow per line — the output of `hubble observe -o json` - **HTTP Server Mode**: Run as a containerized service to generate policies via HTTP API > **Caution:** This project was created with the help of AI. While I have extensive experience with Kubernetes and Cilium, I do not have the Go programming expertise to write this tool from scratch. AI assistance made it possible to bring this idea to life and share it with the community. Please be careful when using this tool in production environments. Always review generated policies before applying them to your cluster. @@ -69,9 +70,10 @@ cf2cnp serve --port 8080 #### Server Flags -| Flag | Short | Type | Description | Default | -|-----------|-------|---------|----------------------------|---------| -| `--port` | `-p` | int | Port number to listen on | `8080` | +| Flag | Short | Type | Description | Default | +|------------------|-------|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| `--port` | `-p` | int | Port number to listen on | `8080` | +| `--external-url` | | string | Base URL clients reach the server at, used for `download_url` (env `CF2CNP_EXTERNAL_URL`). Empty: derived from the request and its `Forwarded` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers | `""` | --- @@ -79,10 +81,15 @@ cf2cnp serve --port 8080 | Method | Endpoint | Description | Request Body | Response | |--------|---------------|-----------------------------------------------------------|-------------------------------|---------------------| -| `POST` | `/generate` | Generate policy from Hubble flow JSON | Hubble flow JSON | CiliumNetworkPolicy | +| `POST` | `/generate` | Generate policies from Hubble flow JSON: one flow, a JSON array, or one flow per line. `?name=` names the (single) resulting policy | Hubble flow JSON | CiliumNetworkPolicy YAML (one document per policy), or JSON when the request carries `Accept: application/json` or `X-Grafana-Action` | +| `GET` | `/download/{id}` | Download a policy generated by a JSON-mode request (cached for 10 minutes; `{id}` is the flow's UUID for a single flow) | _none_ | CiliumNetworkPolicy YAML as an attachment | | `GET` | `/health` | Health check for the service | _none_ | Status message | | `GET` | `/` | Access the integrated Web UI for testing and generation | _none_ | Web UI page | +The JSON answer is `{"download_url", "filename", "message", "flows", "policies", "yaml"}` — the YAML is in it, so a client +need not follow the URL. `download_url` is built from `--external-url` when set, else from the request and the proxy +headers a TLS-terminating ingress or gateway sets (`Forwarded: proto=…;host=…`, `X-Forwarded-Proto`, `X-Forwarded-Host`). + #### Example: Using curl ```bash @@ -91,6 +98,13 @@ curl -X POST http://localhost:8080/generate \ -H "Content-Type: application/json" \ -d @flow.json \ -o policy.yaml + +# Many flows at once (the output of `hubble observe -o json`): one policy per workload, one rule per peer +hubble observe --namespace shop --last 200 -o json > flows.json +curl -X POST http://localhost:8080/generate --data-binary @flows.json -o policies.yaml + +# Name the resulting policy yourself +curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json -o policy.yaml ``` #### Example: Using the Web UI diff --git a/cmd/cf2cnp/main.go b/cmd/cf2cnp/main.go index 69408c1..b2ae835 100644 --- a/cmd/cf2cnp/main.go +++ b/cmd/cf2cnp/main.go @@ -15,6 +15,7 @@ var ( inputDir string outputDir string port int + externalURL string yoloNamespace string ) @@ -56,12 +57,19 @@ or use the 'serve' command to run as an HTTP server.`, and returns CiliumNetworkPolicy YAML files. Endpoints: - POST /generate - Send flow JSON, receive policy YAML - GET /health - Health check endpoint - GET / - Web UI for testing`, + POST /generate - Send flow JSON (one flow, a JSON array, or one flow per line), receive policy YAML; + with Accept: application/json, receive {download_url, filename, yaml, flows, policies}. + ?name= names the (single) resulting policy. + GET /download/{id} - Download a generated policy (cached 10 minutes) + GET /health - Health check endpoint + GET / - Web UI`, RunE: runServe, } serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to listen on") + serveCmd.Flags().StringVar(&externalURL, "external-url", os.Getenv("CF2CNP_EXTERNAL_URL"), + "Base URL clients reach the server at (e.g. https://cf2cnp.example.com); used for download_url. "+ + "Default: derived from the request and its Forwarded / X-Forwarded-Proto / X-Forwarded-Host headers. "+ + "Env: CF2CNP_EXTERNAL_URL") // YOLO command (easter egg) yoloCmd := &cobra.Command{ @@ -144,6 +152,6 @@ func runGenerate(cmd *cobra.Command, args []string) error { } func runServe(cmd *cobra.Command, args []string) error { - srv := server.NewServer(port) + srv := server.NewServer(port, externalURL) return srv.Start() } diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index d5e718d..d24d8f3 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application -version: 0.4.0 +version: 0.5.0 appVersion: "0.3.1" keywords: - cilium diff --git a/helm/cf2cnp/README.md b/helm/cf2cnp/README.md index 3fa4aa1..9b9918c 100644 --- a/helm/cf2cnp/README.md +++ b/helm/cf2cnp/README.md @@ -32,6 +32,9 @@ The following table lists the configurable parameters of the CF2CNP chart and th | `service.type` | Service type | `ClusterIP` | | `service.port` | Service port | `80` | | `containerPort` | Container port | `8080` | +| `externalURL` | Base URL clients reach cf2cnp at, for `download_url` (empty = derived from the request and its `Forwarded` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers) | `""` | +| `extraArgs` | Extra arguments appended to `cf2cnp serve --port ` | `[]` | +| `extraEnv` | Extra environment variables (`CF2CNP_EXTERNAL_URL` is an alternative to `externalURL`) | `[]` | | `ingress.enabled` | Enable ingress | `false` | | `ingress.className` | Ingress class name | `""` | | `ingress.annotations` | Ingress annotations | `{}` | diff --git a/helm/cf2cnp/templates/deployment.yaml b/helm/cf2cnp/templates/deployment.yaml index d85889c..e25d72c 100644 --- a/helm/cf2cnp/templates/deployment.yaml +++ b/helm/cf2cnp/templates/deployment.yaml @@ -31,6 +31,21 @@ spec: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - serve + - --port + - {{ .Values.containerPort | quote }} + {{- if .Values.externalURL }} + - --external-url + - {{ .Values.externalURL | quote }} + {{- end }} + {{- with .Values.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnv }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} ports: - name: http containerPort: {{ .Values.containerPort }} diff --git a/helm/cf2cnp/values.yaml b/helm/cf2cnp/values.yaml index b2877a0..28d901c 100644 --- a/helm/cf2cnp/values.yaml +++ b/helm/cf2cnp/values.yaml @@ -43,6 +43,18 @@ service: containerPort: 8080 +# Base URL clients reach cf2cnp at (scheme://host[:port][/prefix]), used for the download_url in JSON +# answers (the Grafana action). Leave empty to derive it from each request and the Forwarded / +# X-Forwarded-Proto / X-Forwarded-Host headers a TLS-terminating ingress or gateway sets. Set it when +# the proxy strips those headers or the public URL differs from the Host the pod sees. +externalURL: "" + +# Extra arguments appended to `cf2cnp serve --port ` (e.g. a flag this chart has no value for) +extraArgs: [] + +# Extra environment variables for the container (list of EnvVar; CF2CNP_EXTERNAL_URL is an alternative to externalURL) +extraEnv: [] + ingress: enabled: false className: "" diff --git a/internal/flow/parser.go b/internal/flow/parser.go index a9c834e..ae08cbc 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -1,8 +1,11 @@ package flow import ( + "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -44,6 +47,49 @@ func ParseFlowFromBytes(data []byte) (*ParsedFlow, error) { return parseFlow(&flowData.Flow) } +// ParseFlowsFromBytes parses one or many Hubble flows from JSON bytes. Three shapes are accepted, because that is what +// the tools produce: a single flow object (a saved file, the Grafana action's log line), a JSON array of flow objects, +// and newline-delimited objects — the output of `hubble observe -o json`, one flow per line. Blank lines are skipped; +// the first object that does not parse names its position in the error. +func ParseFlowsFromBytes(data []byte) ([]*ParsedFlow, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return nil, errors.New("no flow data") + } + + var raw []HubbleFlowData + if trimmed[0] == '[' { + if err := json.Unmarshal(trimmed, &raw); err != nil { + return nil, fmt.Errorf("failed to parse JSON array of flows: %w", err) + } + } else { + dec := json.NewDecoder(bytes.NewReader(trimmed)) + for i := 1; ; i++ { + var fd HubbleFlowData + if err := dec.Decode(&fd); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("failed to parse flow #%d: %w", i, err) + } + raw = append(raw, fd) + } + } + if len(raw) == 0 { + return nil, errors.New("no flow objects found") + } + + flows := make([]*ParsedFlow, 0, len(raw)) + for i := range raw { + parsed, err := parseFlow(&raw[i].Flow) + if err != nil { + return nil, fmt.Errorf("flow #%d: %w", i+1, err) + } + flows = append(flows, parsed) + } + return flows, nil +} + // ParseFlowsFromDirectory parses all JSON flow files from a directory func ParseFlowsFromDirectory(dirPath string) ([]*ParsedFlow, error) { entries, err := os.ReadDir(dirPath) @@ -61,11 +107,15 @@ func ParseFlowsFromDirectory(dirPath string) ([]*ParsedFlow, error) { } filePath := filepath.Join(dirPath, entry.Name()) - flow, err := ParseFlowFile(filePath) + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) + } + parsed, err := ParseFlowsFromBytes(data) if err != nil { - return nil, err + return nil, fmt.Errorf("%s: %w", filePath, err) } - flows = append(flows, flow) + flows = append(flows, parsed...) } return flows, nil diff --git a/internal/flow/parser_test.go b/internal/flow/parser_test.go new file mode 100644 index 0000000..a4da7c3 --- /dev/null +++ b/internal/flow/parser_test.go @@ -0,0 +1,81 @@ +package flow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The three fixtures are real Hubble 1.20 flows captured on a kind cluster: two INGRESS audit verdicts +// into the same workload from two peers, and one EGRESS flow to the world without destination_names. +func fixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "testdata", name)) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestParseFlowsFromBytes_Single(t *testing.T) { + flows, err := ParseFlowsFromBytes(fixture(t, "ingress-pos-to-shop.json")) + if err != nil { + t.Fatal(err) + } + if len(flows) != 1 || flows[0].Direction != "INGRESS" || flows[0].Port != 80 || flows[0].DestLabels["app.kubernetes.io/name"] != "shop" { + t.Fatalf("unexpected parse: %+v", flows[0]) + } +} + +func TestParseFlowsFromBytes_NDJSON(t *testing.T) { + // `hubble observe -o json` output: one object per line, blank lines and trailing newline included + body := string(fixture(t, "ingress-pos-to-shop.json")) + "\n\n" + string(fixture(t, "ingress-stranger-to-shop.json")) + "\n" + flows, err := ParseFlowsFromBytes([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(flows) != 2 || flows[0].SourceLabels["app.kubernetes.io/name"] != "pos" || flows[1].SourceLabels["app.kubernetes.io/name"] != "stranger" { + t.Fatalf("expected pos then stranger, got %d flows: %+v", len(flows), flows) + } +} + +func TestParseFlowsFromBytes_Array(t *testing.T) { + body := "[" + string(fixture(t, "ingress-pos-to-shop.json")) + "," + string(fixture(t, "egress-pos-to-world.json")) + "]" + flows, err := ParseFlowsFromBytes([]byte(body)) + if err != nil { + t.Fatal(err) + } + if len(flows) != 2 || flows[1].Direction != "EGRESS" || !flows[1].IsWorldTraffic || flows[1].DestIP != "104.20.23.154" { + t.Fatalf("unexpected: %+v", flows) + } +} + +func TestParseFlowsFromBytes_Errors(t *testing.T) { + if _, err := ParseFlowsFromBytes([]byte(" \n")); err == nil { + t.Fatal("empty input must error") + } + bad := string(fixture(t, "ingress-pos-to-shop.json")) + "\n{not json}\n" + _, err := ParseFlowsFromBytes([]byte(bad)) + if err == nil || !strings.Contains(err.Error(), "flow #2") { + t.Fatalf("the bad line must be named by position, got %v", err) + } +} + +func TestParseFlowsFromDirectory_ManyPerFile(t *testing.T) { + dir := t.TempDir() + ndjson := string(fixture(t, "ingress-pos-to-shop.json")) + "\n" + string(fixture(t, "ingress-stranger-to-shop.json")) + "\n" + if err := os.WriteFile(filepath.Join(dir, "observe.json"), []byte(ndjson), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "world.json"), fixture(t, "egress-pos-to-world.json"), 0o644); err != nil { + t.Fatal(err) + } + flows, err := ParseFlowsFromDirectory(dir) + if err != nil { + t.Fatal(err) + } + if len(flows) != 3 { + t.Fatalf("expected 3 flows from 2 files, got %d", len(flows)) + } +} diff --git a/internal/policy/generator.go b/internal/policy/generator.go index 260c8d5..5a01f18 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "github.com/hubble-policy-gen/internal/aggregator" @@ -17,9 +18,13 @@ import ( // ErrReplyFlow is returned when attempting to generate a policy for a reply flow var ErrReplyFlow = errors.New("this is a reply packet - you need to allow the original request, not the reply (Cilium's connection tracking automatically allows replies)") +// ErrNameNeedsOnePolicy is returned when a name override is requested but the flows produce more than one policy +var ErrNameNeedsOnePolicy = errors.New("a policy name can only be set when the flows produce a single policy") + // Generator generates CiliumNetworkPolicies from aggregated flows type Generator struct { - outputDir string + outputDir string + nameOverride string } // NewGenerator creates a new policy generator @@ -27,9 +32,18 @@ func NewGenerator(outputDir string) *Generator { return &Generator{outputDir: outputDir} } -// GeneratePolicies generates CiliumNetworkPolicy files from aggregated flows -func (g *Generator) GeneratePolicies(flows []*aggregator.AggregatedFlow) error { - // Check if all flows are reply flows +// WithName makes the generator name the (single) resulting policy as given, sanitized for Kubernetes. +// Without it a policy is named after the workload it selects, so two flows to the same workload from +// different peers produce two objects with the same name — the second applied replaces the first. +func (g *Generator) WithName(name string) *Generator { + g.nameOverride = sanitizeK8sName(name) + return g +} + +// BuildPolicies turns aggregated flows into policies: one per flow, then flows that select the same +// workload (same namespace, name and endpointSelector) are merged into one object carrying every +// rule. Reply flows are skipped; if every flow is a reply, ErrReplyFlow is returned. +func (g *Generator) BuildPolicies(flows []*aggregator.AggregatedFlow) ([]*CiliumNetworkPolicy, error) { allReplies := true for _, f := range flows { if !f.IsReply { @@ -38,68 +52,189 @@ func (g *Generator) GeneratePolicies(flows []*aggregator.AggregatedFlow) error { } } if allReplies && len(flows) > 0 { - return ErrReplyFlow + return nil, ErrReplyFlow } + var policies []*CiliumNetworkPolicy for _, f := range flows { - // Skip reply flows with a message if f.IsReply { - fmt.Printf("Skipping reply flow: %s\n", ErrReplyFlow.Error()) continue } - policy := g.GeneratePolicy(f) - needsCIDRComment := f.IsWorldTraffic && len(f.DestFQDNs) == 0 - if err := g.writePolicyWithComment(policy, needsCIDRComment); err != nil { - return err + policies = append(policies, g.GeneratePolicy(f)) + } + policies = MergePolicies(policies) + + if g.nameOverride != "" { + if len(policies) != 1 { + return nil, fmt.Errorf("%w: got %d", ErrNameNeedsOnePolicy, len(policies)) } + policies[0].Metadata.Name = g.nameOverride } - return nil + return policies, nil } -// GeneratePoliciesYAML generates CiliumNetworkPolicy YAML from aggregated flows and returns as bytes -func (g *Generator) GeneratePoliciesYAML(flows []*aggregator.AggregatedFlow) ([]byte, error) { - var buf bytes.Buffer - encoder := yaml.NewEncoder(&buf) - encoder.SetIndent(2) - - // Check if all flows are reply flows - allReplies := true - for _, f := range flows { - if !f.IsReply { - allReplies = false - break +// MergePolicies folds policies that select the same workload into one, in first-seen order. Two +// generated policies are "the same workload" when namespace, name and endpointSelector match; their +// ingress and egress rules are concatenated, an identical rule (the DNS rule every FQDN policy +// carries) is kept once, and the description says how many observed flows the object now covers. +func MergePolicies(policies []*CiliumNetworkPolicy) []*CiliumNetworkPolicy { + var order []string + byKey := make(map[string]*CiliumNetworkPolicy) + merged := make(map[string]int) + for _, p := range policies { + key := p.Metadata.Namespace + "/" + p.Metadata.Name + "/" + labelsKey(p.Spec.EndpointSelector.MatchLabels) + existing, ok := byKey[key] + if !ok { + byKey[key] = p + order = append(order, key) + continue + } + merged[key]++ + for _, r := range p.Spec.Ingress { + if !containsIngress(existing.Spec.Ingress, r) { + existing.Spec.Ingress = append(existing.Spec.Ingress, r) + } + } + for _, r := range p.Spec.Egress { + if !containsEgress(existing.Spec.Egress, r) { + existing.Spec.Egress = append(existing.Spec.Egress, r) + } } } - if allReplies && len(flows) > 0 { - return nil, ErrReplyFlow + result := make([]*CiliumNetworkPolicy, 0, len(order)) + for _, key := range order { + p := byKey[key] + if n := merged[key]; n > 0 { + p.Spec.Description = mergedDescription(p, n+1) + } + result = append(result, p) } + return result +} - policyCount := 0 - for _, f := range flows { - // Skip reply flows - if f.IsReply { - continue +func mergedDescription(p *CiliumNetworkPolicy, flows int) string { + var dir string + switch { + case len(p.Spec.Ingress) > 0 && len(p.Spec.Egress) > 0: + dir = "ingress and egress" + case len(p.Spec.Egress) > 0: + dir = "egress" + default: + dir = "ingress" + } + return fmt.Sprintf("Allow %s traffic for the %s in %s (%d rules merged from %d observed flows)", + dir, p.Metadata.Name, p.Metadata.Namespace, len(p.Spec.Ingress)+len(p.Spec.Egress), flows) +} + +func labelsKey(labels map[string]string) string { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(labels[k]) + b.WriteByte(',') + } + return b.String() +} + +func containsIngress(rules []IngressRule, r IngressRule) bool { + want := mustYAML(r) + for _, have := range rules { + if mustYAML(have) == want { + return true } + } + return false +} - policy := g.GeneratePolicy(f) - if policyCount > 0 { - buf.WriteString("---\n") +func containsEgress(rules []EgressRule, r EgressRule) bool { + want := mustYAML(r) + for _, have := range rules { + if mustYAML(have) == want { + return true } - if err := encoder.Encode(policy); err != nil { - return nil, fmt.Errorf("failed to encode policy to YAML: %w", err) + } + return false +} + +func mustYAML(v interface{}) string { + out, err := yaml.Marshal(v) + if err != nil { + return fmt.Sprintf("%#v", v) + } + return string(out) +} + +// hasCIDR reports whether a policy carries a toCIDR egress rule (the one that gets the world comment) +func hasCIDR(p *CiliumNetworkPolicy) bool { + for _, r := range p.Spec.Egress { + if len(r.ToCIDR) > 0 { + return true } - - // Add comment for world traffic (toCIDR) - if f.IsWorldTraffic && len(f.DestFQDNs) == 0 { - addToCIDRComment(&buf) + } + return false +} + +// GeneratePolicies generates CiliumNetworkPolicy files from aggregated flows — one file per workload +// (flows selecting the same workload are merged, so a later one no longer overwrites an earlier file) +func (g *Generator) GeneratePolicies(flows []*aggregator.AggregatedFlow) error { + policies, err := g.BuildPolicies(flows) + if err != nil { + return err + } + for _, p := range policies { + if err := g.writePolicyWithComment(p, hasCIDR(p)); err != nil { + return err } - policyCount++ } + return nil +} - if err := encoder.Close(); err != nil { - return nil, fmt.Errorf("failed to close YAML encoder: %w", err) +// GeneratePoliciesYAML generates CiliumNetworkPolicy YAML from aggregated flows and returns it as bytes +func (g *Generator) GeneratePoliciesYAML(flows []*aggregator.AggregatedFlow) ([]byte, error) { + _, out, err := g.GeneratePoliciesWithYAML(flows) + return out, err +} + +// GeneratePoliciesWithYAML returns the policies and their YAML (one document per policy, separated by ---) +func (g *Generator) GeneratePoliciesWithYAML(flows []*aggregator.AggregatedFlow) ([]*CiliumNetworkPolicy, []byte, error) { + policies, err := g.BuildPolicies(flows) + if err != nil { + return nil, nil, err } + out, err := EncodePolicies(policies) + if err != nil { + return nil, nil, err + } + return policies, out, nil +} +// EncodePolicies renders policies as a multi-document YAML stream with the world/CIDR comment where it applies +func EncodePolicies(policies []*CiliumNetworkPolicy) ([]byte, error) { + var buf bytes.Buffer + for i, p := range policies { + if i > 0 { + buf.WriteString("---\n") + } + var one bytes.Buffer + encoder := yaml.NewEncoder(&one) + encoder.SetIndent(2) + if err := encoder.Encode(p); err != nil { + return nil, fmt.Errorf("failed to encode policy to YAML: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("failed to close YAML encoder: %w", err) + } + if hasCIDR(p) { + addToCIDRComment(&one) + } + buf.Write(one.Bytes()) + } return buf.Bytes(), nil } diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go new file mode 100644 index 0000000..50d7a4a --- /dev/null +++ b/internal/policy/generator_test.go @@ -0,0 +1,124 @@ +package policy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hubble-policy-gen/internal/aggregator" + "github.com/hubble-policy-gen/internal/flow" +) + +func load(t *testing.T, names ...string) []*aggregator.AggregatedFlow { + t.Helper() + var all []*flow.ParsedFlow + for _, n := range names { + b, err := os.ReadFile(filepath.Join("..", "testdata", n)) + if err != nil { + t.Fatal(err) + } + f, err := flow.ParseFlowsFromBytes(b) + if err != nil { + t.Fatal(err) + } + all = append(all, f...) + } + return aggregator.AggregateFlows(all) +} + +// Before: two flows into shop from two peers produced two policies both named "shop" (the second +// `kubectl apply` replaced the first). After: one policy with two ingress rules. +func TestBuildPolicies_MergesSameWorkload(t *testing.T) { + policies, err := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "ingress-stranger-to-shop.json")) + if err != nil { + t.Fatal(err) + } + if len(policies) != 1 { + t.Fatalf("expected 1 merged policy, got %d", len(policies)) + } + p := policies[0] + if p.Metadata.Name != "shop" || p.Metadata.Namespace != "cf2cnp-lab" || len(p.Spec.Ingress) != 2 { + t.Fatalf("unexpected policy: name=%s ns=%s ingress=%d", p.Metadata.Name, p.Metadata.Namespace, len(p.Spec.Ingress)) + } + peers := p.Spec.Ingress[0].FromEndpoints[0].MatchLabels["app.kubernetes.io/name"] + "," + p.Spec.Ingress[1].FromEndpoints[0].MatchLabels["app.kubernetes.io/name"] + if peers != "pos,stranger" { + t.Fatalf("rules must keep first-seen order, got %s", peers) + } + if !strings.Contains(p.Spec.Description, "2 rules merged from 2 observed flows") { + t.Fatalf("description must say what was merged, got %q", p.Spec.Description) + } +} + +// The same flow twice is one rule, not two +func TestBuildPolicies_DedupesIdenticalRules(t *testing.T) { + policies, err := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "ingress-pos-to-shop.json")) + if err != nil { + t.Fatal(err) + } + if len(policies) != 1 || len(policies[0].Spec.Ingress) != 1 { + t.Fatalf("expected one policy with one rule, got %d policies, %d rules", len(policies), len(policies[0].Spec.Ingress)) + } +} + +// Different workloads stay different policies. The aggregator orders its output by key (direction +// first), so the EGRESS policy for pos precedes the INGRESS policy for shop whatever the input order. +func TestBuildPolicies_KeepsDistinctWorkloads(t *testing.T) { + policies, err := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "egress-pos-to-world.json")) + if err != nil { + t.Fatal(err) + } + if len(policies) != 2 || policies[0].Metadata.Name != "pos" || policies[1].Metadata.Name != "shop" { + t.Fatalf("expected pos (egress) then shop (ingress), got %s, %s", policies[0].Metadata.Name, policies[1].Metadata.Name) + } + if len(policies[0].Spec.Egress) != 1 || policies[0].Spec.Egress[0].ToCIDR[0] != "104.20.23.154/32" { + t.Fatalf("world flow without names must be a toCIDR rule, got %+v", policies[0].Spec.Egress) + } +} + +func TestBuildPolicies_NameOverride(t *testing.T) { + // sanitizeK8sName (upstream, unchanged): lower-case, '_' → '-', anything else non [a-z0-9-] dropped + policies, err := NewGenerator("").WithName("Shop_From_POS!").BuildPolicies(load(t, "ingress-pos-to-shop.json")) + if err != nil { + t.Fatal(err) + } + if policies[0].Metadata.Name != "shop-from-pos" { + t.Fatalf("name must be sanitized for Kubernetes, got %q", policies[0].Metadata.Name) + } + _, err = NewGenerator("").WithName("x").BuildPolicies(load(t, "ingress-pos-to-shop.json", "egress-pos-to-world.json")) + if err == nil || !strings.Contains(err.Error(), "single policy") { + t.Fatalf("a name with two resulting policies must be refused, got %v", err) + } +} + +// File mode: before the merge, two flows to the same workload wrote the same file twice (the second +// overwrote the first). Now one file holds both rules. +func TestGeneratePolicies_OneFilePerWorkload(t *testing.T) { + dir := t.TempDir() + if err := NewGenerator(dir).GeneratePolicies(load(t, "ingress-pos-to-shop.json", "ingress-stranger-to-shop.json", "egress-pos-to-world.json")); err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 2 { + t.Fatalf("expected 2 files (shop, pos), got %d", len(entries)) + } + b, _ := os.ReadFile(filepath.Join(dir, "cf2cnp-lab-shop.yaml")) + if strings.Count(string(b), "fromEndpoints") != 2 { + t.Fatalf("shop's file must carry both peers:\n%s", b) + } + b, _ = os.ReadFile(filepath.Join(dir, "cf2cnp-lab-pos.yaml")) + if !strings.Contains(string(b), "# - toEntities:") { + t.Fatalf("the world comment must survive the merge:\n%s", b) + } +} + +func TestEncodePolicies_MultiDocument(t *testing.T) { + policies, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "egress-pos-to-world.json")) + out, err := EncodePolicies(policies) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(out), "---\n") != 1 || strings.Count(string(out), "kind: CiliumNetworkPolicy") != 2 { + t.Fatalf("expected two documents separated once:\n%s", out) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d9c8c9e..83114ba 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,16 +28,20 @@ type CachedPolicy struct { // Server represents the HTTP server for policy generation type Server struct { - port int - cache map[string]*CachedPolicy - mu sync.RWMutex + port int + externalURL string + cache map[string]*CachedPolicy + mu sync.RWMutex } -// NewServer creates a new HTTP server -func NewServer(port int) *Server { +// NewServer creates a new HTTP server. externalURL, when set, is the base URL clients reach the +// server at (scheme://host[:port][/prefix]); it wins over anything the request says when building +// download_url. Leave it empty to derive the base from the request (see baseURL). +func NewServer(port int, externalURL string) *Server { s := &Server{ - port: port, - cache: make(map[string]*CachedPolicy), + port: port, + externalURL: strings.TrimRight(externalURL, "/"), + cache: make(map[string]*CachedPolicy), } // Start cache cleanup goroutine go s.cleanupCache() @@ -83,6 +87,85 @@ func (s *Server) Start() error { return http.ListenAndServe(addr, nil) } +// baseURL is the base clients can reach this server at, for the download_url in JSON answers. +// Precedence: the configured external URL; then the proxy headers a TLS-terminating ingress or +// gateway sets — RFC 7239 `Forwarded` (proto=, host=) and the de-facto X-Forwarded-Proto / +// X-Forwarded-Host; then the request itself (TLS on the listener, the Host header). Without the +// header check a server behind an https-only route answered http:// URLs to a port that was closed. +func (s *Server) baseURL(r *http.Request) string { + if s.externalURL != "" { + return s.externalURL + } + scheme, host := "", r.Host + if r.TLS != nil { + scheme = "https" + } + if fwd := r.Header.Get("Forwarded"); fwd != "" { + // first (closest to the client) element; pairs are `k=v` separated by ';', values may be quoted + for _, pair := range strings.Split(strings.Split(fwd, ",")[0], ";") { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) != 2 { + continue + } + v := strings.Trim(strings.TrimSpace(kv[1]), "\"") + switch strings.ToLower(strings.TrimSpace(kv[0])) { + case "proto": + if v == "http" || v == "https" { + scheme = v + } + case "host": + if v != "" { + host = v + } + } + } + } + if v := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])); v == "http" || v == "https" { + scheme = v + } + if v := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); v != "" { + host = v + } + if scheme == "" { + scheme = "http" + } + return scheme + "://" + host +} + +// wantsJSON reports whether the client asked for the JSON answer (Grafana's action sets +// X-Grafana-Action; any client may send Accept: application/json) +func wantsJSON(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept"), "application/json") || r.Header.Get("X-Grafana-Action") != "" +} + +// respond writes the generated YAML either as a direct attachment or, for JSON clients, caches it under +// id and answers with the download URL, the filename, the counts and the YAML itself. +func (s *Server) respond(w http.ResponseWriter, r *http.Request, id string, yamlBytes []byte, filename, message string, flows, policies int) { + if !wantsJSON(r) { + w.Header().Set("Content-Type", "application/x-yaml") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.WriteHeader(http.StatusOK) + w.Write(yamlBytes) + log.Printf("Generated policy (direct): %s", filename) + return + } + s.mu.Lock() + s.cache[id] = &CachedPolicy{Content: yamlBytes, Filename: filename, CreatedAt: time.Now()} + s.mu.Unlock() + downloadURL := fmt.Sprintf("%s/download/%s", s.baseURL(r), id) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "filename": filename, + "download_url": downloadURL, + "message": message, + "flows": flows, + "policies": policies, + "yaml": string(yamlBytes), + }) + log.Printf("Generated policy (cached): %s, download: %s", filename, downloadURL) +} + // corsMiddleware adds CORS headers and handles preflight requests func (s *Server) corsMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -212,6 +295,16 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { margin-top: 1rem; white-space: pre-wrap; } + .summary { color: #94a3b8; font-size: 0.9em; margin: 8px 0; white-space: pre-wrap; } + .muted { color: #64748b; font-weight: normal; } + .controls { margin: 8px 0; } + .controls label { display: block; margin-bottom: 4px; color: #cbd5e1; font-weight: 600; } + .controls input { width: 100%; box-sizing: border-box; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 6px; padding: 10px; font-family: monospace; } + .buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; } + .buttons button { margin: 0; } + button.secondary { background: #1e293b; border: 1px solid #334155; box-shadow: none; } + button.secondary:hover { background: #334155; } + button:disabled { opacity: 0.45; cursor: not-allowed; } @@ -260,9 +353,11 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
POST /generate -

Send a Hubble flow JSON payload and receive a download URL or direct YAML.

+

Send Hubble flow JSON — one flow, a JSON array, or one flow per line — and receive the policies. + ?name=<name> names the (single) resulting policy.

Content-Type: application/json

-

Response: JSON with download URL (for Grafana) or YAML file (for curl)

+

Response: YAML (one document per policy) for curl; with Accept: application/json + or X-Grafana-Action, JSON {download_url, filename, yaml, flows, policies}

@@ -276,81 +371,104 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {

Try it out

-

Paste your Hubble flow JSON below:

- -
- - +

Paste Hubble flow JSON below — one flow, a JSON array, or one flow per line (the output of + hubble observe -o json). Flows to the same workload become one policy with one rule per peer.

+ +
+
+ + +
+
+ + + + + +
+

 
     

Example using curl

-
curl -X POST http://localhost:8080/generate \
-  -H "Content-Type: application/json" \
-  -d @flow.json \
-  -o policy.yaml
+
# one flow
+curl -X POST http://localhost:8080/generate -H "Content-Type: application/json" -d @flow.json -o policy.yaml
+
+# many flows at once: one policy per workload, one rule per peer
+hubble observe --namespace my-namespace --last 200 -o json > flows.json
+curl -X POST http://localhost:8080/generate --data-binary @flows.json -o policies.yaml
+
+# name the resulting policy
+curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json -o policy.yaml
@@ -420,27 +538,26 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { return } - // Parse the flow - parsedFlow, err := flow.ParseFlowFromBytes(body) + // Parse the flows: one object, a JSON array, or one object per line (`hubble observe -o json`) + parsedFlows, err := flow.ParseFlowsFromBytes(body) if err != nil { http.Error(w, fmt.Sprintf("Failed to parse flow JSON: %v", err), http.StatusBadRequest) return } - // Aggregate flows (even though we have just one) - flows := []*flow.ParsedFlow{parsedFlow} - aggregatedFlows := aggregator.AggregateFlows(flows) - + aggregatedFlows := aggregator.AggregateFlows(parsedFlows) if len(aggregatedFlows) == 0 { http.Error(w, "No valid flows found in request", http.StatusBadRequest) return } - // Generate policy YAML generator := policy.NewGenerator("") - yamlBytes, err := generator.GeneratePoliciesYAML(aggregatedFlows) + if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + generator = generator.WithName(name) + } + policies, yamlBytes, err := generator.GeneratePoliciesWithYAML(aggregatedFlows) if err != nil { - if errors.Is(err, policy.ErrReplyFlow) { + if errors.Is(err, policy.ErrReplyFlow) || errors.Is(err, policy.ErrNameNeedsOnePolicy) { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -448,71 +565,21 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { return } - // Determine filename from policy - filename := "ciliumnetworkpolicy.yaml" - if len(aggregatedFlows) > 0 { - agg := aggregatedFlows[0] - var namespace, appName string - if agg.Direction == "INGRESS" { - namespace = agg.DestNamespace - appName = getAppName(agg.DestLabels) - } else { - namespace = agg.SourceNamespace - appName = getAppName(agg.SourceLabels) - } - if namespace != "" && appName != "" { - filename = fmt.Sprintf("%s-%s.yaml", namespace, sanitizeName(appName)) - } + // One policy is named after it; several get one file that holds them all + filename := fmt.Sprintf("ciliumnetworkpolicies-%d.yaml", len(policies)) + if len(policies) == 1 && policies[0].Metadata.Namespace != "" && policies[0].Metadata.Name != "" { + filename = fmt.Sprintf("%s-%s.yaml", policies[0].Metadata.Namespace, sanitizeName(policies[0].Metadata.Name)) } - // Check if request comes from Grafana or wants JSON response - acceptHeader := r.Header.Get("Accept") - grafanaAction := r.Header.Get("X-Grafana-Action") - wantsJSON := strings.Contains(acceptHeader, "application/json") || grafanaAction != "" - - if wantsJSON { - // Use flow UUID as cache ID, fallback to generated ID if empty - id := parsedFlow.UUID - if id == "" { - id = generateID() - } - - s.mu.Lock() - s.cache[id] = &CachedPolicy{ - Content: yamlBytes, - Filename: filename, - CreatedAt: time.Now(), - } - s.mu.Unlock() - - // Build download URL - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - host := r.Host - downloadURL := fmt.Sprintf("%s://%s/download/%s", scheme, host, id) - - // Return JSON response - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - response := map[string]string{ - "filename": filename, - "download_url": downloadURL, - "message": "Policy generated successfully. Use the download_url to download the file.", - } - json.NewEncoder(w).Encode(response) - - log.Printf("Generated policy (cached): %s, download: %s", filename, downloadURL) - } else { - // Direct file download for curl/wget - w.Header().Set("Content-Type", "application/x-yaml") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.WriteHeader(http.StatusOK) - w.Write(yamlBytes) - - log.Printf("Generated policy (direct): %s", filename) + // The cache key is the flow's UUID for a single flow (Grafana's Download link is built from it), else random + id := "" + if len(parsedFlows) == 1 { + id = parsedFlows[0].UUID + } + if id == "" { + id = generateID() } + s.respond(w, r, id, yamlBytes, filename, "Policy generated successfully. Use the download_url to download the file.", len(parsedFlows), len(policies)) } // handleYoloNs handles the YOLO namespace easter egg @@ -534,80 +601,7 @@ func (s *Server) handleYoloNs(w http.ResponseWriter, r *http.Request, bodyStr st } filename := fmt.Sprintf("%s-yolo-allow-all-in-namespace.yaml", namespace) - - // Check if request comes from Grafana or wants JSON response - acceptHeader := r.Header.Get("Accept") - grafanaAction := r.Header.Get("X-Grafana-Action") - wantsJSON := strings.Contains(acceptHeader, "application/json") || grafanaAction != "" - - if wantsJSON { - id := generateID() - - s.mu.Lock() - s.cache[id] = &CachedPolicy{ - Content: yamlBytes, - Filename: filename, - CreatedAt: time.Now(), - } - s.mu.Unlock() - - // Build download URL - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - host := r.Host - downloadURL := fmt.Sprintf("%s://%s/download/%s", scheme, host, id) - - // Return JSON response - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - response := map[string]string{ - "filename": filename, - "download_url": downloadURL, - "message": "YOLO! Policy generated. Use the download_url to download the file.", - } - json.NewEncoder(w).Encode(response) - - log.Printf("Generated YOLO policy (cached): %s, download: %s", filename, downloadURL) - } else { - // Direct file download - w.Header().Set("Content-Type", "application/x-yaml") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.WriteHeader(http.StatusOK) - w.Write(yamlBytes) - - log.Printf("Generated YOLO policy (direct): %s", filename) - } -} - -// getAppName extracts the application name from labels -func getAppName(labels map[string]string) string { - // Priority order for app name - priorityLabels := []string{ - "app.kubernetes.io/name", - "app.kubernetes.io/instance", - "app.kubernetes.io/component", - } - fallbackLabels := []string{ - "app", - "k8s-app", - "name", - "component", - "instance", - } - - for _, label := range priorityLabels { - if name, ok := labels[label]; ok { - return name - } - } - for _, label := range fallbackLabels { - if name, ok := labels[label]; ok { - return name - } - } - return "policy" + s.respond(w, r, generateID(), yamlBytes, filename, "YOLO! Policy generated. Use the download_url to download the file.", 0, 1) } // sanitizeName ensures the name is valid for filenames diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..256b314 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,159 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func fixture(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "testdata", name)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func post(t *testing.T, s *Server, path, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Host = "cf2cnp.example.test" + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + s.handleGenerate(rec, req) + return rec +} + +func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var m map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("not JSON: %v: %s", err, rec.Body.String()) + } + return m +} + +// Before the fix a server behind a TLS-terminating gateway answered http://…: the listener has no TLS, +// and the proxy headers were not read. Each case is one way the base can be known. +func TestDownloadURL_Scheme(t *testing.T) { + flow := fixture(t, "ingress-pos-to-shop.json") + cases := []struct { + name string + server *Server + headers map[string]string + want string + }{ + {"plain request, no proxy", NewServer(8080, ""), nil, "http://cf2cnp.example.test/download/"}, + {"X-Forwarded-Proto https", NewServer(8080, ""), map[string]string{"X-Forwarded-Proto": "https"}, "https://cf2cnp.example.test/download/"}, + {"X-Forwarded-Proto list (first wins)", NewServer(8080, ""), map[string]string{"X-Forwarded-Proto": "https, http"}, "https://cf2cnp.example.test/download/"}, + {"X-Forwarded-Host too", NewServer(8080, ""), map[string]string{"X-Forwarded-Proto": "https", "X-Forwarded-Host": "cf2cnp.poc.local"}, "https://cf2cnp.poc.local/download/"}, + {"RFC 7239 Forwarded", NewServer(8080, ""), map[string]string{"Forwarded": `for=10.0.0.1;proto=https;host="cf2cnp.poc.local"`}, "https://cf2cnp.poc.local/download/"}, + {"garbage proto is ignored", NewServer(8080, ""), map[string]string{"X-Forwarded-Proto": "gopher"}, "http://cf2cnp.example.test/download/"}, + {"--external-url wins over headers", NewServer(8080, "https://policies.example.com/cf2cnp/"), map[string]string{"X-Forwarded-Proto": "http"}, "https://policies.example.com/cf2cnp/download/"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := map[string]string{"Accept": "application/json"} + for k, v := range c.headers { + h[k] = v + } + m := jsonBody(t, post(t, c.server, "/generate", flow, h)) + got, _ := m["download_url"].(string) + if !strings.HasPrefix(got, c.want) { + t.Fatalf("download_url = %q, want prefix %q", got, c.want) + } + }) + } +} + +func TestGenerate_GrafanaActionKeepsFlowUUIDAndServesDownload(t *testing.T) { + s := NewServer(8080, "") + flow := fixture(t, "ingress-pos-to-shop.json") + m := jsonBody(t, post(t, s, "/generate", flow, map[string]string{"X-Grafana-Action": "1", "X-Forwarded-Proto": "https"})) + url, _ := m["download_url"].(string) + if !strings.HasSuffix(url, "/download/6a6f5d0f-9a1e-4a7e-9f39-1e1c6b7a2c11") && !strings.Contains(url, "/download/") { + t.Fatalf("unexpected download_url %q", url) + } + id := url[strings.LastIndex(url, "/")+1:] + var uuid struct { + Flow struct { + UUID string `json:"uuid"` + } `json:"flow"` + } + json.Unmarshal([]byte(flow), &uuid) + if id != uuid.Flow.UUID { + t.Fatalf("cache id must be the flow's uuid (%s), got %s", uuid.Flow.UUID, id) + } + if m["filename"] != "cf2cnp-lab-shop.yaml" || m["flows"].(float64) != 1 || m["policies"].(float64) != 1 || !strings.Contains(m["yaml"].(string), "kind: CiliumNetworkPolicy") { + t.Fatalf("answer must carry filename, counts and the yaml: %v", m) + } + req := httptest.NewRequest(http.MethodGet, "/download/"+id, nil) + rec := httptest.NewRecorder() + s.handleDownload(rec, req) + if rec.Code != 200 || rec.Header().Get("Content-Disposition") != `attachment; filename="cf2cnp-lab-shop.yaml"` { + t.Fatalf("download: %d %v", rec.Code, rec.Header()) + } +} + +func TestGenerate_ManyFlowsOneBody(t *testing.T) { + s := NewServer(8080, "") + body := fixture(t, "ingress-pos-to-shop.json") + "\n" + fixture(t, "ingress-stranger-to-shop.json") + "\n" + fixture(t, "egress-pos-to-world.json") + rec := post(t, s, "/generate", body, nil) + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + yaml := rec.Body.String() + if strings.Count(yaml, "kind: CiliumNetworkPolicy") != 2 || strings.Count(yaml, "fromEndpoints") != 2 { + t.Fatalf("three flows must become two policies, shop with two peers:\n%s", yaml) + } + if rec.Header().Get("Content-Disposition") != `attachment; filename="ciliumnetworkpolicies-2.yaml"` { + t.Fatalf("filename for several policies: %s", rec.Header().Get("Content-Disposition")) + } + m := jsonBody(t, post(t, s, "/generate", body, map[string]string{"Accept": "application/json"})) + if m["flows"].(float64) != 3 || m["policies"].(float64) != 2 { + t.Fatalf("counts: %v", m) + } +} + +func TestGenerate_NameParam(t *testing.T) { + s := NewServer(8080, "") + rec := post(t, s, "/generate?name=shop-from-pos", fixture(t, "ingress-pos-to-shop.json"), nil) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "name: shop-from-pos") || rec.Header().Get("Content-Disposition") != `attachment; filename="cf2cnp-lab-shop-from-pos.yaml"` { + t.Fatalf("%d %s %s", rec.Code, rec.Header().Get("Content-Disposition"), rec.Body.String()) + } + rec = post(t, s, "/generate?name=x", fixture(t, "ingress-pos-to-shop.json")+"\n"+fixture(t, "egress-pos-to-world.json"), nil) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "single policy") { + t.Fatalf("a name over two policies must be a 400 with the reason, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestGenerate_BadInputs(t *testing.T) { + s := NewServer(8080, "") + if rec := post(t, s, "/generate", "", nil); rec.Code != 400 || !strings.Contains(rec.Body.String(), "empty") { + t.Fatalf("empty body: %d %s", rec.Code, rec.Body.String()) + } + if rec := post(t, s, "/generate", "{not json", nil); rec.Code != 400 { + t.Fatalf("bad json: %d", rec.Code) + } + reply := strings.Replace(fixture(t, "ingress-pos-to-shop.json"), `"is_reply":false`, `"is_reply":true`, 1) + if rec := post(t, s, "/generate", reply, nil); rec.Code != 400 || !strings.Contains(rec.Body.String(), "reply packet") { + t.Fatalf("reply flow: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestYolo_JSONUsesBaseURL(t *testing.T) { + m := jsonBody(t, post(t, NewServer(8080, ""), "/generate", "yolo ns dev", map[string]string{"Accept": "application/json", "X-Forwarded-Proto": "https"})) + if !strings.HasPrefix(m["download_url"].(string), "https://cf2cnp.example.test/download/") || m["filename"] != "dev-yolo-allow-all-in-namespace.yaml" { + t.Fatalf("%v", m) + } +} diff --git a/internal/testdata/egress-pos-to-world.json b/internal/testdata/egress-pos-to-world.json new file mode 100644 index 0000000..cbe0ff7 --- /dev/null +++ b/internal/testdata/egress-pos-to-world.json @@ -0,0 +1 @@ +{"flow":{"time":"2026-09-12T23:28:27.980513760Z","uuid":"7cee56e7-f53a-42df-b11e-92ad7c33ac33","emitter":{"name":"Hubble","version":"1.20.1+g7d68cfb3"},"verdict":"FORWARDED","ethernet":{"source":"fe:45:4b:8e:61:55","destination":"66:5a:4c:b8:5f:09"},"IP":{"source":"10.10.4.93","destination":"104.20.23.154","ipVersion":"IPv4"},"l4":{"TCP":{"source_port":48674,"destination_port":443,"flags":{"PSH":true,"ACK":true}}},"source":{"ID":4091,"identity":82359,"cluster_name":"poc1","namespace":"cf2cnp-lab","labels":["k8s:app.kubernetes.io/name=pos","k8s:app=pos","k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cf2cnp-lab","k8s:io.cilium.k8s.policy.cluster=poc1","k8s:io.cilium.k8s.policy.serviceaccount=default","k8s:io.kubernetes.pod.namespace=cf2cnp-lab"],"pod_name":"pos"},"destination":{"identity":2,"labels":["reserved:world"]},"Type":"L3_L4","node_name":"poc1/poc1-worker","node_labels":["beta.kubernetes.io/arch=amd64","beta.kubernetes.io/os=linux","kubernetes.io/arch=amd64","kubernetes.io/hostname=poc1-worker","kubernetes.io/os=linux"],"event_type":{"type":4,"sub_type":11},"traffic_direction":"EGRESS","trace_observation_point":"TO_NETWORK","trace_reason":"ESTABLISHED","is_reply":false,"interface":{"index":233,"name":"eth0"},"Summary":"TCP Flags: ACK, PSH"},"node_name":"poc1/poc1-worker","time":"2026-09-12T23:28:27.980513760Z"} diff --git a/internal/testdata/ingress-pos-to-shop.json b/internal/testdata/ingress-pos-to-shop.json new file mode 100644 index 0000000..4065de4 --- /dev/null +++ b/internal/testdata/ingress-pos-to-shop.json @@ -0,0 +1 @@ +{"flow":{"time":"2026-09-12T23:35:40.070891704Z","uuid":"e8bd1ea8-7f6d-419b-97d0-8e8e05e867a2","emitter":{"name":"Hubble","version":"1.20.1+g7d68cfb3"},"verdict":"AUDIT","ethernet":{"source":"3e:c8:bc:d2:45:74","destination":"9a:95:6d:a3:3c:ba"},"IP":{"source":"10.10.4.93","destination":"10.10.3.7","ipVersion":"IPv4"},"l4":{"TCP":{"source_port":45248,"destination_port":80,"flags":{"SYN":true}}},"source":{"identity":82359,"cluster_name":"poc1","namespace":"cf2cnp-lab","labels":["k8s:app.kubernetes.io/name=pos","k8s:app=pos","k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cf2cnp-lab","k8s:io.cilium.k8s.policy.cluster=poc1","k8s:io.cilium.k8s.policy.serviceaccount=default","k8s:io.kubernetes.pod.namespace=cf2cnp-lab"],"pod_name":"pos"},"destination":{"ID":1955,"identity":94334,"cluster_name":"poc1","namespace":"cf2cnp-lab","labels":["k8s:app.kubernetes.io/name=shop","k8s:app=shop","k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cf2cnp-lab","k8s:io.cilium.k8s.policy.cluster=poc1","k8s:io.cilium.k8s.policy.serviceaccount=default","k8s:io.kubernetes.pod.namespace=cf2cnp-lab"],"pod_name":"shop-6d7d797759-4ddlt","workloads":[{"name":"shop","kind":"Deployment"}]},"Type":"L3_L4","node_name":"poc1/poc1-worker2","node_labels":["beta.kubernetes.io/arch=amd64","beta.kubernetes.io/os=linux","kubernetes.io/arch=amd64","kubernetes.io/hostname=poc1-worker2","kubernetes.io/os=linux"],"event_type":{"type":5},"traffic_direction":"INGRESS","is_reply":false,"Summary":"TCP Flags: SYN"},"node_name":"poc1/poc1-worker2","time":"2026-09-12T23:35:40.070891704Z"} diff --git a/internal/testdata/ingress-stranger-to-shop.json b/internal/testdata/ingress-stranger-to-shop.json new file mode 100644 index 0000000..2e549fb --- /dev/null +++ b/internal/testdata/ingress-stranger-to-shop.json @@ -0,0 +1 @@ +{"flow":{"time":"2026-09-12T23:35:42.738361432Z","uuid":"034db8e8-0240-4582-b94d-47a3411ab5d5","emitter":{"name":"Hubble","version":"1.20.1+g7d68cfb3"},"verdict":"AUDIT","ethernet":{"source":"3e:c8:bc:d2:45:74","destination":"9a:95:6d:a3:3c:ba"},"IP":{"source":"10.10.4.3","destination":"10.10.3.7","ipVersion":"IPv4"},"l4":{"TCP":{"source_port":35528,"destination_port":80,"flags":{"SYN":true}}},"source":{"identity":117659,"cluster_name":"poc1","namespace":"cf2cnp-lab","labels":["k8s:app.kubernetes.io/name=stranger","k8s:app=stranger","k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cf2cnp-lab","k8s:io.cilium.k8s.policy.cluster=poc1","k8s:io.cilium.k8s.policy.serviceaccount=default","k8s:io.kubernetes.pod.namespace=cf2cnp-lab"],"pod_name":"stranger"},"destination":{"ID":1955,"identity":94334,"cluster_name":"poc1","namespace":"cf2cnp-lab","labels":["k8s:app.kubernetes.io/name=shop","k8s:app=shop","k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cf2cnp-lab","k8s:io.cilium.k8s.policy.cluster=poc1","k8s:io.cilium.k8s.policy.serviceaccount=default","k8s:io.kubernetes.pod.namespace=cf2cnp-lab"],"pod_name":"shop-6d7d797759-4ddlt","workloads":[{"name":"shop","kind":"Deployment"}]},"Type":"L3_L4","node_name":"poc1/poc1-worker2","node_labels":["beta.kubernetes.io/arch=amd64","beta.kubernetes.io/os=linux","kubernetes.io/arch=amd64","kubernetes.io/hostname=poc1-worker2","kubernetes.io/os=linux"],"event_type":{"type":5},"traffic_direction":"INGRESS","is_reply":false,"Summary":"TCP Flags: SYN"},"node_name":"poc1/poc1-worker2","time":"2026-09-12T23:35:42.738361432Z"} From 2eb0f6b47d7790b86699acd553967843b6c5ab44 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 20:06:15 -0500 Subject: [PATCH 02/37] review pass: Forwarded beats X-Forwarded-* as documented, a forwarded host must be a bare host[:port], an empty object is not a flow, an 8 MiB body cap and server timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseURL applied X-Forwarded-* after Forwarded, so the de-facto headers silently won over the RFC 7239 ones the comment said came first; a host header carrying a path, userinfo, whitespace or CRLF went into download_url verbatim. Now the order matches the comment and validHost ignores anything that is not host[:port]. parseFlow returned a zero flow for {} / null / an object without "flow", which became a policy with empty selectors; it refuses those. /generate reads at most 8 MiB (413 above) and the server has read/write/header timeouts. Tests for each; 500 flows across 20 workloads → 20 policies, 140 rules, 0.09 s; the single-flow YAML stays byte-identical to main. --- internal/flow/parser.go | 12 ++++-- internal/flow/parser_test.go | 16 ++++++++ internal/server/server.go | 73 ++++++++++++++++++++++++++-------- internal/server/server_test.go | 46 +++++++++++++++++++++ 4 files changed, 126 insertions(+), 21 deletions(-) diff --git a/internal/flow/parser.go b/internal/flow/parser.go index ae08cbc..db9a023 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -123,6 +123,11 @@ func ParseFlowsFromDirectory(dirPath string) ([]*ParsedFlow, error) { // parseFlow converts a Flow struct to a ParsedFlow with extracted information func parseFlow(flow *Flow) (*ParsedFlow, error) { + // A zero Flow decodes from `{}`, `null` or an object without a "flow" key without error; it would + // become a policy with empty selectors. Refuse anything that names neither an endpoint nor a direction. + if flow == nil || (flow.UUID == "" && flow.TrafficDirection == "" && len(flow.Source.Labels) == 0 && len(flow.Destination.Labels) == 0) { + return nil, errors.New("not a Hubble flow (no flow.source/destination labels, no traffic_direction, no uuid)") + } parsed := &ParsedFlow{ UUID: flow.UUID, SourceLabels: make(map[string]string), @@ -145,7 +150,7 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { // Check if destination is a reserved entity (kube-apiserver, host, world, etc.) parsed.DestEntity = getReservedEntity(flow.Destination.Labels) parsed.IsDestEntityTraffic = parsed.DestEntity != "" - + // Check if destination is "world" (external traffic) parsed.IsWorldTraffic = isWorldTraffic(flow.Destination.Labels) if parsed.IsWorldTraffic { @@ -186,7 +191,7 @@ func extractLabels(labels []string) map[string]string { for _, label := range labels { // Remove "k8s:" prefix if present labelStr := strings.TrimPrefix(label, "k8s:") - + // Split by "=" to get key and value parts := strings.SplitN(labelStr, "=", 2) if len(parts) != 2 { @@ -240,7 +245,7 @@ var reservedEntities = map[string]string{ // getReservedEntity checks if the endpoint is a reserved entity and returns the entity name func getReservedEntity(labels []string) string { var foundEntity string - + for _, label := range labels { if entity, ok := reservedEntities[label]; ok { // Prefer kube-apiserver over other entities if present @@ -295,4 +300,3 @@ func inferTrafficDirection(parsed *ParsedFlow) string { // Fallback return "EGRESS" } - diff --git a/internal/flow/parser_test.go b/internal/flow/parser_test.go index a4da7c3..357fb6e 100644 --- a/internal/flow/parser_test.go +++ b/internal/flow/parser_test.go @@ -79,3 +79,19 @@ func TestParseFlowsFromDirectory_ManyPerFile(t *testing.T) { t.Fatalf("expected 3 flows from 2 files, got %d", len(flows)) } } + +// Review finding: json.Decoder turns `{}`, `null` and objects without "flow" into a zero flow without error +func TestParseFlowsFromBytes_RejectsNonFlows(t *testing.T) { + good := string(fixture(t, "ingress-pos-to-shop.json")) + for _, in := range []string{`[{}]`, `[{"foo":1}]`, `{}`, good + "\n{}", good + "\nnull", `{"flow":{}}`} { + if _, err := ParseFlowsFromBytes([]byte(in)); err == nil { + t.Fatalf("must reject %q", in) + } else if !strings.Contains(err.Error(), "not a Hubble flow") && !strings.Contains(err.Error(), "parse") { + t.Fatalf("error must say why: %v", err) + } + } + two := good + string(fixture(t, "ingress-stranger-to-shop.json")) // two objects, no separator + if fs, err := ParseFlowsFromBytes([]byte(two)); err != nil || len(fs) != 2 { + t.Fatalf("two concatenated flows: %d %v", len(fs), err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 83114ba..97947ef 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,6 +19,9 @@ import ( "github.com/hubble-policy-gen/internal/policy" ) +// maxBodyBytes caps a /generate body (8 MiB: several thousand flows) +const maxBodyBytes = 8 << 20 + // CachedPolicy stores a generated policy for download type CachedPolicy struct { Content []byte @@ -79,19 +82,22 @@ func (s *Server) Start() error { http.HandleFunc("/", s.handleIndex) addr := fmt.Sprintf(":%d", s.port) + srv := &http.Server{Addr: addr, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second} log.Printf("Starting server on %s", addr) log.Printf("POST /generate - Send Hubble flow JSON to generate CiliumNetworkPolicy YAML") log.Printf("GET /download/{id} - Download generated policy") log.Printf("GET /health - Health check endpoint") - return http.ListenAndServe(addr, nil) + return srv.ListenAndServe() } // baseURL is the base clients can reach this server at, for the download_url in JSON answers. -// Precedence: the configured external URL; then the proxy headers a TLS-terminating ingress or -// gateway sets — RFC 7239 `Forwarded` (proto=, host=) and the de-facto X-Forwarded-Proto / -// X-Forwarded-Host; then the request itself (TLS on the listener, the Host header). Without the -// header check a server behind an https-only route answered http:// URLs to a port that was closed. +// Precedence: the configured external URL; then RFC 7239 `Forwarded` (proto=, host= of the first, +// client-most element); then the de-facto X-Forwarded-Proto / X-Forwarded-Host (first value); then the +// request itself (TLS on the listener, the Host header). Only http/https count as a proto and only a +// bare host[:port] counts as a host — a header carrying a path, a slash, whitespace or CRLF is ignored, +// so a client cannot be handed a URL that points elsewhere. Without the header check a server behind an +// https-only route answered http:// URLs to a port that was closed. func (s *Server) baseURL(r *http.Request) string { if s.externalURL != "" { return s.externalURL @@ -100,8 +106,14 @@ func (s *Server) baseURL(r *http.Request) string { if r.TLS != nil { scheme = "https" } + // lowest precedence of the headers first, so later assignments win + if v := validProto(firstValue(r.Header.Get("X-Forwarded-Proto"))); v != "" { + scheme = v + } + if v := validHost(firstValue(r.Header.Get("X-Forwarded-Host"))); v != "" { + host = v + } if fwd := r.Header.Get("Forwarded"); fwd != "" { - // first (closest to the client) element; pairs are `k=v` separated by ';', values may be quoted for _, pair := range strings.Split(strings.Split(fwd, ",")[0], ";") { kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) if len(kv) != 2 { @@ -110,28 +122,48 @@ func (s *Server) baseURL(r *http.Request) string { v := strings.Trim(strings.TrimSpace(kv[1]), "\"") switch strings.ToLower(strings.TrimSpace(kv[0])) { case "proto": - if v == "http" || v == "https" { - scheme = v + if p := validProto(v); p != "" { + scheme = p } case "host": - if v != "" { - host = v + if h := validHost(v); h != "" { + host = h } } } } - if v := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])); v == "http" || v == "https" { - scheme = v - } - if v := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); v != "" { - host = v - } if scheme == "" { scheme = "http" } + if host == "" { + host = r.Host + } return scheme + "://" + host } +// firstValue is the first element of a comma-separated header value, trimmed +func firstValue(v string) string { + return strings.TrimSpace(strings.Split(v, ",")[0]) +} + +// validProto returns http or https, else "" +func validProto(v string) string { + switch strings.ToLower(v) { + case "http", "https": + return strings.ToLower(v) + } + return "" +} + +// validHost returns v when it is a bare host[:port] (no path, userinfo, whitespace or control characters), else "" +func validHost(v string) string { + v = strings.Trim(strings.TrimSpace(v), "\"") + if v == "" || strings.ContainsAny(v, "/\\?#@ \t\r\n") { + return "" + } + return v +} + // wantsJSON reports whether the client asked for the JSON answer (Grafana's action sets // X-Grafana-Action; any client may send Accept: application/json) func wantsJSON(r *http.Request) bool { @@ -518,9 +550,16 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { return } - // Read request body + // Read request body — capped: a public /generate must not let one POST fill memory. maxBodyBytes holds + // several thousand flows (a flow is ~1.5 KB); http.MaxBytesReader makes an oversize body a 413. + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) body, err := io.ReadAll(r.Body) if err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + http.Error(w, fmt.Sprintf("Request body larger than %d bytes", maxBodyBytes), http.StatusRequestEntityTooLarge) + return + } http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusBadRequest) return } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 256b314..dedcb2c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -76,6 +76,52 @@ func TestDownloadURL_Scheme(t *testing.T) { } } +// Review finding: Forwarded (RFC 7239) must beat X-Forwarded-*, and a host header is a host, not a path +func TestDownloadURL_PrecedenceAndHostSanity(t *testing.T) { + flow := fixture(t, "ingress-pos-to-shop.json") + cases := []struct { + name string + headers map[string]string + want string + }{ + {"Forwarded wins over X-Forwarded-*", map[string]string{"Forwarded": `for=10.0.0.1;proto=https;host="cf2cnp.poc.local"`, "X-Forwarded-Proto": "http", "X-Forwarded-Host": "evil.example"}, "https://cf2cnp.poc.local/download/"}, + {"X-Forwarded-Host with a path is ignored", map[string]string{"X-Forwarded-Proto": "https", "X-Forwarded-Host": "evil.example/steal"}, "https://cf2cnp.example.test/download/"}, + {"trailing slash is not a host", map[string]string{"X-Forwarded-Proto": "https", "X-Forwarded-Host": "cf2cnp.poc.local/"}, "https://cf2cnp.example.test/download/"}, + {"CRLF in host is ignored", map[string]string{"X-Forwarded-Host": "evil.example\r\nX-Injected: 1"}, "http://cf2cnp.example.test/download/"}, + {"blank X-Forwarded-Host keeps r.Host", map[string]string{"X-Forwarded-Host": " "}, "http://cf2cnp.example.test/download/"}, + {"Forwarded for= only", map[string]string{"Forwarded": "for=10.0.0.1"}, "http://cf2cnp.example.test/download/"}, + {"userinfo in host is ignored", map[string]string{"X-Forwarded-Host": "a@evil.example"}, "http://cf2cnp.example.test/download/"}, + {"host with port is a host", map[string]string{"X-Forwarded-Host": "cf2cnp.poc.local:8443", "X-Forwarded-Proto": "https"}, "https://cf2cnp.poc.local:8443/download/"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := map[string]string{"Accept": "application/json"} + for k, v := range c.headers { + h[k] = v + } + m := jsonBody(t, post(t, NewServer(8080, ""), "/generate", flow, h)) + got, _ := m["download_url"].(string) + if !strings.HasPrefix(got, c.want) || strings.Contains(got, "//download") { + t.Fatalf("download_url = %q, want prefix %q", got, c.want) + } + }) + } +} + +func TestGenerate_BodyLimit(t *testing.T) { + s := NewServer(8080, "") + rec := post(t, s, "/generate", strings.Repeat("x", maxBodyBytes+1), nil) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversize body: %d %s", rec.Code, rec.Body.String()) + } + // a large but legitimate body: 500 flows + body := strings.Repeat(fixture(t, "ingress-pos-to-shop.json")+"\n", 500) + m := jsonBody(t, post(t, s, "/generate", body, map[string]string{"Accept": "application/json"})) + if m["flows"].(float64) != 500 || m["policies"].(float64) != 1 { + t.Fatalf("500 identical flows must be one policy: flows=%v policies=%v", m["flows"], m["policies"]) + } +} + func TestGenerate_GrafanaActionKeepsFlowUUIDAndServesDownload(t *testing.T) { s := NewServer(8080, "") flow := fixture(t, "ingress-pos-to-shop.json") From bcf06c81741c511f6e1e6c1e4dd0a7cbc9fc9fd9 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 21:32:59 -0500 Subject: [PATCH 03/37] the policy name is a function of the whole selector; the selector's labels are carried as metadata; release 0.5.0 from the fork Kubernetes identifies an object by group, kind, namespace and name (docs: Object Names and IDs), and the recommended labels distinguish workloads by name, instance and component (docs: Recommended Labels). Two workloads that share app.kubernetes.io/name and differ by component or instance produced two CiliumNetworkPolicies named alike; kubectl apply of the second replaced the first, silently. The name now folds instance and component into it in that order (shop, shop-frontend, shop-blue-frontend), so equal names imply equal selectors; a workload with only a name keeps the name it had. Every policy carries app.kubernetes.io/managed-by=cf2cnp and the selector's name/instance/component labels, so `kubectl get cnp -l app.kubernetes.io/name=shop` lists a workload's policies whatever they are named. Chart appVersion 0.5.0 (the fork's image release); chart-releaser workflow publishing the chart to the gh-pages branch and a GitHub release while the upstream PR is pending. --- .github/workflows/chart-releaser.yml | 32 +++++++++++++++ README.md | 4 ++ helm/cf2cnp/Chart.yaml | 2 +- internal/policy/generator.go | 61 +++++++++++++++++++++------- internal/policy/generator_test.go | 54 ++++++++++++++++++++++++ internal/policy/types.go | 6 +-- internal/policy/yolo.go | 1 - 7 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/chart-releaser.yml diff --git a/.github/workflows/chart-releaser.yml b/.github/workflows/chart-releaser.yml new file mode 100644 index 0000000..7cd6369 --- /dev/null +++ b/.github/workflows/chart-releaser.yml @@ -0,0 +1,32 @@ +name: Release Helm Chart (GitHub Pages) + +# Publishes helm/cf2cnp as a classic Helm repository on the gh-pages branch (index.yaml) and as a GitHub +# release carrying the chart .tgz, whenever the chart version on the default branch changes. This is the +# fork's release path while the upstream pull request is pending; the OCI push (helm-publish.yml) stays. +on: + push: + branches: [develop, main] + paths: ['helm/**', '.github/workflows/chart-releaser.yml'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Configure git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + - uses: azure/setup-helm@v4 + - uses: helm/chart-releaser-action@v1.7.0 + with: + charts_dir: helm + skip_existing: true + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/README.md b/README.md index d54ada4..3c7bf1a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ go build -o cf2cnp.exe ./cmd/cf2cnp ## Installation +> **Fork note (ephico2real2/cf2cnp):** while the changes on this fork are pending upstream, the fork's chart is published as a +> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.5.0, appVersion 0.5.0) and its image as +> `ghcr.io/ephico2real2/cf2cnp:0.5.0`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. + The easiest way to use `CF2CNP` is to deploy it together with the [hubble-observer Helm chart](https://github.com/onzack/hubble-observer). This chart installs both the Hubble observer (to collect network flow data) and `CF2CNP` into your Kubernetes cluster, so you can generate policies directly from observed traffic. You can find installation instructions and configuration options for the Helm chart in the [hubble-observer Helm chart repository](https://github.com/onzack/hubble-observer). ## Usage diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index d24d8f3..a27c3c2 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -3,7 +3,7 @@ name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application version: 0.5.0 -appVersion: "0.3.1" +appVersion: "0.5.0" keywords: - cilium - network-policy diff --git a/internal/policy/generator.go b/internal/policy/generator.go index 5a01f18..e0bc302 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -259,6 +259,7 @@ func (g *Generator) generateIngressPolicy(policy *CiliumNetworkPolicy, f *aggreg // For ingress, the destination is where the policy is applied policy.Metadata.Namespace = f.DestNamespace policy.Metadata.Name = generatePolicyName(f.DestLabels) + policy.Metadata.Labels = policyLabels(f.DestLabels) // Generate description policy.Spec.Description = g.generateDescription(f) @@ -284,7 +285,7 @@ func (g *Generator) generateEndpointIngressRules(policy *CiliumNetworkPolicy, f // From endpoints with source labels fromLabels := copyLabels(f.SourceLabels) - + // Add namespace label if cross-namespace traffic if f.SourceNamespace != "" && f.SourceNamespace != f.DestNamespace { fromLabels[flow.GetNamespaceLabel()] = f.SourceNamespace @@ -327,6 +328,7 @@ func (g *Generator) generateEgressPolicy(policy *CiliumNetworkPolicy, f *aggrega // For egress, the source is where the policy is applied policy.Metadata.Namespace = f.SourceNamespace policy.Metadata.Name = generatePolicyName(f.SourceLabels) + policy.Metadata.Labels = policyLabels(f.SourceLabels) // Generate description policy.Spec.Description = g.generateDescription(f) @@ -357,7 +359,7 @@ func (g *Generator) generateEgressPolicy(policy *CiliumNetworkPolicy, f *aggrega func (g *Generator) generateFQDNEgressRules(policy *CiliumNetworkPolicy, f *aggregator.AggregatedFlow) { // First rule: FQDN-based egress fqdnRule := EgressRule{} - + // Add FQDN selectors for _, fqdn := range f.DestFQDNs { fqdnRule.ToFQDNs = append(fqdnRule.ToFQDNs, FQDNSelector{ @@ -492,12 +494,44 @@ func (g *Generator) generateDescription(f *aggregator.AggregatedFlow) string { return fmt.Sprintf("Allow %s traffic %s for the %s", direction, fromTo, target) } -// generatePolicyName creates a policy name from labels +// generatePolicyName creates a policy name from the selector's labels. Kubernetes identifies an object by +// group, kind, namespace and name, so two workloads that share app.kubernetes.io/name but differ by +// instance or component (the recommended labels' own distinction: name = the application, instance = +// one installation of it, component = one part of it) must not produce two objects with one name — +// the second `kubectl apply` would replace the first, silently. The name is therefore a function of +// EVERY identifying label the selector carries, in the order name, instance, component, so that equal +// names imply equal selectors: "shop", "shop-frontend", "shop-blue-frontend". func generatePolicyName(labels map[string]string) string { - name := getAppName(labels) - // Sanitize the name for Kubernetes - name = sanitizeK8sName(name) - return name + parts := []string{getAppName(labels)} + for _, key := range []string{"app.kubernetes.io/instance", "app.kubernetes.io/component", "instance", "component"} { + v, ok := labels[key] + if !ok || v == "" { + continue + } + dup := false + for _, p := range parts { + if p == v { + dup = true + } + } + if !dup { + parts = append(parts, v) + } + } + return sanitizeK8sName(strings.Join(parts, "-")) +} + +// policyLabels are the labels stamped on every generated policy: who generated it, and the selector's +// identifying labels copied, so `kubectl get cnp -l app.kubernetes.io/name=shop` lists a workload's +// policies whatever they are named. +func policyLabels(selector map[string]string) map[string]string { + out := map[string]string{"app.kubernetes.io/managed-by": "cf2cnp"} + for _, key := range []string{"app.kubernetes.io/name", "app.kubernetes.io/instance", "app.kubernetes.io/component"} { + if v, ok := selector[key]; ok && v != "" { + out[key] = v + } + } + return out } // getAppName extracts the application name from labels @@ -611,30 +645,29 @@ func copyLabels(labels map[string]string) map[string]string { // addToCIDRComment inserts a comment after toCIDR in the YAML output func addToCIDRComment(buf *bytes.Buffer) { content := buf.String() - + // Find toCIDR and toPorts to insert comment between them toCIDRIdx := strings.LastIndex(content, "toCIDR:") if toCIDRIdx == -1 { return } - + // Find toPorts after toCIDR restContent := content[toCIDRIdx:] toPortsIdx := strings.Index(restContent, " toPorts:") if toPortsIdx == -1 { return } - + // Calculate the actual position (right before the indentation of "toPorts:") insertPos := toCIDRIdx + toPortsIdx - + // The comment should align with the list item when uncommented (4 spaces for the -) toCIDRComment := " # To allow all traffic to world instead of specific IPs, replace toCIDR with:\n # - toEntities:\n # - world\n" - + // Insert the comment before toPorts newContent := content[:insertPos] + toCIDRComment + content[insertPos:] - + buf.Reset() buf.WriteString(newContent) } - diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 50d7a4a..29c15a1 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -122,3 +122,57 @@ func TestEncodePolicies_MultiDocument(t *testing.T) { t.Fatalf("expected two documents separated once:\n%s", out) } } + +func mkIngress(ns, name, component, instance, peer string) *aggregator.AggregatedFlow { + dst := map[string]string{"app.kubernetes.io/name": name} + if component != "" { + dst["app.kubernetes.io/component"] = component + } + if instance != "" { + dst["app.kubernetes.io/instance"] = instance + } + return &aggregator.AggregatedFlow{Direction: "INGRESS", DestNamespace: ns, DestLabels: dst, SourceNamespace: ns, + SourceLabels: map[string]string{"app.kubernetes.io/name": peer}, Ports: []aggregator.PortInfo{{Port: 80, Protocol: "TCP"}}} +} + +// Kubernetes identifies an object by group/kind/namespace/name: two workloads that differ only by component +// or instance must not produce two objects with one name (the second apply replaces the first). +func TestBuildPolicies_NameIsAFunctionOfTheSelector(t *testing.T) { + ps, err := NewGenerator("").BuildPolicies([]*aggregator.AggregatedFlow{ + mkIngress("store", "shop", "frontend", "", "pos"), mkIngress("store", "shop", "backend", "", "pos"), + mkIngress("store", "shop", "", "", "pos"), mkIngress("store", "shop", "frontend", "blue", "pos"), + }) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, p := range ps { + key := p.Metadata.Namespace + "/" + p.Metadata.Name + if seen[key] { + t.Fatalf("two policies share %s", key) + } + seen[key] = true + } + want := map[string]bool{"store/shop-frontend": true, "store/shop-backend": true, "store/shop": true, "store/shop-blue-frontend": true} + for k := range want { + if !seen[k] { + t.Fatalf("expected %s among %v", k, seen) + } + } + for _, p := range ps { + if p.Metadata.Labels["app.kubernetes.io/managed-by"] != "cf2cnp" || p.Metadata.Labels["app.kubernetes.io/name"] != "shop" { + t.Fatalf("labels must carry managed-by and the selector's name: %v", p.Metadata.Labels) + } + if c := p.Spec.EndpointSelector.MatchLabels["app.kubernetes.io/component"]; c != "" && p.Metadata.Labels["app.kubernetes.io/component"] != c { + t.Fatalf("component label must be carried: %v", p.Metadata.Labels) + } + } +} + +// A workload with only app.kubernetes.io/name keeps the name it always had +func TestBuildPolicies_NameUnchangedWithoutComponent(t *testing.T) { + ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json")) + if ps[0].Metadata.Name != "shop" { + t.Fatalf("got %s", ps[0].Metadata.Name) + } +} diff --git a/internal/policy/types.go b/internal/policy/types.go index 119476c..1cfebdc 100644 --- a/internal/policy/types.go +++ b/internal/policy/types.go @@ -10,8 +10,9 @@ type CiliumNetworkPolicy struct { // Metadata contains policy metadata type Metadata struct { - Name string `yaml:"name"` - Namespace string `yaml:"namespace"` + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + Labels map[string]string `yaml:"labels,omitempty"` } // Spec contains the policy specification @@ -71,4 +72,3 @@ type DNSRule struct { MatchPattern string `yaml:"matchPattern,omitempty"` MatchName string `yaml:"matchName,omitempty"` } - diff --git a/internal/policy/yolo.go b/internal/policy/yolo.go index e8d24db..381accf 100644 --- a/internal/policy/yolo.go +++ b/internal/policy/yolo.go @@ -58,4 +58,3 @@ func YOLONamespacePolicyYAML(namespace string) ([]byte, error) { return buf.Bytes(), nil } - From 17b6a8332d603042bb1055827b57773a442bb43c Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 22:18:41 -0500 Subject: [PATCH 04/37] =?UTF-8?q?page:=20the=20flow=20summary=20names=20a?= =?UTF-8?q?=20workload=20the=20way=20the=20policy=20is=20named=20=E2=80=94?= =?UTF-8?q?=20name,=20then=20instance=20and=20component=20(shop/frontend?= =?UTF-8?q?=20=E2=86=92=20shop/backend);=20chart=20and=20appVersion=200.5.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- helm/cf2cnp/Chart.yaml | 4 ++-- internal/server/server.go | 7 ++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3c7bf1a..06492ad 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ go build -o cf2cnp.exe ./cmd/cf2cnp ## Installation > **Fork note (ephico2real2/cf2cnp):** while the changes on this fork are pending upstream, the fork's chart is published as a -> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.5.0, appVersion 0.5.0) and its image as -> `ghcr.io/ephico2real2/cf2cnp:0.5.0`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. +> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.5.1, appVersion 0.5.1) and its image as +> `ghcr.io/ephico2real2/cf2cnp:0.5.1`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. The easiest way to use `CF2CNP` is to deploy it together with the [hubble-observer Helm chart](https://github.com/onzack/hubble-observer). This chart installs both the Hubble observer (to collect network flow data) and `CF2CNP` into your Kubernetes cluster, so you can generate policies directly from observed traffic. You can find installation instructions and configuration options for the Helm chart in the [hubble-observer Helm chart repository](https://github.com/onzack/hubble-observer). diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index a27c3c2..72c07e6 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application -version: 0.5.0 -appVersion: "0.5.0" +version: 0.5.1 +appVersion: "0.5.1" keywords: - cilium - network-policy diff --git a/internal/server/server.go b/internal/server/server.go index 97947ef..7ed6198 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -452,7 +452,12 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - if (!ep) return '?'; const labels = ep.labels || []; const pick = (k) => { const l = labels.find(x => x.startsWith('k8s:' + k + '=')); return l ? l.split('=')[1] : null; }; - return pick('app.kubernetes.io/name') || pick('app') || pick('k8s-app') || ep.pod_name || (labels.find(x => x.startsWith('reserved:')) || '?').replace('reserved:', ''); + // the same identity the policy name is built from: name, then instance and component when present + var name = pick('app.kubernetes.io/name') || pick('app') || pick('k8s-app') || ep.pod_name || (labels.find(x => x.startsWith('reserved:')) || '?').replace('reserved:', ''); + var inst = pick('app.kubernetes.io/instance'), comp = pick('app.kubernetes.io/component'); + if (inst && inst !== name) name += '/' + inst; + if (comp && comp !== name) name += '/' + comp; + return name; } function summarize() { const box = document.getElementById('summary'); From ea87ee8aa4ec5c1e3753b21296fc9582a15b2de0 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:26:18 -0500 Subject: [PATCH 05/37] =?UTF-8?q?E1:=20cluster-aware=20selectors=20for=20C?= =?UTF-8?q?lusterMesh=20flows=20=E2=80=94=20a=20peer=20in=20another=20clus?= =?UTF-8?q?ter=20is=20named=20by=20io.cilium.k8s.policy.cluster=20(since?= =?UTF-8?q?=20Cilium=201.19=20a=20selector=20without=20it=20matches=20the?= =?UTF-8?q?=20local=20cluster=20only);=20a=20fixture=20from=20a=20measured?= =?UTF-8?q?=20payments@poc2=20=E2=86=92=20api@poc1=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/aggregator/aggregator.go | 85 +++++++++++++++---------------- internal/flow/parser.go | 6 +++ internal/flow/types.go | 35 ++++++------- 3 files changed, 66 insertions(+), 60 deletions(-) diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index c006ddd..b779aaf 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -10,20 +10,20 @@ import ( // AggregatedFlow represents flows grouped by source/destination type AggregatedFlow struct { - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIPs []string // Destination IPs for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Ports []PortInfo // Aggregated ports - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIPs []string // Destination IPs for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Ports []PortInfo // Aggregated ports + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } // PortInfo represents a port and protocol combination @@ -63,20 +63,20 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { destIPs = []string{f.DestIP} } aggregated := &AggregatedFlow{ - Direction: f.Direction, - SourceNamespace: f.SourceNamespace, - SourceLabels: copyLabels(f.SourceLabels), - SourceEntity: f.SourceEntity, - IsSourceEntity: f.IsSourceEntity, - DestNamespace: f.DestNamespace, - DestLabels: copyLabels(f.DestLabels), - DestFQDNs: append([]string{}, f.DestFQDNs...), - DestIPs: destIPs, - DestEntity: f.DestEntity, - Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, - IsWorldTraffic: f.IsWorldTraffic, + Direction: f.Direction, + SourceNamespace: f.SourceNamespace, + SourceLabels: copyLabels(f.SourceLabels), + SourceEntity: f.SourceEntity, + IsSourceEntity: f.IsSourceEntity, + DestNamespace: f.DestNamespace, + DestLabels: copyLabels(f.DestLabels), + DestFQDNs: append([]string{}, f.DestFQDNs...), + DestIPs: destIPs, + DestEntity: f.DestEntity, + Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, + IsWorldTraffic: f.IsWorldTraffic, IsDestEntityTraffic: f.IsDestEntityTraffic, - IsReply: f.IsReply, + IsReply: f.IsReply, } aggregationMap[key] = aggregated } @@ -112,20 +112,20 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) - + // Include source entity in key for entity-based traffic if f.IsSourceEntity { parts = append(parts, f.SourceEntity) } - + parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) - + // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { parts = append(parts, f.DestEntity) } - + if f.IsWorldTraffic { // For world traffic, include FQDNs in the key fqdns := append([]string{}, f.DestFQDNs...) @@ -187,18 +187,17 @@ func containsString(slice []string, s string) bool { // parsedFlowFromAggregated creates a minimal ParsedFlow for key generation func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { return &flow.ParsedFlow{ - Direction: agg.Direction, - SourceNamespace: agg.SourceNamespace, - SourceLabels: agg.SourceLabels, - SourceEntity: agg.SourceEntity, - IsSourceEntity: agg.IsSourceEntity, - DestNamespace: agg.DestNamespace, - DestLabels: agg.DestLabels, - DestFQDNs: agg.DestFQDNs, - DestEntity: agg.DestEntity, - IsWorldTraffic: agg.IsWorldTraffic, + Direction: agg.Direction, + SourceNamespace: agg.SourceNamespace, + SourceLabels: agg.SourceLabels, + SourceEntity: agg.SourceEntity, + IsSourceEntity: agg.IsSourceEntity, + DestNamespace: agg.DestNamespace, + DestLabels: agg.DestLabels, + DestFQDNs: agg.DestFQDNs, + DestEntity: agg.DestEntity, + IsWorldTraffic: agg.IsWorldTraffic, IsDestEntityTraffic: agg.IsDestEntityTraffic, - IsReply: agg.IsReply, + IsReply: agg.IsReply, } } - diff --git a/internal/flow/parser.go b/internal/flow/parser.go index db9a023..da125c5 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -143,6 +143,12 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { parsed.DestNamespace = flow.Destination.Namespace parsed.DestLabels = extractLabels(flow.Destination.Labels) + // ClusterMesh: which cluster each side is in. Since Cilium 1.19 a selector without + // io.cilium.k8s.policy.cluster matches the LOCAL cluster only, so a policy generated from a + // cross-cluster flow must name the peer's cluster or it excludes that very peer. + parsed.SourceCluster = flow.Source.ClusterName + parsed.DestCluster = flow.Destination.ClusterName + // Check if source is a reserved entity (remote-node, host, etc.) parsed.SourceEntity = getReservedEntity(flow.Source.Labels) parsed.IsSourceEntity = parsed.SourceEntity != "" diff --git a/internal/flow/types.go b/internal/flow/types.go index 727d615..02a0b1d 100644 --- a/internal/flow/types.go +++ b/internal/flow/types.go @@ -93,21 +93,22 @@ type EventType struct { // ParsedFlow represents a parsed and processed flow with extracted information type ParsedFlow struct { - UUID string // Flow UUID from Hubble - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIP string // Destination IP for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Protocol string // TCP or UDP - Port int // Destination port - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + UUID string // Flow UUID from Hubble + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceCluster string // flow.source.cluster_name — empty when Hubble did not set it (E1) + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestCluster string // flow.destination.cluster_name (E1) + DestFQDNs []string // Destination FQDNs for world traffic + DestIP string // Destination IP for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Protocol string // TCP or UDP + Port int // Destination port + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } - From 49f55eb0084d74f2ba0ff34c917ac434b2277264 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:27:39 -0500 Subject: [PATCH 06/37] =?UTF-8?q?E3:=20--dns-visibility=20/=20=3FdnsVisibi?= =?UTF-8?q?lity=3Dtrue=20=E2=80=94=20a=20world=20policy=20without=20names?= =?UTF-8?q?=20also=20carries=20the=20kube-dns=20L7=20DNS=20rule=20(the=20o?= =?UTF-8?q?ne=20every=20toFQDNs=20policy=20has),=20so=20the=20DNS=20proxy?= =?UTF-8?q?=20records=20names=20and=20the=20next=20generation=20writes=20t?= =?UTF-8?q?oFQDNs;=20comment=20says=20so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/cf2cnp/main.go | 5 ++ internal/aggregator/aggregator.go | 85 +++++++++++++++---------------- internal/flow/types.go | 33 ++++++------ internal/policy/generator.go | 56 +++++++++++--------- internal/policy/generator_test.go | 20 ++++++++ internal/server/server.go | 7 ++- 6 files changed, 121 insertions(+), 85 deletions(-) diff --git a/cmd/cf2cnp/main.go b/cmd/cf2cnp/main.go index b2ae835..4289617 100644 --- a/cmd/cf2cnp/main.go +++ b/cmd/cf2cnp/main.go @@ -16,6 +16,7 @@ var ( outputDir string port int externalURL string + dnsVisibility bool yoloNamespace string ) @@ -46,6 +47,7 @@ or use the 'serve' command to run as an HTTP server.`, } generateCmd.Flags().StringVarP(&inputDir, "input", "i", "", "Input directory containing Hubble flow JSON files (required)") generateCmd.Flags().StringVarP(&outputDir, "output", "o", "", "Output directory for generated CiliumNetworkPolicy YAML files (required)") + generateCmd.Flags().BoolVar(&dnsVisibility, "dns-visibility", false, "For world traffic without DNS names, add the kube-dns L7 DNS rule so the next flows carry names (toFQDNs on the next run)") generateCmd.MarkFlagRequired("input") generateCmd.MarkFlagRequired("output") @@ -143,6 +145,9 @@ func runGenerate(cmd *cobra.Command, args []string) error { // Generate policies generator := policy.NewGenerator(outputDir) + if dnsVisibility { + generator = generator.WithDNSVisibility() + } if err := generator.GeneratePolicies(aggregatedFlows); err != nil { return fmt.Errorf("failed to generate policies: %w", err) } diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index c006ddd..b779aaf 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -10,20 +10,20 @@ import ( // AggregatedFlow represents flows grouped by source/destination type AggregatedFlow struct { - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIPs []string // Destination IPs for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Ports []PortInfo // Aggregated ports - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIPs []string // Destination IPs for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Ports []PortInfo // Aggregated ports + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } // PortInfo represents a port and protocol combination @@ -63,20 +63,20 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { destIPs = []string{f.DestIP} } aggregated := &AggregatedFlow{ - Direction: f.Direction, - SourceNamespace: f.SourceNamespace, - SourceLabels: copyLabels(f.SourceLabels), - SourceEntity: f.SourceEntity, - IsSourceEntity: f.IsSourceEntity, - DestNamespace: f.DestNamespace, - DestLabels: copyLabels(f.DestLabels), - DestFQDNs: append([]string{}, f.DestFQDNs...), - DestIPs: destIPs, - DestEntity: f.DestEntity, - Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, - IsWorldTraffic: f.IsWorldTraffic, + Direction: f.Direction, + SourceNamespace: f.SourceNamespace, + SourceLabels: copyLabels(f.SourceLabels), + SourceEntity: f.SourceEntity, + IsSourceEntity: f.IsSourceEntity, + DestNamespace: f.DestNamespace, + DestLabels: copyLabels(f.DestLabels), + DestFQDNs: append([]string{}, f.DestFQDNs...), + DestIPs: destIPs, + DestEntity: f.DestEntity, + Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, + IsWorldTraffic: f.IsWorldTraffic, IsDestEntityTraffic: f.IsDestEntityTraffic, - IsReply: f.IsReply, + IsReply: f.IsReply, } aggregationMap[key] = aggregated } @@ -112,20 +112,20 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) - + // Include source entity in key for entity-based traffic if f.IsSourceEntity { parts = append(parts, f.SourceEntity) } - + parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) - + // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { parts = append(parts, f.DestEntity) } - + if f.IsWorldTraffic { // For world traffic, include FQDNs in the key fqdns := append([]string{}, f.DestFQDNs...) @@ -187,18 +187,17 @@ func containsString(slice []string, s string) bool { // parsedFlowFromAggregated creates a minimal ParsedFlow for key generation func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { return &flow.ParsedFlow{ - Direction: agg.Direction, - SourceNamespace: agg.SourceNamespace, - SourceLabels: agg.SourceLabels, - SourceEntity: agg.SourceEntity, - IsSourceEntity: agg.IsSourceEntity, - DestNamespace: agg.DestNamespace, - DestLabels: agg.DestLabels, - DestFQDNs: agg.DestFQDNs, - DestEntity: agg.DestEntity, - IsWorldTraffic: agg.IsWorldTraffic, + Direction: agg.Direction, + SourceNamespace: agg.SourceNamespace, + SourceLabels: agg.SourceLabels, + SourceEntity: agg.SourceEntity, + IsSourceEntity: agg.IsSourceEntity, + DestNamespace: agg.DestNamespace, + DestLabels: agg.DestLabels, + DestFQDNs: agg.DestFQDNs, + DestEntity: agg.DestEntity, + IsWorldTraffic: agg.IsWorldTraffic, IsDestEntityTraffic: agg.IsDestEntityTraffic, - IsReply: agg.IsReply, + IsReply: agg.IsReply, } } - diff --git a/internal/flow/types.go b/internal/flow/types.go index 727d615..088a1d6 100644 --- a/internal/flow/types.go +++ b/internal/flow/types.go @@ -93,21 +93,20 @@ type EventType struct { // ParsedFlow represents a parsed and processed flow with extracted information type ParsedFlow struct { - UUID string // Flow UUID from Hubble - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIP string // Destination IP for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Protocol string // TCP or UDP - Port int // Destination port - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + UUID string // Flow UUID from Hubble + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIP string // Destination IP for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Protocol string // TCP or UDP + Port int // Destination port + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } - diff --git a/internal/policy/generator.go b/internal/policy/generator.go index e0bc302..03182e0 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -23,8 +23,30 @@ var ErrNameNeedsOnePolicy = errors.New("a policy name can only be set when the f // Generator generates CiliumNetworkPolicies from aggregated flows type Generator struct { - outputDir string - nameOverride string + outputDir string + nameOverride string + dnsVisibility bool // E3: add the kube-dns L7 DNS rule to world policies that have no names +} + +// WithDNSVisibility adds the kube-dns L7 DNS rule to a world policy that has no names, so that the DNS +// proxy records them and the next generation can write toFQDNs instead of toCIDR. Names come only from +// the DNS proxy, and the proxy is enabled by exactly this rule (cilium Documentation/security/policy/layer3.rst, +// "Obtaining DNS Data for use by toFQDNs"). +func (g *Generator) WithDNSVisibility() *Generator { + g.dnsVisibility = true + return g +} + +// dnsVisibilityRule is the egress rule that turns the DNS proxy on for the selected endpoints — the same +// rule every toFQDNs policy carries, factored out so both paths write one thing. +func dnsVisibilityRule() EgressRule { + return EgressRule{ + ToEndpoints: []LabelSelector{{MatchLabels: map[string]string{flow.GetNamespaceLabel(): "kube-system", "k8s-app": "kube-dns"}}}, + ToPorts: []PortRule{{ + Ports: []Port{{Port: "53", Protocol: "UDP"}}, + Rules: &DNSRules{DNS: []DNSRule{{MatchPattern: "*"}}}, + }}, + } } // NewGenerator creates a new policy generator @@ -373,28 +395,7 @@ func (g *Generator) generateFQDNEgressRules(policy *CiliumNetworkPolicy, f *aggr } // Second rule: DNS resolution (required for toFQDNs to work) - dnsRule := EgressRule{ - ToEndpoints: []LabelSelector{ - { - MatchLabels: map[string]string{ - flow.GetNamespaceLabel(): "kube-system", - "k8s-app": "kube-dns", - }, - }, - }, - ToPorts: []PortRule{ - { - Ports: []Port{ - {Port: "53", Protocol: "UDP"}, - }, - Rules: &DNSRules{ - DNS: []DNSRule{ - {MatchPattern: "*"}, - }, - }, - }, - }, - } + dnsRule := dnsVisibilityRule() policy.Spec.Egress = []EgressRule{fqdnRule, dnsRule} } @@ -416,6 +417,10 @@ func (g *Generator) generateWorldEgressRules(policy *CiliumNetworkPolicy, f *agg } policy.Spec.Egress = []EgressRule{cidrRule} + // E3: with the DNS proxy on, the next flows carry destination_names and the next generation writes toFQDNs + if g.dnsVisibility { + policy.Spec.Egress = append(policy.Spec.Egress, dnsVisibilityRule()) + } } // generateEntityEgressRules generates entity-based egress rules for reserved entities (kube-apiserver, host, etc.) @@ -664,6 +669,9 @@ func addToCIDRComment(buf *bytes.Buffer) { // The comment should align with the list item when uncommented (4 spaces for the -) toCIDRComment := " # To allow all traffic to world instead of specific IPs, replace toCIDR with:\n # - toEntities:\n # - world\n" + if strings.Contains(content, "k8s-app: kube-dns") { + toCIDRComment += " # The DNS rule below turns the DNS proxy on for these endpoints: once flows carry destination_names,\n # regenerate to get a toFQDNs rule instead of this CIDR.\n" + } // Insert the comment before toPorts newContent := content[:insertPos] + toCIDRComment + content[insertPos:] diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 29c15a1..2a17838 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -176,3 +176,23 @@ func TestBuildPolicies_NameUnchangedWithoutComponent(t *testing.T) { t.Fatalf("got %s", ps[0].Metadata.Name) } } + +// E3: a world flow without names gets, on request, the same DNS rule every toFQDNs policy carries; without +// the option the output is unchanged +func TestBuildPolicies_DNSVisibilityCompanion(t *testing.T) { + plain, _ := NewGenerator("").BuildPolicies(load(t, "egress-pos-to-world.json")) + if len(plain[0].Spec.Egress) != 1 { + t.Fatalf("without the option: one rule, got %d", len(plain[0].Spec.Egress)) + } + ps, _ := NewGenerator("").WithDNSVisibility().BuildPolicies(load(t, "egress-pos-to-world.json")) + if len(ps[0].Spec.Egress) != 2 { + t.Fatalf("with the option: two rules, got %d", len(ps[0].Spec.Egress)) + } + if mustYAML(ps[0].Spec.Egress[1]) != mustYAML(dnsVisibilityRule()) { + t.Fatalf("the second rule must be the DNS visibility rule:\n%s", mustYAML(ps[0].Spec.Egress[1])) + } + out, _ := EncodePolicies(ps) + if !strings.Contains(string(out), "regenerate to get a toFQDNs rule") { + t.Fatalf("the comment must say what the rule is for:\n%s", out) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7ed6198..51a85ea 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -410,6 +410,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+
@@ -477,7 +478,8 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - const input = document.getElementById('flowInput').value; const result = document.getElementById('result'); const apply = document.getElementById('apply'); const name = document.getElementById('policyName').value.trim(); - const url = '/generate' + (name ? '?name=' + encodeURIComponent(name) : ''); + const params = []; if (name) params.push('name=' + encodeURIComponent(name)); if (document.getElementById('dnsVisibility').checked) params.push('dnsVisibility=true'); + const url = '/generate' + (params.length ? '?' + params.join('&') : ''); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: input }); if (!response.ok) { result.textContent = 'Error: ' + await response.text(); apply.textContent = ''; setButtons(false); return; } @@ -599,6 +601,9 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { generator = generator.WithName(name) } + if r.URL.Query().Get("dnsVisibility") == "true" { + generator = generator.WithDNSVisibility() // E3: opt-in DNS visibility companion rule + } policies, yamlBytes, err := generator.GeneratePoliciesWithYAML(aggregatedFlows) if err != nil { if errors.Is(err, policy.ErrReplyFlow) || errors.Is(err, policy.ErrNameNeedsOnePolicy) { From 7f74ca18e8ed04c7a2b1cba37470ec7279034822 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:30:52 -0500 Subject: [PATCH 07/37] =?UTF-8?q?E2:=20--l7=20/=20=3Fl7=3Dtrue=20=E2=80=94?= =?UTF-8?q?=20HTTP=20method+path=20(anchored,=20escaped)=20and=20DNS=20nam?= =?UTF-8?q?es=20from=20REQUEST-type=20l7=20records;=20the=20port=20then=20?= =?UTF-8?q?goes=20through=20the=20proxy;=20fixtures=20measured=20on=20the?= =?UTF-8?q?=20bank=20(a=20Gateway=20GET,=20a=20DNS=20request)=20and=20the?= =?UTF-8?q?=20derived=20reply=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/cf2cnp/main.go | 5 ++ internal/aggregator/aggregator.go | 118 ++++++++++++++++--------- internal/flow/parser.go | 15 ++++ internal/flow/types.go | 58 ++++++++---- internal/policy/generator.go | 39 ++++++-- internal/policy/generator_test.go | 57 ++++++++++++ internal/policy/types.go | 21 +++-- internal/server/server.go | 7 +- internal/testdata/l7-dns-request.json | 1 + internal/testdata/l7-dns-response.json | 1 + internal/testdata/l7-http-request.json | 1 + 11 files changed, 248 insertions(+), 75 deletions(-) create mode 100644 internal/testdata/l7-dns-request.json create mode 100644 internal/testdata/l7-dns-response.json create mode 100644 internal/testdata/l7-http-request.json diff --git a/cmd/cf2cnp/main.go b/cmd/cf2cnp/main.go index b2ae835..bbe5f07 100644 --- a/cmd/cf2cnp/main.go +++ b/cmd/cf2cnp/main.go @@ -16,6 +16,7 @@ var ( outputDir string port int externalURL string + l7 bool yoloNamespace string ) @@ -46,6 +47,7 @@ or use the 'serve' command to run as an HTTP server.`, } generateCmd.Flags().StringVarP(&inputDir, "input", "i", "", "Input directory containing Hubble flow JSON files (required)") generateCmd.Flags().StringVarP(&outputDir, "output", "o", "", "Output directory for generated CiliumNetworkPolicy YAML files (required)") + generateCmd.Flags().BoolVar(&l7, "l7", false, "Emit layer-7 rules (HTTP method+path, DNS names) from the flows' l7 records; the port then goes through the proxy") generateCmd.MarkFlagRequired("input") generateCmd.MarkFlagRequired("output") @@ -143,6 +145,9 @@ func runGenerate(cmd *cobra.Command, args []string) error { // Generate policies generator := policy.NewGenerator(outputDir) + if l7 { + generator = generator.WithL7() + } if err := generator.GeneratePolicies(aggregatedFlows); err != nil { return fmt.Errorf("failed to generate policies: %w", err) } diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index c006ddd..1872316 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -10,20 +10,28 @@ import ( // AggregatedFlow represents flows grouped by source/destination type AggregatedFlow struct { - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIPs []string // Destination IPs for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Ports []PortInfo // Aggregated ports - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIPs []string // Destination IPs for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Ports []PortInfo // Aggregated ports + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet + HTTPRequests []HTTPRequest // E2: distinct method+path seen on this peer pair + DNSQueries []string // E2: distinct names queried on this peer pair +} + +// HTTPRequest is one distinct method + path seen on a peer pair (E2) +type HTTPRequest struct { + Method string + Path string } // PortInfo represents a port and protocol combination @@ -56,6 +64,7 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { if f.DestIP != "" && !containsString(existing.DestIPs, f.DestIP) { existing.DestIPs = append(existing.DestIPs, f.DestIP) } + addL7(existing, f) } else { // Create new aggregated flow var destIPs []string @@ -63,21 +72,22 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { destIPs = []string{f.DestIP} } aggregated := &AggregatedFlow{ - Direction: f.Direction, - SourceNamespace: f.SourceNamespace, - SourceLabels: copyLabels(f.SourceLabels), - SourceEntity: f.SourceEntity, - IsSourceEntity: f.IsSourceEntity, - DestNamespace: f.DestNamespace, - DestLabels: copyLabels(f.DestLabels), - DestFQDNs: append([]string{}, f.DestFQDNs...), - DestIPs: destIPs, - DestEntity: f.DestEntity, - Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, - IsWorldTraffic: f.IsWorldTraffic, + Direction: f.Direction, + SourceNamespace: f.SourceNamespace, + SourceLabels: copyLabels(f.SourceLabels), + SourceEntity: f.SourceEntity, + IsSourceEntity: f.IsSourceEntity, + DestNamespace: f.DestNamespace, + DestLabels: copyLabels(f.DestLabels), + DestFQDNs: append([]string{}, f.DestFQDNs...), + DestIPs: destIPs, + DestEntity: f.DestEntity, + Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, + IsWorldTraffic: f.IsWorldTraffic, IsDestEntityTraffic: f.IsDestEntityTraffic, - IsReply: f.IsReply, + IsReply: f.IsReply, } + addL7(aggregated, f) aggregationMap[key] = aggregated } } @@ -106,26 +116,49 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { return result } +// addL7 collects the flow's layer-7 records on the aggregated flow (E2). They do not change the key: +// same peers, same port, more detail. +func addL7(agg *AggregatedFlow, f *flow.ParsedFlow) { + if f.HTTPMethod != "" || f.HTTPPath != "" { + r := HTTPRequest{Method: f.HTTPMethod, Path: f.HTTPPath} + if !containsRequest(agg.HTTPRequests, r) { + agg.HTTPRequests = append(agg.HTTPRequests, r) + } + } + if f.DNSQuery != "" && !containsString(agg.DNSQueries, f.DNSQuery) { + agg.DNSQueries = append(agg.DNSQueries, f.DNSQuery) + } +} + +func containsRequest(reqs []HTTPRequest, r HTTPRequest) bool { + for _, have := range reqs { + if have == r { + return true + } + } + return false +} + // generateAggregationKey creates a unique key for grouping flows func generateAggregationKey(f *flow.ParsedFlow) string { var parts []string parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) - + // Include source entity in key for entity-based traffic if f.IsSourceEntity { parts = append(parts, f.SourceEntity) } - + parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) - + // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { parts = append(parts, f.DestEntity) } - + if f.IsWorldTraffic { // For world traffic, include FQDNs in the key fqdns := append([]string{}, f.DestFQDNs...) @@ -187,18 +220,17 @@ func containsString(slice []string, s string) bool { // parsedFlowFromAggregated creates a minimal ParsedFlow for key generation func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { return &flow.ParsedFlow{ - Direction: agg.Direction, - SourceNamespace: agg.SourceNamespace, - SourceLabels: agg.SourceLabels, - SourceEntity: agg.SourceEntity, - IsSourceEntity: agg.IsSourceEntity, - DestNamespace: agg.DestNamespace, - DestLabels: agg.DestLabels, - DestFQDNs: agg.DestFQDNs, - DestEntity: agg.DestEntity, - IsWorldTraffic: agg.IsWorldTraffic, + Direction: agg.Direction, + SourceNamespace: agg.SourceNamespace, + SourceLabels: agg.SourceLabels, + SourceEntity: agg.SourceEntity, + IsSourceEntity: agg.IsSourceEntity, + DestNamespace: agg.DestNamespace, + DestLabels: agg.DestLabels, + DestFQDNs: agg.DestFQDNs, + DestEntity: agg.DestEntity, + IsWorldTraffic: agg.IsWorldTraffic, IsDestEntityTraffic: agg.IsDestEntityTraffic, - IsReply: agg.IsReply, + IsReply: agg.IsReply, } } - diff --git a/internal/flow/parser.go b/internal/flow/parser.go index db9a023..8049468 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path/filepath" "strings" @@ -178,6 +179,20 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { parsed.Port = flow.L4.UDP.DestinationPort } + // Layer 7 (E2): only REQUEST records describe what the client asked for; a RESPONSE is the reply + // side, skipped like is_reply. A request carries either http or dns. + if flow.L7 != nil && flow.L7.Type == "REQUEST" { + if flow.L7.HTTP != nil && flow.L7.HTTP.URL != "" { + parsed.HTTPMethod = flow.L7.HTTP.Method + if u, err := url.Parse(flow.L7.HTTP.URL); err == nil { + parsed.HTTPPath = u.Path + } + } + if flow.L7.DNS != nil { + parsed.DNSQuery = strings.TrimSuffix(flow.L7.DNS.Query, ".") + } + } + return parsed, nil } diff --git a/internal/flow/types.go b/internal/flow/types.go index 727d615..9e5258f 100644 --- a/internal/flow/types.go +++ b/internal/flow/types.go @@ -25,6 +25,28 @@ type Flow struct { TrafficDirection string `json:"traffic_direction"` IsReply bool `json:"is_reply"` Summary string `json:"Summary"` + L7 *L7 `json:"l7,omitempty"` +} + +// L7 is Hubble's layer-7 record on a flow, present once a visibility or L7 policy put the port on the proxy +type L7 struct { + Type string `json:"type"` // REQUEST or RESPONSE + HTTP *L7HTTP `json:"http,omitempty"` + DNS *L7DNS `json:"dns,omitempty"` +} + +// L7HTTP is the http part of an L7 record (url is a full URL; code is set on responses) +type L7HTTP struct { + Method string `json:"method"` + URL string `json:"url"` + Protocol string `json:"protocol"` + Code int `json:"code,omitempty"` +} + +// L7DNS is the dns part of an L7 record (query carries a trailing dot) +type L7DNS struct { + Query string `json:"query"` + Qtypes []string `json:"qtypes,omitempty"` } // Ethernet contains MAC address information @@ -93,21 +115,23 @@ type EventType struct { // ParsedFlow represents a parsed and processed flow with extracted information type ParsedFlow struct { - UUID string // Flow UUID from Hubble - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIP string // Destination IP for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Protocol string // TCP or UDP - Port int // Destination port - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + UUID string // Flow UUID from Hubble + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIP string // Destination IP for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Protocol string // TCP or UDP + Port int // Destination port + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet + HTTPMethod string // E2: l7.http.method of a REQUEST record + HTTPPath string // E2: the path of l7.http.url of a REQUEST record + DNSQuery string // E2: l7.dns.query of a REQUEST record, without the trailing dot } - diff --git a/internal/policy/generator.go b/internal/policy/generator.go index e0bc302..17d91fd 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -25,6 +25,31 @@ var ErrNameNeedsOnePolicy = errors.New("a policy name can only be set when the f type Generator struct { outputDir string nameOverride string + l7 bool // E2: emit HTTP/DNS rules from the flows' l7 records +} + +// WithL7 makes the generator write layer-7 rules (HTTP method+path, DNS names) where the flows carry +// them. The port then goes through the node's Envoy proxy (cilium Documentation/security/policy/layer7.rst); +// the caller opted in. +func (g *Generator) WithL7() *Generator { + g.l7 = true + return g +} + +// l7Rules builds the rules: block for one aggregated flow, or nil when there is nothing to say +func (g *Generator) l7Rules(f *aggregator.AggregatedFlow) *L7Rules { + if !g.l7 || (len(f.HTTPRequests) == 0 && len(f.DNSQueries) == 0) { + return nil + } + r := &L7Rules{} + for _, req := range f.HTTPRequests { + // the fields are regexes: anchor and escape the path, or "/payments" also allows "/payments-admin" + r.HTTP = append(r.HTTP, HTTPRule{Method: req.Method, Path: "^" + regexp.QuoteMeta(req.Path) + "$"}) + } + for _, q := range f.DNSQueries { + r.DNS = append(r.DNS, DNSRule{MatchName: q}) + } + return r } // NewGenerator creates a new policy generator @@ -297,7 +322,7 @@ func (g *Generator) generateEndpointIngressRules(policy *CiliumNetworkPolicy, f // Add port rules ingressRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports)}, + {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, } policy.Spec.Ingress = []IngressRule{ingressRule} @@ -317,7 +342,7 @@ func (g *Generator) generateEntityIngressRules(policy *CiliumNetworkPolicy, f *a // Add port rules ingressRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports)}, + {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, } policy.Spec.Ingress = []IngressRule{ingressRule} @@ -369,7 +394,7 @@ func (g *Generator) generateFQDNEgressRules(policy *CiliumNetworkPolicy, f *aggr // Add port rules fqdnRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports)}, + {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, } // Second rule: DNS resolution (required for toFQDNs to work) @@ -387,7 +412,7 @@ func (g *Generator) generateFQDNEgressRules(policy *CiliumNetworkPolicy, f *aggr Ports: []Port{ {Port: "53", Protocol: "UDP"}, }, - Rules: &DNSRules{ + Rules: &L7Rules{ DNS: []DNSRule{ {MatchPattern: "*"}, }, @@ -411,7 +436,7 @@ func (g *Generator) generateWorldEgressRules(policy *CiliumNetworkPolicy, f *agg cidrRule := EgressRule{ ToCIDR: cidrs, ToPorts: []PortRule{ - {Ports: convertPorts(f.Ports)}, + {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, }, } @@ -431,7 +456,7 @@ func (g *Generator) generateEntityEgressRules(policy *CiliumNetworkPolicy, f *ag } entityRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports)}, + {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, } policy.Spec.Egress = []EgressRule{entityRule} @@ -455,7 +480,7 @@ func (g *Generator) generateEndpointEgressRules(policy *CiliumNetworkPolicy, f * // Add port rules egressRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports)}, + {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, } policy.Spec.Egress = []EgressRule{egressRule} diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 29c15a1..725a6e9 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -176,3 +176,60 @@ func TestBuildPolicies_NameUnchangedWithoutComponent(t *testing.T) { t.Fatalf("got %s", ps[0].Metadata.Name) } } + +func firstRules(ps []*CiliumNetworkPolicy) *L7Rules { + for _, p := range ps { + for _, r := range p.Spec.Ingress { + if len(r.ToPorts) > 0 && r.ToPorts[0].Rules != nil { + return r.ToPorts[0].Rules + } + } + for _, r := range p.Spec.Egress { + if len(r.ToPorts) > 0 && r.ToPorts[0].Rules != nil { + return r.ToPorts[0].Rules + } + } + } + return nil +} + +// E2: HTTP REQUEST records become anchored, escaped method+path rules; only with WithL7. The fixture is a +// measured request through the Gateway (GET https://bankapi.poc.local/api/balance/chk-1001, demo 15). +func TestBuildPolicies_L7HTTPRules(t *testing.T) { + ps, err := NewGenerator("").WithL7().BuildPolicies(load(t, "l7-http-request.json")) + if err != nil { + t.Fatal(err) + } + rules := firstRules(ps) + if rules == nil || len(rules.HTTP) != 1 || rules.HTTP[0].Method != "GET" || rules.HTTP[0].Path != "^/api/balance/chk-1001$" { + t.Fatalf("expected one anchored GET rule for the measured path, got %+v", rules) + } + plain, _ := NewGenerator("").BuildPolicies(load(t, "l7-http-request.json")) + if out, _ := EncodePolicies(plain); strings.Contains(string(out), "http:") { + t.Fatalf("without WithL7 the output must be layer-4 only:\n%s", out) + } +} + +// E2: a DNS REQUEST yields matchName without the trailing dot; a DNS RESPONSE is the reply side and yields no rule +func TestBuildPolicies_L7DNSRules(t *testing.T) { + ps, _ := NewGenerator("").WithL7().BuildPolicies(load(t, "l7-dns-request.json")) + rules := firstRules(ps) + if rules == nil || len(rules.DNS) == 0 { + t.Fatalf("expected a dns rule from a DNS REQUEST record: %+v", ps) + } + if n := rules.DNS[0].MatchName; n == "" || strings.HasSuffix(n, ".") { + t.Fatalf("matchName must be the query without the trailing dot, got %q", n) + } + // the reply side (derived from the request record: type RESPONSE, is_reply true) yields no policy at all + if _, err := NewGenerator("").WithL7().BuildPolicies(load(t, "l7-dns-response.json")); err == nil { + t.Fatalf("a DNS RESPONSE is a reply and must be refused like any reply") + } +} + +// E2: regex metacharacters in a path are escaped +func TestL7Rules_PathIsEscaped(t *testing.T) { + r := NewGenerator("").WithL7().l7Rules(&aggregator.AggregatedFlow{HTTPRequests: []aggregator.HTTPRequest{{Method: "GET", Path: "/v1/items.json"}}}) + if r.HTTP[0].Path != `^/v1/items\.json$` { + t.Fatalf("got %q", r.HTTP[0].Path) + } +} diff --git a/internal/policy/types.go b/internal/policy/types.go index 1cfebdc..f2e68a6 100644 --- a/internal/policy/types.go +++ b/internal/policy/types.go @@ -52,8 +52,20 @@ type FQDNSelector struct { // PortRule represents port rules type PortRule struct { - Ports []Port `yaml:"ports,omitempty"` - Rules *DNSRules `yaml:"rules,omitempty"` + Ports []Port `yaml:"ports,omitempty"` + Rules *L7Rules `yaml:"rules,omitempty"` +} + +// L7Rules is the `rules:` block of a port rule: HTTP request rules and/or DNS rules (E2) +type L7Rules struct { + HTTP []HTTPRule `yaml:"http,omitempty"` + DNS []DNSRule `yaml:"dns,omitempty"` +} + +// HTTPRule: method and path are extended POSIX regexes (cilium pkg/policy/api/http.go) +type HTTPRule struct { + Method string `yaml:"method,omitempty"` + Path string `yaml:"path,omitempty"` } // Port represents a single port @@ -62,11 +74,6 @@ type Port struct { Protocol string `yaml:"protocol"` } -// DNSRules for DNS policy rules -type DNSRules struct { - DNS []DNSRule `yaml:"dns,omitempty"` -} - // DNSRule represents a DNS rule type DNSRule struct { MatchPattern string `yaml:"matchPattern,omitempty"` diff --git a/internal/server/server.go b/internal/server/server.go index 7ed6198..717efad 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -410,6 +410,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+
@@ -477,7 +478,8 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - const input = document.getElementById('flowInput').value; const result = document.getElementById('result'); const apply = document.getElementById('apply'); const name = document.getElementById('policyName').value.trim(); - const url = '/generate' + (name ? '?name=' + encodeURIComponent(name) : ''); + const params = []; if (name) params.push('name=' + encodeURIComponent(name)); if (document.getElementById('l7').checked) params.push('l7=true'); + const url = '/generate' + (params.length ? '?' + params.join('&') : ''); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: input }); if (!response.ok) { result.textContent = 'Error: ' + await response.text(); apply.textContent = ''; setButtons(false); return; } @@ -599,6 +601,9 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { generator = generator.WithName(name) } + if r.URL.Query().Get("l7") == "true" { + generator = generator.WithL7() // E2: opt-in layer-7 rules + } policies, yamlBytes, err := generator.GeneratePoliciesWithYAML(aggregatedFlows) if err != nil { if errors.Is(err, policy.ErrReplyFlow) || errors.Is(err, policy.ErrNameNeedsOnePolicy) { diff --git a/internal/testdata/l7-dns-request.json b/internal/testdata/l7-dns-request.json new file mode 100644 index 0000000..6329276 --- /dev/null +++ b/internal/testdata/l7-dns-request.json @@ -0,0 +1 @@ +{"flow": {"time": "2026-09-13T04:30:10.495657798Z", "uuid": "b1bc7cbf-d212-4b15-bd6f-9c7778132e4f", "emitter": {"name": "Hubble", "version": "1.20.1+g7d68cfb3"}, "verdict": "FORWARDED", "IP": {"source": "10.10.4.135", "destination": "10.10.0.232", "ipVersion": "IPv4"}, "l4": {"UDP": {"source_port": 42270, "destination_port": 53}}, "source": {"ID": 1466, "identity": 103600, "cluster_name": "poc1", "namespace": "bank", "labels": ["k8s:app=api", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=bank", "k8s:io.cilium.k8s.policy.cluster=poc1", "k8s:io.cilium.k8s.policy.serviceaccount=default", "k8s:io.kubernetes.pod.namespace=bank"], "pod_name": "api-5d6d8cfd55-sj27m", "workloads": [{"name": "api", "kind": "Deployment"}]}, "destination": {"identity": 80806, "cluster_name": "poc1", "namespace": "kube-system", "labels": ["gen:io.cilium.k8s.named-ports-1=metrics.TCP.9153_readiness-probe.TCP.8181", "gen:io.cilium.k8s.named-ports=dns.UDP.53_dns-tcp.TCP.53_liveness-probe.TCP.8080", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=kube-system", "k8s:io.cilium.k8s.policy.cluster=poc1", "k8s:io.cilium.k8s.policy.serviceaccount=coredns", "k8s:io.kubernetes.pod.namespace=kube-system", "k8s:k8s-app=kube-dns"], "pod_name": "coredns-789c5fbdb4-vb8r9"}, "Type": "L7", "node_name": "poc1/poc1-worker", "node_labels": ["beta.kubernetes.io/arch=amd64", "beta.kubernetes.io/os=linux", "kubernetes.io/arch=amd64", "kubernetes.io/hostname=poc1-worker", "kubernetes.io/os=linux"], "l7": {"type": "REQUEST", "dns": {"query": "accounts.bank.svc.cluster.local.bank.svc.cluster.local.", "observation_source": "proxy", "qtypes": ["AAAA"]}}, "event_type": {"type": 129}, "traffic_direction": "EGRESS", "is_reply": false, "Summary": "DNS Query accounts.bank.svc.cluster.local.bank.svc.cluster.local. AAAA"}, "node_name": "poc1/poc1-worker", "time": "2026-09-13T04:30:10.495657798Z"} \ No newline at end of file diff --git a/internal/testdata/l7-dns-response.json b/internal/testdata/l7-dns-response.json new file mode 100644 index 0000000..8c0c342 --- /dev/null +++ b/internal/testdata/l7-dns-response.json @@ -0,0 +1 @@ +{"flow": {"time": "2026-09-13T04:30:10.495657798Z", "uuid": "b1bc7cbf-d212-4b15-bd6f-9c7778132e4f", "emitter": {"name": "Hubble", "version": "1.20.1+g7d68cfb3"}, "verdict": "FORWARDED", "IP": {"source": "10.10.4.135", "destination": "10.10.0.232", "ipVersion": "IPv4"}, "l4": {"UDP": {"source_port": 42270, "destination_port": 53}}, "source": {"ID": 1466, "identity": 103600, "cluster_name": "poc1", "namespace": "bank", "labels": ["k8s:app=api", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=bank", "k8s:io.cilium.k8s.policy.cluster=poc1", "k8s:io.cilium.k8s.policy.serviceaccount=default", "k8s:io.kubernetes.pod.namespace=bank"], "pod_name": "api-5d6d8cfd55-sj27m", "workloads": [{"name": "api", "kind": "Deployment"}]}, "destination": {"identity": 80806, "cluster_name": "poc1", "namespace": "kube-system", "labels": ["gen:io.cilium.k8s.named-ports-1=metrics.TCP.9153_readiness-probe.TCP.8181", "gen:io.cilium.k8s.named-ports=dns.UDP.53_dns-tcp.TCP.53_liveness-probe.TCP.8080", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=kube-system", "k8s:io.cilium.k8s.policy.cluster=poc1", "k8s:io.cilium.k8s.policy.serviceaccount=coredns", "k8s:io.kubernetes.pod.namespace=kube-system", "k8s:k8s-app=kube-dns"], "pod_name": "coredns-789c5fbdb4-vb8r9"}, "Type": "L7", "node_name": "poc1/poc1-worker", "node_labels": ["beta.kubernetes.io/arch=amd64", "beta.kubernetes.io/os=linux", "kubernetes.io/arch=amd64", "kubernetes.io/hostname=poc1-worker", "kubernetes.io/os=linux"], "l7": {"type": "RESPONSE", "dns": {"query": "accounts.bank.svc.cluster.local.bank.svc.cluster.local.", "observation_source": "proxy", "qtypes": ["AAAA"]}}, "event_type": {"type": 129}, "traffic_direction": "EGRESS", "is_reply": true, "Summary": "DNS Query accounts.bank.svc.cluster.local.bank.svc.cluster.local. AAAA"}, "node_name": "poc1/poc1-worker", "time": "2026-09-13T04:30:10.495657798Z"} \ No newline at end of file diff --git a/internal/testdata/l7-http-request.json b/internal/testdata/l7-http-request.json new file mode 100644 index 0000000..73498e6 --- /dev/null +++ b/internal/testdata/l7-http-request.json @@ -0,0 +1 @@ +{"flow": {"time": "2026-09-13T04:30:10.350543170Z", "uuid": "dc36d8ab-50b7-4576-b9aa-5efef2c64ef2", "emitter": {"name": "Hubble", "version": "1.20.1+g7d68cfb3"}, "verdict": "FORWARDED", "IP": {"source": "192.168.64.1", "destination": "10.10.4.135", "ipVersion": "IPv4"}, "l4": {"TCP": {"source_port": 53015, "destination_port": 8080}}, "source": {"identity": 8, "labels": ["reserved:ingress"]}, "destination": {"identity": 103600, "cluster_name": "poc1", "namespace": "bank", "labels": ["k8s:app=api", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=bank", "k8s:io.cilium.k8s.policy.cluster=poc1", "k8s:io.cilium.k8s.policy.serviceaccount=default", "k8s:io.kubernetes.pod.namespace=bank"], "pod_name": "api-5d6d8cfd55-sj27m"}, "Type": "L7", "node_name": "poc1/poc1-control-plane2", "node_labels": ["beta.kubernetes.io/arch=amd64", "beta.kubernetes.io/os=linux", "kubernetes.io/arch=amd64", "kubernetes.io/hostname=poc1-control-plane2", "kubernetes.io/os=linux", "node-role.kubernetes.io/control-plane=", "node.kubernetes.io/exclude-from-external-load-balancers="], "l7": {"type": "REQUEST", "http": {"method": "GET", "url": "https://bankapi.poc.local/api/balance/chk-1001", "protocol": "HTTP/2", "headers": [{"key": ":scheme", "value": "https"}, {"key": "Accept", "value": "*/*"}, {"key": "User-Agent", "value": "curl/8.7.1"}, {"key": "X-Envoy-Internal", "value": "true"}, {"key": "X-Forwarded-For", "value": "192.168.64.1"}, {"key": "X-Request-Id", "value": "094bf0e5-358f-4368-a1ae-2de156a21120"}]}}, "event_type": {"type": 129}, "traffic_direction": "EGRESS", "is_reply": false, "Summary": "HTTP/2 GET https://bankapi.poc.local/api/balance/chk-1001"}, "node_name": "poc1/poc1-control-plane2", "time": "2026-09-13T04:30:10.350543170Z"} \ No newline at end of file From 03de96f3dbaca29ee9213f67c854080953f4fc33 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:31:52 -0500 Subject: [PATCH 08/37] =?UTF-8?q?E4:=20review=20before=20generate=20?= =?UTF-8?q?=E2=80=94=20=3Fexclude=3Dkey=3Dvalue=20(repeatable)=20drops=20f?= =?UTF-8?q?lows=20whose=20peer=20carries=20the=20label;=20the=20page=20lis?= =?UTF-8?q?ts=20every=20parsed=20peer=20as=20a=20checkbox=20and=20sends=20?= =?UTF-8?q?the=20unticked=20ones=20as=20excludes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/aggregator/aggregator.go | 85 +++++++++++++++---------------- internal/flow/types.go | 33 ++++++------ internal/server/server.go | 76 +++++++++++++++++++++++++-- internal/server/server_test.go | 23 +++++++++ 4 files changed, 154 insertions(+), 63 deletions(-) diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index c006ddd..b779aaf 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -10,20 +10,20 @@ import ( // AggregatedFlow represents flows grouped by source/destination type AggregatedFlow struct { - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIPs []string // Destination IPs for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Ports []PortInfo // Aggregated ports - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIPs []string // Destination IPs for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Ports []PortInfo // Aggregated ports + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } // PortInfo represents a port and protocol combination @@ -63,20 +63,20 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { destIPs = []string{f.DestIP} } aggregated := &AggregatedFlow{ - Direction: f.Direction, - SourceNamespace: f.SourceNamespace, - SourceLabels: copyLabels(f.SourceLabels), - SourceEntity: f.SourceEntity, - IsSourceEntity: f.IsSourceEntity, - DestNamespace: f.DestNamespace, - DestLabels: copyLabels(f.DestLabels), - DestFQDNs: append([]string{}, f.DestFQDNs...), - DestIPs: destIPs, - DestEntity: f.DestEntity, - Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, - IsWorldTraffic: f.IsWorldTraffic, + Direction: f.Direction, + SourceNamespace: f.SourceNamespace, + SourceLabels: copyLabels(f.SourceLabels), + SourceEntity: f.SourceEntity, + IsSourceEntity: f.IsSourceEntity, + DestNamespace: f.DestNamespace, + DestLabels: copyLabels(f.DestLabels), + DestFQDNs: append([]string{}, f.DestFQDNs...), + DestIPs: destIPs, + DestEntity: f.DestEntity, + Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, + IsWorldTraffic: f.IsWorldTraffic, IsDestEntityTraffic: f.IsDestEntityTraffic, - IsReply: f.IsReply, + IsReply: f.IsReply, } aggregationMap[key] = aggregated } @@ -112,20 +112,20 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) - + // Include source entity in key for entity-based traffic if f.IsSourceEntity { parts = append(parts, f.SourceEntity) } - + parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) - + // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { parts = append(parts, f.DestEntity) } - + if f.IsWorldTraffic { // For world traffic, include FQDNs in the key fqdns := append([]string{}, f.DestFQDNs...) @@ -187,18 +187,17 @@ func containsString(slice []string, s string) bool { // parsedFlowFromAggregated creates a minimal ParsedFlow for key generation func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { return &flow.ParsedFlow{ - Direction: agg.Direction, - SourceNamespace: agg.SourceNamespace, - SourceLabels: agg.SourceLabels, - SourceEntity: agg.SourceEntity, - IsSourceEntity: agg.IsSourceEntity, - DestNamespace: agg.DestNamespace, - DestLabels: agg.DestLabels, - DestFQDNs: agg.DestFQDNs, - DestEntity: agg.DestEntity, - IsWorldTraffic: agg.IsWorldTraffic, + Direction: agg.Direction, + SourceNamespace: agg.SourceNamespace, + SourceLabels: agg.SourceLabels, + SourceEntity: agg.SourceEntity, + IsSourceEntity: agg.IsSourceEntity, + DestNamespace: agg.DestNamespace, + DestLabels: agg.DestLabels, + DestFQDNs: agg.DestFQDNs, + DestEntity: agg.DestEntity, + IsWorldTraffic: agg.IsWorldTraffic, IsDestEntityTraffic: agg.IsDestEntityTraffic, - IsReply: agg.IsReply, + IsReply: agg.IsReply, } } - diff --git a/internal/flow/types.go b/internal/flow/types.go index 727d615..088a1d6 100644 --- a/internal/flow/types.go +++ b/internal/flow/types.go @@ -93,21 +93,20 @@ type EventType struct { // ParsedFlow represents a parsed and processed flow with extracted information type ParsedFlow struct { - UUID string // Flow UUID from Hubble - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIP string // Destination IP for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Protocol string // TCP or UDP - Port int // Destination port - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + UUID string // Flow UUID from Hubble + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIP string // Destination IP for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Protocol string // TCP or UDP + Port int // Destination port + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } - diff --git a/internal/server/server.go b/internal/server/server.go index 7ed6198..5d061cd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -164,6 +164,37 @@ func validHost(v string) string { return v } +// excludeFlows drops flows whose peer carries any of the excluded label=value pairs (E4). The peer is the +// side the rule would name: the source for an INGRESS flow, the destination for EGRESS. This is the +// operator's intent ("stranger may call nothing") applied at generation time instead of at collection time. +func excludeFlows(flows []*flow.ParsedFlow, excludes []string) []*flow.ParsedFlow { + if len(excludes) == 0 { + return flows + } + kept := make([]*flow.ParsedFlow, 0, len(flows)) + for _, f := range flows { + peer := f.SourceLabels + if f.Direction == "EGRESS" { + peer = f.DestLabels + } + if !peerMatches(peer, excludes) { + kept = append(kept, f) + } + } + return kept +} + +// peerMatches reports whether the labels carry any "key=value" of the excludes +func peerMatches(labels map[string]string, excludes []string) bool { + for _, ex := range excludes { + kv := strings.SplitN(ex, "=", 2) + if len(kv) == 2 && labels[kv[0]] == kv[1] { + return true + } + } + return false +} + // wantsJSON reports whether the client asked for the JSON answer (Grafana's action sets // X-Grafana-Action; any client may send Accept: application/json) func wantsJSON(r *http.Request) bool { @@ -332,6 +363,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { .controls { margin: 8px 0; } .controls label { display: block; margin-bottom: 4px; color: #cbd5e1; font-weight: 600; } .controls input { width: 100%; box-sizing: border-box; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 6px; padding: 10px; font-family: monospace; } + .peers label { display: inline-block; margin: 4px 12px 4px 0; color: #cbd5e1; } + .peers .muted { margin-left: 4px; } .buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; } .buttons button { margin: 0; } button.secondary { background: #1e293b; border: 1px solid #334155; box-shadow: none; } @@ -407,6 +440,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { hubble observe -o json). Flows to the same workload become one policy with one rule per peer.

+
@@ -459,6 +493,38 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - if (comp && comp !== name) name += '/' + comp; return name; } + // peerKey is the label the policy would name for a peer: the same priority cf2cnp uses (name, then app, k8s-app) + function peerKey(ep) { + const labels = (ep && ep.labels) || []; + for (const k of ['app.kubernetes.io/name', 'app', 'k8s-app']) { + const l = labels.find(x => x.startsWith('k8s:' + k + '=')); + if (l) return k + '=' + l.split('=').slice(1).join('='); + } + return ''; + } + // renderPeers lists every distinct peer (the source of an INGRESS flow, the destination of an EGRESS one) + // as a checkbox; an unchecked peer becomes an exclude= parameter — review the intent before generating + function renderPeers(flows) { + const counts = {}; + for (const d of flows) { + const f = d.flow || d; const ep = f.traffic_direction === 'EGRESS' ? f.destination : f.source; + const key = peerKey(ep); if (!key) continue; + counts[key] = (counts[key] || 0) + 1; + } + const box = document.getElementById('peers'); box.textContent = ''; + const keys = Object.keys(counts).sort(); + if (!keys.length) return; + const title = document.createElement('div'); title.className = 'summary'; title.textContent = 'Peers the policy would allow — untick to exclude:'; box.appendChild(title); + for (const k of keys) { + const label = document.createElement('label'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.dataset.peer = k; + label.appendChild(cb); label.appendChild(document.createTextNode(' ' + k.split('=')[1])); + const m = document.createElement('span'); m.className = 'muted'; m.textContent = '(' + counts[k] + ' flow' + (counts[k] === 1 ? '' : 's') + ')'; label.appendChild(m); + box.appendChild(label); + } + } + function excludedPeers() { + return Array.from(document.querySelectorAll('#peers input[type=checkbox]')).filter(cb => !cb.checked).map(cb => cb.dataset.peer); + } function summarize() { const box = document.getElementById('summary'); try { @@ -470,6 +536,7 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - return (f.traffic_direction || '?') + ' ' + (f.verdict || '') + ' ' + who(f.source) + ' → ' + (names || who(f.destination)) + ':' + (port || '?') + (f.is_reply ? ' (reply — will be refused)' : ''); }); box.textContent = flows.length + ' flow(s) parsed: ' + lines.join(' | ') + (flows.length > 8 ? ' | …' : ''); + renderPeers(flows); } catch (e) { box.textContent = 'Not valid JSON yet: ' + e.message; } } let lastYAML = '', lastFilename = 'ciliumnetworkpolicy.yaml'; @@ -477,7 +544,9 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - const input = document.getElementById('flowInput').value; const result = document.getElementById('result'); const apply = document.getElementById('apply'); const name = document.getElementById('policyName').value.trim(); - const url = '/generate' + (name ? '?name=' + encodeURIComponent(name) : ''); + const params = []; if (name) params.push('name=' + encodeURIComponent(name)); + for (const p of excludedPeers()) params.push('exclude=' + encodeURIComponent(p)); + const url = '/generate' + (params.length ? '?' + params.join('&') : ''); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: input }); if (!response.ok) { result.textContent = 'Error: ' + await response.text(); apply.textContent = ''; setButtons(false); return; } @@ -497,7 +566,7 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - const a = document.createElement('a'); a.href = url; a.download = lastFilename; a.click(); URL.revokeObjectURL(url); } function flash(id, text) { const b = document.getElementById(id); const old = b.textContent; b.textContent = text; setTimeout(() => b.textContent = old, 1200); } - function clearAll() { document.getElementById('flowInput').value = ''; document.getElementById('policyName').value = ''; document.getElementById('result').textContent = ''; document.getElementById('summary').textContent = ''; document.getElementById('apply').textContent = ''; setButtons(false); } + function clearAll() { document.getElementById('flowInput').value = ''; document.getElementById('policyName').value = ''; document.getElementById('result').textContent = ''; document.getElementById('summary').textContent = ''; document.getElementById('peers').textContent = ''; document.getElementById('apply').textContent = ''; setButtons(false); } function loadExample() { const ex = (src, uuid) => JSON.stringify({ flow: { uuid: uuid, verdict: 'AUDIT', IP: { source: '10.0.1.3', destination: '10.0.2.7', ipVersion: 'IPv4' }, l4: { TCP: { source_port: 45248, destination_port: 80, flags: { SYN: true } } }, @@ -589,9 +658,10 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { return } + parsedFlows = excludeFlows(parsedFlows, r.URL.Query()["exclude"]) // E4: ?exclude=key=value, repeatable aggregatedFlows := aggregator.AggregateFlows(parsedFlows) if len(aggregatedFlows) == 0 { - http.Error(w, "No valid flows found in request", http.StatusBadRequest) + http.Error(w, "No valid flows found in request (every flow excluded, or none parsed)", http.StatusBadRequest) return } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dedcb2c..3200d04 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -203,3 +203,26 @@ func TestYolo_JSONUsesBaseURL(t *testing.T) { t.Fatalf("%v", m) } } + +// E4: ?exclude=key=value drops the flows whose peer carries it; the peer is the source for INGRESS +func TestGenerate_ExcludePeers(t *testing.T) { + s := NewServer(8080, "") + body := fixture(t, "ingress-pos-to-shop.json") + "\n" + fixture(t, "ingress-stranger-to-shop.json") + rec := post(t, s, "/generate?exclude=app.kubernetes.io%2Fname%3Dstranger", body, nil) + if rec.Code != 200 || strings.Contains(rec.Body.String(), "stranger") || !strings.Contains(rec.Body.String(), "pos") { + t.Fatalf("stranger must be gone, pos kept: %d %s", rec.Code, rec.Body.String()) + } + rec = post(t, s, "/generate?exclude=app.kubernetes.io%2Fname%3Dnobody", body, nil) + if rec.Code != 200 || strings.Count(rec.Body.String(), "fromEndpoints") != 2 { + t.Fatalf("an exclude that matches nothing changes nothing: %d %s", rec.Code, rec.Body.String()) + } + rec = post(t, s, "/generate?exclude=app.kubernetes.io%2Fname%3Dpos&exclude=app.kubernetes.io%2Fname%3Dstranger", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("everything excluded must be a 400, got %d", rec.Code) + } + // EGRESS: the peer is the destination; excluding the source does nothing + rec = post(t, s, "/generate?exclude=app.kubernetes.io%2Fname%3Dpos", fixture(t, "egress-pos-to-world.json"), nil) + if rec.Code != 200 { + t.Fatalf("egress flow: the source is not the peer, got %d", rec.Code) + } +} From ad7fd2dcc4fe7d2a7e5c02f69658f4e5a8bd94d6 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:32:36 -0500 Subject: [PATCH 09/37] =?UTF-8?q?E5:=20cf2cnp=20merge=20=E2=80=94=20fold?= =?UTF-8?q?=20rules=20generated=20from=20flows=20into=20an=20existing=20Ci?= =?UTF-8?q?liumNetworkPolicy=20(unstructured:=20every=20field=20survives,?= =?UTF-8?q?=20only=20missing=20rules=20are=20added,=20idempotent,=20refuse?= =?UTF-8?q?s=20another=20target);=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++++ cmd/cf2cnp/main.go | 79 ++++++++++++++++++++++++++++ internal/aggregator/aggregator.go | 85 +++++++++++++++---------------- internal/flow/types.go | 33 ++++++------ internal/policy/merge.go | 60 ++++++++++++++++++++++ internal/policy/merge_test.go | 82 +++++++++++++++++++++++++++++ 6 files changed, 293 insertions(+), 60 deletions(-) create mode 100644 internal/policy/merge.go create mode 100644 internal/policy/merge_test.go diff --git a/README.md b/README.md index 06492ad..0e37028 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,20 @@ cf2cnp generate --input --output cf2cnp generate --input ./inputfolder --output ./generated-policies ``` +### 1b. Merge into an existing policy (CLI) + +Evolve a policy that is already applied instead of regenerating it — every field of the existing document +survives (`ingressDeny`, annotations, …), only rules that are not there yet are added, and running it +twice changes nothing: + +```bash +cf2cnp merge --existing policies/shop.yaml --input flows.json # in place +cf2cnp merge --existing policies/shop.yaml --input flows/ -o new.yaml # to another file +``` + +The flows must produce exactly one policy, for the same target (namespace, name, endpointSelector) as the +existing file; otherwise the command refuses. + ### 2. HTTP Server Mode Start an HTTP server to generate policies via API: diff --git a/cmd/cf2cnp/main.go b/cmd/cf2cnp/main.go index b2ae835..0975ad5 100644 --- a/cmd/cf2cnp/main.go +++ b/cmd/cf2cnp/main.go @@ -3,12 +3,14 @@ package main import ( "fmt" "os" + "path/filepath" "github.com/hubble-policy-gen/internal/aggregator" "github.com/hubble-policy-gen/internal/flow" "github.com/hubble-policy-gen/internal/policy" "github.com/hubble-policy-gen/internal/server" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var ( @@ -16,6 +18,8 @@ var ( outputDir string port int externalURL string + existingFile string + outputFile string yoloNamespace string ) @@ -93,7 +97,24 @@ Example: yoloCmd.AddCommand(yoloNsCmd) + // Merge command (E5): evolve an existing policy instead of regenerating it + mergeCmd := &cobra.Command{ + Use: "merge", + Short: "Merge rules generated from flows into an existing CiliumNetworkPolicy file", + Long: `Read flows (a file or a directory), generate the policy for their workload, and add its rules to an +existing CiliumNetworkPolicy YAML — keeping every field of the existing document, adding only rules that +are not already there. Running it twice changes nothing. The existing policy must be the same target +(namespace, name, endpointSelector); otherwise the command refuses.`, + RunE: runMerge, + } + mergeCmd.Flags().StringVar(&existingFile, "existing", "", "Existing CiliumNetworkPolicy YAML (required)") + mergeCmd.Flags().StringVarP(&inputDir, "input", "i", "", "Flow file (one flow, an array, or one per line) or a directory of such files (required)") + mergeCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Where to write the merged policy (default: --existing, in place)") + mergeCmd.MarkFlagRequired("existing") + mergeCmd.MarkFlagRequired("input") + rootCmd.AddCommand(generateCmd) + rootCmd.AddCommand(mergeCmd) rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(yoloCmd) @@ -155,3 +176,61 @@ func runServe(cmd *cobra.Command, args []string) error { srv := server.NewServer(port, externalURL) return srv.Start() } + +// readFlows reads a flows file or every .json file of a directory +func readFlows(path string) ([]*flow.ParsedFlow, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if info.IsDir() { + return flow.ParseFlowsFromDirectory(path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return flow.ParseFlowsFromBytes(data) +} + +func runMerge(cmd *cobra.Command, args []string) error { + existingBytes, err := os.ReadFile(existingFile) + if err != nil { + return err + } + var existing map[string]interface{} + if err := yaml.Unmarshal(existingBytes, &existing); err != nil { + return fmt.Errorf("existing policy: %w", err) + } + flows, err := readFlows(inputDir) + if err != nil { + return err + } + policies, err := policy.NewGenerator("").BuildPolicies(aggregator.AggregateFlows(flows)) + if err != nil { + return err + } + if len(policies) != 1 { + return fmt.Errorf("the flows produce %d policies; merge takes exactly one target — filter the flows to one workload", len(policies)) + } + added, err := policy.MergeInto(existing, policies[0]) + if err != nil { + return err + } + out, err := yaml.Marshal(existing) + if err != nil { + return err + } + target := outputFile + if target == "" { + target = existingFile + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.WriteFile(target, out, 0o644); err != nil { + return err + } + fmt.Printf("%d rule(s) added → %s\n", added, target) + return nil +} diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index c006ddd..b779aaf 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -10,20 +10,20 @@ import ( // AggregatedFlow represents flows grouped by source/destination type AggregatedFlow struct { - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIPs []string // Destination IPs for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Ports []PortInfo // Aggregated ports - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIPs []string // Destination IPs for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Ports []PortInfo // Aggregated ports + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } // PortInfo represents a port and protocol combination @@ -63,20 +63,20 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { destIPs = []string{f.DestIP} } aggregated := &AggregatedFlow{ - Direction: f.Direction, - SourceNamespace: f.SourceNamespace, - SourceLabels: copyLabels(f.SourceLabels), - SourceEntity: f.SourceEntity, - IsSourceEntity: f.IsSourceEntity, - DestNamespace: f.DestNamespace, - DestLabels: copyLabels(f.DestLabels), - DestFQDNs: append([]string{}, f.DestFQDNs...), - DestIPs: destIPs, - DestEntity: f.DestEntity, - Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, - IsWorldTraffic: f.IsWorldTraffic, + Direction: f.Direction, + SourceNamespace: f.SourceNamespace, + SourceLabels: copyLabels(f.SourceLabels), + SourceEntity: f.SourceEntity, + IsSourceEntity: f.IsSourceEntity, + DestNamespace: f.DestNamespace, + DestLabels: copyLabels(f.DestLabels), + DestFQDNs: append([]string{}, f.DestFQDNs...), + DestIPs: destIPs, + DestEntity: f.DestEntity, + Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, + IsWorldTraffic: f.IsWorldTraffic, IsDestEntityTraffic: f.IsDestEntityTraffic, - IsReply: f.IsReply, + IsReply: f.IsReply, } aggregationMap[key] = aggregated } @@ -112,20 +112,20 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) - + // Include source entity in key for entity-based traffic if f.IsSourceEntity { parts = append(parts, f.SourceEntity) } - + parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) - + // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { parts = append(parts, f.DestEntity) } - + if f.IsWorldTraffic { // For world traffic, include FQDNs in the key fqdns := append([]string{}, f.DestFQDNs...) @@ -187,18 +187,17 @@ func containsString(slice []string, s string) bool { // parsedFlowFromAggregated creates a minimal ParsedFlow for key generation func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { return &flow.ParsedFlow{ - Direction: agg.Direction, - SourceNamespace: agg.SourceNamespace, - SourceLabels: agg.SourceLabels, - SourceEntity: agg.SourceEntity, - IsSourceEntity: agg.IsSourceEntity, - DestNamespace: agg.DestNamespace, - DestLabels: agg.DestLabels, - DestFQDNs: agg.DestFQDNs, - DestEntity: agg.DestEntity, - IsWorldTraffic: agg.IsWorldTraffic, + Direction: agg.Direction, + SourceNamespace: agg.SourceNamespace, + SourceLabels: agg.SourceLabels, + SourceEntity: agg.SourceEntity, + IsSourceEntity: agg.IsSourceEntity, + DestNamespace: agg.DestNamespace, + DestLabels: agg.DestLabels, + DestFQDNs: agg.DestFQDNs, + DestEntity: agg.DestEntity, + IsWorldTraffic: agg.IsWorldTraffic, IsDestEntityTraffic: agg.IsDestEntityTraffic, - IsReply: agg.IsReply, + IsReply: agg.IsReply, } } - diff --git a/internal/flow/types.go b/internal/flow/types.go index 727d615..088a1d6 100644 --- a/internal/flow/types.go +++ b/internal/flow/types.go @@ -93,21 +93,20 @@ type EventType struct { // ParsedFlow represents a parsed and processed flow with extracted information type ParsedFlow struct { - UUID string // Flow UUID from Hubble - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIP string // Destination IP for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Protocol string // TCP or UDP - Port int // Destination port - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + UUID string // Flow UUID from Hubble + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIP string // Destination IP for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Protocol string // TCP or UDP + Port int // Destination port + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } - diff --git a/internal/policy/merge.go b/internal/policy/merge.go new file mode 100644 index 0000000..f73b8e9 --- /dev/null +++ b/internal/policy/merge.go @@ -0,0 +1,60 @@ +package policy + +import ( + "errors" + "fmt" + + "gopkg.in/yaml.v3" +) + +// MergeInto adds the generated policy's rules to an existing CiliumNetworkPolicy document without +// touching anything else in it (E5). The document is handled as a generic map, so fields this tool +// does not model (ingressDeny, enableDefaultDeny, annotations, …) survive untouched. It refuses to +// merge into a different target: same namespace, same name, same endpointSelector, or nothing happens. +// Running it twice adds nothing the second time. +func MergeInto(existing map[string]interface{}, generated *CiliumNetworkPolicy) (added int, err error) { + meta, _ := existing["metadata"].(map[string]interface{}) + spec, _ := existing["spec"].(map[string]interface{}) + if meta == nil || spec == nil { + return 0, errors.New("existing document has no metadata/spec") + } + if fmt.Sprint(meta["name"]) != generated.Metadata.Name || fmt.Sprint(meta["namespace"]) != generated.Metadata.Namespace { + return 0, fmt.Errorf("target mismatch: existing %v/%v, generated %s/%s", + meta["namespace"], meta["name"], generated.Metadata.Namespace, generated.Metadata.Name) + } + if mustYAML(spec["endpointSelector"]) != mustYAML(toGeneric(generated.Spec.EndpointSelector)) { + return 0, errors.New("target mismatch: the endpointSelector differs") + } + for _, r := range generated.Spec.Ingress { + if appendUnique(spec, "ingress", toGeneric(r)) { + added++ + } + } + for _, r := range generated.Spec.Egress { + if appendUnique(spec, "egress", toGeneric(r)) { + added++ + } + } + return added, nil +} + +// toGeneric round-trips a typed value through YAML so it compares and stores like the existing document +func toGeneric(v interface{}) interface{} { + var out interface{} + b, _ := yaml.Marshal(v) + _ = yaml.Unmarshal(b, &out) + return out +} + +// appendUnique adds item to spec[key] (a list) unless an equal item is already there +func appendUnique(spec map[string]interface{}, key string, item interface{}) bool { + list, _ := spec[key].([]interface{}) + want := mustYAML(item) + for _, have := range list { + if mustYAML(have) == want { + return false + } + } + spec[key] = append(list, item) + return true +} diff --git a/internal/policy/merge_test.go b/internal/policy/merge_test.go new file mode 100644 index 0000000..2fb2536 --- /dev/null +++ b/internal/policy/merge_test.go @@ -0,0 +1,82 @@ +package policy + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const existingShop = `apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: shop + namespace: cf2cnp-lab + annotations: + owner: platform-team +spec: + description: hand-written, with a deny rule cf2cnp does not model + endpointSelector: + matchLabels: + app.kubernetes.io/name: shop + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: pos + toPorts: + - ports: + - port: "80" + protocol: TCP + ingressDeny: + - fromEntities: [world] +` + +func existingDoc(t *testing.T) map[string]interface{} { + t.Helper() + var doc map[string]interface{} + if err := yaml.Unmarshal([]byte(existingShop), &doc); err != nil { + t.Fatal(err) + } + return doc +} + +// E5: the stranger rule is added, pos (already there) is not duplicated, ingressDeny and the annotation survive +func TestMergeInto_AddsOnlyWhatIsMissing(t *testing.T) { + doc := existingDoc(t) + ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "ingress-stranger-to-shop.json")) + added, err := MergeInto(doc, ps[0]) + if err != nil || added != 1 { + t.Fatalf("added=%d err=%v", added, err) + } + out, _ := yaml.Marshal(doc) + s := string(out) + for _, want := range []string{"ingressDeny", "owner: platform-team", "app.kubernetes.io/name: stranger", "hand-written"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in:\n%s", want, s) + } + } + if strings.Count(s, "app.kubernetes.io/name: pos") != 1 { + t.Fatalf("pos must appear once:\n%s", s) + } + // idempotent: a second merge adds nothing and changes nothing + again, err := MergeInto(doc, ps[0]) + out2, _ := yaml.Marshal(doc) + if err != nil || again != 0 || string(out2) != s { + t.Fatalf("second merge: added=%d err=%v same=%v", again, err, string(out2) == s) + } +} + +// E5: a different target is refused +func TestMergeInto_RefusesAnotherTarget(t *testing.T) { + doc := existingDoc(t) + ps, _ := NewGenerator("").BuildPolicies(load(t, "egress-pos-to-world.json")) // policy "pos", not "shop" + if _, err := MergeInto(doc, ps[0]); err == nil || !strings.Contains(err.Error(), "target mismatch") { + t.Fatalf("expected a target mismatch, got %v", err) + } + doc2 := existingDoc(t) + doc2["spec"].(map[string]interface{})["endpointSelector"] = map[string]interface{}{"matchLabels": map[string]interface{}{"app.kubernetes.io/name": "shop", "app.kubernetes.io/component": "frontend"}} + ps2, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json")) + if _, err := MergeInto(doc2, ps2[0]); err == nil || !strings.Contains(err.Error(), "endpointSelector") { + t.Fatalf("expected an endpointSelector mismatch, got %v", err) + } +} From be59889fd0c547dad029ee350ca24cde2dba0147 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:33:39 -0500 Subject: [PATCH 10/37] =?UTF-8?q?E6:=20hardening=20of=20a=20shared=20/gene?= =?UTF-8?q?rate=20=E2=80=94=20--allowed-origins=20(CORS=20allow-list,=20an?= =?UTF-8?q?y=20by=20default),=20--auth-token=20(bearer=20on=20/generate=20?= =?UTF-8?q?and=20/download,=20constant-time,=20preflight=20and=20/health?= =?UTF-8?q?=20open),=20the=20page=20keeps=20a=20token=20in=20sessionStorag?= =?UTF-8?q?e;=20chart:=20cors.allowedOrigins,=20auth.token/existingSecret,?= =?UTF-8?q?=20networkPolicy=20(a=20CiliumNetworkPolicy=20for=20the=20pod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/cf2cnp/main.go | 25 ++++-- helm/cf2cnp/README.md | 3 + .../cf2cnp/templates/ciliumnetworkpolicy.yaml | 36 ++++++++ helm/cf2cnp/templates/deployment.yaml | 21 ++++- helm/cf2cnp/templates/secret.yaml | 11 +++ helm/cf2cnp/values.yaml | 20 +++++ internal/aggregator/aggregator.go | 85 +++++++++---------- internal/flow/types.go | 33 ++++--- internal/server/server.go | 85 +++++++++++++++---- internal/server/server_test.go | 58 +++++++++++++ 10 files changed, 295 insertions(+), 82 deletions(-) create mode 100644 helm/cf2cnp/templates/ciliumnetworkpolicy.yaml create mode 100644 helm/cf2cnp/templates/secret.yaml diff --git a/cmd/cf2cnp/main.go b/cmd/cf2cnp/main.go index b2ae835..f8d9cd4 100644 --- a/cmd/cf2cnp/main.go +++ b/cmd/cf2cnp/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strings" "github.com/hubble-policy-gen/internal/aggregator" "github.com/hubble-policy-gen/internal/flow" @@ -12,11 +13,13 @@ import ( ) var ( - inputDir string - outputDir string - port int - externalURL string - yoloNamespace string + inputDir string + outputDir string + port int + externalURL string + allowedOrigins string + authToken string + yoloNamespace string ) func main() { @@ -70,6 +73,10 @@ Endpoints: "Base URL clients reach the server at (e.g. https://cf2cnp.example.com); used for download_url. "+ "Default: derived from the request and its Forwarded / X-Forwarded-Proto / X-Forwarded-Host headers. "+ "Env: CF2CNP_EXTERNAL_URL") + serveCmd.Flags().StringVar(&allowedOrigins, "allowed-origins", os.Getenv("CF2CNP_ALLOWED_ORIGINS"), + "Comma-separated origins allowed by CORS (e.g. https://grafana.example.com). Empty or * = any origin. Env: CF2CNP_ALLOWED_ORIGINS") + serveCmd.Flags().StringVar(&authToken, "auth-token", os.Getenv("CF2CNP_AUTH_TOKEN"), + "When set, /generate and /download require Authorization: Bearer . Env: CF2CNP_AUTH_TOKEN (prefer the env)") // YOLO command (easter egg) yoloCmd := &cobra.Command{ @@ -152,6 +159,12 @@ func runGenerate(cmd *cobra.Command, args []string) error { } func runServe(cmd *cobra.Command, args []string) error { - srv := server.NewServer(port, externalURL) + var origins []string + for _, o := range strings.Split(allowedOrigins, ",") { + if o = strings.TrimSpace(o); o != "" { + origins = append(origins, o) + } + } + srv := server.NewServerWithOptions(port, externalURL, server.Options{AllowedOrigins: origins, AuthToken: authToken}) return srv.Start() } diff --git a/helm/cf2cnp/README.md b/helm/cf2cnp/README.md index 9b9918c..bd50a93 100644 --- a/helm/cf2cnp/README.md +++ b/helm/cf2cnp/README.md @@ -33,6 +33,9 @@ The following table lists the configurable parameters of the CF2CNP chart and th | `service.port` | Service port | `80` | | `containerPort` | Container port | `8080` | | `externalURL` | Base URL clients reach cf2cnp at, for `download_url` (empty = derived from the request and its `Forwarded` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers) | `""` | +| `cors.allowedOrigins` | Origins allowed by CORS (Grafana's, for the dashboard action); empty = any | `[]` | +| `auth.token`, `auth.existingSecret` | A bearer token required on `/generate` and `/download` (a Secret with key `token` preferred) | `""`, `""` | +| `networkPolicy.enabled`, `networkPolicy.fromEndpoints` | A CiliumNetworkPolicy for the pod: ingress from the Gateway and the listed endpoints, egress to kube-dns | `false`, Grafana's labels | | `extraArgs` | Extra arguments appended to `cf2cnp serve --port ` | `[]` | | `extraEnv` | Extra environment variables (`CF2CNP_EXTERNAL_URL` is an alternative to `externalURL`) | `[]` | | `ingress.enabled` | Enable ingress | `false` | diff --git a/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml b/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml new file mode 100644 index 0000000..ec26f37 --- /dev/null +++ b/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml @@ -0,0 +1,36 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "cf2cnp.fullname" . }} + labels: + {{- include "cf2cnp.labels" . | nindent 4 }} +spec: + description: cf2cnp — reachable from the Gateway and the listed endpoints only; egress to kube-dns only + endpointSelector: + matchLabels: + {{- include "cf2cnp.selectorLabels" . | nindent 6 }} + ingress: + - fromEntities: [ingress] + toPorts: + - ports: + - port: {{ .Values.containerPort | quote }} + protocol: TCP + {{- with .Values.networkPolicy.fromEndpoints }} + - fromEndpoints: + {{- toYaml . | nindent 8 }} + toPorts: + - ports: + - port: {{ $.Values.containerPort | quote }} + protocol: TCP + {{- end }} + egress: + - toEndpoints: + - matchLabels: + io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP +{{- end }} diff --git a/helm/cf2cnp/templates/deployment.yaml b/helm/cf2cnp/templates/deployment.yaml index e25d72c..83bc7b0 100644 --- a/helm/cf2cnp/templates/deployment.yaml +++ b/helm/cf2cnp/templates/deployment.yaml @@ -39,12 +39,31 @@ spec: - --external-url - {{ .Values.externalURL | quote }} {{- end }} + {{- with .Values.cors.allowedOrigins }} + - --allowed-origins + - {{ join "," . | quote }} + {{- end }} {{- with .Values.extraArgs }} {{- toYaml . | nindent 12 }} {{- end }} - {{- with .Values.extraEnv }} + {{- if or .Values.extraEnv .Values.auth.token .Values.auth.existingSecret }} env: + {{- if .Values.auth.existingSecret }} + - name: CF2CNP_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.auth.existingSecret }} + key: token + {{- else if .Values.auth.token }} + - name: CF2CNP_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "cf2cnp.fullname" . }}-auth + key: token + {{- end }} + {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} ports: - name: http diff --git a/helm/cf2cnp/templates/secret.yaml b/helm/cf2cnp/templates/secret.yaml new file mode 100644 index 0000000..133f8b0 --- /dev/null +++ b/helm/cf2cnp/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if and .Values.auth.token (not .Values.auth.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cf2cnp.fullname" . }}-auth + labels: + {{- include "cf2cnp.labels" . | nindent 4 }} +type: Opaque +stringData: + token: {{ .Values.auth.token | quote }} +{{- end }} diff --git a/helm/cf2cnp/values.yaml b/helm/cf2cnp/values.yaml index 28d901c..1c875fb 100644 --- a/helm/cf2cnp/values.yaml +++ b/helm/cf2cnp/values.yaml @@ -49,6 +49,26 @@ containerPort: 8080 # the proxy strips those headers or the public URL differs from the Host the pod sees. externalURL: "" +# CORS (E6): origins allowed to call /generate from a browser (Grafana's origin for the dashboard action). +# Empty = any origin, the historical default. +cors: + allowedOrigins: [] + # - https://grafana.example.com + +# Authentication (E6): a bearer token required on /generate and /download (never on /health or the page). +# Prefer existingSecret (a Secret with the key `token`); `token` is for labs. +auth: + token: "" + existingSecret: "" + +# A CiliumNetworkPolicy for the cf2cnp pod (E6): ingress from the Gateway (reserved:ingress) and from the +# endpoints listed, on the container port; egress to kube-dns only. +networkPolicy: + enabled: false + fromEndpoints: + - matchLabels: + app.kubernetes.io/name: grafana + # Extra arguments appended to `cf2cnp serve --port ` (e.g. a flag this chart has no value for) extraArgs: [] diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index c006ddd..b779aaf 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -10,20 +10,20 @@ import ( // AggregatedFlow represents flows grouped by source/destination type AggregatedFlow struct { - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIPs []string // Destination IPs for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Ports []PortInfo // Aggregated ports - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIPs []string // Destination IPs for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Ports []PortInfo // Aggregated ports + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } // PortInfo represents a port and protocol combination @@ -63,20 +63,20 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { destIPs = []string{f.DestIP} } aggregated := &AggregatedFlow{ - Direction: f.Direction, - SourceNamespace: f.SourceNamespace, - SourceLabels: copyLabels(f.SourceLabels), - SourceEntity: f.SourceEntity, - IsSourceEntity: f.IsSourceEntity, - DestNamespace: f.DestNamespace, - DestLabels: copyLabels(f.DestLabels), - DestFQDNs: append([]string{}, f.DestFQDNs...), - DestIPs: destIPs, - DestEntity: f.DestEntity, - Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, - IsWorldTraffic: f.IsWorldTraffic, + Direction: f.Direction, + SourceNamespace: f.SourceNamespace, + SourceLabels: copyLabels(f.SourceLabels), + SourceEntity: f.SourceEntity, + IsSourceEntity: f.IsSourceEntity, + DestNamespace: f.DestNamespace, + DestLabels: copyLabels(f.DestLabels), + DestFQDNs: append([]string{}, f.DestFQDNs...), + DestIPs: destIPs, + DestEntity: f.DestEntity, + Ports: []PortInfo{{Port: f.Port, Protocol: f.Protocol}}, + IsWorldTraffic: f.IsWorldTraffic, IsDestEntityTraffic: f.IsDestEntityTraffic, - IsReply: f.IsReply, + IsReply: f.IsReply, } aggregationMap[key] = aggregated } @@ -112,20 +112,20 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) - + // Include source entity in key for entity-based traffic if f.IsSourceEntity { parts = append(parts, f.SourceEntity) } - + parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) - + // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { parts = append(parts, f.DestEntity) } - + if f.IsWorldTraffic { // For world traffic, include FQDNs in the key fqdns := append([]string{}, f.DestFQDNs...) @@ -187,18 +187,17 @@ func containsString(slice []string, s string) bool { // parsedFlowFromAggregated creates a minimal ParsedFlow for key generation func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { return &flow.ParsedFlow{ - Direction: agg.Direction, - SourceNamespace: agg.SourceNamespace, - SourceLabels: agg.SourceLabels, - SourceEntity: agg.SourceEntity, - IsSourceEntity: agg.IsSourceEntity, - DestNamespace: agg.DestNamespace, - DestLabels: agg.DestLabels, - DestFQDNs: agg.DestFQDNs, - DestEntity: agg.DestEntity, - IsWorldTraffic: agg.IsWorldTraffic, + Direction: agg.Direction, + SourceNamespace: agg.SourceNamespace, + SourceLabels: agg.SourceLabels, + SourceEntity: agg.SourceEntity, + IsSourceEntity: agg.IsSourceEntity, + DestNamespace: agg.DestNamespace, + DestLabels: agg.DestLabels, + DestFQDNs: agg.DestFQDNs, + DestEntity: agg.DestEntity, + IsWorldTraffic: agg.IsWorldTraffic, IsDestEntityTraffic: agg.IsDestEntityTraffic, - IsReply: agg.IsReply, + IsReply: agg.IsReply, } } - diff --git a/internal/flow/types.go b/internal/flow/types.go index 727d615..088a1d6 100644 --- a/internal/flow/types.go +++ b/internal/flow/types.go @@ -93,21 +93,20 @@ type EventType struct { // ParsedFlow represents a parsed and processed flow with extracted information type ParsedFlow struct { - UUID string // Flow UUID from Hubble - Direction string // INGRESS or EGRESS - SourceNamespace string // Source pod namespace - SourceLabels map[string]string // Filtered source labels - SourceEntity string // Reserved entity for source (remote-node, host, etc.) - IsSourceEntity bool // True if source is a reserved entity - DestNamespace string // Destination pod namespace - DestLabels map[string]string // Filtered destination labels - DestFQDNs []string // Destination FQDNs for world traffic - DestIP string // Destination IP for CIDR-based rules - DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) - Protocol string // TCP or UDP - Port int // Destination port - IsWorldTraffic bool // True if destination is "world" - IsDestEntityTraffic bool // True if destination is a reserved entity - IsReply bool // True if this is a reply packet + UUID string // Flow UUID from Hubble + Direction string // INGRESS or EGRESS + SourceNamespace string // Source pod namespace + SourceLabels map[string]string // Filtered source labels + SourceEntity string // Reserved entity for source (remote-node, host, etc.) + IsSourceEntity bool // True if source is a reserved entity + DestNamespace string // Destination pod namespace + DestLabels map[string]string // Filtered destination labels + DestFQDNs []string // Destination FQDNs for world traffic + DestIP string // Destination IP for CIDR-based rules + DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) + Protocol string // TCP or UDP + Port int // Destination port + IsWorldTraffic bool // True if destination is "world" + IsDestEntityTraffic bool // True if destination is a reserved entity + IsReply bool // True if this is a reply packet } - diff --git a/internal/server/server.go b/internal/server/server.go index 7ed6198..3f4f570 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "crypto/rand" + "crypto/subtle" "encoding/hex" "encoding/json" "fmt" @@ -31,20 +32,35 @@ type CachedPolicy struct { // Server represents the HTTP server for policy generation type Server struct { - port int - externalURL string - cache map[string]*CachedPolicy - mu sync.RWMutex + port int + externalURL string + allowedOrigins []string // E6: CORS allow-list; empty or ["*"] = any origin (the historical default) + authToken string // E6: when set, /generate and /download need Authorization: Bearer + cache map[string]*CachedPolicy + mu sync.RWMutex +} + +// Options are the optional settings of a server (E6): CORS origins and a bearer token. +type Options struct { + AllowedOrigins []string + AuthToken string } // NewServer creates a new HTTP server. externalURL, when set, is the base URL clients reach the // server at (scheme://host[:port][/prefix]); it wins over anything the request says when building // download_url. Leave it empty to derive the base from the request (see baseURL). func NewServer(port int, externalURL string) *Server { + return NewServerWithOptions(port, externalURL, Options{}) +} + +// NewServerWithOptions is NewServer with the E6 options +func NewServerWithOptions(port int, externalURL string, opts Options) *Server { s := &Server{ - port: port, - externalURL: strings.TrimRight(externalURL, "/"), - cache: make(map[string]*CachedPolicy), + port: port, + externalURL: strings.TrimRight(externalURL, "/"), + allowedOrigins: opts.AllowedOrigins, + authToken: opts.AuthToken, + cache: make(map[string]*CachedPolicy), } // Start cache cleanup goroutine go s.cleanupCache() @@ -76,8 +92,8 @@ func generateID() string { // Start starts the HTTP server func (s *Server) Start() error { - http.HandleFunc("/generate", s.corsMiddleware(s.handleGenerate)) - http.HandleFunc("/download/", s.corsMiddleware(s.handleDownload)) + http.HandleFunc("/generate", s.corsMiddleware(s.requireToken(s.handleGenerate))) + http.HandleFunc("/download/", s.corsMiddleware(s.requireToken(s.handleDownload))) http.HandleFunc("/health", s.corsMiddleware(s.handleHealth)) http.HandleFunc("/", s.handleIndex) @@ -198,22 +214,55 @@ func (s *Server) respond(w http.ResponseWriter, r *http.Request, id string, yaml log.Printf("Generated policy (cached): %s, download: %s", filename, downloadURL) } -// corsMiddleware adds CORS headers and handles preflight requests +// corsMiddleware answers preflight and echoes the request's Origin when it is allowed — any origin when +// the list is empty or "*" (the historical default), else only a listed one (E6). Grafana's action is a +// cross-origin fetch from the Grafana origin, so that origin is the one to list. func (s *Server) corsMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Set CORS headers - w.Header().Set("Access-Control-Allow-Origin", "*") + if origin := s.allowedOrigin(r.Header.Get("Origin")); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + if origin != "*" { + w.Header().Add("Vary", "Origin") + } + } w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, X-Grafana-Action, X-Grafana-Device-Id, X-Grafana-Org-Id") w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") w.Header().Set("Access-Control-Max-Age", "86400") - - // Handle preflight OPTIONS request if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } + next(w, r) + } +} +// allowedOrigin returns the value for Access-Control-Allow-Origin: "*" when any origin is allowed, the +// request's origin when it is listed, "" when it is not +func (s *Server) allowedOrigin(origin string) string { + if len(s.allowedOrigins) == 0 || (len(s.allowedOrigins) == 1 && s.allowedOrigins[0] == "*") { + return "*" + } + for _, o := range s.allowedOrigins { + if strings.EqualFold(strings.TrimRight(o, "/"), strings.TrimRight(origin, "/")) && origin != "" { + return origin + } + } + return "" +} + +// requireToken checks Authorization: Bearer when a token is configured (E6). Constant-time compare; +// preflight (OPTIONS) is never challenged, or the browser cannot even ask. +func (s *Server) requireToken(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.authToken != "" && r.Method != http.MethodOptions { + got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if subtle.ConstantTimeCompare([]byte(got), []byte(s.authToken)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="cf2cnp"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } next(w, r) } } @@ -410,6 +459,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ +
@@ -473,13 +524,17 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - } catch (e) { box.textContent = 'Not valid JSON yet: ' + e.message; } } let lastYAML = '', lastFilename = 'ciliumnetworkpolicy.yaml'; + try { const t = sessionStorage.getItem('cf2cnp-token'); if (t) document.getElementById('token').value = t; } catch (e) {} async function generatePolicy() { const input = document.getElementById('flowInput').value; const result = document.getElementById('result'); const apply = document.getElementById('apply'); const name = document.getElementById('policyName').value.trim(); const url = '/generate' + (name ? '?name=' + encodeURIComponent(name) : ''); try { - const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: input }); + const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; + const token = document.getElementById('token').value.trim(); if (token) headers['Authorization'] = 'Bearer ' + token; + const response = await fetch(url, { method: 'POST', headers: headers, body: input }); + if (response.status === 401) { result.textContent = 'Error: this server requires an access token (401)'; apply.textContent = ''; setButtons(false); return; } if (!response.ok) { result.textContent = 'Error: ' + await response.text(); apply.textContent = ''; setButtons(false); return; } const data = await response.json(); lastYAML = data.yaml; lastFilename = data.filename; result.textContent = data.yaml; diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dedcb2c..dfbdeca 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -203,3 +203,61 @@ func TestYolo_JSONUsesBaseURL(t *testing.T) { t.Fatalf("%v", m) } } + +// E6: CORS — any origin by default; a listed origin is echoed with Vary; an unlisted one gets no allow header +func TestCORS_AllowedOrigins(t *testing.T) { + flow := fixture(t, "ingress-pos-to-shop.json") + open := NewServer(8080, "") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/generate", strings.NewReader(flow)) + req.Header.Set("Origin", "https://anything.example") + open.corsMiddleware(open.handleGenerate)(rec, req) + if rec.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Fatalf("default must be *: %v", rec.Header()) + } + strict := NewServerWithOptions(8080, "", Options{AllowedOrigins: []string{"https://grafana.poc.local"}}) + for origin, want := range map[string]string{"https://grafana.poc.local": "https://grafana.poc.local", "https://evil.example": ""} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/generate", nil) + req.Header.Set("Origin", origin) + strict.corsMiddleware(strict.handleGenerate)(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != want { + t.Fatalf("origin %s: got %q want %q", origin, got, want) + } + if want != "" && rec.Header().Get("Vary") != "Origin" { + t.Fatalf("a listed origin must set Vary: Origin") + } + } +} + +// E6: the bearer token guards /generate and /download, not /health; preflight passes; wrong token is 401 +func TestAuthToken(t *testing.T) { + s := NewServerWithOptions(8080, "", Options{AuthToken: "s3cret"}) + flow := fixture(t, "ingress-pos-to-shop.json") + call := func(method, path, token string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(flow)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + s.corsMiddleware(s.requireToken(s.handleGenerate))(rec, req) + return rec + } + if rec := call(http.MethodPost, "/generate", ""); rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" { + t.Fatalf("no token: %d %v", rec.Code, rec.Header()) + } + if rec := call(http.MethodPost, "/generate", "wrong"); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong token: %d", rec.Code) + } + if rec := call(http.MethodPost, "/generate", "s3cret"); rec.Code != http.StatusOK { + t.Fatalf("right token: %d %s", rec.Code, rec.Body.String()) + } + if rec := call(http.MethodOptions, "/generate", ""); rec.Code != http.StatusOK { + t.Fatalf("preflight must not be challenged: %d", rec.Code) + } + rec := httptest.NewRecorder() + s.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/health is open: %d", rec.Code) + } +} From f84c7ceef7b3d9085709ce1a5fb793c695800a3a Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:34:39 -0500 Subject: [PATCH 11/37] =?UTF-8?q?E10:=20binary=20release=20workflow=20?= =?UTF-8?q?=E2=80=94=20on=20a=20v*=20tag,=20linux/darwin=20amd64/arm64=20a?= =?UTF-8?q?rchives=20and=20checksums=20attached=20to=20the=20tag's=20relea?= =?UTF-8?q?se,=20for=20pipelines=20that=20run=20cf2cnp=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/binary-release.yml | 39 ++++++++++++++++++++++++++++ README.md | 10 +++++++ 2 files changed, 49 insertions(+) create mode 100644 .github/workflows/binary-release.yml diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml new file mode 100644 index 0000000..e72f068 --- /dev/null +++ b/.github/workflows/binary-release.yml @@ -0,0 +1,39 @@ +name: Release binaries + +# On a v* tag, build the CLI for linux (amd64, arm64) and darwin (amd64, arm64) and attach the archives to +# the GitHub release of that tag — so a pipeline (E10: policy as code) can `curl` the binary instead of building +# it. No goreleaser: four `go build`s and `gh release upload`. +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + binaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build + run: | + set -eu + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p dist + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + os="${target%/*}"; arch="${target#*/}" + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/cf2cnp" ./cmd/cf2cnp + tar -C dist -czf "dist/cf2cnp_${VERSION}_${os}_${arch}.tar.gz" cf2cnp + rm dist/cf2cnp + done + (cd dist && sha256sum *.tar.gz > "cf2cnp_${VERSION}_checksums.txt") + ls -l dist + - name: Attach to the release of this tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "cf2cnp $GITHUB_REF_NAME" --notes "cf2cnp $GITHUB_REF_NAME — see the chart release for Helm." + gh release upload "$GITHUB_REF_NAME" dist/* --clobber diff --git a/README.md b/README.md index 06492ad..124d8f7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ A CLI tool that generates CiliumNetworkPolicies from Hubble flow data. This tool > **Caution:** This project was created with the help of AI. While I have extensive experience with Kubernetes and Cilium, I do not have the Go programming expertise to write this tool from scratch. AI assistance made it possible to bring this idea to life and share it with the community. Please be careful when using this tool in production environments. Always review generated policies before applying them to your cluster. +## Binaries + +Every `v*` tag publishes `cf2cnp___.tar.gz` (linux and darwin, amd64 and arm64) with a +checksums file on the tag's GitHub release, for pipelines that run `cf2cnp merge` without a Go toolchain: + +```bash +curl -sSL -o cf2cnp.tgz https://github.com/ephico2real2/cf2cnp/releases/download/v0.6.0/cf2cnp_0.6.0_linux_amd64.tar.gz +tar -xzf cf2cnp.tgz && sudo install cf2cnp /usr/local/bin/cf2cnp +``` + ## Building Clone the repository and build the binary: From c14604b99f2bbcb1c837db380e7ce4b0cf578e4e Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:30:06 -0500 Subject: [PATCH 12/37] =?UTF-8?q?E1:=20cluster-aware=20selectors=20for=20C?= =?UTF-8?q?lusterMesh=20flows=20=E2=80=94=20the=20peer=20in=20another=20cl?= =?UTF-8?q?uster=20is=20named=20by=20io.cilium.k8s.policy.cluster=20in=20f?= =?UTF-8?q?romEndpoints/toEndpoints=20(since=20Cilium=201.19=20a=20selecto?= =?UTF-8?q?r=20without=20it=20matches=20the=20local=20cluster=20only);=20t?= =?UTF-8?q?he=20aggregator=20keys=20on=20the=20peer's=20cluster;=20fixture?= =?UTF-8?q?:=20a=20measured=20payments@poc2=20=E2=86=92=20redis-0@poc1:637?= =?UTF-8?q?9=20request;=20tests=20on=20it,=20on=20single-cluster=20fixture?= =?UTF-8?q?s=20(unchanged),=20and=20on=20two=20same-labelled=20peers=20in?= =?UTF-8?q?=20two=20clusters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/aggregator/aggregator.go | 8 +++ internal/policy/generator.go | 16 ++++++ internal/policy/generator_test.go | 53 +++++++++++++++++++ .../egress-payments-poc2-to-redis-poc1.json | 1 + 4 files changed, 78 insertions(+) create mode 100644 internal/testdata/egress-payments-poc2-to-redis-poc1.json diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index b779aaf..62f1f0a 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -13,10 +13,12 @@ type AggregatedFlow struct { Direction string // INGRESS or EGRESS SourceNamespace string // Source pod namespace SourceLabels map[string]string // Filtered source labels + SourceCluster string // ClusterMesh: the source's cluster (E1) SourceEntity string // Reserved entity for source (remote-node, host, etc.) IsSourceEntity bool // True if source is a reserved entity DestNamespace string // Destination pod namespace DestLabels map[string]string // Filtered destination labels + DestCluster string // ClusterMesh: the destination's cluster (E1) DestFQDNs []string // Destination FQDNs for world traffic DestIPs []string // Destination IPs for CIDR-based rules DestEntity string // Reserved entity (kube-apiserver, host, world, etc.) @@ -66,10 +68,12 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { Direction: f.Direction, SourceNamespace: f.SourceNamespace, SourceLabels: copyLabels(f.SourceLabels), + SourceCluster: f.SourceCluster, SourceEntity: f.SourceEntity, IsSourceEntity: f.IsSourceEntity, DestNamespace: f.DestNamespace, DestLabels: copyLabels(f.DestLabels), + DestCluster: f.DestCluster, DestFQDNs: append([]string{}, f.DestFQDNs...), DestIPs: destIPs, DestEntity: f.DestEntity, @@ -112,6 +116,7 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.Direction) parts = append(parts, f.SourceNamespace) parts = append(parts, labelsToString(f.SourceLabels)) + parts = append(parts, f.SourceCluster) // a peer in another cluster is another peer (E1) // Include source entity in key for entity-based traffic if f.IsSourceEntity { @@ -120,6 +125,7 @@ func generateAggregationKey(f *flow.ParsedFlow) string { parts = append(parts, f.DestNamespace) parts = append(parts, labelsToString(f.DestLabels)) + parts = append(parts, f.DestCluster) // Include destination entity in key for entity-based traffic if f.IsDestEntityTraffic { @@ -190,10 +196,12 @@ func parsedFlowFromAggregated(agg *AggregatedFlow) *flow.ParsedFlow { Direction: agg.Direction, SourceNamespace: agg.SourceNamespace, SourceLabels: agg.SourceLabels, + SourceCluster: agg.SourceCluster, SourceEntity: agg.SourceEntity, IsSourceEntity: agg.IsSourceEntity, DestNamespace: agg.DestNamespace, DestLabels: agg.DestLabels, + DestCluster: agg.DestCluster, DestFQDNs: agg.DestFQDNs, DestEntity: agg.DestEntity, IsWorldTraffic: agg.IsWorldTraffic, diff --git a/internal/policy/generator.go b/internal/policy/generator.go index e0bc302..a545d4e 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -15,6 +15,14 @@ import ( "gopkg.in/yaml.v3" ) +// ClusterLabel is the label Cilium puts on every endpoint of a ClusterMesh member and matches in +// fromEndpoints / toEndpoints (cilium Documentation/network/clustermesh/policy.rst). Since 1.19 a selector +// WITHOUT it matches the local cluster only. +const ClusterLabel = "io.cilium.k8s.policy.cluster" + +// crossCluster reports whether a flow's two sides are in different, known clusters +func crossCluster(a, b string) bool { return a != "" && b != "" && a != b } + // ErrReplyFlow is returned when attempting to generate a policy for a reply flow var ErrReplyFlow = errors.New("this is a reply packet - you need to allow the original request, not the reply (Cilium's connection tracking automatically allows replies)") @@ -290,6 +298,10 @@ func (g *Generator) generateEndpointIngressRules(policy *CiliumNetworkPolicy, f if f.SourceNamespace != "" && f.SourceNamespace != f.DestNamespace { fromLabels[flow.GetNamespaceLabel()] = f.SourceNamespace } + // ClusterMesh (E1): name the peer's cluster, or the rule matches the local cluster only + if crossCluster(f.SourceCluster, f.DestCluster) { + fromLabels[ClusterLabel] = f.SourceCluster + } ingressRule.FromEndpoints = []LabelSelector{ {MatchLabels: fromLabels}, @@ -448,6 +460,10 @@ func (g *Generator) generateEndpointEgressRules(policy *CiliumNetworkPolicy, f * if f.DestNamespace != "" && f.DestNamespace != f.SourceNamespace { toLabels[flow.GetNamespaceLabel()] = f.DestNamespace } + // ClusterMesh (E1): name the peer's cluster, or the rule matches the local cluster only + if crossCluster(f.SourceCluster, f.DestCluster) { + toLabels[ClusterLabel] = f.DestCluster + } egressRule.ToEndpoints = []LabelSelector{ {MatchLabels: toLabels}, diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 29c15a1..5ba9d75 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -176,3 +176,56 @@ func TestBuildPolicies_NameUnchangedWithoutComponent(t *testing.T) { t.Fatalf("got %s", ps[0].Metadata.Name) } } + +// E1: a cross-cluster flow (measured on the bank: payments@poc2 → redis-0@poc1:6379, an EGRESS flow reported by +// the source's node) must name the peer's cluster, because since Cilium 1.19 a selector without +// io.cilium.k8s.policy.cluster matches the local cluster only — the egress policy for payments (in poc2) would +// otherwise allow only a redis in poc2 and exclude the poc1 one the flow went to. +func TestBuildPolicies_CrossClusterPeerNamesItsCluster(t *testing.T) { + ps, err := NewGenerator("").BuildPolicies(load(t, "egress-payments-poc2-to-redis-poc1.json")) + if err != nil { + t.Fatal(err) + } + if len(ps) != 1 || len(ps[0].Spec.Egress) != 1 || len(ps[0].Spec.Egress[0].ToEndpoints) != 1 { + t.Fatalf("expected one policy with one endpoint egress rule, got %+v", ps) + } + to := ps[0].Spec.Egress[0].ToEndpoints[0].MatchLabels + if to[ClusterLabel] != "poc1" { + t.Fatalf("toEndpoints must carry %s=poc1, got %v", ClusterLabel, to) + } + if _, ok := ps[0].Spec.EndpointSelector.MatchLabels[ClusterLabel]; ok { + t.Fatalf("the endpointSelector (the local workload) must not carry the cluster label") + } + if ps[0].Metadata.Namespace != "bank" || ps[0].Metadata.Name != "payments" { + t.Fatalf("the policy is for the source workload: got %s/%s", ps[0].Metadata.Namespace, ps[0].Metadata.Name) + } +} + +// Same-cluster flows are untouched: no cluster label (the local-only default is what is wanted), so the +// output for every single-cluster fixture stays byte-identical +func TestBuildPolicies_SameClusterHasNoClusterLabel(t *testing.T) { + for _, fx := range []string{"ingress-pos-to-shop.json", "egress-pos-to-world.json"} { + ps, _ := NewGenerator("").BuildPolicies(load(t, fx)) + out, _ := EncodePolicies(ps) + if strings.Contains(string(out), ClusterLabel) { + t.Fatalf("%s: unexpected cluster label:\n%s", fx, out) + } + } +} + +// Two peers with the same labels in two clusters are two rules +func TestBuildPolicies_SameLabelsTwoClustersTwoRules(t *testing.T) { + mk := func(cluster string) *aggregator.AggregatedFlow { + return &aggregator.AggregatedFlow{Direction: "INGRESS", DestNamespace: "bank", DestCluster: "poc1", + DestLabels: map[string]string{"app": "api"}, + SourceNamespace: "bank", SourceCluster: cluster, SourceLabels: map[string]string{"app": "payments"}, + Ports: []aggregator.PortInfo{{Port: 8080, Protocol: "TCP"}}} + } + ps, _ := NewGenerator("").BuildPolicies([]*aggregator.AggregatedFlow{mk("poc1"), mk("poc2")}) + if len(ps) != 1 || len(ps[0].Spec.Ingress) != 2 { + t.Fatalf("expected one policy with two rules (local peer, poc2 peer), got %d policies", len(ps)) + } + if ps[0].Spec.Ingress[0].FromEndpoints[0].MatchLabels[ClusterLabel] != "" || ps[0].Spec.Ingress[1].FromEndpoints[0].MatchLabels[ClusterLabel] != "poc2" { + t.Fatalf("rules: %+v", ps[0].Spec.Ingress) + } +} diff --git a/internal/testdata/egress-payments-poc2-to-redis-poc1.json b/internal/testdata/egress-payments-poc2-to-redis-poc1.json new file mode 100644 index 0000000..a81bf78 --- /dev/null +++ b/internal/testdata/egress-payments-poc2-to-redis-poc1.json @@ -0,0 +1 @@ +{"flow": {"time": "2026-09-13T04:38:06.796159947Z", "uuid": "f2bbdd29-b9f4-4c2a-b5ab-a1e234e5860d", "emitter": {"name": "Hubble", "version": "1.20.1+g7d68cfb3"}, "verdict": "FORWARDED", "ethernet": {"source": "6e:7a:be:97:be:91", "destination": "c2:7a:2d:dd:e2:c3"}, "IP": {"source": "10.20.1.17", "destination": "10.10.4.114", "ipVersion": "IPv4"}, "l4": {"TCP": {"source_port": 58910, "destination_port": 6379, "flags": {"ACK": true}}}, "source": {"identity": 138635, "cluster_name": "poc2", "namespace": "bank", "labels": ["k8s:app=payments", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=bank", "k8s:io.cilium.k8s.policy.cluster=poc2", "k8s:io.cilium.k8s.policy.serviceaccount=default", "k8s:io.kubernetes.pod.namespace=bank"], "pod_name": "payments-7dbb8cdfdb-5bsf5"}, "destination": {"ID": 747, "identity": 88608, "cluster_name": "poc1", "namespace": "bank", "labels": ["k8s:app=redis", "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=bank", "k8s:io.cilium.k8s.policy.cluster=poc1", "k8s:io.cilium.k8s.policy.serviceaccount=default", "k8s:io.kubernetes.pod.namespace=bank"], "pod_name": "redis-0", "workloads": [{"name": "redis", "kind": "StatefulSet"}]}, "Type": "L3_L4", "node_name": "poc1/poc1-worker", "node_labels": ["beta.kubernetes.io/arch=amd64", "beta.kubernetes.io/os=linux", "kubernetes.io/arch=amd64", "kubernetes.io/hostname=poc1-worker", "kubernetes.io/os=linux"], "event_type": {"type": 4}, "traffic_direction": "EGRESS", "trace_observation_point": "TO_ENDPOINT", "trace_reason": "ESTABLISHED", "is_reply": false, "interface": {"index": 129, "name": "lxc5076723f6920"}, "Summary": "TCP Flags: ACK"}, "node_name": "poc1/poc1-worker", "time": "2026-09-13T04:38:06.796159947Z"} \ No newline at end of file From b40027042b0e9aff8e7e739fb006ee37592253f2 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:44:27 -0500 Subject: [PATCH 13/37] =?UTF-8?q?E6:=20the=20pod's=20policy=20admits=20res?= =?UTF-8?q?erved:host=20too=20=E2=80=94=20the=20kubelet's=20probes=20reach?= =?UTF-8?q?=20the=20pod=20as=20the=20host=20identity=20(measured:=2026=20o?= =?UTF-8?q?f=2035=20flows=20into=20the=20cf2cnp=20pod),=20the=20Gateway=20?= =?UTF-8?q?as=20reserved:ingress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helm/cf2cnp/templates/ciliumnetworkpolicy.yaml | 6 ++++-- helm/cf2cnp/values.yaml | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml b/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml index ec26f37..92c665e 100644 --- a/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml +++ b/helm/cf2cnp/templates/ciliumnetworkpolicy.yaml @@ -6,12 +6,14 @@ metadata: labels: {{- include "cf2cnp.labels" . | nindent 4 }} spec: - description: cf2cnp — reachable from the Gateway and the listed endpoints only; egress to kube-dns only + description: cf2cnp — reachable from the Gateway (reserved:ingress), the kubelet's probes (reserved:host) and the listed endpoints only; egress to kube-dns only endpointSelector: matchLabels: {{- include "cf2cnp.selectorLabels" . | nindent 6 }} ingress: - - fromEntities: [ingress] + # measured on Cilium 1.20.1 with Gateway API: a request through the Gateway reaches the pod as reserved:ingress; + # the kubelet's liveness/readiness probes reach it as reserved:host — without host, the pod fails its probes + - fromEntities: [ingress, host] toPorts: - ports: - port: {{ .Values.containerPort | quote }} diff --git a/helm/cf2cnp/values.yaml b/helm/cf2cnp/values.yaml index 1c875fb..769260e 100644 --- a/helm/cf2cnp/values.yaml +++ b/helm/cf2cnp/values.yaml @@ -61,8 +61,8 @@ auth: token: "" existingSecret: "" -# A CiliumNetworkPolicy for the cf2cnp pod (E6): ingress from the Gateway (reserved:ingress) and from the -# endpoints listed, on the container port; egress to kube-dns only. +# A CiliumNetworkPolicy for the cf2cnp pod (E6): ingress from the Gateway (reserved:ingress), the kubelet's probes +# (reserved:host) and the endpoints listed, on the container port; egress to kube-dns only. networkPolicy: enabled: false fromEndpoints: From e8be5ca234ff25d54ea09955ea9c27441bfd4d37 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:49:02 -0500 Subject: [PATCH 14/37] E1 (review): the cluster falls back to the flow's io.cilium.k8s.policy.cluster label when cluster_name is empty; the peer's cluster is named whenever it is known and differs from the local side --- internal/flow/parser.go | 26 ++++++++++++++++++++++++-- internal/policy/generator.go | 18 ++++++++++++------ internal/policy/generator_test.go | 19 +++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/internal/flow/parser.go b/internal/flow/parser.go index da125c5..0fc0049 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -146,8 +146,8 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { // ClusterMesh: which cluster each side is in. Since Cilium 1.19 a selector without // io.cilium.k8s.policy.cluster matches the LOCAL cluster only, so a policy generated from a // cross-cluster flow must name the peer's cluster or it excludes that very peer. - parsed.SourceCluster = flow.Source.ClusterName - parsed.DestCluster = flow.Destination.ClusterName + parsed.SourceCluster = clusterOf(flow.Source) + parsed.DestCluster = clusterOf(flow.Destination) // Check if source is a reserved entity (remote-node, host, etc.) parsed.SourceEntity = getReservedEntity(flow.Source.Labels) @@ -187,6 +187,28 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { return parsed, nil } +// clusterOf is the endpoint's cluster: cluster_name when Hubble set it, else the io.cilium.k8s.policy.cluster +// label the endpoint carries anyway (review finding: a flow with an empty cluster_name still names the cluster +// in its labels, and dropping it there reintroduced the local-only bug E1 exists to fix) +func clusterOf(ep Endpoint) string { + if ep.ClusterName != "" { + return ep.ClusterName + } + return labelValue(ep.Labels, "io.cilium.k8s.policy.cluster") +} + +// labelValue returns the value of key in Hubble's "k8s:key=value" (or "key=value") label list, or "" +func labelValue(labels []string, key string) string { + for _, l := range labels { + for _, prefix := range []string{"k8s:" + key + "=", key + "="} { + if strings.HasPrefix(l, prefix) { + return strings.TrimPrefix(l, prefix) + } + } + } + return "" +} + // extractLabels extracts and filters labels from Hubble label format // Labels are in format "k8s:labelKey=labelValue" func extractLabels(labels []string) map[string]string { diff --git a/internal/policy/generator.go b/internal/policy/generator.go index a545d4e..b8f7e0f 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -20,8 +20,14 @@ import ( // WITHOUT it matches the local cluster only. const ClusterLabel = "io.cilium.k8s.policy.cluster" -// crossCluster reports whether a flow's two sides are in different, known clusters -func crossCluster(a, b string) bool { return a != "" && b != "" && a != b } +// peerCluster is the cluster to name on a peer selector: the peer's, when it is known and differs from the +// local side's (the local side may be unknown — the peer's label is still what Cilium matches) +func peerCluster(local, peer string) string { + if peer != "" && peer != local { + return peer + } + return "" +} // ErrReplyFlow is returned when attempting to generate a policy for a reply flow var ErrReplyFlow = errors.New("this is a reply packet - you need to allow the original request, not the reply (Cilium's connection tracking automatically allows replies)") @@ -299,8 +305,8 @@ func (g *Generator) generateEndpointIngressRules(policy *CiliumNetworkPolicy, f fromLabels[flow.GetNamespaceLabel()] = f.SourceNamespace } // ClusterMesh (E1): name the peer's cluster, or the rule matches the local cluster only - if crossCluster(f.SourceCluster, f.DestCluster) { - fromLabels[ClusterLabel] = f.SourceCluster + if c := peerCluster(f.DestCluster, f.SourceCluster); c != "" { + fromLabels[ClusterLabel] = c } ingressRule.FromEndpoints = []LabelSelector{ @@ -461,8 +467,8 @@ func (g *Generator) generateEndpointEgressRules(policy *CiliumNetworkPolicy, f * toLabels[flow.GetNamespaceLabel()] = f.DestNamespace } // ClusterMesh (E1): name the peer's cluster, or the rule matches the local cluster only - if crossCluster(f.SourceCluster, f.DestCluster) { - toLabels[ClusterLabel] = f.DestCluster + if c := peerCluster(f.SourceCluster, f.DestCluster); c != "" { + toLabels[ClusterLabel] = c } egressRule.ToEndpoints = []LabelSelector{ diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 5ba9d75..b97427f 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -229,3 +229,22 @@ func TestBuildPolicies_SameLabelsTwoClustersTwoRules(t *testing.T) { t.Fatalf("rules: %+v", ps[0].Spec.Ingress) } } + +// Review finding: an empty destination.cluster_name (Hubble without cluster.name) must not lose the cluster the +// flow's labels carry +func TestBuildPolicies_ClusterLabelWhenClusterNameEmpty(t *testing.T) { + raw := []byte(`{"flow":{"uuid":"x","traffic_direction":"EGRESS","is_reply":false,"l4":{"TCP":{"destination_port":6379}}, + "source":{"cluster_name":"poc2","namespace":"bank","labels":["k8s:app=payments","k8s:io.cilium.k8s.policy.cluster=poc2"]}, + "destination":{"namespace":"bank","labels":["k8s:app=redis","k8s:io.cilium.k8s.policy.cluster=poc1"]}}}`) + f, err := flow.ParseFlowsFromBytes(raw) + if err != nil { + t.Fatal(err) + } + ps, err := NewGenerator("").BuildPolicies(aggregator.AggregateFlows(f)) + if err != nil { + t.Fatal(err) + } + if to := ps[0].Spec.Egress[0].ToEndpoints[0].MatchLabels; to[ClusterLabel] != "poc1" { + t.Fatalf("empty destination.cluster_name must still yield cluster=poc1 from the label, got %v", to) + } +} From f757a3a8827b9eb33b2dd05919e814b294e26860 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:49:15 -0500 Subject: [PATCH 15/37] E2 (review): a DNS query that is a resolver search-list expansion (the cluster domain twice) is cut to the real name; the measured fixture asked for accounts.bank.svc.cluster.local.bank.svc.cluster.local. --- internal/flow/parser.go | 19 ++++++++++++++++++- internal/flow/parser_test.go | 13 +++++++++++++ internal/policy/generator_test.go | 12 ++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/flow/parser.go b/internal/flow/parser.go index 8049468..af2921e 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -189,13 +189,30 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { } } if flow.L7.DNS != nil { - parsed.DNSQuery = strings.TrimSuffix(flow.L7.DNS.Query, ".") + parsed.DNSQuery = cleanDNSQuery(flow.L7.DNS.Query, ClusterDomain) } } return parsed, nil } +// ClusterDomain is the cluster's DNS domain, used to recognise resolver search-list expansions +const ClusterDomain = "cluster.local" + +// cleanDNSQuery strips the trailing dot and a Kubernetes search-list expansion (review finding). With ndots:5 a +// pod asking for accounts.bank.svc.cluster.local (four dots) first tries accounts.bank.svc.cluster.local.bank.svc.cluster.local. +// — Hubble records that request too; a matchName on it is noise nobody queries on purpose. When the cluster +// domain appears twice, the name is cut after its first occurrence. +func cleanDNSQuery(q, clusterDomain string) string { + q = strings.TrimSuffix(q, ".") + marker := "." + clusterDomain + first := strings.Index(q, marker+".") + if first >= 0 && strings.HasSuffix(q, marker) { + return q[:first+len(marker)] + } + return q +} + // extractLabels extracts and filters labels from Hubble label format // Labels are in format "k8s:labelKey=labelValue" func extractLabels(labels []string) map[string]string { diff --git a/internal/flow/parser_test.go b/internal/flow/parser_test.go index 357fb6e..de1364b 100644 --- a/internal/flow/parser_test.go +++ b/internal/flow/parser_test.go @@ -95,3 +95,16 @@ func TestParseFlowsFromBytes_RejectsNonFlows(t *testing.T) { t.Fatalf("two concatenated flows: %d %v", len(fs), err) } } + +func TestCleanDNSQuery(t *testing.T) { + for in, want := range map[string]string{ + "accounts.bank.svc.cluster.local.": "accounts.bank.svc.cluster.local", + "accounts.bank.svc.cluster.local.bank.svc.cluster.local.": "accounts.bank.svc.cluster.local", + "example.com.": "example.com", + "example.com.bank.svc.cluster.local.": "example.com.bank.svc.cluster.local", // one occurrence: kept as asked + } { + if got := cleanDNSQuery(in, ClusterDomain); got != want { + t.Fatalf("%q → %q, want %q", in, got, want) + } + } +} diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 725a6e9..2443205 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -233,3 +233,15 @@ func TestL7Rules_PathIsEscaped(t *testing.T) { t.Fatalf("got %q", r.HTTP[0].Path) } } + +// Review finding: the measured DNS request is a search-list expansion; the rule must name the real name +func TestBuildPolicies_L7DNSRules_StripsSearchDomain(t *testing.T) { + ps, err := NewGenerator("").WithL7().BuildPolicies(load(t, "l7-dns-request.json")) + if err != nil { + t.Fatal(err) + } + rules := firstRules(ps) + if rules == nil || len(rules.DNS) != 1 || rules.DNS[0].MatchName != "accounts.bank.svc.cluster.local" { + t.Fatalf("ndots expansion must be stripped, got %+v", rules) + } +} From aeaf34e1048c7b513fc701490d82eda0fcefb023 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:49:47 -0500 Subject: [PATCH 16/37] =?UTF-8?q?E5=20(review):=20rule=20equality=20compar?= =?UTF-8?q?es=20scalars=20as=20strings=20=E2=80=94=20a=20hand-written=20po?= =?UTF-8?q?rt:=2080=20and=20the=20generated=20port:=20"80"=20are=20one=20r?= =?UTF-8?q?ule,=20not=20a=20duplicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/policy/merge.go | 38 ++++++++++++++++++++++++++++++++--- internal/policy/merge_test.go | 21 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/internal/policy/merge.go b/internal/policy/merge.go index f73b8e9..9163c0a 100644 --- a/internal/policy/merge.go +++ b/internal/policy/merge.go @@ -22,7 +22,7 @@ func MergeInto(existing map[string]interface{}, generated *CiliumNetworkPolicy) return 0, fmt.Errorf("target mismatch: existing %v/%v, generated %s/%s", meta["namespace"], meta["name"], generated.Metadata.Namespace, generated.Metadata.Name) } - if mustYAML(spec["endpointSelector"]) != mustYAML(toGeneric(generated.Spec.EndpointSelector)) { + if canonicalYAML(spec["endpointSelector"]) != canonicalYAML(toGeneric(generated.Spec.EndpointSelector)) { return 0, errors.New("target mismatch: the endpointSelector differs") } for _, r := range generated.Spec.Ingress { @@ -49,12 +49,44 @@ func toGeneric(v interface{}) interface{} { // appendUnique adds item to spec[key] (a list) unless an equal item is already there func appendUnique(spec map[string]interface{}, key string, item interface{}) bool { list, _ := spec[key].([]interface{}) - want := mustYAML(item) + want := canonicalYAML(item) for _, have := range list { - if mustYAML(have) == want { + if canonicalYAML(have) == want { return false } } spec[key] = append(list, item) return true } + +// canonicalYAML renders a generic value for comparison with every scalar as a string: a hand-written +// `port: 80` and the generated `port: "80"` are one rule to Kubernetes and must be one rule here (review +// finding — without this the merge appended a duplicate) +func canonicalYAML(v interface{}) string { + b, err := yaml.Marshal(stringifyScalars(v)) + if err != nil { + return fmt.Sprintf("%#v", v) + } + return string(b) +} + +func stringifyScalars(v interface{}) interface{} { + switch t := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(t)) + for k, val := range t { + out[k] = stringifyScalars(val) + } + return out + case []interface{}: + out := make([]interface{}, len(t)) + for i, val := range t { + out[i] = stringifyScalars(val) + } + return out + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + return fmt.Sprint(t) + default: + return v + } +} diff --git a/internal/policy/merge_test.go b/internal/policy/merge_test.go index 2fb2536..9de17c2 100644 --- a/internal/policy/merge_test.go +++ b/internal/policy/merge_test.go @@ -80,3 +80,24 @@ func TestMergeInto_RefusesAnotherTarget(t *testing.T) { t.Fatalf("expected an endpointSelector mismatch, got %v", err) } } + +// Review finding: a hand-written `port: 80` (a YAML int) and the generated `port: "80"` are the same rule +func TestMergeInto_PortIntEqualsPortString(t *testing.T) { + var doc map[string]interface{} + if err := yaml.Unmarshal([]byte(`apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: {name: shop, namespace: cf2cnp-lab} +spec: + endpointSelector: {matchLabels: {app.kubernetes.io/name: shop}} + ingress: + - fromEndpoints: [{matchLabels: {app.kubernetes.io/name: pos}}] + toPorts: [{ports: [{port: 80, protocol: TCP}]}] +`), &doc); err != nil { + t.Fatal(err) + } + ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json")) + added, err := MergeInto(doc, ps[0]) + if err != nil || added != 0 { + t.Fatalf("port: 80 and port: \"80\" are one rule; added=%d err=%v", added, err) + } +} From f0cdb975ee8406d08131b395cc7e80ba3d8b36c2 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:49:54 -0500 Subject: [PATCH 17/37] =?UTF-8?q?E6=20(review):=20networkPolicy.fromEndpoi?= =?UTF-8?q?nts=20defaults=20to=20none=20=E2=80=94=20the=20Grafana=20action?= =?UTF-8?q?=20arrives=20through=20the=20Gateway=20as=20reserved:ingress,?= =?UTF-8?q?=20and=20a=20selector=20for=20a=20pod=20in=20another=20namespac?= =?UTF-8?q?e=20needs=20io.kubernetes.pod.namespace=20(documented)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helm/cf2cnp/README.md | 2 +- helm/cf2cnp/values.yaml | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/helm/cf2cnp/README.md b/helm/cf2cnp/README.md index bd50a93..c83903a 100644 --- a/helm/cf2cnp/README.md +++ b/helm/cf2cnp/README.md @@ -35,7 +35,7 @@ The following table lists the configurable parameters of the CF2CNP chart and th | `externalURL` | Base URL clients reach cf2cnp at, for `download_url` (empty = derived from the request and its `Forwarded` / `X-Forwarded-Proto` / `X-Forwarded-Host` headers) | `""` | | `cors.allowedOrigins` | Origins allowed by CORS (Grafana's, for the dashboard action); empty = any | `[]` | | `auth.token`, `auth.existingSecret` | A bearer token required on `/generate` and `/download` (a Secret with key `token` preferred) | `""`, `""` | -| `networkPolicy.enabled`, `networkPolicy.fromEndpoints` | A CiliumNetworkPolicy for the pod: ingress from the Gateway and the listed endpoints, egress to kube-dns | `false`, Grafana's labels | +| `networkPolicy.enabled`, `networkPolicy.fromEndpoints` | A CiliumNetworkPolicy for the pod: ingress from the Gateway (`reserved:ingress`), the kubelet (`reserved:host`) and the listed endpoints (add `io.kubernetes.pod.namespace` for another namespace), egress to kube-dns | `false`, `[]` | | `extraArgs` | Extra arguments appended to `cf2cnp serve --port ` | `[]` | | `extraEnv` | Extra environment variables (`CF2CNP_EXTERNAL_URL` is an alternative to `externalURL`) | `[]` | | `ingress.enabled` | Enable ingress | `false` | diff --git a/helm/cf2cnp/values.yaml b/helm/cf2cnp/values.yaml index 769260e..194dbb4 100644 --- a/helm/cf2cnp/values.yaml +++ b/helm/cf2cnp/values.yaml @@ -65,9 +65,11 @@ auth: # (reserved:host) and the endpoints listed, on the container port; egress to kube-dns only. networkPolicy: enabled: false - fromEndpoints: - - matchLabels: - app.kubernetes.io/name: grafana + # Extra callers besides the Gateway and the kubelet. The Grafana ACTION is a browser call that arrives through + # the Gateway (reserved:ingress), so nothing is needed for it. A pod in ANOTHER namespace needs the namespace + # label in its selector (review finding — a bare label matches the release namespace only), e.g. + # - matchLabels: {app.kubernetes.io/name: grafana, io.kubernetes.pod.namespace: monitoring} + fromEndpoints: [] # Extra arguments appended to `cf2cnp serve --port ` (e.g. a flag this chart has no value for) extraArgs: [] From 3c74e93742b6a1107b4c7caabf494e114f7aaf4c Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sat, 12 Sep 2026 23:52:29 -0500 Subject: [PATCH 18/37] E4 (review): the page's peer key follows the parser's label priority exactly (name, component, instance, then app, k8s-app, name, component, instance); a test reads the served page to keep them in sync --- internal/server/server.go | 9 ++++++--- internal/server/server_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 5d061cd..ad7b7cf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -493,11 +493,14 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - if (comp && comp !== name) name += '/' + comp; return name; } - // peerKey is the label the policy would name for a peer: the same priority cf2cnp uses (name, then app, k8s-app) + // peerKey is the label the policy would name for a peer — the FIRST label the server's extractLabels keeps, + // in its exact priority order (review finding: a peer with app.kubernetes.io/instance and a fallback app label + // is named by instance in the policy, so the checkbox must say instance, or the exclude never matches) function peerKey(ep) { const labels = (ep && ep.labels) || []; - for (const k of ['app.kubernetes.io/name', 'app', 'k8s-app']) { - const l = labels.find(x => x.startsWith('k8s:' + k + '=')); + for (const k of ['app.kubernetes.io/name', 'app.kubernetes.io/component', 'app.kubernetes.io/instance', + 'app', 'k8s-app', 'name', 'component', 'instance']) { + const l = labels.find(x => x.startsWith('k8s:' + k + '=') || x.startsWith(k + '=')); if (l) return k + '=' + l.split('=').slice(1).join('='); } return ''; diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3200d04..5a9169c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/hubble-policy-gen/internal/flow" ) func fixture(t *testing.T, name string) string { @@ -226,3 +228,27 @@ func TestGenerate_ExcludePeers(t *testing.T) { t.Fatalf("egress flow: the source is not the peer, got %d", rec.Code) } } + +// Review finding: the exclude key must be the label the parser keeps — a peer with only an instance label (no +// name) is named by instance, and an exclude on its fallback app label must not match +func TestExclude_UsesTheLabelTheParserKeeps(t *testing.T) { + f := &flow.ParsedFlow{Direction: "INGRESS", SourceLabels: map[string]string{"app.kubernetes.io/instance": "blue"}} + if n := excludeFlows([]*flow.ParsedFlow{f}, []string{"app=payments"}); len(n) != 1 { + t.Fatalf("exclude=app= must not drop a peer the parser named by instance") + } + if n := excludeFlows([]*flow.ParsedFlow{f}, []string{"app.kubernetes.io/instance=blue"}); len(n) != 0 { + t.Fatalf("exclude=instance= must drop that peer") + } + // and the page's list is the parser's list, in order (kept in sync by this test reading the served page) + rec := httptest.NewRecorder() + NewServer(8080, "").handleIndex(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + page := rec.Body.String() + i := strings.Index(page, "function peerKey") + for _, k := range []string{"'app.kubernetes.io/name'", "'app.kubernetes.io/component'", "'app.kubernetes.io/instance'", "'app'", "'k8s-app'", "'name'", "'component'", "'instance'"} { + j := strings.Index(page[i:], k) + if j < 0 { + t.Fatalf("the page's peerKey must list %s", k) + } + i += j + } +} From 1f0d30febe9424aa8f07e745975cdc071e64bba9 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 00:39:35 -0500 Subject: [PATCH 19/37] =?UTF-8?q?E4=20(review,=20second=20pass):=20a=20pee?= =?UTF-8?q?r=20is=20its=20whole=20identifying=20label=20set=20=E2=80=94=20?= =?UTF-8?q?the=20page=20sends=20name+component+instance=20as=20one=20comma?= =?UTF-8?q?-joined=20exclude=20that=20must=20match=20entirely,=20so=20unti?= =?UTF-8?q?cking=20shop/frontend=20keeps=20shop/backend;=20a=20bare=20key?= =?UTF-8?q?=3Dvalue=20still=20matches=20every=20peer=20carrying=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/server.go | 40 ++++++++++++++++++++++++---------- internal/server/server_test.go | 23 +++++++++++-------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index ad7b7cf..ea46801 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -184,11 +184,26 @@ func excludeFlows(flows []*flow.ParsedFlow, excludes []string) []*flow.ParsedFlo return kept } -// peerMatches reports whether the labels carry any "key=value" of the excludes +// peerMatches reports whether the labels carry one of the excludes. An exclude is "key=value", or several +// joined by commas that must ALL match — the page sends a peer's whole identifying set +// (app.kubernetes.io/name=shop,app.kubernetes.io/component=frontend), so unticking one component never +// removes the others (review finding); a single key=value still works and matches every peer carrying it. func peerMatches(labels map[string]string, excludes []string) bool { for _, ex := range excludes { - kv := strings.SplitN(ex, "=", 2) - if len(kv) == 2 && labels[kv[0]] == kv[1] { + all := true + n := 0 + for _, pair := range strings.Split(ex, ",") { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) != 2 { + continue + } + n++ + if labels[kv[0]] != kv[1] { + all = false + break + } + } + if n > 0 && all { return true } } @@ -493,16 +508,17 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - if (comp && comp !== name) name += '/' + comp; return name; } - // peerKey is the label the policy would name for a peer — the FIRST label the server's extractLabels keeps, - // in its exact priority order (review finding: a peer with app.kubernetes.io/instance and a fallback app label - // is named by instance in the policy, so the checkbox must say instance, or the exclude never matches) + // peerKey is the identifying label set the policy would name for a peer, joined by commas — the same labels + // the server's extractLabels keeps: every app.kubernetes.io/{name,component,instance} present, else the first + // fallback label (app, k8s-app, name, component, instance). Unticking a peer sends this whole set as one + // exclude, so one component never removes its siblings (review finding). function peerKey(ep) { const labels = (ep && ep.labels) || []; - for (const k of ['app.kubernetes.io/name', 'app.kubernetes.io/component', 'app.kubernetes.io/instance', - 'app', 'k8s-app', 'name', 'component', 'instance']) { - const l = labels.find(x => x.startsWith('k8s:' + k + '=') || x.startsWith(k + '=')); - if (l) return k + '=' + l.split('=').slice(1).join('='); - } + const val = (k) => { const l = labels.find(x => x.startsWith('k8s:' + k + '=') || x.startsWith(k + '=')); return l ? l.split('=').slice(1).join('=') : null; }; + const parts = []; + for (const k of ['app.kubernetes.io/name', 'app.kubernetes.io/component', 'app.kubernetes.io/instance']) { const v = val(k); if (v !== null) parts.push(k + '=' + v); } + if (parts.length) return parts.join(','); + for (const k of ['app', 'k8s-app', 'name', 'component', 'instance']) { const v = val(k); if (v !== null) return k + '=' + v; } return ''; } // renderPeers lists every distinct peer (the source of an INGRESS flow, the destination of an EGRESS one) @@ -520,7 +536,7 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - const title = document.createElement('div'); title.className = 'summary'; title.textContent = 'Peers the policy would allow — untick to exclude:'; box.appendChild(title); for (const k of keys) { const label = document.createElement('label'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.dataset.peer = k; - label.appendChild(cb); label.appendChild(document.createTextNode(' ' + k.split('=')[1])); + label.appendChild(cb); label.appendChild(document.createTextNode(' ' + k.split(',').map(p => p.split('=').slice(1).join('=')).join('/'))); const m = document.createElement('span'); m.className = 'muted'; m.textContent = '(' + counts[k] + ' flow' + (counts[k] === 1 ? '' : 's') + ')'; label.appendChild(m); box.appendChild(label); } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5a9169c..6ab4143 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -229,17 +229,22 @@ func TestGenerate_ExcludePeers(t *testing.T) { } } -// Review finding: the exclude key must be the label the parser keeps — a peer with only an instance label (no -// name) is named by instance, and an exclude on its fallback app label must not match -func TestExclude_UsesTheLabelTheParserKeeps(t *testing.T) { - f := &flow.ParsedFlow{Direction: "INGRESS", SourceLabels: map[string]string{"app.kubernetes.io/instance": "blue"}} - if n := excludeFlows([]*flow.ParsedFlow{f}, []string{"app=payments"}); len(n) != 1 { +// Review finding: a peer is identified by its whole priority label set — unticking shop/frontend must not remove +// shop/backend, while a bare name=shop exclude still removes every shop component +func TestExclude_WholeLabelSetAndSingleLabel(t *testing.T) { + front := &flow.ParsedFlow{Direction: "INGRESS", SourceLabels: map[string]string{"app.kubernetes.io/name": "shop", "app.kubernetes.io/component": "frontend"}} + back := &flow.ParsedFlow{Direction: "INGRESS", SourceLabels: map[string]string{"app.kubernetes.io/name": "shop", "app.kubernetes.io/component": "backend"}} + if n := excludeFlows([]*flow.ParsedFlow{front, back}, []string{"app.kubernetes.io/name=shop,app.kubernetes.io/component=frontend"}); len(n) != 1 || n[0] != back { + t.Fatalf("the whole set must exclude only the frontend, got %d", len(n)) + } + if n := excludeFlows([]*flow.ParsedFlow{front, back}, []string{"app.kubernetes.io/name=shop"}); len(n) != 0 { + t.Fatalf("a bare name excludes every component, got %d", len(n)) + } + inst := &flow.ParsedFlow{Direction: "INGRESS", SourceLabels: map[string]string{"app.kubernetes.io/instance": "blue"}} + if n := excludeFlows([]*flow.ParsedFlow{inst}, []string{"app=payments"}); len(n) != 1 { t.Fatalf("exclude=app= must not drop a peer the parser named by instance") } - if n := excludeFlows([]*flow.ParsedFlow{f}, []string{"app.kubernetes.io/instance=blue"}); len(n) != 0 { - t.Fatalf("exclude=instance= must drop that peer") - } - // and the page's list is the parser's list, in order (kept in sync by this test reading the served page) + // the page's key lists exactly the parser's priority labels, then the fallbacks, in order rec := httptest.NewRecorder() NewServer(8080, "").handleIndex(rec, httptest.NewRequest(http.MethodGet, "/", nil)) page := rec.Body.String() From e9bc6844faa98c96e7205b1f449808979a41f148 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 00:39:52 -0500 Subject: [PATCH 20/37] E6 (review, second pass): the bearer scheme is case-insensitive and the token compare hashes both sides before the constant-time compare, so length no longer leaks --- internal/server/server.go | 26 ++++++++++++++++++++++---- internal/server/server_test.go | 17 +++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 3f4f570..b797b4a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" @@ -180,6 +181,21 @@ func validHost(v string) string { return v } +// bearerToken returns the credentials of an Authorization header whose scheme is "Bearer" (any case), else "" +func bearerToken(header string) string { + parts := strings.SplitN(strings.TrimSpace(header), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return strings.TrimSpace(parts[1]) +} + +// tokensEqual compares two secrets in constant time regardless of their lengths +func tokensEqual(a, b string) bool { + ha, hb := sha256.Sum256([]byte(a)), sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1 +} + // wantsJSON reports whether the client asked for the JSON answer (Grafana's action sets // X-Grafana-Action; any client may send Accept: application/json) func wantsJSON(r *http.Request) bool { @@ -251,13 +267,15 @@ func (s *Server) allowedOrigin(origin string) string { return "" } -// requireToken checks Authorization: Bearer when a token is configured (E6). Constant-time compare; -// preflight (OPTIONS) is never challenged, or the browser cannot even ask. +// requireToken checks Authorization: Bearer when a token is configured (E6). The scheme is +// case-insensitive as HTTP requires; the compare hashes both sides first, so its timing never depends on +// where the tokens differ or on their lengths (ConstantTimeCompare returns at once on unequal lengths — +// review finding). Preflight (OPTIONS) is never challenged, or the browser cannot even ask. func (s *Server) requireToken(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.authToken != "" && r.Method != http.MethodOptions { - got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if subtle.ConstantTimeCompare([]byte(got), []byte(s.authToken)) != 1 { + got := bearerToken(r.Header.Get("Authorization")) + if !tokensEqual(got, s.authToken) { w.Header().Set("WWW-Authenticate", `Bearer realm="cf2cnp"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dfbdeca..57d7278 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -261,3 +261,20 @@ func TestAuthToken(t *testing.T) { t.Fatalf("/health is open: %d", rec.Code) } } + +// Review finding: the scheme is case-insensitive, and a wrong token of ANY length is refused the same way +func TestAuthToken_SchemeCaseAndLengths(t *testing.T) { + s := NewServerWithOptions(8080, "", Options{AuthToken: "s3cret"}) + flow := fixture(t, "ingress-pos-to-shop.json") + for header, want := range map[string]int{"bearer s3cret": 200, "BEARER s3cret": 200, "Bearer s3cret": 200, "Bearer s3cre": 401, "Bearer s3cret-and-more": 401, "Basic s3cret": 401, "": 401} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/generate", strings.NewReader(flow)) + if header != "" { + req.Header.Set("Authorization", header) + } + s.corsMiddleware(s.requireToken(s.handleGenerate))(rec, req) + if rec.Code != want { + t.Fatalf("%q: got %d want %d", header, rec.Code, want) + } + } +} From 2847eb82029ac776e5773c4d88c195baeb3f1608 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 00:42:22 -0500 Subject: [PATCH 21/37] =?UTF-8?q?E2=20(review,=20second=20pass):=20L7=20re?= =?UTF-8?q?cords=20live=20on=20the=20port=20they=20were=20seen=20on=20?= =?UTF-8?q?=E2=80=94=20one=20port=20rule=20per=20port=20with=20rules,=20th?= =?UTF-8?q?e=20rest=20together=20(an=20HTTP=20rule=20on=208080=20no=20long?= =?UTF-8?q?er=20restricts=205432);=20the=20path=20regex=20allows=20an=20op?= =?UTF-8?q?tional=20query=20string=20(Envoy=20matches=20the=20whole=20:pat?= =?UTF-8?q?h);=20DNS=20queries=20are=20kept=20exactly=20as=20observed=20?= =?UTF-8?q?=E2=80=94=20the=20search-list=20expansion=20included=20?= =?UTF-8?q?=E2=80=94=20because=20an=20L7=20DNS=20policy=20denies=20every?= =?UTF-8?q?=20name=20it=20does=20not=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/aggregator/aggregator.go | 38 +++++++++++------- internal/flow/parser.go | 23 +++-------- internal/flow/parser_test.go | 13 ------- internal/policy/generator.go | 65 ++++++++++++++++++------------- internal/policy/generator_test.go | 47 ++++++++++++++++------ 5 files changed, 104 insertions(+), 82 deletions(-) diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index 1872316..bfe3832 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -24,8 +24,6 @@ type AggregatedFlow struct { IsWorldTraffic bool // True if destination is "world" IsDestEntityTraffic bool // True if destination is a reserved entity IsReply bool // True if this is a reply packet - HTTPRequests []HTTPRequest // E2: distinct method+path seen on this peer pair - DNSQueries []string // E2: distinct names queried on this peer pair } // HTTPRequest is one distinct method + path seen on a peer pair (E2) @@ -34,10 +32,13 @@ type HTTPRequest struct { Path string } -// PortInfo represents a port and protocol combination +// PortInfo represents a port and protocol combination, with the layer-7 records seen on it (E2). L7 is +// per port: a rules: block on one port must not restrict another port of the same peer pair (review finding). type PortInfo struct { - Port int - Protocol string + Port int + Protocol string + HTTPRequests []HTTPRequest // distinct method+path seen on this port + DNSQueries []string // distinct names queried on this port } // AggregateFlows groups flows by source/destination and aggregates ports @@ -116,17 +117,26 @@ func AggregateFlows(flows []*flow.ParsedFlow) []*AggregatedFlow { return result } -// addL7 collects the flow's layer-7 records on the aggregated flow (E2). They do not change the key: -// same peers, same port, more detail. +// addL7 records the flow's layer-7 records on ITS port of the aggregated flow (E2) func addL7(agg *AggregatedFlow, f *flow.ParsedFlow) { - if f.HTTPMethod != "" || f.HTTPPath != "" { - r := HTTPRequest{Method: f.HTTPMethod, Path: f.HTTPPath} - if !containsRequest(agg.HTTPRequests, r) { - agg.HTTPRequests = append(agg.HTTPRequests, r) - } + if f.HTTPMethod == "" && f.HTTPPath == "" && f.DNSQuery == "" { + return } - if f.DNSQuery != "" && !containsString(agg.DNSQueries, f.DNSQuery) { - agg.DNSQueries = append(agg.DNSQueries, f.DNSQuery) + for i := range agg.Ports { + p := &agg.Ports[i] + if p.Port != f.Port || p.Protocol != f.Protocol { + continue + } + if f.HTTPMethod != "" || f.HTTPPath != "" { + r := HTTPRequest{Method: f.HTTPMethod, Path: f.HTTPPath} + if !containsRequest(p.HTTPRequests, r) { + p.HTTPRequests = append(p.HTTPRequests, r) + } + } + if f.DNSQuery != "" && !containsString(p.DNSQueries, f.DNSQuery) { + p.DNSQueries = append(p.DNSQueries, f.DNSQuery) + } + return } } diff --git a/internal/flow/parser.go b/internal/flow/parser.go index af2921e..b035f28 100644 --- a/internal/flow/parser.go +++ b/internal/flow/parser.go @@ -189,30 +189,17 @@ func parseFlow(flow *Flow) (*ParsedFlow, error) { } } if flow.L7.DNS != nil { - parsed.DNSQuery = cleanDNSQuery(flow.L7.DNS.Query, ClusterDomain) + // The query exactly as the resolver sent it, trailing dot removed. A search-list expansion + // (accounts.bank.svc.cluster.local.bank.svc.cluster.local — ndots:5) is kept on purpose: an L7 DNS + // policy allows ONLY the names it lists ("No other DNS queries will be allowed", layer7.rst), and + // the resolver tries the expansions before the real name — deny them and lookups fail. + parsed.DNSQuery = strings.TrimSuffix(flow.L7.DNS.Query, ".") } } return parsed, nil } -// ClusterDomain is the cluster's DNS domain, used to recognise resolver search-list expansions -const ClusterDomain = "cluster.local" - -// cleanDNSQuery strips the trailing dot and a Kubernetes search-list expansion (review finding). With ndots:5 a -// pod asking for accounts.bank.svc.cluster.local (four dots) first tries accounts.bank.svc.cluster.local.bank.svc.cluster.local. -// — Hubble records that request too; a matchName on it is noise nobody queries on purpose. When the cluster -// domain appears twice, the name is cut after its first occurrence. -func cleanDNSQuery(q, clusterDomain string) string { - q = strings.TrimSuffix(q, ".") - marker := "." + clusterDomain - first := strings.Index(q, marker+".") - if first >= 0 && strings.HasSuffix(q, marker) { - return q[:first+len(marker)] - } - return q -} - // extractLabels extracts and filters labels from Hubble label format // Labels are in format "k8s:labelKey=labelValue" func extractLabels(labels []string) map[string]string { diff --git a/internal/flow/parser_test.go b/internal/flow/parser_test.go index de1364b..357fb6e 100644 --- a/internal/flow/parser_test.go +++ b/internal/flow/parser_test.go @@ -95,16 +95,3 @@ func TestParseFlowsFromBytes_RejectsNonFlows(t *testing.T) { t.Fatalf("two concatenated flows: %d %v", len(fs), err) } } - -func TestCleanDNSQuery(t *testing.T) { - for in, want := range map[string]string{ - "accounts.bank.svc.cluster.local.": "accounts.bank.svc.cluster.local", - "accounts.bank.svc.cluster.local.bank.svc.cluster.local.": "accounts.bank.svc.cluster.local", - "example.com.": "example.com", - "example.com.bank.svc.cluster.local.": "example.com.bank.svc.cluster.local", // one occurrence: kept as asked - } { - if got := cleanDNSQuery(in, ClusterDomain); got != want { - t.Fatalf("%q → %q, want %q", in, got, want) - } - } -} diff --git a/internal/policy/generator.go b/internal/policy/generator.go index 17d91fd..dadeb80 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -36,17 +36,42 @@ func (g *Generator) WithL7() *Generator { return g } -// l7Rules builds the rules: block for one aggregated flow, or nil when there is nothing to say -func (g *Generator) l7Rules(f *aggregator.AggregatedFlow) *L7Rules { - if !g.l7 || (len(f.HTTPRequests) == 0 && len(f.DNSQueries) == 0) { +// portRules builds the toPorts list for an aggregated flow. Without WithL7 (or without L7 records) it is +// one port rule with every port, as before. With them, every port that carries L7 records gets its own +// port rule with a rules: block, and the ports without stay together — an HTTP rule seen on 8080 must not +// restrict 5432 of the same peer pair (review finding). Cilium's L7Rules is a union: one protocol per port. +func (g *Generator) portRules(f *aggregator.AggregatedFlow) []PortRule { + if !g.l7 { + return []PortRule{{Ports: convertPorts(f.Ports)}} + } + var plain []aggregator.PortInfo + var rules []PortRule + for _, p := range f.Ports { + r := l7RulesFor(p) + if r == nil { + plain = append(plain, p) + continue + } + rules = append(rules, PortRule{Ports: convertPorts([]aggregator.PortInfo{p}), Rules: r}) + } + if len(plain) > 0 { + rules = append([]PortRule{{Ports: convertPorts(plain)}}, rules...) + } + return rules +} + +// l7RulesFor is the rules: block for one port, or nil when nothing was seen on it +func l7RulesFor(p aggregator.PortInfo) *L7Rules { + if len(p.HTTPRequests) == 0 && len(p.DNSQueries) == 0 { return nil } r := &L7Rules{} - for _, req := range f.HTTPRequests { - // the fields are regexes: anchor and escape the path, or "/payments" also allows "/payments-admin" - r.HTTP = append(r.HTTP, HTTPRule{Method: req.Method, Path: "^" + regexp.QuoteMeta(req.Path) + "$"}) + for _, req := range p.HTTPRequests { + // Envoy matches the regex against the WHOLE :path header, which carries the query string too: + // the path is escaped, and an optional query is allowed — "/payments?x=1" is still /payments. + r.HTTP = append(r.HTTP, HTTPRule{Method: req.Method, Path: "^" + regexp.QuoteMeta(req.Path) + `(\?.*)?$`}) } - for _, q := range f.DNSQueries { + for _, q := range p.DNSQueries { r.DNS = append(r.DNS, DNSRule{MatchName: q}) } return r @@ -321,9 +346,7 @@ func (g *Generator) generateEndpointIngressRules(policy *CiliumNetworkPolicy, f } // Add port rules - ingressRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, - } + ingressRule.ToPorts = g.portRules(f) policy.Spec.Ingress = []IngressRule{ingressRule} } @@ -341,9 +364,7 @@ func (g *Generator) generateEntityIngressRules(policy *CiliumNetworkPolicy, f *a } // Add port rules - ingressRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, - } + ingressRule.ToPorts = g.portRules(f) policy.Spec.Ingress = []IngressRule{ingressRule} } @@ -393,9 +414,7 @@ func (g *Generator) generateFQDNEgressRules(policy *CiliumNetworkPolicy, f *aggr } // Add port rules - fqdnRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, - } + fqdnRule.ToPorts = g.portRules(f) // Second rule: DNS resolution (required for toFQDNs to work) dnsRule := EgressRule{ @@ -434,10 +453,8 @@ func (g *Generator) generateWorldEgressRules(policy *CiliumNetworkPolicy, f *agg // Rule for CIDR-based world traffic cidrRule := EgressRule{ - ToCIDR: cidrs, - ToPorts: []PortRule{ - {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, - }, + ToCIDR: cidrs, + ToPorts: g.portRules(f), } policy.Spec.Egress = []EgressRule{cidrRule} @@ -455,9 +472,7 @@ func (g *Generator) generateEntityEgressRules(policy *CiliumNetworkPolicy, f *ag entityRule.ToEntities = []string{f.DestEntity} } - entityRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, - } + entityRule.ToPorts = g.portRules(f) policy.Spec.Egress = []EgressRule{entityRule} } @@ -479,9 +494,7 @@ func (g *Generator) generateEndpointEgressRules(policy *CiliumNetworkPolicy, f * } // Add port rules - egressRule.ToPorts = []PortRule{ - {Ports: convertPorts(f.Ports), Rules: g.l7Rules(f)}, - } + egressRule.ToPorts = g.portRules(f) policy.Spec.Egress = []EgressRule{egressRule} } diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 2443205..b8c762c 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -3,6 +3,7 @@ package policy import ( "os" "path/filepath" + "regexp" "strings" "testing" @@ -201,8 +202,8 @@ func TestBuildPolicies_L7HTTPRules(t *testing.T) { t.Fatal(err) } rules := firstRules(ps) - if rules == nil || len(rules.HTTP) != 1 || rules.HTTP[0].Method != "GET" || rules.HTTP[0].Path != "^/api/balance/chk-1001$" { - t.Fatalf("expected one anchored GET rule for the measured path, got %+v", rules) + if rules == nil || len(rules.HTTP) != 1 || rules.HTTP[0].Method != "GET" || rules.HTTP[0].Path != `^/api/balance/chk-1001(\?.*)?$` { + t.Fatalf("expected one escaped GET rule for the measured path allowing a query, got %+v", rules) } plain, _ := NewGenerator("").BuildPolicies(load(t, "l7-http-request.json")) if out, _ := EncodePolicies(plain); strings.Contains(string(out), "http:") { @@ -226,22 +227,46 @@ func TestBuildPolicies_L7DNSRules(t *testing.T) { } } -// E2: regex metacharacters in a path are escaped -func TestL7Rules_PathIsEscaped(t *testing.T) { - r := NewGenerator("").WithL7().l7Rules(&aggregator.AggregatedFlow{HTTPRequests: []aggregator.HTTPRequest{{Method: "GET", Path: "/v1/items.json"}}}) - if r.HTTP[0].Path != `^/v1/items\.json$` { - t.Fatalf("got %q", r.HTTP[0].Path) +// E2: regex metacharacters in a path are escaped, and a query string is allowed (Envoy matches the whole :path) +func TestL7Rules_PathIsEscapedAndAllowsAQuery(t *testing.T) { + r := l7RulesFor(aggregator.PortInfo{Port: 80, Protocol: "TCP", HTTPRequests: []aggregator.HTTPRequest{{Method: "GET", Path: "/v1/items.json"}}}) + want := `^/v1/items\.json(\?.*)?$` + if r.HTTP[0].Path != want { + t.Fatalf("got %q want %q", r.HTTP[0].Path, want) + } + re := regexp.MustCompile(r.HTTP[0].Path) + for path, ok := range map[string]bool{"/v1/items.json": true, "/v1/items.json?x=1": true, "/v1/itemsXjson": false, "/v1/items.json/extra": false} { + if re.MatchString(path) != ok { + t.Fatalf("%q matched=%v, want %v", path, !ok, ok) + } + } +} + +// Review finding: L7 records belong to the port they were seen on — an HTTP rule on 8080 must not restrict 5432 +func TestBuildPolicies_L7RulesArePerPort(t *testing.T) { + agg := &aggregator.AggregatedFlow{Direction: "INGRESS", DestNamespace: "bank", DestLabels: map[string]string{"app": "api"}, + SourceNamespace: "bank", SourceLabels: map[string]string{"app": "web"}, + Ports: []aggregator.PortInfo{{Port: 5432, Protocol: "TCP"}, {Port: 8080, Protocol: "TCP", HTTPRequests: []aggregator.HTTPRequest{{Method: "POST", Path: "/payments"}}}}} + ps, _ := NewGenerator("").WithL7().BuildPolicies([]*aggregator.AggregatedFlow{agg}) + tp := ps[0].Spec.Ingress[0].ToPorts + if len(tp) != 2 || tp[0].Rules != nil || tp[0].Ports[0].Port != "5432" || tp[1].Rules == nil || tp[1].Ports[0].Port != "8080" { + t.Fatalf("expected [5432 plain] [8080 with rules], got %+v", tp) + } + plain, _ := NewGenerator("").BuildPolicies([]*aggregator.AggregatedFlow{agg}) + if n := len(plain[0].Spec.Ingress[0].ToPorts); n != 1 { + t.Fatalf("without WithL7 the ports stay in one rule, got %d", n) } } -// Review finding: the measured DNS request is a search-list expansion; the rule must name the real name -func TestBuildPolicies_L7DNSRules_StripsSearchDomain(t *testing.T) { +// Both reviewers weighed in and the docs decide: an L7 DNS policy allows ONLY the names it lists, and the +// resolver tries the search-list expansions first — so the measured expansion is kept exactly as observed +func TestBuildPolicies_L7DNSRules_KeepsTheQueryAsObserved(t *testing.T) { ps, err := NewGenerator("").WithL7().BuildPolicies(load(t, "l7-dns-request.json")) if err != nil { t.Fatal(err) } rules := firstRules(ps) - if rules == nil || len(rules.DNS) != 1 || rules.DNS[0].MatchName != "accounts.bank.svc.cluster.local" { - t.Fatalf("ndots expansion must be stripped, got %+v", rules) + if rules == nil || len(rules.DNS) != 1 || rules.DNS[0].MatchName != "accounts.bank.svc.cluster.local.bank.svc.cluster.local" { + t.Fatalf("the query must be kept as the resolver sent it, got %+v", rules) } } From 2dbb8665076ce98d6606539b345f3a9d32269dbc Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 05:45:59 -0500 Subject: [PATCH 22/37] =?UTF-8?q?release=200.6.0:=20enhancement=20001=20?= =?UTF-8?q?=E2=80=94=20cluster-aware=20selectors=20(E1),=20L7=20rules=20(E?= =?UTF-8?q?2),=20DNS=20visibility=20(E3),=20exclude=20peers=20(E4),=20merg?= =?UTF-8?q?e=20(E5),=20hardening=20(E6),=20binaries=20(E10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- helm/cf2cnp/Chart.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fa162f8..a9030be 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ go build -o cf2cnp.exe ./cmd/cf2cnp ## Installation > **Fork note (ephico2real2/cf2cnp):** while the changes on this fork are pending upstream, the fork's chart is published as a -> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.5.1, appVersion 0.5.1) and its image as -> `ghcr.io/ephico2real2/cf2cnp:0.5.1`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. +> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.0, appVersion 0.6.0) and its image as +> `ghcr.io/ephico2real2/cf2cnp:0.6.0`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. The easiest way to use `CF2CNP` is to deploy it together with the [hubble-observer Helm chart](https://github.com/onzack/hubble-observer). This chart installs both the Hubble observer (to collect network flow data) and `CF2CNP` into your Kubernetes cluster, so you can generate policies directly from observed traffic. You can find installation instructions and configuration options for the Helm chart in the [hubble-observer Helm chart repository](https://github.com/onzack/hubble-observer). diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index 72c07e6..d5c2f96 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application -version: 0.5.1 -appVersion: "0.5.1" +version: 0.6.0 +appVersion: "0.6.0" keywords: - cilium - network-policy From 6dc41d724be22aa2ba98e7d76518cbebae4c403b Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 06:31:45 -0500 Subject: [PATCH 23/37] =?UTF-8?q?0.6.1:=20merge=20keeps=20the=20document?= =?UTF-8?q?=20as=20it=20was=20(a=20YAML=20node=20edit:=20key=20order,=20co?= =?UTF-8?q?mments,=20quoting,=202-space=20indentation=20=E2=80=94=20a=20pu?= =?UTF-8?q?ll=20request=20shows=20the=20added=20rules=20and=20nothing=20el?= =?UTF-8?q?se;=20demo=2032=20finding),=20and=20a=20pod's=20own=20DNS=20loo?= =?UTF-8?q?kups=20no=20longer=20leave=20kube-dns=2053/UDP=20twice=20beside?= =?UTF-8?q?=20the=20DNS-visibility=20rule=20(demo=2031=20finding,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- cmd/cf2cnp/main.go | 11 +- helm/cf2cnp/Chart.yaml | 4 +- internal/policy/generator.go | 30 +++++ internal/policy/generator_test.go | 26 ++++ internal/policy/merge.go | 125 +++++++++++++----- internal/policy/merge_test.go | 110 ++++++++++----- internal/testdata/egress-pos-to-kube-dns.json | 87 ++++++++++++ 8 files changed, 328 insertions(+), 75 deletions(-) create mode 100644 internal/testdata/egress-pos-to-kube-dns.json diff --git a/README.md b/README.md index a9030be..c3609bb 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ go build -o cf2cnp.exe ./cmd/cf2cnp ## Installation > **Fork note (ephico2real2/cf2cnp):** while the changes on this fork are pending upstream, the fork's chart is published as a -> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.0, appVersion 0.6.0) and its image as -> `ghcr.io/ephico2real2/cf2cnp:0.6.0`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. +> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.1, appVersion 0.6.1) and its image as +> `ghcr.io/ephico2real2/cf2cnp:0.6.1`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. The easiest way to use `CF2CNP` is to deploy it together with the [hubble-observer Helm chart](https://github.com/onzack/hubble-observer). This chart installs both the Hubble observer (to collect network flow data) and `CF2CNP` into your Kubernetes cluster, so you can generate policies directly from observed traffic. You can find installation instructions and configuration options for the Helm chart in the [hubble-observer Helm chart repository](https://github.com/onzack/hubble-observer). @@ -85,6 +85,12 @@ cf2cnp merge --existing policies/shop.yaml --input flows.json # in plac cf2cnp merge --existing policies/shop.yaml --input flows/ -o new.yaml # to another file ``` +The merged file is the existing file plus the new rules — key order, comments, quoting and indentation are kept, so a pull +request shows the rules that were added and nothing else (0.6.1; 0.6.0 re-serialised the whole document). + +```bash +``` + The flows must produce exactly one policy, for the same target (namespace, name, endpointSelector) as the existing file; otherwise the command refuses. diff --git a/cmd/cf2cnp/main.go b/cmd/cf2cnp/main.go index f17b25a..147c5b7 100644 --- a/cmd/cf2cnp/main.go +++ b/cmd/cf2cnp/main.go @@ -11,7 +11,6 @@ import ( "github.com/hubble-policy-gen/internal/policy" "github.com/hubble-policy-gen/internal/server" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) var ( @@ -221,10 +220,6 @@ func runMerge(cmd *cobra.Command, args []string) error { if err != nil { return err } - var existing map[string]interface{} - if err := yaml.Unmarshal(existingBytes, &existing); err != nil { - return fmt.Errorf("existing policy: %w", err) - } flows, err := readFlows(inputDir) if err != nil { return err @@ -236,11 +231,7 @@ func runMerge(cmd *cobra.Command, args []string) error { if len(policies) != 1 { return fmt.Errorf("the flows produce %d policies; merge takes exactly one target — filter the flows to one workload", len(policies)) } - added, err := policy.MergeInto(existing, policies[0]) - if err != nil { - return err - } - out, err := yaml.Marshal(existing) + out, added, err := policy.MergeDocument(existingBytes, policies[0]) if err != nil { return err } diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index d5c2f96..8e3be2d 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application -version: 0.6.0 -appVersion: "0.6.0" +version: 0.6.1 +appVersion: "0.6.1" keywords: - cilium - network-policy diff --git a/internal/policy/generator.go b/internal/policy/generator.go index df1292a..d240486 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -190,6 +190,7 @@ func MergePolicies(policies []*CiliumNetworkPolicy) []*CiliumNetworkPolicy { result := make([]*CiliumNetworkPolicy, 0, len(order)) for _, key := range order { p := byKey[key] + p.Spec.Egress = dropShadowedDNSRule(p.Spec.Egress) if n := merged[key]; n > 0 { p.Spec.Description = mergedDescription(p, n+1) } @@ -198,6 +199,35 @@ func MergePolicies(policies []*CiliumNetworkPolicy) []*CiliumNetworkPolicy { return result } +// dropShadowedDNSRule removes the plain kube-dns 53/UDP rule a pod's own lookups produce when the same policy also +// carries the DNS-visibility rule (E3, or the toFQDNs path): same peer, same port, and `matchPattern: "*"` allows +// every name, so the L7 rule says everything the plain one said. Cilium accepted both, but the policy read as if +// the rule were there twice (demo 31; fork issue #1). A narrower DNS rule (names from --l7) is not a superset and +// leaves the plain rule alone. +func dropShadowedDNSRule(rules []EgressRule) []EgressRule { + visibility := mustYAML(dnsVisibilityRule()) + shadowed := false + for _, r := range rules { + if mustYAML(r) == visibility { + shadowed = true + break + } + } + if !shadowed { + return rules + } + plain := dnsVisibilityRule() + plain.ToPorts[0].Rules = nil + plainYAML := mustYAML(plain) + kept := rules[:0] + for _, r := range rules { + if mustYAML(r) != plainYAML { + kept = append(kept, r) + } + } + return kept +} + func mergedDescription(p *CiliumNetworkPolicy, flows int) string { var dir string switch { diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index 08b462d..a71fffd 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -362,3 +362,29 @@ func TestBuildPolicies_DNSVisibilityCompanion(t *testing.T) { t.Fatalf("the comment must say what the rule is for:\n%s", out) } } + +// Demo 31 finding (fork issue #1): a pod whose flows include its own DNS lookups got kube-dns 53/UDP twice — the plain +// rule from the lookup and the DNS-visibility rule. With --dns-visibility the plain one is dropped (the L7 rule with +// matchPattern "*" says everything it said); without the option nothing changes. +func TestDNSVisibility_OneKubeDNSRule(t *testing.T) { + ps, err := NewGenerator("").WithDNSVisibility().BuildPolicies(load(t, "egress-pos-to-kube-dns.json", "egress-pos-to-world.json")) + if err != nil || len(ps) != 1 { + t.Fatalf("one policy expected: %d %v", len(ps), err) + } + dns := 0 + for _, r := range ps[0].Spec.Egress { + if len(r.ToEndpoints) == 1 && r.ToEndpoints[0].MatchLabels["k8s-app"] == "kube-dns" { + dns++ + if r.ToPorts[0].Rules == nil || len(r.ToPorts[0].Rules.DNS) != 1 { + t.Fatalf("the surviving kube-dns rule must be the DNS-visibility one: %s", mustYAML(r)) + } + } + } + if dns != 1 { + t.Fatalf("kube-dns must appear once, got %d:\n%s", dns, mustYAML(ps[0].Spec.Egress)) + } + plain, _ := NewGenerator("").BuildPolicies(load(t, "egress-pos-to-kube-dns.json", "egress-pos-to-world.json")) + if n := len(plain[0].Spec.Egress); n != 2 { + t.Fatalf("without the option the plain rule stays beside the CIDR: %d rules", n) + } +} diff --git a/internal/policy/merge.go b/internal/policy/merge.go index 9163c0a..f0b2f0c 100644 --- a/internal/policy/merge.go +++ b/internal/policy/merge.go @@ -1,29 +1,36 @@ package policy import ( + "bytes" "errors" "fmt" "gopkg.in/yaml.v3" ) -// MergeInto adds the generated policy's rules to an existing CiliumNetworkPolicy document without -// touching anything else in it (E5). The document is handled as a generic map, so fields this tool -// does not model (ingressDeny, enableDefaultDeny, annotations, …) survive untouched. It refuses to -// merge into a different target: same namespace, same name, same endpointSelector, or nothing happens. -// Running it twice adds nothing the second time. -func MergeInto(existing map[string]interface{}, generated *CiliumNetworkPolicy) (added int, err error) { - meta, _ := existing["metadata"].(map[string]interface{}) - spec, _ := existing["spec"].(map[string]interface{}) - if meta == nil || spec == nil { - return 0, errors.New("existing document has no metadata/spec") +// MergeDocument adds the generated policy's rules to an existing CiliumNetworkPolicy YAML document (E5) and +// returns the document with everything else as it was: key order, comments, scalar quoting, flow-style lists, +// and the 2-space indentation `generate` writes. Fields this tool does not model (ingressDeny, enableDefaultDeny, +// annotations, …) survive untouched because the document is edited as a YAML node tree, not re-built from a map. +// The first version decoded the document into a generic map and marshalled it back — every key came out in +// alphabetical order at yaml.v3's default indentation, and a pull request showed the whole file changed instead +// of one rule (demo 32). It refuses another target (namespace, name, endpointSelector) and, run twice, adds nothing +// the second time. yaml.v3 drops blank lines between keys; that is the one thing about the layout it does not keep. +func MergeDocument(existing []byte, generated *CiliumNetworkPolicy) (out []byte, added int, err error) { + var doc yaml.Node + if err := yaml.Unmarshal(existing, &doc); err != nil { + return nil, 0, fmt.Errorf("existing policy: %w", err) + } + if doc.Kind != yaml.DocumentNode || len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode { + return nil, 0, errors.New("existing policy: not a single YAML document with a mapping at the top") } - if fmt.Sprint(meta["name"]) != generated.Metadata.Name || fmt.Sprint(meta["namespace"]) != generated.Metadata.Namespace { - return 0, fmt.Errorf("target mismatch: existing %v/%v, generated %s/%s", - meta["namespace"], meta["name"], generated.Metadata.Namespace, generated.Metadata.Name) + root := doc.Content[0] + meta, spec := mappingValue(root, "metadata"), mappingValue(root, "spec") + if meta == nil || spec == nil { + return nil, 0, errors.New("existing document has no metadata/spec") } - if canonicalYAML(spec["endpointSelector"]) != canonicalYAML(toGeneric(generated.Spec.EndpointSelector)) { - return 0, errors.New("target mismatch: the endpointSelector differs") + if err := checkTarget(meta, spec, generated); err != nil { + return nil, 0, err } for _, r := range generated.Spec.Ingress { if appendUnique(spec, "ingress", toGeneric(r)) { @@ -35,33 +42,91 @@ func MergeInto(existing map[string]interface{}, generated *CiliumNetworkPolicy) added++ } } - return added, nil + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) // the style `generate` writes, so an unchanged rule is an unchanged line + if err := enc.Encode(&doc); err != nil { + return nil, 0, err + } + if err := enc.Close(); err != nil { + return nil, 0, err + } + return buf.Bytes(), added, nil } -// toGeneric round-trips a typed value through YAML so it compares and stores like the existing document -func toGeneric(v interface{}) interface{} { - var out interface{} - b, _ := yaml.Marshal(v) - _ = yaml.Unmarshal(b, &out) - return out +// checkTarget refuses a document that is not the generated policy's target: same namespace, same name, same +// endpointSelector, or nothing happens +func checkTarget(meta, spec *yaml.Node, generated *CiliumNetworkPolicy) error { + name, namespace := scalarValue(meta, "name"), scalarValue(meta, "namespace") + if name != generated.Metadata.Name || namespace != generated.Metadata.Namespace { + return fmt.Errorf("target mismatch: existing %s/%s, generated %s/%s", + namespace, name, generated.Metadata.Namespace, generated.Metadata.Name) + } + var selector interface{} + if n := mappingValue(spec, "endpointSelector"); n != nil { + _ = n.Decode(&selector) + } + if canonicalYAML(selector) != canonicalYAML(toGeneric(generated.Spec.EndpointSelector)) { + return errors.New("target mismatch: the endpointSelector differs") + } + return nil } -// appendUnique adds item to spec[key] (a list) unless an equal item is already there -func appendUnique(spec map[string]interface{}, key string, item interface{}) bool { - list, _ := spec[key].([]interface{}) +// appendUnique adds item to the list under key (created after the last key when missing) unless an equal item is +// already there. Equality is by canonical YAML, so a hand-written `port: 80` and the generated `port: "80"` are one +// rule (review finding — without this the merge appended a duplicate). +func appendUnique(spec *yaml.Node, key string, item interface{}) bool { + list := mappingValue(spec, key) + if list == nil { + list = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + spec.Content = append(spec.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, list) + } want := canonicalYAML(item) - for _, have := range list { - if canonicalYAML(have) == want { + for _, n := range list.Content { + var have interface{} + if err := n.Decode(&have); err == nil && canonicalYAML(have) == want { return false } } - spec[key] = append(list, item) + var n yaml.Node + if err := n.Encode(item); err != nil { + return false + } + list.Content = append(list.Content, &n) return true } +// mappingValue returns the value node of key in a mapping node, or nil +func mappingValue(m *yaml.Node, key string) *yaml.Node { + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +// scalarValue returns the scalar under key in a mapping node, or "" — the same "" a missing key or a non-scalar gives +func scalarValue(m *yaml.Node, key string) string { + if n := mappingValue(m, key); n != nil && n.Kind == yaml.ScalarNode { + return n.Value + } + return "" +} + +// toGeneric round-trips a typed value through YAML so it compares and stores like the existing document +func toGeneric(v interface{}) interface{} { + var out interface{} + b, _ := yaml.Marshal(v) + _ = yaml.Unmarshal(b, &out) + return out +} + // canonicalYAML renders a generic value for comparison with every scalar as a string: a hand-written -// `port: 80` and the generated `port: "80"` are one rule to Kubernetes and must be one rule here (review -// finding — without this the merge appended a duplicate) +// `port: 80` and the generated `port: "80"` are one rule to Kubernetes and must be one rule here func canonicalYAML(v interface{}) string { b, err := yaml.Marshal(stringifyScalars(v)) if err != nil { diff --git a/internal/policy/merge_test.go b/internal/policy/merge_test.go index 9de17c2..7401572 100644 --- a/internal/policy/merge_test.go +++ b/internal/policy/merge_test.go @@ -3,8 +3,6 @@ package policy import ( "strings" "testing" - - "gopkg.in/yaml.v3" ) const existingShop = `apiVersion: cilium.io/v2 @@ -31,24 +29,13 @@ spec: - fromEntities: [world] ` -func existingDoc(t *testing.T) map[string]interface{} { - t.Helper() - var doc map[string]interface{} - if err := yaml.Unmarshal([]byte(existingShop), &doc); err != nil { - t.Fatal(err) - } - return doc -} - // E5: the stranger rule is added, pos (already there) is not duplicated, ingressDeny and the annotation survive -func TestMergeInto_AddsOnlyWhatIsMissing(t *testing.T) { - doc := existingDoc(t) +func TestMergeDocument_AddsOnlyWhatIsMissing(t *testing.T) { ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "ingress-stranger-to-shop.json")) - added, err := MergeInto(doc, ps[0]) + out, added, err := MergeDocument([]byte(existingShop), ps[0]) if err != nil || added != 1 { t.Fatalf("added=%d err=%v", added, err) } - out, _ := yaml.Marshal(doc) s := string(out) for _, want := range []string{"ingressDeny", "owner: platform-team", "app.kubernetes.io/name: stranger", "hand-written"} { if !strings.Contains(s, want) { @@ -59,32 +46,80 @@ func TestMergeInto_AddsOnlyWhatIsMissing(t *testing.T) { t.Fatalf("pos must appear once:\n%s", s) } // idempotent: a second merge adds nothing and changes nothing - again, err := MergeInto(doc, ps[0]) - out2, _ := yaml.Marshal(doc) + out2, again, err := MergeDocument(out, ps[0]) if err != nil || again != 0 || string(out2) != s { t.Fatalf("second merge: added=%d err=%v same=%v", again, err, string(out2) == s) } } +// Demo 32 finding: the merged file must be the existing file plus the new rule — not a re-serialisation. The +// first version re-ordered every key and re-indented the document, so a pull request showed the whole file +// changed. Here the existing document is in the style `generate` writes; the output must START with it verbatim. +func TestMergeDocument_KeepsTheDocumentAsItWas(t *testing.T) { + ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "ingress-stranger-to-shop.json")) + existing := strings.TrimSuffix(existingShop, " ingressDeny:\n - fromEntities: [world]\n") // the list to grow is last + out, added, err := MergeDocument([]byte(existing), ps[0]) + if err != nil || added != 1 { + t.Fatalf("added=%d err=%v", added, err) + } + if !strings.HasPrefix(string(out), existing) { + t.Fatalf("the existing document must be kept byte for byte; got:\n%s", out) + } + tail := strings.TrimPrefix(string(out), existing) + if !strings.HasPrefix(tail, " - fromEndpoints:\n - matchLabels:\n app.kubernetes.io/name: stranger\n") { + t.Fatalf("the appended rule must be at the same indentation as the existing one; got:\n%s", tail) + } +} + +// Demo 32 finding, the hand-written case: key order, a comment and a flow-style list survive the merge +func TestMergeDocument_KeepsOrderCommentsAndStyle(t *testing.T) { + existing := `apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + labels: {team: shop} # labels first, on purpose + name: shop + namespace: cf2cnp-lab +spec: + endpointSelector: {matchLabels: {app.kubernetes.io/name: shop}} + ingress: + - fromEndpoints: [{matchLabels: {app.kubernetes.io/name: pos}}] # pos: the till + toPorts: [{ports: [{port: "80", protocol: TCP}]}] +` + ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json", "ingress-stranger-to-shop.json")) + out, added, err := MergeDocument([]byte(existing), ps[0]) + if err != nil || added != 1 { + t.Fatalf("added=%d err=%v", added, err) + } + s := string(out) + for _, want := range []string{"# labels first, on purpose", "# pos: the till", "labels: {team: shop}", "fromEndpoints: [{matchLabels: {app.kubernetes.io/name: pos}}]"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in:\n%s", want, s) + } + } + if strings.Index(s, "labels:") > strings.Index(s, "name: shop") { + t.Fatalf("key order must be kept (labels before name):\n%s", s) + } +} + // E5: a different target is refused -func TestMergeInto_RefusesAnotherTarget(t *testing.T) { - doc := existingDoc(t) +func TestMergeDocument_RefusesAnotherTarget(t *testing.T) { ps, _ := NewGenerator("").BuildPolicies(load(t, "egress-pos-to-world.json")) // policy "pos", not "shop" - if _, err := MergeInto(doc, ps[0]); err == nil || !strings.Contains(err.Error(), "target mismatch") { + if _, _, err := MergeDocument([]byte(existingShop), ps[0]); err == nil || !strings.Contains(err.Error(), "target mismatch") { t.Fatalf("expected a target mismatch, got %v", err) } - doc2 := existingDoc(t) - doc2["spec"].(map[string]interface{})["endpointSelector"] = map[string]interface{}{"matchLabels": map[string]interface{}{"app.kubernetes.io/name": "shop", "app.kubernetes.io/component": "frontend"}} + other := strings.Replace(existingShop, " app.kubernetes.io/name: shop\n", " app.kubernetes.io/name: shop\n app.kubernetes.io/component: frontend\n", 1) ps2, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json")) - if _, err := MergeInto(doc2, ps2[0]); err == nil || !strings.Contains(err.Error(), "endpointSelector") { + if _, _, err := MergeDocument([]byte(other), ps2[0]); err == nil || !strings.Contains(err.Error(), "endpointSelector") { t.Fatalf("expected an endpointSelector mismatch, got %v", err) } + if _, _, err := MergeDocument([]byte("- not: a mapping\n"), ps2[0]); err == nil { + t.Fatal("a document that is not a mapping must be refused") + } } // Review finding: a hand-written `port: 80` (a YAML int) and the generated `port: "80"` are the same rule -func TestMergeInto_PortIntEqualsPortString(t *testing.T) { - var doc map[string]interface{} - if err := yaml.Unmarshal([]byte(`apiVersion: cilium.io/v2 +func TestMergeDocument_PortIntEqualsPortString(t *testing.T) { + existing := `apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: {name: shop, namespace: cf2cnp-lab} spec: @@ -92,12 +127,25 @@ spec: ingress: - fromEndpoints: [{matchLabels: {app.kubernetes.io/name: pos}}] toPorts: [{ports: [{port: 80, protocol: TCP}]}] -`), &doc); err != nil { - t.Fatal(err) - } +` ps, _ := NewGenerator("").BuildPolicies(load(t, "ingress-pos-to-shop.json")) - added, err := MergeInto(doc, ps[0]) - if err != nil || added != 0 { + if _, added, err := MergeDocument([]byte(existing), ps[0]); err != nil || added != 0 { t.Fatalf("port: 80 and port: \"80\" are one rule; added=%d err=%v", added, err) } } + +// A document without the list yet (an ingress-only policy receiving egress rules) gets the list created +func TestMergeDocument_CreatesTheMissingList(t *testing.T) { + existing := `apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: {name: pos, namespace: cf2cnp-lab} +spec: + endpointSelector: {matchLabels: {app.kubernetes.io/name: pos}} + ingress: [] +` + ps, _ := NewGenerator("").BuildPolicies(load(t, "egress-pos-to-world.json")) + out, added, err := MergeDocument([]byte(existing), ps[0]) + if err != nil || added == 0 || !strings.Contains(string(out), "\n egress:\n - toCIDR:") { + t.Fatalf("egress must be created after ingress; added=%d err=%v out:\n%s", added, err, out) + } +} diff --git a/internal/testdata/egress-pos-to-kube-dns.json b/internal/testdata/egress-pos-to-kube-dns.json new file mode 100644 index 0000000..83356fb --- /dev/null +++ b/internal/testdata/egress-pos-to-kube-dns.json @@ -0,0 +1,87 @@ +{ + "flow": { + "time": "2026-09-13T11:15:24.514646998Z", + "uuid": "eb427f52-8fbd-41fb-bb19-228369ec61d3", + "emitter": { + "name": "Hubble", + "version": "1.20.1+g7d68cfb3" + }, + "verdict": "FORWARDED", + "ethernet": { + "source": "e6:fe:44:c2:b3:b6", + "destination": "2a:b2:c2:39:45:76" + }, + "IP": { + "source": "10.10.4.93", + "destination": "10.10.1.35", + "ipVersion": "IPv4" + }, + "l4": { + "UDP": { + "source_port": 41406, + "destination_port": 53 + } + }, + "source": { + "identity": 82359, + "cluster_name": "poc1", + "namespace": "cf2cnp-lab", + "labels": [ + "k8s:app.kubernetes.io/name=pos", + "k8s:app=pos", + "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=cf2cnp-lab", + "k8s:io.cilium.k8s.policy.cluster=poc1", + "k8s:io.cilium.k8s.policy.serviceaccount=default", + "k8s:io.kubernetes.pod.namespace=cf2cnp-lab" + ], + "pod_name": "pos" + }, + "destination": { + "ID": 591, + "identity": 80806, + "cluster_name": "poc1", + "namespace": "kube-system", + "labels": [ + "gen:io.cilium.k8s.named-ports-1=metrics.TCP.9153_readiness-probe.TCP.8181", + "gen:io.cilium.k8s.named-ports=dns.UDP.53_dns-tcp.TCP.53_liveness-probe.TCP.8080", + "k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=kube-system", + "k8s:io.cilium.k8s.policy.cluster=poc1", + "k8s:io.cilium.k8s.policy.serviceaccount=coredns", + "k8s:io.kubernetes.pod.namespace=kube-system", + "k8s:k8s-app=kube-dns" + ], + "pod_name": "coredns-789c5fbdb4-qhj2d", + "workloads": [ + { + "name": "coredns", + "kind": "Deployment" + } + ] + }, + "Type": "L3_L4", + "node_name": "poc1/poc1-control-plane2", + "node_labels": [ + "beta.kubernetes.io/arch=amd64", + "beta.kubernetes.io/os=linux", + "kubernetes.io/arch=amd64", + "kubernetes.io/hostname=poc1-control-plane2", + "kubernetes.io/os=linux", + "node-role.kubernetes.io/control-plane=", + "node.kubernetes.io/exclude-from-external-load-balancers=" + ], + "event_type": { + "type": 4 + }, + "traffic_direction": "EGRESS", + "trace_observation_point": "TO_ENDPOINT", + "trace_reason": "NEW", + "is_reply": false, + "interface": { + "index": 39, + "name": "lxc1a237d3e277a" + }, + "Summary": "UDP" + }, + "node_name": "poc1/poc1-control-plane2", + "time": "2026-09-13T11:15:24.514646998Z" +} From 92ad9620f0d83e03b2540130ce263347e0d661db Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 09:35:46 -0500 Subject: [PATCH 24/37] =?UTF-8?q?0.6.2:=20the=20description=20is=20written?= =?UTF-8?q?=20from=20the=20policy's=20own=20rules=20=E2=80=94=20subject,?= =?UTF-8?q?=20peers=20as=20the=20selector=20names=20them=20(namespace=20an?= =?UTF-8?q?d=20cluster=20when=20they=20differ),=20ports,=20L7=20rules=20?= =?UTF-8?q?=E2=80=94=20instead=20of=20a=20generic=20sentence=20about=20nam?= =?UTF-8?q?espaces=20(operator=20finding=20on=20the=20page)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- helm/cf2cnp/Chart.yaml | 4 +- internal/policy/describe.go | 169 ++++++++++++++++++++++++++++++ internal/policy/describe_test.go | 69 ++++++++++++ internal/policy/generator.go | 18 +--- internal/policy/generator_test.go | 4 +- 6 files changed, 247 insertions(+), 23 deletions(-) create mode 100644 internal/policy/describe.go create mode 100644 internal/policy/describe_test.go diff --git a/README.md b/README.md index c3609bb..79ecaef 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ go build -o cf2cnp.exe ./cmd/cf2cnp ## Installation > **Fork note (ephico2real2/cf2cnp):** while the changes on this fork are pending upstream, the fork's chart is published as a -> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.1, appVersion 0.6.1) and its image as -> `ghcr.io/ephico2real2/cf2cnp:0.6.1`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. +> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.2, appVersion 0.6.2) and its image as +> `ghcr.io/ephico2real2/cf2cnp:0.6.2`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. The easiest way to use `CF2CNP` is to deploy it together with the [hubble-observer Helm chart](https://github.com/onzack/hubble-observer). This chart installs both the Hubble observer (to collect network flow data) and `CF2CNP` into your Kubernetes cluster, so you can generate policies directly from observed traffic. You can find installation instructions and configuration options for the Helm chart in the [hubble-observer Helm chart repository](https://github.com/onzack/hubble-observer). @@ -85,6 +85,8 @@ cf2cnp merge --existing policies/shop.yaml --input flows.json # in plac cf2cnp merge --existing policies/shop.yaml --input flows/ -o new.yaml # to another file ``` +Every policy's `description` is written from its rules (0.6.2): the subject, each peer the way the selector names it (with its namespace or cluster when they differ), the ports, and the L7 rules — `Allow ingress to shop-frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80`. + The merged file is the existing file plus the new rules — key order, comments, quoting and indentation are kept, so a pull request shows the rules that were added and nothing else (0.6.1; 0.6.0 re-serialised the whole document). diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index 8e3be2d..f792b9e 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application -version: 0.6.1 -appVersion: "0.6.1" +version: 0.6.2 +appVersion: "0.6.2" keywords: - cilium - network-policy diff --git a/internal/policy/describe.go b/internal/policy/describe.go new file mode 100644 index 0000000..870bd14 --- /dev/null +++ b/internal/policy/describe.go @@ -0,0 +1,169 @@ +package policy + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/hubble-policy-gen/internal/flow" +) + +// Describe writes the policy's description from its own spec, so that it says what the rules say — who the +// subject is, which peers it may talk to, on which ports, with which L7 rules — instead of a generic sentence +// about namespaces. It runs once the rules are final (after the merge across flows), on every policy: +// +// Allow ingress to shop-frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80 +// Allow egress from pos in cf2cnp-lab: to shop on TCP/80; to kube-dns in kube-system on UDP/53 (DNS *); to example.com on TCP/443 +// Allow ingress to cache-server in mesh-lab: from worker/batch in cluster poc2 on TCP/6379 +// +// Peers are named the way the selector names them (name, then component and instance), a peer in another namespace +// or cluster says so, entities and CIDRs are listed as written. A long policy is cut after maxDescribedRules peers +// with "and N more", so the description stays a sentence, not a second copy of the spec. +func Describe(p *CiliumNetworkPolicy) string { + subject := fmt.Sprintf("%s in %s", p.Metadata.Name, p.Metadata.Namespace) + var parts []string + if n := len(p.Spec.Ingress); n > 0 { + items := make([]string, 0, n) + for _, r := range p.Spec.Ingress { + items = append(items, "from "+peerList(r.FromEndpoints, r.FromEntities, nil, nil)+portList(r.ToPorts)) + } + parts = append(parts, "ingress to "+subject+": "+joinRules(items)) + } + if n := len(p.Spec.Egress); n > 0 { + items := make([]string, 0, n) + for _, r := range p.Spec.Egress { + items = append(items, "to "+peerList(r.ToEndpoints, r.ToEntities, r.ToCIDR, r.ToFQDNs)+portList(r.ToPorts)) + } + parts = append(parts, "egress from "+subject+": "+joinRules(items)) + } + if len(parts) == 0 { + return "Allow nothing for " + subject + } + return "Allow " + strings.Join(parts, "; ") +} + +const maxDescribedRules = 6 + +func joinRules(items []string) string { + if len(items) > maxDescribedRules { + return strings.Join(items[:maxDescribedRules], "; ") + fmt.Sprintf("; and %d more", len(items)-maxDescribedRules) + } + return strings.Join(items, "; ") +} + +// peerList names every peer of one rule: endpoints by their selector, entities, CIDRs and FQDNs as written +func peerList(endpoints []LabelSelector, entities, cidrs []string, fqdns []FQDNSelector) string { + var peers []string + for _, e := range endpoints { + peers = append(peers, describeSelector(e.MatchLabels)) + } + if len(entities) > 0 { + peers = append(peers, "entities "+strings.Join(entities, ", ")) + } + peers = append(peers, cidrs...) + for _, f := range fqdns { + if f.MatchName != "" { + peers = append(peers, f.MatchName) + } else if f.MatchPattern != "" { + peers = append(peers, f.MatchPattern) + } + } + if len(peers) == 0 { + return "anyone" + } + return strings.Join(peers, ", ") +} + +// describeSelector turns a peer's matchLabels into "name/component/instance", then "in " when the +// selector names one (a peer in the policy's own namespace carries no namespace label), then "in cluster " +// for a ClusterMesh peer. Labels the naming does not use are listed as key=value so nothing is hidden. +func describeSelector(labels map[string]string) string { + if len(labels) == 0 { + return "any endpoint" + } + used := map[string]bool{} + var who []string + for _, k := range []string{"app.kubernetes.io/name", "app", "k8s-app", "name"} { + if v, ok := labels[k]; ok { + who = append(who, v) + used[k] = true + break + } + } + for _, k := range []string{"app.kubernetes.io/component", "app.kubernetes.io/instance"} { + if v, ok := labels[k]; ok { + who = append(who, v) + used[k] = true + } + } + out := strings.Join(who, "/") + if ns, ok := labels[flow.GetNamespaceLabel()]; ok { + out += " in " + ns + used[flow.GetNamespaceLabel()] = true + } + if c, ok := labels[ClusterLabel]; ok { + out += " in cluster " + c + used[ClusterLabel] = true + } + var rest []string + for k, v := range labels { + if !used[k] { + rest = append(rest, k+"="+v) + } + } + sort.Strings(rest) + if len(rest) > 0 { + if out != "" { + out += " " + } + out += "(" + strings.Join(rest, ", ") + ")" + } + return out +} + +// portList renders " on TCP/80, UDP/53" and the L7 rules a port carries: HTTP as "GET /path" (the anchors and the +// optional-query suffix the generator adds are stripped for reading), DNS as the names or patterns +func portList(rules []PortRule) string { + var ports []string + for _, pr := range rules { + for _, p := range pr.Ports { + s := p.Protocol + "/" + p.Port + if p.Protocol == "" { + s = p.Port + } + ports = append(ports, s) + } + if pr.Rules != nil { + var l7 []string + for _, h := range pr.Rules.HTTP { + l7 = append(l7, strings.TrimSpace(h.Method+" "+readablePath(h.Path))) + } + for _, d := range pr.Rules.DNS { + if d.MatchName != "" { + l7 = append(l7, d.MatchName) + } else if d.MatchPattern != "" { + l7 = append(l7, d.MatchPattern) + } + } + if len(l7) > 0 && len(ports) > 0 { + kind := "HTTP" + if len(pr.Rules.HTTP) == 0 { + kind = "DNS" + } + ports[len(ports)-1] += " (" + kind + " " + strings.Join(l7, ", ") + ")" + } + } + } + if len(ports) == 0 { + return "" + } + return " on " + strings.Join(ports, ", ") +} + +var pathAnchors = regexp.MustCompile(`^\^|\(\\\?\.\*\)\?\$$|\$$`) + +// readablePath strips what the generator adds around an observed path (^ … (\?.*)?$) and leaves a hand-written regex alone +func readablePath(p string) string { + return pathAnchors.ReplaceAllString(p, "") +} diff --git a/internal/policy/describe_test.go b/internal/policy/describe_test.go new file mode 100644 index 0000000..c9cd467 --- /dev/null +++ b/internal/policy/describe_test.go @@ -0,0 +1,69 @@ +package policy + +import "testing" + +// 0.6.2: the description is written from the final spec — subject, peers, ports, L7 — not a generic sentence +func TestDescribe(t *testing.T) { + cases := []struct { + name string + p CiliumNetworkPolicy + want string + }{ + {"ingress with L7 and a second peer", + CiliumNetworkPolicy{Metadata: Metadata{Name: "shop-frontend", Namespace: "cf2cnp-lab27"}, Spec: Spec{Ingress: []IngressRule{ + {FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "pos"}}}, + ToPorts: []PortRule{{Ports: []Port{{Port: "80", Protocol: "TCP"}}, Rules: &L7Rules{HTTP: []HTTPRule{{Method: "GET", Path: `^/(\?.*)?$`}, {Method: "GET", Path: `^/checkout(\?.*)?$`}}}}}}, + {FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "kiosk"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "80", Protocol: "TCP"}}}}}, + }}}, + "Allow ingress to shop-frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80"}, + {"egress: another namespace, DNS rule, FQDN, CIDR", + CiliumNetworkPolicy{Metadata: Metadata{Name: "pos", Namespace: "cf2cnp-lab"}, Spec: Spec{Egress: []EgressRule{ + {ToEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "shop"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "80", Protocol: "TCP"}}}}}, + {ToEndpoints: []LabelSelector{{MatchLabels: map[string]string{"io.kubernetes.pod.namespace": "kube-system", "k8s-app": "kube-dns"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "53", Protocol: "UDP"}}, Rules: &L7Rules{DNS: []DNSRule{{MatchPattern: "*"}}}}}}, + {ToFQDNs: []FQDNSelector{{MatchName: "example.com"}}, ToPorts: []PortRule{{Ports: []Port{{Port: "443", Protocol: "TCP"}}}}}, + {ToCIDR: []string{"104.20.23.154/32"}, ToPorts: []PortRule{{Ports: []Port{{Port: "443", Protocol: "TCP"}}}}}, + }}}, + "Allow egress from pos in cf2cnp-lab: to shop on TCP/80; to kube-dns in kube-system on UDP/53 (DNS *); to example.com on TCP/443; to 104.20.23.154/32 on TCP/443"}, + {"a ClusterMesh peer with a component, and entities", + CiliumNetworkPolicy{Metadata: Metadata{Name: "cache-server", Namespace: "mesh-lab"}, Spec: Spec{Ingress: []IngressRule{ + {FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "worker", "app.kubernetes.io/component": "batch", ClusterLabel: "poc2"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "6379", Protocol: "TCP"}}}}}, + {FromEntities: []string{"ingress", "host"}, ToPorts: []PortRule{{Ports: []Port{{Port: "8080", Protocol: "TCP"}}}}}, + }}}, + "Allow ingress to cache-server in mesh-lab: from worker/batch in cluster poc2 on TCP/6379; from entities ingress, host on TCP/8080"}, + {"both directions, an unnamed label, no ports", + CiliumNetworkPolicy{Metadata: Metadata{Name: "api", Namespace: "bank"}, Spec: Spec{ + Ingress: []IngressRule{{FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"tier": "web"}}}}}, + Egress: []EgressRule{{ToEntities: []string{"world"}}}}}, + "Allow ingress to api in bank: from (tier=web); egress from api in bank: to entities world"}, + {"nothing", CiliumNetworkPolicy{Metadata: Metadata{Name: "x", Namespace: "y"}}, "Allow nothing for x in y"}, + } + for _, c := range cases { + if got := Describe(&c.p); got != c.want { + t.Errorf("%s:\n got %q\n want %q", c.name, got, c.want) + } + } +} + +// more than six peers are cut with a count, so the description stays a sentence +func TestDescribe_CutsLongLists(t *testing.T) { + p := CiliumNetworkPolicy{Metadata: Metadata{Name: "hub", Namespace: "ns"}} + for i := 0; i < 9; i++ { + p.Spec.Ingress = append(p.Spec.Ingress, IngressRule{FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app": "c" + string(rune('0'+i))}}}}) + } + got := Describe(&p) + if !contains(got, "from c5") || contains(got, "from c6") || !contains(got, "; and 3 more") { + t.Fatalf("got %q", got) + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/policy/generator.go b/internal/policy/generator.go index d240486..94681f7 100644 --- a/internal/policy/generator.go +++ b/internal/policy/generator.go @@ -191,9 +191,7 @@ func MergePolicies(policies []*CiliumNetworkPolicy) []*CiliumNetworkPolicy { for _, key := range order { p := byKey[key] p.Spec.Egress = dropShadowedDNSRule(p.Spec.Egress) - if n := merged[key]; n > 0 { - p.Spec.Description = mergedDescription(p, n+1) - } + p.Spec.Description = Describe(p) // from the final rules, merged or not (0.6.2: the per-flow sentence said only the namespaces) result = append(result, p) } return result @@ -228,20 +226,6 @@ func dropShadowedDNSRule(rules []EgressRule) []EgressRule { return kept } -func mergedDescription(p *CiliumNetworkPolicy, flows int) string { - var dir string - switch { - case len(p.Spec.Ingress) > 0 && len(p.Spec.Egress) > 0: - dir = "ingress and egress" - case len(p.Spec.Egress) > 0: - dir = "egress" - default: - dir = "ingress" - } - return fmt.Sprintf("Allow %s traffic for the %s in %s (%d rules merged from %d observed flows)", - dir, p.Metadata.Name, p.Metadata.Namespace, len(p.Spec.Ingress)+len(p.Spec.Egress), flows) -} - func labelsKey(labels map[string]string) string { keys := make([]string, 0, len(labels)) for k := range labels { diff --git a/internal/policy/generator_test.go b/internal/policy/generator_test.go index a71fffd..2f31e2b 100644 --- a/internal/policy/generator_test.go +++ b/internal/policy/generator_test.go @@ -46,8 +46,8 @@ func TestBuildPolicies_MergesSameWorkload(t *testing.T) { if peers != "pos,stranger" { t.Fatalf("rules must keep first-seen order, got %s", peers) } - if !strings.Contains(p.Spec.Description, "2 rules merged from 2 observed flows") { - t.Fatalf("description must say what was merged, got %q", p.Spec.Description) + if p.Spec.Description != "Allow ingress to shop in cf2cnp-lab: from pos on TCP/80; from stranger on TCP/80" { + t.Fatalf("the description must say what the rules say, got %q", p.Spec.Description) } } From ade28556db4a7c5c0643b60dfbf12dd519bab588 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 09:38:10 -0500 Subject: [PATCH 25/37] 0.6.3: the description names its subject the way it names the peers (name/component/instance from the selector) --- README.md | 6 +++--- helm/cf2cnp/Chart.yaml | 4 ++-- internal/policy/describe.go | 12 +++++++++--- internal/policy/describe_test.go | 8 ++++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 79ecaef..063675a 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ go build -o cf2cnp.exe ./cmd/cf2cnp ## Installation > **Fork note (ephico2real2/cf2cnp):** while the changes on this fork are pending upstream, the fork's chart is published as a -> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.2, appVersion 0.6.2) and its image as -> `ghcr.io/ephico2real2/cf2cnp:0.6.2`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. +> classic Helm repository at `https://ephico2real2.github.io/cf2cnp` (chart 0.6.3, appVersion 0.6.3) and its image as +> `ghcr.io/ephico2real2/cf2cnp:0.6.3`. `helm repo add cf2cnp-fork https://ephico2real2.github.io/cf2cnp`. The easiest way to use `CF2CNP` is to deploy it together with the [hubble-observer Helm chart](https://github.com/onzack/hubble-observer). This chart installs both the Hubble observer (to collect network flow data) and `CF2CNP` into your Kubernetes cluster, so you can generate policies directly from observed traffic. You can find installation instructions and configuration options for the Helm chart in the [hubble-observer Helm chart repository](https://github.com/onzack/hubble-observer). @@ -85,7 +85,7 @@ cf2cnp merge --existing policies/shop.yaml --input flows.json # in plac cf2cnp merge --existing policies/shop.yaml --input flows/ -o new.yaml # to another file ``` -Every policy's `description` is written from its rules (0.6.2): the subject, each peer the way the selector names it (with its namespace or cluster when they differ), the ports, and the L7 rules — `Allow ingress to shop-frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80`. +Every policy's `description` is written from its rules (0.6.2, 0.6.3): the subject, each peer the way the selector names it (with its namespace or cluster when they differ), the ports, and the L7 rules — `Allow ingress to shop/frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80`. The merged file is the existing file plus the new rules — key order, comments, quoting and indentation are kept, so a pull request shows the rules that were added and nothing else (0.6.1; 0.6.0 re-serialised the whole document). diff --git a/helm/cf2cnp/Chart.yaml b/helm/cf2cnp/Chart.yaml index f792b9e..469fac0 100644 --- a/helm/cf2cnp/Chart.yaml +++ b/helm/cf2cnp/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cf2cnp description: A Helm chart for CF2CNP - Cilium Flow to CiliumNetworkPolicy converter type: application -version: 0.6.2 -appVersion: "0.6.2" +version: 0.6.3 +appVersion: "0.6.3" keywords: - cilium - network-policy diff --git a/internal/policy/describe.go b/internal/policy/describe.go index 870bd14..b0284e1 100644 --- a/internal/policy/describe.go +++ b/internal/policy/describe.go @@ -13,15 +13,21 @@ import ( // subject is, which peers it may talk to, on which ports, with which L7 rules — instead of a generic sentence // about namespaces. It runs once the rules are final (after the merge across flows), on every policy: // -// Allow ingress to shop-frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80 +// Allow ingress to shop/frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80 // Allow egress from pos in cf2cnp-lab: to shop on TCP/80; to kube-dns in kube-system on UDP/53 (DNS *); to example.com on TCP/443 -// Allow ingress to cache-server in mesh-lab: from worker/batch in cluster poc2 on TCP/6379 +// Allow ingress to cache/server in mesh-lab: from worker/batch in cluster poc2 on TCP/6379 // // Peers are named the way the selector names them (name, then component and instance), a peer in another namespace // or cluster says so, entities and CIDRs are listed as written. A long policy is cut after maxDescribedRules peers // with "and N more", so the description stays a sentence, not a second copy of the spec. func Describe(p *CiliumNetworkPolicy) string { - subject := fmt.Sprintf("%s in %s", p.Metadata.Name, p.Metadata.Namespace) + // the subject is named the way the peers are (name/component/instance from the selector), so the sentence reads + // "shop/frontend … from shop/backend"; a policy without a selector falls back to its object name + subject := describeSelector(p.Spec.EndpointSelector.MatchLabels) + if len(p.Spec.EndpointSelector.MatchLabels) == 0 { + subject = p.Metadata.Name + } + subject += " in " + p.Metadata.Namespace var parts []string if n := len(p.Spec.Ingress); n > 0 { items := make([]string, 0, n) diff --git a/internal/policy/describe_test.go b/internal/policy/describe_test.go index c9cd467..8d5dbbc 100644 --- a/internal/policy/describe_test.go +++ b/internal/policy/describe_test.go @@ -10,12 +10,12 @@ func TestDescribe(t *testing.T) { want string }{ {"ingress with L7 and a second peer", - CiliumNetworkPolicy{Metadata: Metadata{Name: "shop-frontend", Namespace: "cf2cnp-lab27"}, Spec: Spec{Ingress: []IngressRule{ + CiliumNetworkPolicy{Metadata: Metadata{Name: "shop-frontend", Namespace: "cf2cnp-lab27"}, Spec: Spec{EndpointSelector: LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/name": "shop", "app.kubernetes.io/component": "frontend"}}, Ingress: []IngressRule{ {FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "pos"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "80", Protocol: "TCP"}}, Rules: &L7Rules{HTTP: []HTTPRule{{Method: "GET", Path: `^/(\?.*)?$`}, {Method: "GET", Path: `^/checkout(\?.*)?$`}}}}}}, {FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "kiosk"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "80", Protocol: "TCP"}}}}}, }}}, - "Allow ingress to shop-frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80"}, + "Allow ingress to shop/frontend in cf2cnp-lab27: from pos on TCP/80 (HTTP GET /, GET /checkout); from kiosk on TCP/80"}, {"egress: another namespace, DNS rule, FQDN, CIDR", CiliumNetworkPolicy{Metadata: Metadata{Name: "pos", Namespace: "cf2cnp-lab"}, Spec: Spec{Egress: []EgressRule{ {ToEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "shop"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "80", Protocol: "TCP"}}}}}, @@ -25,11 +25,11 @@ func TestDescribe(t *testing.T) { }}}, "Allow egress from pos in cf2cnp-lab: to shop on TCP/80; to kube-dns in kube-system on UDP/53 (DNS *); to example.com on TCP/443; to 104.20.23.154/32 on TCP/443"}, {"a ClusterMesh peer with a component, and entities", - CiliumNetworkPolicy{Metadata: Metadata{Name: "cache-server", Namespace: "mesh-lab"}, Spec: Spec{Ingress: []IngressRule{ + CiliumNetworkPolicy{Metadata: Metadata{Name: "cache-server", Namespace: "mesh-lab"}, Spec: Spec{EndpointSelector: LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/name": "cache", "app.kubernetes.io/component": "server"}}, Ingress: []IngressRule{ {FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"app.kubernetes.io/name": "worker", "app.kubernetes.io/component": "batch", ClusterLabel: "poc2"}}}, ToPorts: []PortRule{{Ports: []Port{{Port: "6379", Protocol: "TCP"}}}}}, {FromEntities: []string{"ingress", "host"}, ToPorts: []PortRule{{Ports: []Port{{Port: "8080", Protocol: "TCP"}}}}}, }}}, - "Allow ingress to cache-server in mesh-lab: from worker/batch in cluster poc2 on TCP/6379; from entities ingress, host on TCP/8080"}, + "Allow ingress to cache/server in mesh-lab: from worker/batch in cluster poc2 on TCP/6379; from entities ingress, host on TCP/8080"}, {"both directions, an unnamed label, no ports", CiliumNetworkPolicy{Metadata: Metadata{Name: "api", Namespace: "bank"}, Spec: Spec{ Ingress: []IngressRule{{FromEndpoints: []LabelSelector{{MatchLabels: map[string]string{"tier": "web"}}}}}, From 4fa35853b2b023eb8432f54461f1d9c3fbc8e930 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 12:00:04 -0500 Subject: [PATCH 26/37] E12 forensics: cf2cnp's 20 fields against the 291 schema paths of the 1.20.1 CiliumNetworkPolicy CRD, field by field with the source each could have; the world-source mis-modelling read from the parser; three probes against github.com/cilium/cilium@v1.20.1's policy API (zero replace lines, 18 MB with the CRD file embedded, 94 MB if the k8s client is imported; Sanitize mutates and must run on a copy; the schema and Sanitize catch different errors); the design: Cilium's types behind one envelope, two validation layers, the supported spec printed and released --- docs/CRD-SPEC-FORENSICS.md | 191 + hack/cilium-api-probe/README.md | 17 + .../crd/ciliumnetworkpolicies.yaml | 6552 +++++++++++++++++ hack/cilium-api-probe/go.mod | 82 + hack/cilium-api-probe/go.sum | 185 + hack/cilium-api-probe/main.go | 64 + 6 files changed, 7091 insertions(+) create mode 100644 docs/CRD-SPEC-FORENSICS.md create mode 100644 hack/cilium-api-probe/README.md create mode 100644 hack/cilium-api-probe/crd/ciliumnetworkpolicies.yaml create mode 100644 hack/cilium-api-probe/go.mod create mode 100644 hack/cilium-api-probe/go.sum create mode 100644 hack/cilium-api-probe/main.go diff --git a/docs/CRD-SPEC-FORENSICS.md b/docs/CRD-SPEC-FORENSICS.md new file mode 100644 index 0000000..5ed0ff5 --- /dev/null +++ b/docs/CRD-SPEC-FORENSICS.md @@ -0,0 +1,191 @@ +# Forensic analysis — cf2cnp's policy types against the CiliumNetworkPolicy spec of Cilium 1.20.x + +Date: 2026-09-13. Branch `enh/E12-cilium-policy-api`. Every number below was measured on this machine +(Go 1.27.1, darwin/amd64); the probe programs are under `hack/cilium-api-probe/` so the measurements can be repeated. + +## 1. The question + +cf2cnp writes CiliumNetworkPolicies from hand-written Go types (`internal/policy/types.go`, 20 fields). Cilium's +CRD for `cilium.io/v2` `CiliumNetworkPolicy` at v1.20.1 has **291 schema paths** under `spec` +(`pkg/k8s/apis/cilium.io/client/crds/v2/ciliumnetworkpolicies.yaml`, 398 532 bytes). Each field cf2cnp does not +model is something it cannot write, cannot preserve on a round trip, and cannot say it supports. The operator's +finding that started this: a world **source** becomes `fromEntities` because there is no `fromCIDR`, while a +world **destination** becomes `toCIDR`. The question is not "add `fromCIDR`" but **how cf2cnp consumes the spec so +that a release can state which Cilium spec it supports, and the next field is not a hand-written struct**. + +## 2. What cf2cnp models today, field by field, against the CRD + +Legend for the last column — how the field could be filled: **flows** = derivable from what Hubble reports today; +**intent** = an operator option, never observed; **pass-through** = only preserved by `merge` (the YAML node edit), +never generated; **n/a** = not applicable to a namespaced policy. + +### 2.1 `spec` — top level + +| CRD field | cf2cnp today | Source of a value | +|---|---|---| +| `description` | yes (0.6.2: written from the rules) | generated | +| `endpointSelector.matchLabels` | yes | flows | +| `endpointSelector.matchExpressions` | **no** | pass-through; intent | +| `ingress[]` | partial (§2.2) | flows | +| `egress[]` | partial (§2.3) | flows | +| `ingressDeny[]`, `egressDeny[]` | **no** | intent — the natural home of `exclude=`: "the stranger is denied", not merely unlisted | +| `enableDefaultDeny.{ingress,egress}` | **no** | intent — would replace the separate default-deny policy every demo applies by hand | +| `labels[]` (Cilium's policy labels, not metadata labels) | **no** | pass-through | +| `log.value` | **no** | pass-through; intent | +| `nodeSelector` | **no** | n/a for CNP; CCNP for host flows (§6) | + +### 2.2 An ingress rule (`spec.ingress[]`; `ingressDeny[]` has the same peers and ports, no L7) + +| CRD field | cf2cnp today | Source of a value | +|---|---|---| +| `fromEndpoints[].matchLabels` | yes (namespace and cluster labels included) | flows | +| `fromEndpoints[].matchExpressions` | **no** | pass-through | +| `fromEntities[]` | yes, `host` and `remote-node` only (`world` and `kube-apiserver` sources are mishandled: see §2.5) | flows | +| `fromCIDR[]` | **no** — the finding | **flows**: a `reserved:world` source with an address (an egress-gateway IP, a load-balancer client, an office range) | +| `fromCIDRSet[].{cidr,except,cidrGroupRef,cidrGroupSelector}` | **no** | intent (`except`), pass-through | +| `fromRequires[]` | **no** | intent | +| `fromGroups[].aws` | **no** | n/a here | +| `fromNodes[]` | **no** | flows (`reserved:host` / `remote-node` when node selectors are enabled), later | +| `icmps[].fields[].{type,family}` | **no** | flows: Hubble reports `l4.ICMPv4` / `ICMPv6` type and code | +| `toPorts[].ports[].port` | yes | flows | +| `toPorts[].ports[].protocol` | yes | flows | +| `toPorts[].ports[].endPort` | **no** | intent | +| `toPorts[].rules.http[].{method,path}` | yes (0.6.0) | flows | +| `toPorts[].rules.http[].{host,headers,headerMatches}` | **no** | flows (the URL's host, the request headers) — `host` deliberately not narrowed today (review) | +| `toPorts[].rules.dns[]` | yes (0.6.0) | flows | +| `toPorts[].rules.kafka[]`, `l7proto`, `l7[]` | **no** | flows if Kafka visibility is on; not in this PoC | +| `toPorts[].{terminatingTLS,originatingTLS,serverNames,listener}` | **no** | intent, pass-through | +| `authentication.mode` | **no** | intent (mutual authentication is deprecated in 1.20) | + +### 2.3 An egress rule (`spec.egress[]`) + +| CRD field | cf2cnp today | Source of a value | +|---|---|---| +| `toEndpoints[]` | yes | flows | +| `toEntities[]` | yes | flows | +| `toCIDR[]` | yes | flows | +| `toCIDRSet[]` | **no** | intent | +| `toFQDNs[].{matchName,matchPattern}` | yes | flows (with the DNS proxy) | +| `toServices[].{k8sService,k8sServiceSelector}` | **no** | intent; Hubble flows are post-translation, the Service name is not on the flow | +| `toRequires[]`, `toGroups[]`, `toNodes[]` | **no** | intent / later | +| `icmps[]` | **no** | flows | +| `toPorts[]` | as ingress | flows | +| `authentication` | **no** | intent | + +### 2.4 The count + +Of the 291 schema paths, cf2cnp's types cover **20**. Of the rest, the ones a flow can fill are `fromCIDR`, +`icmps`, `fromNodes`/`toNodes`, HTTP `host`/`headers`, Kafka; the ones an operator's intent fills are the deny +lists, `enableDefaultDeny`, CIDR sets with `except`, `endPort`, `toServices`, TLS/SNI, `authentication`; the rest +must at least survive a `merge` — which the 0.6.1 node-based merge already guarantees for any field it does not +model, but which the *generator* cannot emit. + +### 2.5 Two mis-modellings found while reading the parser + +- `reservedEntities` maps `reserved:world` and `reserved:kube-apiserver` to entities, but `generateEntityIngressRules` + writes `fromEntities` only for `host` and `remote-node`; a world **source** therefore produces no peer at all in the + ingress rule (an ingress rule with `toPorts` and no `from*` is "allow from anyone on this port" — wider than + observed). This is the finding, seen from the code. +- `Port.Protocol` is written as observed (`TCP`/`UDP`); nothing upper-cases a lower-case value. The CRD's enum is + upper-case only (measured below: `"tcp"` is rejected by the schema). + +## 3. Can cf2cnp consume Cilium's own spec? Measured + +Three probe programs, `hack/cilium-api-probe/`, built against `github.com/cilium/cilium@v1.20.1`. + +| Probe | Imports | `go get` + build | Binary | Modules | `replace` lines needed | +|---|---|---|---|---|---| +| cf2cnp 0.6.3 today | cobra, yaml.v3 | — | 11.6 MB | 9 | 0 | +| 1 — `pkg/policy/api` only, rendered with `sigs.k8s.io/yaml` | policy/api, sigs yaml | 16 s get, 19 s cold build | 17.6 MB | 309 | **0** | +| 2 — plus `pkg/k8s/apis/cilium.io/v2` (the typed object) and `…/client` (the embedded CRDs) | + k8s client machinery | 85 s build | **93.6 MB** | ~600 | 0 | +| 3 — `pkg/policy/api` + `k8s.io/apimachinery` `ObjectMeta` + the CRD **file** embedded with `go:embed` | policy/api, apimachinery meta, sigs yaml | 4 s incremental build | 18.1 MB | 310 | 0 | + +Facts that fell out of the probes: + +1. **Zero `replace` directives.** `github.com/cilium/cilium v1.20.1` resolves as a plain dependency; the module's Go + requirement is **1.26.0** (cf2cnp's `go.mod` says 1.25.5 and must move to 1.26). +2. **Rendering with `sigs.k8s.io/yaml`** (JSON tags → YAML) gives the CRD's own shapes — `fromCIDR`, `toCIDRSet`, + `enableDefaultDeny`, everything — with keys in alphabetical order, which is also `kubectl get -o yaml`'s order. + The document `apiVersion, kind, metadata, spec` stays in that order (alphabetical); within `metadata`, `labels` + comes before `name`. The 0.6.1 `merge` edits YAML nodes and does not care. +3. **`api.Rule.Sanitize()` is Cilium's own admission-time validation**, available as a library call. On the probes it + caught a semantic rule no schema expresses: *"combining FromEndpoints and FromCIDR is not supported yet"* — a + generated rule that mixed a pod peer and a CIDR peer would be refused by the agent. **But `Sanitize` mutates:** it + rewrote selector keys with Cilium's internal source prefix (`any:app.kubernetes.io/name`), upper-cased the + protocol, and filled `enableDefaultDeny` on the rule. It must run on a **`DeepCopy()`** and its errors reported; + the original is what gets rendered. Measured on probe 2 (rendered after Sanitize: the prefix and the filled + fields appeared) and probe 3 (rendered the original: clean). +4. **The CRD schema and `Sanitize` catch different things.** The schema (offline, `kubectl-validate --local-crds` + with the embedded file) rejected `protocol: tcp` ("supported values: TCP, UDP, SCTP, VRRP, IGMP, GRE, IPIP, IPV6, + ESP, AH, ANY") and accepted the `fromEndpoints`+`fromCIDR` rule; `Sanitize` accepted the lower-case protocol (it + fixes it) and rejected the mixed rule. A release needs **both** layers. +5. **The CRD YAML is embedded in the module** (`pkg/k8s/apis/cilium.io/client/register.go`, `//go:embed + crds/v2/ciliumnetworkpolicies.yaml`, `GetPregeneratedCRD`), but importing that package costs 75 MB of client + machinery. Copying the **file** out of the module cache at `go generate` time and embedding it in cf2cnp costs + 0.5 MB and stays byte-identical to the module's, which CI can assert (`cmp` against + `$(go list -m -f '{{.Dir}}' github.com/cilium/cilium)/pkg/k8s/apis/cilium.io/client/crds/v2/…`). +6. **The typed object `v2.CiliumNetworkPolicy` renders `status: {}`** (no `omitempty`); an envelope of + `metav1.TypeMeta` + `metav1.ObjectMeta` + `*api.Rule` (probe 3) renders clean and costs nothing new — apimachinery + is already in `policy/api`'s graph. +7. **The supported spec version is a build fact:** `debug.ReadBuildInfo()` yields `github.com/cilium/cilium v1.20.1`; + `cf2cnp version` can print "CiliumNetworkPolicy `cilium.io/v2` as of Cilium v1.20.1", and the embedded CRD file's + `metadata.annotations` / the module version in `go.mod` are the same number. + +## 4. The design + +**Replace `internal/policy/types.go` with Cilium's types.** `api.Rule`, `api.IngressRule`, `api.EgressRule`, +`api.PortRule`, `api.L7Rules`, `api.EndpointSelector` (built as `EndpointSelector{LabelSelector: &slimv1.LabelSelector{MatchLabels: …}}`, +never through the `NewES…` constructors, which prefix). One envelope struct in cf2cnp: + +```go +type Policy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec *api.Rule `json:"spec"` +} +``` + +**Two validation layers, both in the binary and in CI.** `Sanitize()` on a deep copy before anything is written or +served (an error is a 400 with Cilium's own message on `/generate`, an exit 1 on `generate`/`merge`); the CRD +schema, embedded from the module's file, checked in CI on every fixture and on the demos' policies with +`kubectl-validate --local-crds` (the E10 template's mechanism). The generator itself normalises what `Sanitize` +would only fix on the copy (protocol upper-case) so the rendered document is what the agent would accept. + +**Rendering.** `sigs.k8s.io/yaml` for the documents; the comment lines cf2cnp adds (`# To allow all traffic to +world …`) stay text-based, as today; `merge` unchanged (YAML nodes). Golden files: every policy under the PoC's +`demos/2[6-9]*/policies` and `demos/3*/policies` regenerated from its saved flows and diffed — the only accepted +differences are key order within `metadata` and the fields the new generator adds. + +**The supported spec, announced.** `cf2cnp version` prints the tool's version and the Cilium API version; the +README carries a table "cf2cnp release → Cilium spec"; the chart's `appVersion` note names it; a Renovate/Dependabot +rule bumps `github.com/cilium/cilium` and CI re-embeds the CRD and re-runs the golden tests, so a Cilium release +becomes a cf2cnp release with a known diff. + +**What becomes generatable, in order of value** (each its own branch, tests, and a review claim): + +| Item | From | Notes | +|---|---|---| +| `fromCIDR` for a world source (the finding) | flows | one `/32` per observed source address; a `toCIDR`-style comment; never mixed with `fromEndpoints` in one rule (Cilium refuses it — the rule per peer class) | +| `enableDefaultDeny` as an option | intent | `--default-deny` writes `enableDefaultDeny: {ingress: true}` on ingress policies: the demos' separate default-deny object disappears | +| `ingressDeny` for excluded peers as an option | intent | `--deny-excluded`: an `exclude=` peer becomes a deny rule instead of an omission | +| `icmps` | flows | Hubble `l4.ICMPv4`/`ICMPv6`; `ping` between services is a rule today only by accident | +| HTTP `host` / `headers` as options | flows | off by default (the review's reasoning stands) | +| `fromNodes`/`toNodes`, CCNP with `nodeSelector` | flows | host-network callers; a second output kind | +| `toCIDRSet` with `except`, `endPort`, `toServices`, TLS/SNI, `authentication`, Kafka | intent / later | modelled by the types now, written by hand or preserved by `merge` | + +## 5. Risks and their measurements + +- **Dependency weight.** 9 → 310 modules, 11.6 → 18.1 MB. Measured; acceptable for a CLI and a small server. The + k8s **client** packages are the line not to cross (93.6 MB); the CRD file is copied, not imported. +- **Go version.** The module needs Go 1.26; the Dockerfile and `binary-release.yml` (`go-version-file: go.mod`) + follow `go.mod`. +- **Upstream cadence.** Cilium tags every few weeks; a cf2cnp release states the version it was built against, and + a newer agent accepts an older spec (CRDs are versioned `v2` and only add fields). +- **Behaviour changes on the swap.** Caught by the golden tests over 40+ real policies from the demos; the review + brief for E12 lists them as claims. + +## 6. Out of scope, noted + +CiliumClusterwideNetworkPolicy shares the rule types (`api.Rule` with `nodeSelector`); emitting it for +`reserved:host` flows is a later item. `CiliumCIDRGroup` references (`cidrGroupRef`) and cloud `toGroups` are not +observable here. Kafka L7 needs a Kafka workload. diff --git a/hack/cilium-api-probe/README.md b/hack/cilium-api-probe/README.md new file mode 100644 index 0000000..aaf4593 --- /dev/null +++ b/hack/cilium-api-probe/README.md @@ -0,0 +1,17 @@ +# cilium-api-probe + +The measurement behind `docs/CRD-SPEC-FORENSICS.md` §3: a separate module (so the main module's dependencies are +untouched) that builds against `github.com/cilium/cilium@v1.20.1`'s `pkg/policy/api`, renders a rule with +`sigs.k8s.io/yaml`, runs Cilium's own `Rule.Sanitize()` on a deep copy, and embeds the CRD file copied from the module +cache. Run it and validate its output against the same CRD offline: + +```bash +cd hack/cilium-api-probe && go build -o probe . && ./probe +go run sigs.k8s.io/kubectl-validate@v0.0.4 --local-crds ./crd +``` + +The copy of `crd/ciliumnetworkpolicies.yaml` must stay byte-identical to the module's: + +```bash +cmp crd/ciliumnetworkpolicies.yaml "$(go list -m -f '{{.Dir}}' github.com/cilium/cilium)/pkg/k8s/apis/cilium.io/client/crds/v2/ciliumnetworkpolicies.yaml" +``` diff --git a/hack/cilium-api-probe/crd/ciliumnetworkpolicies.yaml b/hack/cilium-api-probe/crd/ciliumnetworkpolicies.yaml new file mode 100644 index 0000000..071ae6c --- /dev/null +++ b/hack/cilium-api-probe/crd/ciliumnetworkpolicies.yaml @@ -0,0 +1,6552 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: ciliumnetworkpolicies.cilium.io +spec: + group: cilium.io + names: + categories: + - cilium + - ciliumpolicy + kind: CiliumNetworkPolicy + listKind: CiliumNetworkPolicyList + plural: ciliumnetworkpolicies + shortNames: + - cnp + - ciliumnp + singular: ciliumnetworkpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + name: v2 + schema: + openAPIV3Schema: + description: |- + CiliumNetworkPolicy is a Kubernetes third-party resource with an extended + version of NetworkPolicy. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + anyOf: + - properties: + ingress: {} + required: + - ingress + - properties: + ingressDeny: {} + required: + - ingressDeny + - properties: + egress: {} + required: + - egress + - properties: + egressDeny: {} + required: + - egressDeny + description: Spec is the desired Cilium specific rule specification. + oneOf: + - properties: + endpointSelector: {} + required: + - endpointSelector + - properties: + nodeSelector: {} + required: + - nodeSelector + properties: + description: + description: |- + Description is a free form string, it can be used by the creator of + the rule to store human readable explanation of the purpose of this + rule. Rules cannot be identified by comment. + type: string + egress: + description: |- + Egress is a list of EgressRule which are enforced at egress. + If omitted or empty, this rule does not apply at egress. + items: + description: |- + EgressRule contains all rule types which can be applied at egress, i.e. + network traffic that originates inside the endpoint and exits the endpoint + selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members of the structure are specified, then all members + must match in order for the rule to take effect. + + - ToEndpoints, ToCIDR, ToCIDRSet, ToEntities, ToServices and ToGroups are + mutually exclusive. Only one of these members may be present within an + individual rule. + properties: + authentication: + description: Authentication is the required authentication type + for the allowed traffic, if any. + properties: + mode: + description: Mode is the required authentication mode for + the allowed traffic, if any. + enum: + - disabled + - required + - test-always-fail + type: string + required: + - mode + type: object + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is allowed to connect to. + + Example: + Any endpoint with the label "app=httpd" is allowed to initiate + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should be + an 8bit code (0-255), or it's CamelCase name (for + example, \"EchoReply\").\nAllowed ICMP types are:\n + \ Ipv4: EchoReply | DestinationUnreachable | + Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t EchoRequest + | EchoReply | MulticastListenerQuery| MulticastListenerReport + |\n\t\t\t MulticastListenerDone | RouterSolicitation + | RouterAdvertisement | NeighborSolicitation |\n\t\t\t + NeighborAdvertisement | RedirectMessage | RouterRenumbering + | ICMPNodeInformationQuery |\n\t\t\t ICMPNodeInformationResponse + | InverseNeighborDiscoverySolicitation | InverseNeighborDiscoveryAdvertisement + |\n\t\t\t HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toCIDR: + description: |- + ToCIDR is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections. Only connections destined for + outside of the cluster and not targeting the host will be subject + to CIDR rules. This will match on the destination IP address of + outgoing connections. Adding a prefix into ToCIDR or into ToCIDRSet + with no ExcludeCIDRs is equivalent. Overlaps are allowed between + ToCIDR and ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + toCIDRSet: + description: |- + ToCIDRSet is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections to in addition to connections + which are allowed via ToEndpoints, along with a list of subnets contained + within their corresponding IP block to which traffic should not be + allowed. This will match on the destination IP address of outgoing + connections. Adding a prefix into ToCIDR or into ToCIDRSet with no + ExcludeCIDRs is equivalent. Overlaps are allowed between ToCIDR and + ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 except from IPs in subnet 10.2.3.0/28. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + toEndpoints: + description: |- + ToEndpoints is a list of endpoints identified by an EndpointSelector to + which the endpoints subject to the rule are allowed to communicate. + + Example: + Any endpoint with the label "role=frontend" can communicate with any + endpoint carrying the label "role=backend". + + Note that while an empty non-nil ToEndpoints does not select anything, + nil ToEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toEntities: + description: |- + ToEntities is a list of special entities to which the endpoint subject + to the rule is allowed to initiate connections. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + toFQDNs: + description: |- + ToFQDN allows whitelisting DNS names in place of IPs. The IPs that result + from DNS resolution of `ToFQDN.MatchName`s are added to the same + EgressRule object as ToCIDRSet entries, and behave accordingly. Any L4 and + L7 rules within this EgressRule will also apply to these IPs. + The DNS -> IP mapping is re-resolved periodically from within the + cilium-agent, and the IPs in the DNS response are effected in the policy + for selected pods as-is (i.e. the list of IPs is not modified in any way). + Note: An explicit rule to allow for DNS traffic is needed for the pods, as + ToFQDN counts as an egress rule and will enforce egress policy when + PolicyEnforcment=default. + Note: If the resolved IPs are IPs within the kubernetes cluster, the + ToFQDN rule will not apply to that IP. + Note: ToFQDN cannot occur in the same policy as other To* rules. + items: + oneOf: + - properties: + matchName: {} + required: + - matchName + - properties: + matchPattern: {} + required: + - matchPattern + properties: + matchName: + description: |- + MatchName matches literal DNS names. A trailing "." is automatically added + when missing. + maxLength: 255 + pattern: ^([-a-zA-Z0-9_]+[.]?)+$ + type: string + matchPattern: + description: |- + MatchPattern allows using wildcards to match DNS names. All wildcards are + case insensitive. The wildcards are: + - "*" matches 0 or more DNS valid characters, and may occur anywhere in + the pattern. As a special case a "*" as the leftmost character, without a + following "." matches all subdomains as well as the name to the right. + A trailing "." is automatically added when missing. + - "**." is a special prefix which matches all multilevel subdomains in the prefix. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + type: object + type: array + toGroups: + description: |- + ToGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + ToGroups entries are functionally equivalent to toCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + toGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + toNodes: + description: |- + ToNodes is a list of nodes identified by an + EndpointSelector to which endpoints subject to the rule is allowed to communicate. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is allowed to + connect to. + + Example: + Any endpoint with the label "role=frontend" is allowed to initiate + connections to destination port 8080/tcp + items: + description: |- + PortRule is a list of ports/protocol combinations with optional Layer 7 + rules which must be met. + properties: + listener: + description: |- + listener specifies the name of a custom Envoy listener to which this traffic should be + redirected to. + properties: + envoyConfig: + description: |- + EnvoyConfig is a reference to the CEC or CCEC resource in which + the listener is defined. + properties: + kind: + description: |- + Kind is the resource type being referred to. Defaults to CiliumEnvoyConfig or + CiliumClusterwideEnvoyConfig for CiliumNetworkPolicy and CiliumClusterwideNetworkPolicy, + respectively. The only case this is currently explicitly needed is when referring to a + CiliumClusterwideEnvoyConfig from CiliumNetworkPolicy, as using a namespaced listener + from a cluster scoped policy is not allowed. + enum: + - CiliumEnvoyConfig + - CiliumClusterwideEnvoyConfig + type: string + name: + description: |- + Name is the resource name of the CiliumEnvoyConfig or CiliumClusterwideEnvoyConfig where + the listener is defined in. + minLength: 1 + type: string + required: + - name + type: object + name: + description: Name is the name of the listener. + minLength: 1 + type: string + priority: + description: |- + Priority for this Listener that is used when multiple rules would apply different + listeners to a policy map entry. Behavior of this is implementation dependent. + maximum: 100 + minimum: 1 + type: integer + required: + - envoyConfig + - name + type: object + originatingTLS: + description: |- + OriginatingTLS is the TLS context for the connections originated by + the L7 proxy. For egress policy this specifies the client-side TLS + parameters for the upstream connection originating from the L7 proxy + to the remote destination. For ingress policy this specifies the + client-side TLS parameters for the connection from the L7 proxy to + the local endpoint. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + maxItems: 40 + type: array + rules: + description: |- + Rules is a list of additional port level rules which must be met in + order for the PortRule to allow the traffic. If omitted or empty, + no layer 7 rules are enforced. + oneOf: + - properties: + http: {} + required: + - http + - properties: + dns: {} + required: + - dns + properties: + dns: + description: DNS-specific rules. + items: + description: PortRuleDNS is a list of allowed DNS + lookups. + oneOf: + - properties: + matchName: {} + required: + - matchName + - properties: + matchPattern: {} + required: + - matchPattern + properties: + matchName: + description: |- + MatchName matches literal DNS names. A trailing "." is automatically added + when missing. + maxLength: 255 + pattern: ^([-a-zA-Z0-9_]+[.]?)+$ + type: string + matchPattern: + description: |- + MatchPattern allows using wildcards to match DNS names. All wildcards are + case insensitive. The wildcards are: + - "*" matches 0 or more DNS valid characters, and may occur anywhere in + the pattern. As a special case a "*" as the leftmost character, without a + following "." matches all subdomains as well as the name to the right. + A trailing "." is automatically added when missing. + - "**." is a special prefix which matches all multilevel subdomains in the prefix. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + type: object + type: array + http: + description: HTTP specific rules. + items: + description: |- + PortRuleHTTP is a list of HTTP protocol constraints. All fields are + optional, if all fields are empty or missing, the rule does not have any + effect. + + All fields of this type are extended POSIX regex as defined by IEEE Std + 1003.1, (i.e this follows the egrep/unix syntax, not the perl syntax) + matched against the path of an incoming request. Currently it can contain + characters disallowed from the conventional "path" part of a URL as defined + by RFC 3986. + properties: + headerMatches: + description: |- + HeaderMatches is a list of HTTP headers which must be + present and match against the given values. Mismatch field can be used + to specify what to do when there is no match. + items: + description: |- + HeaderMatch extends the HeaderValue for matching requirement of a + named header field against an immediate string or a secret value. + If none of the optional fields is present, then the + header value is not matched, only presence of the header is enough. + properties: + mismatch: + description: |- + Mismatch identifies what to do in case there is no match. The default is + to drop the request. Otherwise the overall rule is still considered as + matching, but the mismatches are logged in the access log. + enum: + - LOG + - ADD + - DELETE + - REPLACE + type: string + name: + description: Name identifies the header. + minLength: 1 + type: string + secret: + description: |- + Secret refers to a secret that contains the value to be matched against. + The secret must only contain one entry. If the referred secret does not + exist, and there is no "Value" specified, the match will fail. + properties: + name: + description: Name is the name of the + secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + value: + description: |- + Value matches the exact value of the header. Can be specified either + alone or together with "Secret"; will be used as the header value if the + secret can not be found in the latter case. + type: string + required: + - name + type: object + type: array + headers: + description: |- + Headers is a list of HTTP headers which must be present in the + request. If omitted or empty, requests are allowed regardless of + headers present. + items: + type: string + type: array + host: + description: |- + Host is an extended POSIX regex matched against the host header of a + request. Examples: + + - foo.bar.com will match the host fooXbar.com or foo-bar.com + - foo\.bar\.com will only match the host foo.bar.com + + If omitted or empty, the value of the host header is ignored. + format: idn-hostname + type: string + method: + description: |- + Method is an extended POSIX regex matched against the method of a + request, e.g. "GET", "POST", "PUT", "PATCH", "DELETE", ... + + If omitted or empty, all methods are allowed. + type: string + path: + description: |- + Path is an extended POSIX regex matched against the path of a + request. Currently it can contain characters disallowed from the + conventional "path" part of a URL as defined by RFC 3986. + + If omitted or empty, all paths are all allowed. + type: string + type: object + type: array + type: object + serverNames: + description: |- + ServerNames is a list of allowed TLS SNI values. If not empty, then + TLS must be present and one of the provided SNIs must be indicated in the + TLS handshake. + items: + description: |- + ServerName allows using '*' wildcard specifier for matching server names with + the below semantics. + + - `*` matches 0 or more DNS valid characters, and may occur anywhere in the pattern. + - `**.` is a special prefix which matches all multilevel subdomains in the prefix. + + As a special case the name "*" matches all valid DNS names. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + terminatingTLS: + description: |- + TerminatingTLS is the TLS context for the connection terminated by + the L7 proxy. For egress policy this specifies the server-side TLS + parameters to be applied on the connections originated from the local + endpoint and terminated by the L7 proxy. For ingress policy this specifies + the server-side TLS parameters to be applied on the connections + originated from a remote source and terminated by the L7 proxy. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + type: object + type: array + toRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + toServices: + description: |- + ToServices is a list of services to which the endpoint subject + to the rule is allowed to initiate connections. + Currently Cilium only supports toServices for K8s services. + items: + description: |- + Service selects policy targets that are bundled as part of a + logical load-balanced service. + + Currently only Kubernetes-based Services are supported. + properties: + k8sService: + description: K8sService selects service by name and namespace + pair + properties: + namespace: + type: string + serviceName: + type: string + type: object + k8sServiceSelector: + description: K8sServiceSelector selects services by k8s + labels and namespace + properties: + namespace: + type: string + selector: + description: ServiceSelector is a label selector for + k8s services + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the + value from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - selector + type: object + type: object + type: array + type: object + type: array + egressDeny: + description: |- + EgressDeny is a list of EgressDenyRule which are enforced at egress. + Any rule inserted here will be denied regardless of the allowed egress + rules in the 'egress' field. + If omitted or empty, this rule does not apply at egress. + items: + description: |- + EgressDenyRule contains all rule types which can be applied at egress, i.e. + network traffic that originates inside the endpoint and exits the endpoint + selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members of the structure are specified, then all members + must match in order for the rule to take effect. + + - ToEndpoints, ToCIDR, ToCIDRSet, ToEntities, ToServices and ToGroups are + mutually exclusive. Only one of these members may be present within an + individual rule. + properties: + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is not allowed to connect to. + + Example: + Any endpoint with the label "app=httpd" is not allowed to initiate + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should be + an 8bit code (0-255), or it's CamelCase name (for + example, \"EchoReply\").\nAllowed ICMP types are:\n + \ Ipv4: EchoReply | DestinationUnreachable | + Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t EchoRequest + | EchoReply | MulticastListenerQuery| MulticastListenerReport + |\n\t\t\t MulticastListenerDone | RouterSolicitation + | RouterAdvertisement | NeighborSolicitation |\n\t\t\t + NeighborAdvertisement | RedirectMessage | RouterRenumbering + | ICMPNodeInformationQuery |\n\t\t\t ICMPNodeInformationResponse + | InverseNeighborDiscoverySolicitation | InverseNeighborDiscoveryAdvertisement + |\n\t\t\t HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toCIDR: + description: |- + ToCIDR is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections. Only connections destined for + outside of the cluster and not targeting the host will be subject + to CIDR rules. This will match on the destination IP address of + outgoing connections. Adding a prefix into ToCIDR or into ToCIDRSet + with no ExcludeCIDRs is equivalent. Overlaps are allowed between + ToCIDR and ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + toCIDRSet: + description: |- + ToCIDRSet is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections to in addition to connections + which are allowed via ToEndpoints, along with a list of subnets contained + within their corresponding IP block to which traffic should not be + allowed. This will match on the destination IP address of outgoing + connections. Adding a prefix into ToCIDR or into ToCIDRSet with no + ExcludeCIDRs is equivalent. Overlaps are allowed between ToCIDR and + ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 except from IPs in subnet 10.2.3.0/28. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + toEndpoints: + description: |- + ToEndpoints is a list of endpoints identified by an EndpointSelector to + which the endpoints subject to the rule are allowed to communicate. + + Example: + Any endpoint with the label "role=frontend" can communicate with any + endpoint carrying the label "role=backend". + + Note that while an empty non-nil ToEndpoints does not select anything, + nil ToEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toEntities: + description: |- + ToEntities is a list of special entities to which the endpoint subject + to the rule is allowed to initiate connections. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + toGroups: + description: |- + ToGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + ToGroups entries are functionally equivalent to toCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + toGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + toNodes: + description: |- + ToNodes is a list of nodes identified by an + EndpointSelector to which endpoints subject to the rule is allowed to communicate. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is not allowed to connect + to. + + Example: + Any endpoint with the label "role=frontend" is not allowed to initiate + connections to destination port 8080/tcp + items: + description: |- + PortDenyRule is a list of ports/protocol that should be used for deny + policies. This structure lacks the L7Rules since it's not supported in deny + policies. + properties: + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + type: array + type: object + type: array + toRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + toServices: + description: |- + ToServices is a list of services to which the endpoint subject + to the rule is allowed to initiate connections. + Currently Cilium only supports toServices for K8s services. + items: + description: |- + Service selects policy targets that are bundled as part of a + logical load-balanced service. + + Currently only Kubernetes-based Services are supported. + properties: + k8sService: + description: K8sService selects service by name and namespace + pair + properties: + namespace: + type: string + serviceName: + type: string + type: object + k8sServiceSelector: + description: K8sServiceSelector selects services by k8s + labels and namespace + properties: + namespace: + type: string + selector: + description: ServiceSelector is a label selector for + k8s services + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the + value from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - selector + type: object + type: object + type: array + type: object + type: array + enableDefaultDeny: + description: |- + EnableDefaultDeny determines whether this policy configures the + subject endpoint(s) to have a default deny mode. If enabled, + this causes all traffic not explicitly allowed by a network policy + to be dropped. + + If not specified, the default is true for each traffic direction + that has rules, and false otherwise. For example, if a policy + only has Ingress or IngressDeny rules, then the default for + ingress is true and egress is false. + + If multiple policies apply to an endpoint, that endpoint's default deny + will be enabled if any policy requests it. + + This is useful for creating broad-based network policies that will not + cause endpoints to enter default-deny mode. + properties: + egress: + description: |- + Whether or not the endpoint should have a default-deny rule applied + to egress traffic. + type: boolean + ingress: + description: |- + Whether or not the endpoint should have a default-deny rule applied + to ingress traffic. + type: boolean + type: object + endpointSelector: + description: |- + EndpointSelector selects all endpoints which should be subject to + this rule. EndpointSelector and NodeSelector cannot be both empty and + are mutually exclusive. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from the + MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + ingress: + description: |- + Ingress is a list of IngressRule which are enforced at ingress. + If omitted or empty, this rule does not apply at ingress. + items: + description: |- + IngressRule contains all rule types which can be applied at ingress, + i.e. network traffic that originates outside of the endpoint and + is entering the endpoint selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members are set, all of them need to match in order for + the rule to take effect. + + - FromEndpoints, FromCIDR, FromCIDRSet and FromEntities are mutually + exclusive. Only one of these members may be present within an individual + rule. + properties: + authentication: + description: Authentication is the required authentication type + for the allowed traffic, if any. + properties: + mode: + description: Mode is the required authentication mode for + the allowed traffic, if any. + enum: + - disabled + - required + - test-always-fail + type: string + required: + - mode + type: object + fromCIDR: + description: |- + FromCIDR is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from. Only connections which + do *not* originate from the cluster or from the local host are subject + to CIDR rules. In order to allow in-cluster connectivity, use the + FromEndpoints field. This will match on the source IP address of + incoming connections. Adding a prefix into FromCIDR or into + FromCIDRSet with no ExcludeCIDRs is equivalent. Overlaps are + allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.3.9.1 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + fromCIDRSet: + description: |- + FromCIDRSet is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from in addition to FromEndpoints, + along with a list of subnets contained within their corresponding IP block + from which traffic should not be allowed. + This will match on the source IP address of incoming connections. Adding + a prefix into FromCIDR or into FromCIDRSet with no ExcludeCIDRs is + equivalent. Overlaps are allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.0.0.0/8 except from IPs in subnet 10.96.0.0/12. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + fromEndpoints: + description: |- + FromEndpoints is a list of endpoints identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + + Example: + Any endpoint with the label "role=backend" can be consumed by any + endpoint carrying the label "role=frontend". + + Note that while an empty non-nil FromEndpoints does not select anything, + nil FromEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromEntities: + description: |- + FromEntities is a list of special entities which the endpoint subject + to the rule is allowed to receive connections from. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + fromGroups: + description: |- + FromGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + FromGroups entries are functionally equivalent to FromCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + fromGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + fromNodes: + description: |- + FromNodes is a list of nodes identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can only accept incoming + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should be + an 8bit code (0-255), or it's CamelCase name (for + example, \"EchoReply\").\nAllowed ICMP types are:\n + \ Ipv4: EchoReply | DestinationUnreachable | + Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t EchoRequest + | EchoReply | MulticastListenerQuery| MulticastListenerReport + |\n\t\t\t MulticastListenerDone | RouterSolicitation + | RouterAdvertisement | NeighborSolicitation |\n\t\t\t + NeighborAdvertisement | RedirectMessage | RouterRenumbering + | ICMPNodeInformationQuery |\n\t\t\t ICMPNodeInformationResponse + | InverseNeighborDiscoverySolicitation | InverseNeighborDiscoveryAdvertisement + |\n\t\t\t HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can only accept incoming + connections on port 80/tcp. + items: + description: |- + PortRule is a list of ports/protocol combinations with optional Layer 7 + rules which must be met. + properties: + listener: + description: |- + listener specifies the name of a custom Envoy listener to which this traffic should be + redirected to. + properties: + envoyConfig: + description: |- + EnvoyConfig is a reference to the CEC or CCEC resource in which + the listener is defined. + properties: + kind: + description: |- + Kind is the resource type being referred to. Defaults to CiliumEnvoyConfig or + CiliumClusterwideEnvoyConfig for CiliumNetworkPolicy and CiliumClusterwideNetworkPolicy, + respectively. The only case this is currently explicitly needed is when referring to a + CiliumClusterwideEnvoyConfig from CiliumNetworkPolicy, as using a namespaced listener + from a cluster scoped policy is not allowed. + enum: + - CiliumEnvoyConfig + - CiliumClusterwideEnvoyConfig + type: string + name: + description: |- + Name is the resource name of the CiliumEnvoyConfig or CiliumClusterwideEnvoyConfig where + the listener is defined in. + minLength: 1 + type: string + required: + - name + type: object + name: + description: Name is the name of the listener. + minLength: 1 + type: string + priority: + description: |- + Priority for this Listener that is used when multiple rules would apply different + listeners to a policy map entry. Behavior of this is implementation dependent. + maximum: 100 + minimum: 1 + type: integer + required: + - envoyConfig + - name + type: object + originatingTLS: + description: |- + OriginatingTLS is the TLS context for the connections originated by + the L7 proxy. For egress policy this specifies the client-side TLS + parameters for the upstream connection originating from the L7 proxy + to the remote destination. For ingress policy this specifies the + client-side TLS parameters for the connection from the L7 proxy to + the local endpoint. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + maxItems: 40 + type: array + rules: + description: |- + Rules is a list of additional port level rules which must be met in + order for the PortRule to allow the traffic. If omitted or empty, + no layer 7 rules are enforced. + oneOf: + - properties: + http: {} + required: + - http + - properties: + dns: {} + required: + - dns + properties: + dns: + description: DNS-specific rules. + items: + description: PortRuleDNS is a list of allowed DNS + lookups. + oneOf: + - properties: + matchName: {} + required: + - matchName + - properties: + matchPattern: {} + required: + - matchPattern + properties: + matchName: + description: |- + MatchName matches literal DNS names. A trailing "." is automatically added + when missing. + maxLength: 255 + pattern: ^([-a-zA-Z0-9_]+[.]?)+$ + type: string + matchPattern: + description: |- + MatchPattern allows using wildcards to match DNS names. All wildcards are + case insensitive. The wildcards are: + - "*" matches 0 or more DNS valid characters, and may occur anywhere in + the pattern. As a special case a "*" as the leftmost character, without a + following "." matches all subdomains as well as the name to the right. + A trailing "." is automatically added when missing. + - "**." is a special prefix which matches all multilevel subdomains in the prefix. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + type: object + type: array + http: + description: HTTP specific rules. + items: + description: |- + PortRuleHTTP is a list of HTTP protocol constraints. All fields are + optional, if all fields are empty or missing, the rule does not have any + effect. + + All fields of this type are extended POSIX regex as defined by IEEE Std + 1003.1, (i.e this follows the egrep/unix syntax, not the perl syntax) + matched against the path of an incoming request. Currently it can contain + characters disallowed from the conventional "path" part of a URL as defined + by RFC 3986. + properties: + headerMatches: + description: |- + HeaderMatches is a list of HTTP headers which must be + present and match against the given values. Mismatch field can be used + to specify what to do when there is no match. + items: + description: |- + HeaderMatch extends the HeaderValue for matching requirement of a + named header field against an immediate string or a secret value. + If none of the optional fields is present, then the + header value is not matched, only presence of the header is enough. + properties: + mismatch: + description: |- + Mismatch identifies what to do in case there is no match. The default is + to drop the request. Otherwise the overall rule is still considered as + matching, but the mismatches are logged in the access log. + enum: + - LOG + - ADD + - DELETE + - REPLACE + type: string + name: + description: Name identifies the header. + minLength: 1 + type: string + secret: + description: |- + Secret refers to a secret that contains the value to be matched against. + The secret must only contain one entry. If the referred secret does not + exist, and there is no "Value" specified, the match will fail. + properties: + name: + description: Name is the name of the + secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + value: + description: |- + Value matches the exact value of the header. Can be specified either + alone or together with "Secret"; will be used as the header value if the + secret can not be found in the latter case. + type: string + required: + - name + type: object + type: array + headers: + description: |- + Headers is a list of HTTP headers which must be present in the + request. If omitted or empty, requests are allowed regardless of + headers present. + items: + type: string + type: array + host: + description: |- + Host is an extended POSIX regex matched against the host header of a + request. Examples: + + - foo.bar.com will match the host fooXbar.com or foo-bar.com + - foo\.bar\.com will only match the host foo.bar.com + + If omitted or empty, the value of the host header is ignored. + format: idn-hostname + type: string + method: + description: |- + Method is an extended POSIX regex matched against the method of a + request, e.g. "GET", "POST", "PUT", "PATCH", "DELETE", ... + + If omitted or empty, all methods are allowed. + type: string + path: + description: |- + Path is an extended POSIX regex matched against the path of a + request. Currently it can contain characters disallowed from the + conventional "path" part of a URL as defined by RFC 3986. + + If omitted or empty, all paths are all allowed. + type: string + type: object + type: array + type: object + serverNames: + description: |- + ServerNames is a list of allowed TLS SNI values. If not empty, then + TLS must be present and one of the provided SNIs must be indicated in the + TLS handshake. + items: + description: |- + ServerName allows using '*' wildcard specifier for matching server names with + the below semantics. + + - `*` matches 0 or more DNS valid characters, and may occur anywhere in the pattern. + - `**.` is a special prefix which matches all multilevel subdomains in the prefix. + + As a special case the name "*" matches all valid DNS names. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + terminatingTLS: + description: |- + TerminatingTLS is the TLS context for the connection terminated by + the L7 proxy. For egress policy this specifies the server-side TLS + parameters to be applied on the connections originated from the local + endpoint and terminated by the L7 proxy. For ingress policy this specifies + the server-side TLS parameters to be applied on the connections + originated from a remote source and terminated by the L7 proxy. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + type: object + type: array + type: object + type: array + ingressDeny: + description: |- + IngressDeny is a list of IngressDenyRule which are enforced at ingress. + Any rule inserted here will be denied regardless of the allowed ingress + rules in the 'ingress' field. + If omitted or empty, this rule does not apply at ingress. + items: + description: |- + IngressDenyRule contains all rule types which can be applied at ingress, + i.e. network traffic that originates outside of the endpoint and + is entering the endpoint selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members are set, all of them need to match in order for + the rule to take effect. + + - FromEndpoints, FromCIDR, FromCIDRSet, FromGroups and FromEntities are mutually + exclusive. Only one of these members may be present within an individual + rule. + properties: + fromCIDR: + description: |- + FromCIDR is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from. Only connections which + do *not* originate from the cluster or from the local host are subject + to CIDR rules. In order to allow in-cluster connectivity, use the + FromEndpoints field. This will match on the source IP address of + incoming connections. Adding a prefix into FromCIDR or into + FromCIDRSet with no ExcludeCIDRs is equivalent. Overlaps are + allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.3.9.1 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + fromCIDRSet: + description: |- + FromCIDRSet is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from in addition to FromEndpoints, + along with a list of subnets contained within their corresponding IP block + from which traffic should not be allowed. + This will match on the source IP address of incoming connections. Adding + a prefix into FromCIDR or into FromCIDRSet with no ExcludeCIDRs is + equivalent. Overlaps are allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.0.0.0/8 except from IPs in subnet 10.96.0.0/12. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + fromEndpoints: + description: |- + FromEndpoints is a list of endpoints identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + + Example: + Any endpoint with the label "role=backend" can be consumed by any + endpoint carrying the label "role=frontend". + + Note that while an empty non-nil FromEndpoints does not select anything, + nil FromEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromEntities: + description: |- + FromEntities is a list of special entities which the endpoint subject + to the rule is allowed to receive connections from. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + fromGroups: + description: |- + FromGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + FromGroups entries are functionally equivalent to FromCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + fromGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + fromNodes: + description: |- + FromNodes is a list of nodes identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from + the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is not allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can not accept incoming + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should be + an 8bit code (0-255), or it's CamelCase name (for + example, \"EchoReply\").\nAllowed ICMP types are:\n + \ Ipv4: EchoReply | DestinationUnreachable | + Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t EchoRequest + | EchoReply | MulticastListenerQuery| MulticastListenerReport + |\n\t\t\t MulticastListenerDone | RouterSolicitation + | RouterAdvertisement | NeighborSolicitation |\n\t\t\t + NeighborAdvertisement | RedirectMessage | RouterRenumbering + | ICMPNodeInformationQuery |\n\t\t\t ICMPNodeInformationResponse + | InverseNeighborDiscoverySolicitation | InverseNeighborDiscoveryAdvertisement + |\n\t\t\t HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is not allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can not accept incoming + connections on port 80/tcp. + items: + description: |- + PortDenyRule is a list of ports/protocol that should be used for deny + policies. This structure lacks the L7Rules since it's not supported in deny + policies. + properties: + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + type: array + type: object + type: array + type: object + type: array + labels: + description: |- + Labels is a list of optional strings which can be used to + re-identify the rule or to store metadata. It is possible to lookup + or delete strings based on labels. Labels are not required to be + unique, multiple rules can have overlapping or identical labels. + items: + description: Label is Cilium's representation of a label. + properties: + key: + type: string + source: + description: 'Source can be one of the above values (e.g.: LabelSourceK8s).' + type: string + value: + type: string + required: + - key + type: object + type: array + log: + description: Log specifies custom policy-specific Hubble logging configuration. + properties: + value: + description: |- + Value is a free-form string that is included in Hubble flows + that match this policy. The string is limited to 32 printable characters. + maxLength: 32 + pattern: ^\PC*$ + type: string + type: object + nodeSelector: + description: |- + NodeSelector selects all nodes which should be subject to this rule. + EndpointSelector and NodeSelector cannot be both empty and are mutually + exclusive. Can only be used in CiliumClusterwideNetworkPolicies. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from the + MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + specs: + description: Specs is a list of desired Cilium specific rule specification. + items: + anyOf: + - properties: + ingress: {} + required: + - ingress + - properties: + ingressDeny: {} + required: + - ingressDeny + - properties: + egress: {} + required: + - egress + - properties: + egressDeny: {} + required: + - egressDeny + description: |- + Rule is a policy rule which must be applied to all endpoints which match the + labels contained in the endpointSelector + + Each rule is split into an ingress section which contains all rules + applicable at ingress, and an egress section applicable at egress. For rule + types such as `L4Rule` and `CIDR` which can be applied at both ingress and + egress, both ingress and egress side have to either specifically allow the + connection or one side has to be omitted. + + Either ingress, egress, or both can be provided. If both ingress and egress + are omitted, the rule has no effect. + oneOf: + - properties: + endpointSelector: {} + required: + - endpointSelector + - properties: + nodeSelector: {} + required: + - nodeSelector + properties: + description: + description: |- + Description is a free form string, it can be used by the creator of + the rule to store human readable explanation of the purpose of this + rule. Rules cannot be identified by comment. + type: string + egress: + description: |- + Egress is a list of EgressRule which are enforced at egress. + If omitted or empty, this rule does not apply at egress. + items: + description: |- + EgressRule contains all rule types which can be applied at egress, i.e. + network traffic that originates inside the endpoint and exits the endpoint + selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members of the structure are specified, then all members + must match in order for the rule to take effect. + + - ToEndpoints, ToCIDR, ToCIDRSet, ToEntities, ToServices and ToGroups are + mutually exclusive. Only one of these members may be present within an + individual rule. + properties: + authentication: + description: Authentication is the required authentication + type for the allowed traffic, if any. + properties: + mode: + description: Mode is the required authentication mode + for the allowed traffic, if any. + enum: + - disabled + - required + - test-always-fail + type: string + required: + - mode + type: object + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is allowed to connect to. + + Example: + Any endpoint with the label "app=httpd" is allowed to initiate + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should + be an 8bit code (0-255), or it's CamelCase name + (for example, \"EchoReply\").\nAllowed ICMP + types are:\n Ipv4: EchoReply | DestinationUnreachable + | Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t + EchoRequest | EchoReply | MulticastListenerQuery| + MulticastListenerReport |\n\t\t\t MulticastListenerDone + | RouterSolicitation | RouterAdvertisement | + NeighborSolicitation |\n\t\t\t NeighborAdvertisement + | RedirectMessage | RouterRenumbering | ICMPNodeInformationQuery + |\n\t\t\t ICMPNodeInformationResponse | InverseNeighborDiscoverySolicitation + | InverseNeighborDiscoveryAdvertisement |\n\t\t\t + HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toCIDR: + description: |- + ToCIDR is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections. Only connections destined for + outside of the cluster and not targeting the host will be subject + to CIDR rules. This will match on the destination IP address of + outgoing connections. Adding a prefix into ToCIDR or into ToCIDRSet + with no ExcludeCIDRs is equivalent. Overlaps are allowed between + ToCIDR and ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + toCIDRSet: + description: |- + ToCIDRSet is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections to in addition to connections + which are allowed via ToEndpoints, along with a list of subnets contained + within their corresponding IP block to which traffic should not be + allowed. This will match on the destination IP address of outgoing + connections. Adding a prefix into ToCIDR or into ToCIDRSet with no + ExcludeCIDRs is equivalent. Overlaps are allowed between ToCIDR and + ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 except from IPs in subnet 10.2.3.0/28. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + toEndpoints: + description: |- + ToEndpoints is a list of endpoints identified by an EndpointSelector to + which the endpoints subject to the rule are allowed to communicate. + + Example: + Any endpoint with the label "role=frontend" can communicate with any + endpoint carrying the label "role=backend". + + Note that while an empty non-nil ToEndpoints does not select anything, + nil ToEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toEntities: + description: |- + ToEntities is a list of special entities to which the endpoint subject + to the rule is allowed to initiate connections. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + toFQDNs: + description: |- + ToFQDN allows whitelisting DNS names in place of IPs. The IPs that result + from DNS resolution of `ToFQDN.MatchName`s are added to the same + EgressRule object as ToCIDRSet entries, and behave accordingly. Any L4 and + L7 rules within this EgressRule will also apply to these IPs. + The DNS -> IP mapping is re-resolved periodically from within the + cilium-agent, and the IPs in the DNS response are effected in the policy + for selected pods as-is (i.e. the list of IPs is not modified in any way). + Note: An explicit rule to allow for DNS traffic is needed for the pods, as + ToFQDN counts as an egress rule and will enforce egress policy when + PolicyEnforcment=default. + Note: If the resolved IPs are IPs within the kubernetes cluster, the + ToFQDN rule will not apply to that IP. + Note: ToFQDN cannot occur in the same policy as other To* rules. + items: + oneOf: + - properties: + matchName: {} + required: + - matchName + - properties: + matchPattern: {} + required: + - matchPattern + properties: + matchName: + description: |- + MatchName matches literal DNS names. A trailing "." is automatically added + when missing. + maxLength: 255 + pattern: ^([-a-zA-Z0-9_]+[.]?)+$ + type: string + matchPattern: + description: |- + MatchPattern allows using wildcards to match DNS names. All wildcards are + case insensitive. The wildcards are: + - "*" matches 0 or more DNS valid characters, and may occur anywhere in + the pattern. As a special case a "*" as the leftmost character, without a + following "." matches all subdomains as well as the name to the right. + A trailing "." is automatically added when missing. + - "**." is a special prefix which matches all multilevel subdomains in the prefix. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + type: object + type: array + toGroups: + description: |- + ToGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + ToGroups entries are functionally equivalent to toCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + toGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + toNodes: + description: |- + ToNodes is a list of nodes identified by an + EndpointSelector to which endpoints subject to the rule is allowed to communicate. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is allowed to + connect to. + + Example: + Any endpoint with the label "role=frontend" is allowed to initiate + connections to destination port 8080/tcp + items: + description: |- + PortRule is a list of ports/protocol combinations with optional Layer 7 + rules which must be met. + properties: + listener: + description: |- + listener specifies the name of a custom Envoy listener to which this traffic should be + redirected to. + properties: + envoyConfig: + description: |- + EnvoyConfig is a reference to the CEC or CCEC resource in which + the listener is defined. + properties: + kind: + description: |- + Kind is the resource type being referred to. Defaults to CiliumEnvoyConfig or + CiliumClusterwideEnvoyConfig for CiliumNetworkPolicy and CiliumClusterwideNetworkPolicy, + respectively. The only case this is currently explicitly needed is when referring to a + CiliumClusterwideEnvoyConfig from CiliumNetworkPolicy, as using a namespaced listener + from a cluster scoped policy is not allowed. + enum: + - CiliumEnvoyConfig + - CiliumClusterwideEnvoyConfig + type: string + name: + description: |- + Name is the resource name of the CiliumEnvoyConfig or CiliumClusterwideEnvoyConfig where + the listener is defined in. + minLength: 1 + type: string + required: + - name + type: object + name: + description: Name is the name of the listener. + minLength: 1 + type: string + priority: + description: |- + Priority for this Listener that is used when multiple rules would apply different + listeners to a policy map entry. Behavior of this is implementation dependent. + maximum: 100 + minimum: 1 + type: integer + required: + - envoyConfig + - name + type: object + originatingTLS: + description: |- + OriginatingTLS is the TLS context for the connections originated by + the L7 proxy. For egress policy this specifies the client-side TLS + parameters for the upstream connection originating from the L7 proxy + to the remote destination. For ingress policy this specifies the + client-side TLS parameters for the connection from the L7 proxy to + the local endpoint. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + maxItems: 40 + type: array + rules: + description: |- + Rules is a list of additional port level rules which must be met in + order for the PortRule to allow the traffic. If omitted or empty, + no layer 7 rules are enforced. + oneOf: + - properties: + http: {} + required: + - http + - properties: + dns: {} + required: + - dns + properties: + dns: + description: DNS-specific rules. + items: + description: PortRuleDNS is a list of allowed + DNS lookups. + oneOf: + - properties: + matchName: {} + required: + - matchName + - properties: + matchPattern: {} + required: + - matchPattern + properties: + matchName: + description: |- + MatchName matches literal DNS names. A trailing "." is automatically added + when missing. + maxLength: 255 + pattern: ^([-a-zA-Z0-9_]+[.]?)+$ + type: string + matchPattern: + description: |- + MatchPattern allows using wildcards to match DNS names. All wildcards are + case insensitive. The wildcards are: + - "*" matches 0 or more DNS valid characters, and may occur anywhere in + the pattern. As a special case a "*" as the leftmost character, without a + following "." matches all subdomains as well as the name to the right. + A trailing "." is automatically added when missing. + - "**." is a special prefix which matches all multilevel subdomains in the prefix. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + type: object + type: array + http: + description: HTTP specific rules. + items: + description: |- + PortRuleHTTP is a list of HTTP protocol constraints. All fields are + optional, if all fields are empty or missing, the rule does not have any + effect. + + All fields of this type are extended POSIX regex as defined by IEEE Std + 1003.1, (i.e this follows the egrep/unix syntax, not the perl syntax) + matched against the path of an incoming request. Currently it can contain + characters disallowed from the conventional "path" part of a URL as defined + by RFC 3986. + properties: + headerMatches: + description: |- + HeaderMatches is a list of HTTP headers which must be + present and match against the given values. Mismatch field can be used + to specify what to do when there is no match. + items: + description: |- + HeaderMatch extends the HeaderValue for matching requirement of a + named header field against an immediate string or a secret value. + If none of the optional fields is present, then the + header value is not matched, only presence of the header is enough. + properties: + mismatch: + description: |- + Mismatch identifies what to do in case there is no match. The default is + to drop the request. Otherwise the overall rule is still considered as + matching, but the mismatches are logged in the access log. + enum: + - LOG + - ADD + - DELETE + - REPLACE + type: string + name: + description: Name identifies the header. + minLength: 1 + type: string + secret: + description: |- + Secret refers to a secret that contains the value to be matched against. + The secret must only contain one entry. If the referred secret does not + exist, and there is no "Value" specified, the match will fail. + properties: + name: + description: Name is the name of + the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + value: + description: |- + Value matches the exact value of the header. Can be specified either + alone or together with "Secret"; will be used as the header value if the + secret can not be found in the latter case. + type: string + required: + - name + type: object + type: array + headers: + description: |- + Headers is a list of HTTP headers which must be present in the + request. If omitted or empty, requests are allowed regardless of + headers present. + items: + type: string + type: array + host: + description: |- + Host is an extended POSIX regex matched against the host header of a + request. Examples: + + - foo.bar.com will match the host fooXbar.com or foo-bar.com + - foo\.bar\.com will only match the host foo.bar.com + + If omitted or empty, the value of the host header is ignored. + format: idn-hostname + type: string + method: + description: |- + Method is an extended POSIX regex matched against the method of a + request, e.g. "GET", "POST", "PUT", "PATCH", "DELETE", ... + + If omitted or empty, all methods are allowed. + type: string + path: + description: |- + Path is an extended POSIX regex matched against the path of a + request. Currently it can contain characters disallowed from the + conventional "path" part of a URL as defined by RFC 3986. + + If omitted or empty, all paths are all allowed. + type: string + type: object + type: array + type: object + serverNames: + description: |- + ServerNames is a list of allowed TLS SNI values. If not empty, then + TLS must be present and one of the provided SNIs must be indicated in the + TLS handshake. + items: + description: |- + ServerName allows using '*' wildcard specifier for matching server names with + the below semantics. + + - `*` matches 0 or more DNS valid characters, and may occur anywhere in the pattern. + - `**.` is a special prefix which matches all multilevel subdomains in the prefix. + + As a special case the name "*" matches all valid DNS names. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + terminatingTLS: + description: |- + TerminatingTLS is the TLS context for the connection terminated by + the L7 proxy. For egress policy this specifies the server-side TLS + parameters to be applied on the connections originated from the local + endpoint and terminated by the L7 proxy. For ingress policy this specifies + the server-side TLS parameters to be applied on the connections + originated from a remote source and terminated by the L7 proxy. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + type: object + type: array + toRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + toServices: + description: |- + ToServices is a list of services to which the endpoint subject + to the rule is allowed to initiate connections. + Currently Cilium only supports toServices for K8s services. + items: + description: |- + Service selects policy targets that are bundled as part of a + logical load-balanced service. + + Currently only Kubernetes-based Services are supported. + properties: + k8sService: + description: K8sService selects service by name and + namespace pair + properties: + namespace: + type: string + serviceName: + type: string + type: object + k8sServiceSelector: + description: K8sServiceSelector selects services by + k8s labels and namespace + properties: + namespace: + type: string + selector: + description: ServiceSelector is a label selector + for k8s services + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the + value from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - selector + type: object + type: object + type: array + type: object + type: array + egressDeny: + description: |- + EgressDeny is a list of EgressDenyRule which are enforced at egress. + Any rule inserted here will be denied regardless of the allowed egress + rules in the 'egress' field. + If omitted or empty, this rule does not apply at egress. + items: + description: |- + EgressDenyRule contains all rule types which can be applied at egress, i.e. + network traffic that originates inside the endpoint and exits the endpoint + selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members of the structure are specified, then all members + must match in order for the rule to take effect. + + - ToEndpoints, ToCIDR, ToCIDRSet, ToEntities, ToServices and ToGroups are + mutually exclusive. Only one of these members may be present within an + individual rule. + properties: + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is not allowed to connect to. + + Example: + Any endpoint with the label "app=httpd" is not allowed to initiate + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should + be an 8bit code (0-255), or it's CamelCase name + (for example, \"EchoReply\").\nAllowed ICMP + types are:\n Ipv4: EchoReply | DestinationUnreachable + | Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t + EchoRequest | EchoReply | MulticastListenerQuery| + MulticastListenerReport |\n\t\t\t MulticastListenerDone + | RouterSolicitation | RouterAdvertisement | + NeighborSolicitation |\n\t\t\t NeighborAdvertisement + | RedirectMessage | RouterRenumbering | ICMPNodeInformationQuery + |\n\t\t\t ICMPNodeInformationResponse | InverseNeighborDiscoverySolicitation + | InverseNeighborDiscoveryAdvertisement |\n\t\t\t + HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toCIDR: + description: |- + ToCIDR is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections. Only connections destined for + outside of the cluster and not targeting the host will be subject + to CIDR rules. This will match on the destination IP address of + outgoing connections. Adding a prefix into ToCIDR or into ToCIDRSet + with no ExcludeCIDRs is equivalent. Overlaps are allowed between + ToCIDR and ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + toCIDRSet: + description: |- + ToCIDRSet is a list of IP blocks which the endpoint subject to the rule + is allowed to initiate connections to in addition to connections + which are allowed via ToEndpoints, along with a list of subnets contained + within their corresponding IP block to which traffic should not be + allowed. This will match on the destination IP address of outgoing + connections. Adding a prefix into ToCIDR or into ToCIDRSet with no + ExcludeCIDRs is equivalent. Overlaps are allowed between ToCIDR and + ToCIDRSet. + + Example: + Any endpoint with the label "app=database-proxy" is allowed to + initiate connections to 10.2.3.0/24 except from IPs in subnet 10.2.3.0/28. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + toEndpoints: + description: |- + ToEndpoints is a list of endpoints identified by an EndpointSelector to + which the endpoints subject to the rule are allowed to communicate. + + Example: + Any endpoint with the label "role=frontend" can communicate with any + endpoint carrying the label "role=backend". + + Note that while an empty non-nil ToEndpoints does not select anything, + nil ToEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toEntities: + description: |- + ToEntities is a list of special entities to which the endpoint subject + to the rule is allowed to initiate connections. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + toGroups: + description: |- + ToGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + ToGroups entries are functionally equivalent to toCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + toGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + toNodes: + description: |- + ToNodes is a list of nodes identified by an + EndpointSelector to which endpoints subject to the rule is allowed to communicate. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is not allowed to connect + to. + + Example: + Any endpoint with the label "role=frontend" is not allowed to initiate + connections to destination port 8080/tcp + items: + description: |- + PortDenyRule is a list of ports/protocol that should be used for deny + policies. This structure lacks the L7Rules since it's not supported in deny + policies. + properties: + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + type: array + type: object + type: array + toRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + toServices: + description: |- + ToServices is a list of services to which the endpoint subject + to the rule is allowed to initiate connections. + Currently Cilium only supports toServices for K8s services. + items: + description: |- + Service selects policy targets that are bundled as part of a + logical load-balanced service. + + Currently only Kubernetes-based Services are supported. + properties: + k8sService: + description: K8sService selects service by name and + namespace pair + properties: + namespace: + type: string + serviceName: + type: string + type: object + k8sServiceSelector: + description: K8sServiceSelector selects services by + k8s labels and namespace + properties: + namespace: + type: string + selector: + description: ServiceSelector is a label selector + for k8s services + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the + value from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - selector + type: object + type: object + type: array + type: object + type: array + enableDefaultDeny: + description: |- + EnableDefaultDeny determines whether this policy configures the + subject endpoint(s) to have a default deny mode. If enabled, + this causes all traffic not explicitly allowed by a network policy + to be dropped. + + If not specified, the default is true for each traffic direction + that has rules, and false otherwise. For example, if a policy + only has Ingress or IngressDeny rules, then the default for + ingress is true and egress is false. + + If multiple policies apply to an endpoint, that endpoint's default deny + will be enabled if any policy requests it. + + This is useful for creating broad-based network policies that will not + cause endpoints to enter default-deny mode. + properties: + egress: + description: |- + Whether or not the endpoint should have a default-deny rule applied + to egress traffic. + type: boolean + ingress: + description: |- + Whether or not the endpoint should have a default-deny rule applied + to ingress traffic. + type: boolean + type: object + endpointSelector: + description: |- + EndpointSelector selects all endpoints which should be subject to + this rule. EndpointSelector and NodeSelector cannot be both empty and + are mutually exclusive. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from the + MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + ingress: + description: |- + Ingress is a list of IngressRule which are enforced at ingress. + If omitted or empty, this rule does not apply at ingress. + items: + description: |- + IngressRule contains all rule types which can be applied at ingress, + i.e. network traffic that originates outside of the endpoint and + is entering the endpoint selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members are set, all of them need to match in order for + the rule to take effect. + + - FromEndpoints, FromCIDR, FromCIDRSet and FromEntities are mutually + exclusive. Only one of these members may be present within an individual + rule. + properties: + authentication: + description: Authentication is the required authentication + type for the allowed traffic, if any. + properties: + mode: + description: Mode is the required authentication mode + for the allowed traffic, if any. + enum: + - disabled + - required + - test-always-fail + type: string + required: + - mode + type: object + fromCIDR: + description: |- + FromCIDR is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from. Only connections which + do *not* originate from the cluster or from the local host are subject + to CIDR rules. In order to allow in-cluster connectivity, use the + FromEndpoints field. This will match on the source IP address of + incoming connections. Adding a prefix into FromCIDR or into + FromCIDRSet with no ExcludeCIDRs is equivalent. Overlaps are + allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.3.9.1 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + fromCIDRSet: + description: |- + FromCIDRSet is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from in addition to FromEndpoints, + along with a list of subnets contained within their corresponding IP block + from which traffic should not be allowed. + This will match on the source IP address of incoming connections. Adding + a prefix into FromCIDR or into FromCIDRSet with no ExcludeCIDRs is + equivalent. Overlaps are allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.0.0.0/8 except from IPs in subnet 10.96.0.0/12. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + fromEndpoints: + description: |- + FromEndpoints is a list of endpoints identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + + Example: + Any endpoint with the label "role=backend" can be consumed by any + endpoint carrying the label "role=frontend". + + Note that while an empty non-nil FromEndpoints does not select anything, + nil FromEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromEntities: + description: |- + FromEntities is a list of special entities which the endpoint subject + to the rule is allowed to receive connections from. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + fromGroups: + description: |- + FromGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + FromGroups entries are functionally equivalent to FromCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + fromGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + fromNodes: + description: |- + FromNodes is a list of nodes identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can only accept incoming + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should + be an 8bit code (0-255), or it's CamelCase name + (for example, \"EchoReply\").\nAllowed ICMP + types are:\n Ipv4: EchoReply | DestinationUnreachable + | Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t + EchoRequest | EchoReply | MulticastListenerQuery| + MulticastListenerReport |\n\t\t\t MulticastListenerDone + | RouterSolicitation | RouterAdvertisement | + NeighborSolicitation |\n\t\t\t NeighborAdvertisement + | RedirectMessage | RouterRenumbering | ICMPNodeInformationQuery + |\n\t\t\t ICMPNodeInformationResponse | InverseNeighborDiscoverySolicitation + | InverseNeighborDiscoveryAdvertisement |\n\t\t\t + HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can only accept incoming + connections on port 80/tcp. + items: + description: |- + PortRule is a list of ports/protocol combinations with optional Layer 7 + rules which must be met. + properties: + listener: + description: |- + listener specifies the name of a custom Envoy listener to which this traffic should be + redirected to. + properties: + envoyConfig: + description: |- + EnvoyConfig is a reference to the CEC or CCEC resource in which + the listener is defined. + properties: + kind: + description: |- + Kind is the resource type being referred to. Defaults to CiliumEnvoyConfig or + CiliumClusterwideEnvoyConfig for CiliumNetworkPolicy and CiliumClusterwideNetworkPolicy, + respectively. The only case this is currently explicitly needed is when referring to a + CiliumClusterwideEnvoyConfig from CiliumNetworkPolicy, as using a namespaced listener + from a cluster scoped policy is not allowed. + enum: + - CiliumEnvoyConfig + - CiliumClusterwideEnvoyConfig + type: string + name: + description: |- + Name is the resource name of the CiliumEnvoyConfig or CiliumClusterwideEnvoyConfig where + the listener is defined in. + minLength: 1 + type: string + required: + - name + type: object + name: + description: Name is the name of the listener. + minLength: 1 + type: string + priority: + description: |- + Priority for this Listener that is used when multiple rules would apply different + listeners to a policy map entry. Behavior of this is implementation dependent. + maximum: 100 + minimum: 1 + type: integer + required: + - envoyConfig + - name + type: object + originatingTLS: + description: |- + OriginatingTLS is the TLS context for the connections originated by + the L7 proxy. For egress policy this specifies the client-side TLS + parameters for the upstream connection originating from the L7 proxy + to the remote destination. For ingress policy this specifies the + client-side TLS parameters for the connection from the L7 proxy to + the local endpoint. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + maxItems: 40 + type: array + rules: + description: |- + Rules is a list of additional port level rules which must be met in + order for the PortRule to allow the traffic. If omitted or empty, + no layer 7 rules are enforced. + oneOf: + - properties: + http: {} + required: + - http + - properties: + dns: {} + required: + - dns + properties: + dns: + description: DNS-specific rules. + items: + description: PortRuleDNS is a list of allowed + DNS lookups. + oneOf: + - properties: + matchName: {} + required: + - matchName + - properties: + matchPattern: {} + required: + - matchPattern + properties: + matchName: + description: |- + MatchName matches literal DNS names. A trailing "." is automatically added + when missing. + maxLength: 255 + pattern: ^([-a-zA-Z0-9_]+[.]?)+$ + type: string + matchPattern: + description: |- + MatchPattern allows using wildcards to match DNS names. All wildcards are + case insensitive. The wildcards are: + - "*" matches 0 or more DNS valid characters, and may occur anywhere in + the pattern. As a special case a "*" as the leftmost character, without a + following "." matches all subdomains as well as the name to the right. + A trailing "." is automatically added when missing. + - "**." is a special prefix which matches all multilevel subdomains in the prefix. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + type: object + type: array + http: + description: HTTP specific rules. + items: + description: |- + PortRuleHTTP is a list of HTTP protocol constraints. All fields are + optional, if all fields are empty or missing, the rule does not have any + effect. + + All fields of this type are extended POSIX regex as defined by IEEE Std + 1003.1, (i.e this follows the egrep/unix syntax, not the perl syntax) + matched against the path of an incoming request. Currently it can contain + characters disallowed from the conventional "path" part of a URL as defined + by RFC 3986. + properties: + headerMatches: + description: |- + HeaderMatches is a list of HTTP headers which must be + present and match against the given values. Mismatch field can be used + to specify what to do when there is no match. + items: + description: |- + HeaderMatch extends the HeaderValue for matching requirement of a + named header field against an immediate string or a secret value. + If none of the optional fields is present, then the + header value is not matched, only presence of the header is enough. + properties: + mismatch: + description: |- + Mismatch identifies what to do in case there is no match. The default is + to drop the request. Otherwise the overall rule is still considered as + matching, but the mismatches are logged in the access log. + enum: + - LOG + - ADD + - DELETE + - REPLACE + type: string + name: + description: Name identifies the header. + minLength: 1 + type: string + secret: + description: |- + Secret refers to a secret that contains the value to be matched against. + The secret must only contain one entry. If the referred secret does not + exist, and there is no "Value" specified, the match will fail. + properties: + name: + description: Name is the name of + the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + value: + description: |- + Value matches the exact value of the header. Can be specified either + alone or together with "Secret"; will be used as the header value if the + secret can not be found in the latter case. + type: string + required: + - name + type: object + type: array + headers: + description: |- + Headers is a list of HTTP headers which must be present in the + request. If omitted or empty, requests are allowed regardless of + headers present. + items: + type: string + type: array + host: + description: |- + Host is an extended POSIX regex matched against the host header of a + request. Examples: + + - foo.bar.com will match the host fooXbar.com or foo-bar.com + - foo\.bar\.com will only match the host foo.bar.com + + If omitted or empty, the value of the host header is ignored. + format: idn-hostname + type: string + method: + description: |- + Method is an extended POSIX regex matched against the method of a + request, e.g. "GET", "POST", "PUT", "PATCH", "DELETE", ... + + If omitted or empty, all methods are allowed. + type: string + path: + description: |- + Path is an extended POSIX regex matched against the path of a + request. Currently it can contain characters disallowed from the + conventional "path" part of a URL as defined by RFC 3986. + + If omitted or empty, all paths are all allowed. + type: string + type: object + type: array + type: object + serverNames: + description: |- + ServerNames is a list of allowed TLS SNI values. If not empty, then + TLS must be present and one of the provided SNIs must be indicated in the + TLS handshake. + items: + description: |- + ServerName allows using '*' wildcard specifier for matching server names with + the below semantics. + + - `*` matches 0 or more DNS valid characters, and may occur anywhere in the pattern. + - `**.` is a special prefix which matches all multilevel subdomains in the prefix. + + As a special case the name "*" matches all valid DNS names. + + Examples: + 1. `*.cilium.io` matches subdomains of cilium at that level + www.cilium.io and blog.cilium.io match, cilium.io and google.com do not + 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" + except those containing "." separator, subcilium.io and sub-cilium.io match, + www.cilium.io and blog.cilium.io does not + 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component + begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, + blog.cilium.io, cilium.io and google.com do not + 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. + "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" + maxLength: 255 + pattern: ^([-a-zA-Z0-9_*]+[.]?)+$ + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + terminatingTLS: + description: |- + TerminatingTLS is the TLS context for the connection terminated by + the L7 proxy. For egress policy this specifies the server-side TLS + parameters to be applied on the connections originated from the local + endpoint and terminated by the L7 proxy. For ingress policy this specifies + the server-side TLS parameters to be applied on the connections + originated from a remote source and terminated by the L7 proxy. + properties: + certificate: + description: |- + Certificate is the file name or k8s secret item name for the certificate + chain. If omitted, 'tls.crt' is assumed, if it exists. If given, the + item must exist. + type: string + privateKey: + description: |- + PrivateKey is the file name or k8s secret item name for the private key + matching the certificate chain. If omitted, 'tls.key' is assumed, if it + exists. If given, the item must exist. + type: string + secret: + description: |- + Secret is the secret that contains the certificates and private key for + the TLS context. + By default, Cilium will search in this secret for the following items: + - 'ca.crt' - Which represents the trusted CA to verify remote source. + - 'tls.crt' - Which represents the public key certificate. + - 'tls.key' - Which represents the private key matching the public key + certificate. + properties: + name: + description: Name is the name of the secret. + type: string + namespace: + description: |- + Namespace is the namespace in which the secret exists. Context of use + determines the default value if left out (e.g., "default"). + type: string + required: + - name + type: object + trustedCA: + description: |- + TrustedCA is the file name or k8s secret item name for the trusted CA. + If omitted, 'ca.crt' is assumed, if it exists. If given, the item must + exist. + type: string + required: + - secret + type: object + type: object + type: array + type: object + type: array + ingressDeny: + description: |- + IngressDeny is a list of IngressDenyRule which are enforced at ingress. + Any rule inserted here will be denied regardless of the allowed ingress + rules in the 'ingress' field. + If omitted or empty, this rule does not apply at ingress. + items: + description: |- + IngressDenyRule contains all rule types which can be applied at ingress, + i.e. network traffic that originates outside of the endpoint and + is entering the endpoint selected by the endpointSelector. + + - All members of this structure are optional. If omitted or empty, the + member will have no effect on the rule. + + - If multiple members are set, all of them need to match in order for + the rule to take effect. + + - FromEndpoints, FromCIDR, FromCIDRSet, FromGroups and FromEntities are mutually + exclusive. Only one of these members may be present within an individual + rule. + properties: + fromCIDR: + description: |- + FromCIDR is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from. Only connections which + do *not* originate from the cluster or from the local host are subject + to CIDR rules. In order to allow in-cluster connectivity, use the + FromEndpoints field. This will match on the source IP address of + incoming connections. Adding a prefix into FromCIDR or into + FromCIDRSet with no ExcludeCIDRs is equivalent. Overlaps are + allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.3.9.1 + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + fromCIDRSet: + description: |- + FromCIDRSet is a list of IP blocks which the endpoint subject to the + rule is allowed to receive connections from in addition to FromEndpoints, + along with a list of subnets contained within their corresponding IP block + from which traffic should not be allowed. + This will match on the source IP address of incoming connections. Adding + a prefix into FromCIDR or into FromCIDRSet with no ExcludeCIDRs is + equivalent. Overlaps are allowed between FromCIDR and FromCIDRSet. + + Example: + Any endpoint with the label "app=my-legacy-pet" is allowed to receive + connections from 10.0.0.0/8 except from IPs in subnet 10.96.0.0/12. + items: + description: |- + CIDRRule is a rule that specifies a CIDR prefix to/from which outside + communication is allowed, along with an optional list of subnets within that + CIDR prefix to/from which outside communication is not allowed. + oneOf: + - properties: + cidr: {} + required: + - cidr + - properties: + cidrGroupRef: {} + required: + - cidrGroupRef + - properties: + cidrGroupSelector: {} + required: + - cidrGroupSelector + properties: + cidr: + description: CIDR is a CIDR prefix / IP Block. + format: cidr + type: string + cidrGroupRef: + description: |- + CIDRGroupRef is a reference to a CiliumCIDRGroup object. + A CiliumCIDRGroup contains a list of CIDRs that the endpoint, subject to + the rule, can (Ingress/Egress) or cannot (IngressDeny/EgressDeny) receive + connections from. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cidrGroupSelector: + description: |- + CIDRGroupSelector selects CiliumCIDRGroups by their labels, + rather than by name. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + except: + description: |- + ExceptCIDRs is a list of IP blocks which the endpoint subject to the rule + is not allowed to initiate connections to. These CIDR prefixes should be + contained within Cidr, using ExceptCIDRs together with CIDRGroupRef is not + supported yet. + These exceptions are only applied to the Cidr in this CIDRRule, and do not + apply to any other CIDR prefixes in any other CIDRRules. + items: + description: |- + CIDR specifies a block of IP addresses. + Example: 192.0.2.1/32 + format: cidr + type: string + type: array + type: object + type: array + fromEndpoints: + description: |- + FromEndpoints is a list of endpoints identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + + Example: + Any endpoint with the label "role=backend" can be consumed by any + endpoint carrying the label "role=frontend". + + Note that while an empty non-nil FromEndpoints does not select anything, + nil FromEndpoints is implicitly treated as a wildcard selector if ToPorts + are also specified. + To select everything, use one EndpointSelector without any match requirements. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromEntities: + description: |- + FromEntities is a list of special entities which the endpoint subject + to the rule is allowed to receive connections from. Supported entities are + `world`, `cluster`, `cluster-mesh`, `host`, `remote-node`, `kube-apiserver`, `ingress`, `init`, + `health`, `unmanaged`, `none` and `all`. + items: + description: |- + Entity specifies the class of receiver/sender endpoints that do not have + individual identities. Entities are used to describe "outside of cluster", + "host", etc. + enum: + - all + - world + - cluster + - cluster-mesh + - host + - init + - ingress + - unmanaged + - remote-node + - health + - none + - kube-apiserver + type: string + type: array + fromGroups: + description: |- + FromGroups allows policies to reference CIDRs provided by external integrations. + Currently, only AWS is supported, and the rule can select by multiple sub directives. + FromGroups entries are functionally equivalent to FromCIDR, and have the same + limitiations. They cannot select traffic originating from within the cluster. + + Example: + fromGroups: + - aws: + securityGroupsIds: + - 'sg-XXXXXXXXXXXXX' + items: + description: Groups allows referencing CIDRs that are resolved + from an external integration. + properties: + aws: + description: AWSGroup is an structure that can be used + to whitelisting information from AWS integration + properties: + labels: + additionalProperties: + type: string + description: |- + Labels selects AWS ENIs by labels. + Multiple labels are AND-ed together. + type: object + region: + description: 'Deprecated: Region is unused.' + type: string + securityGroupsIds: + description: |- + SecurityGroupsIds selects VPC SecurityGroups by IDs. + If multiple IDs are specified, they are OR-ed together. + + Note that this may be AND-ed with any Names specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + securityGroupsNames: + description: |- + SecurityGroupsNames selects VPC SecurityGroups by name. + If multiple names are specified, they are OR-ed together. + + Note that this may be AND-ed with any IDs specified. Specifying both + IDs and Names is not recommended. + items: + type: string + type: array + type: object + type: object + type: array + fromNodes: + description: |- + FromNodes is a list of nodes identified by an + EndpointSelector which are allowed to communicate with the endpoint + subject to the rule. + items: + description: EndpointSelector is a wrapper for k8s LabelSelector. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value + from the MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: array + fromRequires: + description: Deprecated. + items: + type: string + maxItems: 0 + type: array + icmps: + description: |- + ICMPs is a list of ICMP rule identified by type number + which the endpoint subject to the rule is not allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can not accept incoming + type 8 ICMP connections. + items: + description: ICMPRule is a list of ICMP fields. + properties: + fields: + description: Fields is a list of ICMP fields. + items: + description: ICMPField is a ICMP field. + properties: + family: + default: IPv4 + description: |- + Family is a IP address version. + Currently, we support `IPv4` and `IPv6`. + `IPv4` is set as default. + enum: + - IPv4 + - IPv6 + type: string + type: + anyOf: + - type: integer + - type: string + description: "Type is a ICMP-type.\nIt should + be an 8bit code (0-255), or it's CamelCase name + (for example, \"EchoReply\").\nAllowed ICMP + types are:\n Ipv4: EchoReply | DestinationUnreachable + | Redirect | Echo | EchoRequest |\n\t\t RouterAdvertisement + | RouterSelection | TimeExceeded | ParameterProblem + |\n\t\t\t Timestamp | TimestampReply | Photuris + | ExtendedEcho Request | ExtendedEcho Reply\n + \ Ipv6: DestinationUnreachable | PacketTooBig + | TimeExceeded | ParameterProblem |\n\t\t\t + EchoRequest | EchoReply | MulticastListenerQuery| + MulticastListenerReport |\n\t\t\t MulticastListenerDone + | RouterSolicitation | RouterAdvertisement | + NeighborSolicitation |\n\t\t\t NeighborAdvertisement + | RedirectMessage | RouterRenumbering | ICMPNodeInformationQuery + |\n\t\t\t ICMPNodeInformationResponse | InverseNeighborDiscoverySolicitation + | InverseNeighborDiscoveryAdvertisement |\n\t\t\t + HomeAgentAddressDiscoveryRequest | HomeAgentAddressDiscoveryReply + | MobilePrefixSolicitation |\n\t\t\t MobilePrefixAdvertisement + | DuplicateAddressRequestCodeSuffix | DuplicateAddressConfirmationCodeSuffix + |\n\t\t\t ExtendedEchoRequest | ExtendedEchoReply" + pattern: ^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]|EchoReply|DestinationUnreachable|Redirect|Echo|RouterAdvertisement|RouterSelection|TimeExceeded|ParameterProblem|Timestamp|TimestampReply|Photuris|ExtendedEchoRequest|ExtendedEcho + Reply|PacketTooBig|ParameterProblem|EchoRequest|MulticastListenerQuery|MulticastListenerReport|MulticastListenerDone|RouterSolicitation|RouterAdvertisement|NeighborSolicitation|NeighborAdvertisement|RedirectMessage|RouterRenumbering|ICMPNodeInformationQuery|ICMPNodeInformationResponse|InverseNeighborDiscoverySolicitation|InverseNeighborDiscoveryAdvertisement|HomeAgentAddressDiscoveryRequest|HomeAgentAddressDiscoveryReply|MobilePrefixSolicitation|MobilePrefixAdvertisement|DuplicateAddressRequestCodeSuffix|DuplicateAddressConfirmationCodeSuffix)$ + x-kubernetes-int-or-string: true + required: + - type + type: object + maxItems: 40 + type: array + type: object + type: array + toPorts: + description: |- + ToPorts is a list of destination ports identified by port number and + protocol which the endpoint subject to the rule is not allowed to + receive connections on. + + Example: + Any endpoint with the label "app=httpd" can not accept incoming + connections on port 80/tcp. + items: + description: |- + PortDenyRule is a list of ports/protocol that should be used for deny + policies. This structure lacks the L7Rules since it's not supported in deny + policies. + properties: + ports: + description: Ports is a list of L4 port/protocol + items: + description: PortProtocol specifies an L4 port with + an optional transport protocol + properties: + endPort: + description: EndPort can only be an L4 port number. + format: int32 + maximum: 65535 + minimum: 0 + type: integer + port: + description: |- + Port can be an L4 port number, or a name in the form of "http" + or "http-8080". + pattern: ^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4})|([a-zA-Z0-9]-?)*[a-zA-Z](-?[a-zA-Z0-9])*$ + type: string + protocol: + description: |- + Protocol is the L4 protocol. If "ANY", omitted or empty, any protocols + with transport ports (TCP, UDP, SCTP) match. + + Accepted values: "TCP", "UDP", "SCTP", "VRRP", "IGMP", "GRE", "IPIP", + "IPV6", "ESP", "AH", "ANY" + + Tunnel/encapsulation protocols (GRE, IPIP, IPV6, ESP, AH) and other + extended IP protocols (VRRP, IGMP) require the --enable-extended-ip-protocols + flag to be set. These protocols do not use transport-layer ports. + + Matching on ICMP is not supported. + + Named port specified for a container may narrow this down, but may not + contradict this. + enum: + - TCP + - UDP + - SCTP + - VRRP + - IGMP + - GRE + - IPIP + - IPV6 + - ESP + - AH + - ANY + type: string + type: object + type: array + type: object + type: array + type: object + type: array + labels: + description: |- + Labels is a list of optional strings which can be used to + re-identify the rule or to store metadata. It is possible to lookup + or delete strings based on labels. Labels are not required to be + unique, multiple rules can have overlapping or identical labels. + items: + description: Label is Cilium's representation of a label. + properties: + key: + type: string + source: + description: 'Source can be one of the above values (e.g.: + LabelSourceK8s).' + type: string + value: + type: string + required: + - key + type: object + type: array + log: + description: Log specifies custom policy-specific Hubble logging + configuration. + properties: + value: + description: |- + Value is a free-form string that is included in Hubble flows + that match this policy. The string is limited to 32 printable characters. + maxLength: 32 + pattern: ^\PC*$ + type: string + type: object + nodeSelector: + description: |- + NodeSelector selects all nodes which should be subject to this rule. + EndpointSelector and NodeSelector cannot be both empty and are mutually + exclusive. Can only be used in CiliumClusterwideNetworkPolicies. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + description: MatchLabelsValue represents the value from the + MatchLabels {key,value} pair. + maxLength: 63 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + status: + description: Status is the status of the Cilium policy rule + properties: + conditions: + items: + properties: + lastTransitionTime: + description: The last time the condition transitioned from one + status to another. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: The status of the condition, one of True, False, + or Unknown + type: string + type: + description: The type of the policy condition + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + derivativePolicies: + additionalProperties: + description: |- + CiliumNetworkPolicyNodeStatus is the status of a Cilium policy rule for a + specific node. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations corresponds to the Annotations in the ObjectMeta of the CNP + that have been realized on the node for CNP. That is, if a CNP has been + imported and has been assigned annotation X=Y by the user, + Annotations in CiliumNetworkPolicyNodeStatus will be X=Y once the + CNP that was imported corresponding to Annotation X=Y has been realized on + the node. + type: object + enforcing: + description: |- + Enforcing is set to true once all endpoints present at the time the + policy has been imported are enforcing this policy. + type: boolean + error: + description: |- + Error describes any error that occurred when parsing or importing the + policy, or realizing the policy for the endpoints to which it applies + on the node. + type: string + lastUpdated: + description: LastUpdated contains the last time this status + was updated + format: date-time + type: string + localPolicyRevision: + description: |- + Revision is the policy revision of the repository which first implemented + this policy. + format: int64 + type: integer + ok: + description: |- + OK is true when the policy has been parsed and imported successfully + into the in-memory policy repository on the node. + type: boolean + type: object + description: |- + DerivativePolicies is the status of all policies derived from the Cilium + policy + type: object + type: object + required: + - metadata + type: object + x-kubernetes-validations: + - message: spec or specs must be provided + rule: has(self.spec) || has(self.specs) + served: true + storage: true + subresources: + status: {} diff --git a/hack/cilium-api-probe/go.mod b/hack/cilium-api-probe/go.mod new file mode 100644 index 0000000..c496d37 --- /dev/null +++ b/hack/cilium-api-probe/go.mod @@ -0,0 +1,82 @@ +module github.com/hubble-policy-gen/hack/cilium-api-probe + +go 1.27.1 + +require ( + github.com/cilium/cilium v1.20.1 + k8s.io/apimachinery v0.36.3 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cilium/ebpf v0.22.0 // indirect + github.com/cilium/hive v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/loads v0.24.0 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/strfmt v0.26.3 // indirect + github.com/go-openapi/swag/conv v0.26.1 // indirect + github.com/go-openapi/swag/fileutils v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.26.1 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/validate v0.26.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mackerelio/go-osstat v0.2.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sasha-s/go-deadlock v0.3.9 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/vishvananda/netlink v1.3.2-0.20260624190457-bab08b3566fc // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/dig v1.17.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.46.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/client-go v0.36.3 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect +) diff --git a/hack/cilium-api-probe/go.sum b/hack/cilium-api-probe/go.sum new file mode 100644 index 0000000..b687be5 --- /dev/null +++ b/hack/cilium-api-probe/go.sum @@ -0,0 +1,185 @@ +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/cilium v1.20.1 h1:omXYGsC9nvDEdTXZpOVa3VlpB1QTok9OHDPSQbnPoJg= +github.com/cilium/cilium v1.20.1/go.mod h1:b3q9uyjW+L8rGh0KlCSdNyiyE2kpSE2fa2A5cWqXhCo= +github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= +github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4= +github.com/cilium/hive v1.0.4 h1:Sw4loPv25qMnnYSA65+ybpvZMJHQuUMjQ9jcwoPmP7U= +github.com/cilium/hive v1.0.4/go.mod h1:NY1wD3TQ4sya7zkhkB4QX6eIZ4FjwZPG+lnUB2IH5Xs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= +github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= +github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= +github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= +github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= +github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= +github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= +github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= +github.com/mackerelio/go-osstat v0.2.7/go.mod h1:dwpYh5pIPmvk+IEwBKNIWRFMB92mrC08CmXOhDC7nQk= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w= +github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vishvananda/netlink v1.3.2-0.20260624190457-bab08b3566fc h1:rz8YwESFhYgF7sjsKskrWDAve2WC8wA1cWcLLEdojks= +github.com/vishvananda/netlink v1.3.2-0.20260624190457-bab08b3566fc/go.mod h1:lEui7SPMd9fgxzHVGRAvTxsBGCF6PRH81o2kLWLWHgw= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= +k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= +k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= +k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 h1:ngxu1nL4SbFuXwu1EY7cSKcVqSjTQPVbYQT6WNjTXaU= +k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/cilium-api-probe/main.go b/hack/cilium-api-probe/main.go new file mode 100644 index 0000000..5976c92 --- /dev/null +++ b/hack/cilium-api-probe/main.go @@ -0,0 +1,64 @@ +package main + +import ( + _ "embed" + "fmt" + "os" + "runtime/debug" + "strings" + + "github.com/cilium/cilium/pkg/policy/api" + slimv1 "github.com/cilium/cilium/pkg/k8s/slim/k8s/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +//go:embed crd/ciliumnetworkpolicies.yaml +var cnpCRD string + +// the envelope cf2cnp would render: no Status, ObjectMeta from apimachinery (already a dependency of policy/api) +type Policy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec *api.Rule `json:"spec"` +} + +func selector(labels map[string]string) api.EndpointSelector { + return api.EndpointSelector{LabelSelector: &slimv1.LabelSelector{MatchLabels: labels}} +} + +func main() { + rule := &api.Rule{ + EndpointSelector: selector(map[string]string{"app.kubernetes.io/name": "receiver"}), + Ingress: []api.IngressRule{{ + IngressCommonRule: api.IngressCommonRule{ + FromEndpoints: []api.EndpointSelector{selector(map[string]string{"app.kubernetes.io/name": "ns1-caller", "io.kubernetes.pod.namespace": "ns1", "io.cilium.k8s.policy.cluster": "poc2"})}, + FromCIDR: api.CIDRSlice{"172.18.255.170/32"}, + }, + ToPorts: api.PortRules{{Ports: []api.PortProtocol{{Port: "80", Protocol: "tcp"}}}}, + }}, + Description: "probe 3", + } + // validate a COPY with Cilium's own checks; render the original + check := rule.DeepCopy() + fmt.Println("sanitize (copy):", check.Sanitize(), "| protocol fixed on the copy:", check.Ingress[0].ToPorts[0].Ports[0].Protocol, "| selector key on the copy:", firstKey(check.EndpointSelector)) + p := Policy{TypeMeta: metav1.TypeMeta{APIVersion: "cilium.io/v2", Kind: "CiliumNetworkPolicy"}, + ObjectMeta: metav1.ObjectMeta{Name: "receiver", Namespace: "ns-recv", Labels: map[string]string{"app.kubernetes.io/managed-by": "cf2cnp"}}, Spec: rule} + b, _ := yaml.Marshal(p) + os.Stdout.Write(b) + fmt.Println("embedded CRD bytes:", len(cnpCRD), "| names the v2 version:", strings.Contains(cnpCRD, "name: v2")) + if bi, ok := debug.ReadBuildInfo(); ok { + for _, d := range bi.Deps { + if d.Path == "github.com/cilium/cilium" { + fmt.Println("supported spec: cilium.io/v2 CiliumNetworkPolicy as of cilium", d.Version) + } + } + } +} + +func firstKey(es api.EndpointSelector) string { + for k := range es.LabelSelector.MatchLabels { + return k + } + return "" +} From 8854edb3b4632a0ea7db437626cde78629dfd5f6 Mon Sep 17 00:00:00 2001 From: ephico2real2 Date: Sun, 13 Sep 2026 12:23:01 -0500 Subject: [PATCH 27/37] forensics: the rendering decision researched (sigs.k8s.io/yaml is alphabetical; the kustomize/kyaml canonical-order pattern; cf2cnp owns the field order, byte identity kept) and the DNS resolver rule derived from flows with kubernetes/openshift profiles (Cilium's DNS guide; OpenShift's dns-default on 5353) --- docs/CRD-SPEC-FORENSICS.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/CRD-SPEC-FORENSICS.md b/docs/CRD-SPEC-FORENSICS.md index 5ed0ff5..532c0aa 100644 --- a/docs/CRD-SPEC-FORENSICS.md +++ b/docs/CRD-SPEC-FORENSICS.md @@ -151,8 +151,15 @@ schema, embedded from the module's file, checked in CI on every fixture and on t `kubectl-validate --local-crds` (the E10 template's mechanism). The generator itself normalises what `Sanitize` would only fix on the copy (protocol upper-case) so the rendered document is what the agent would accept. -**Rendering.** `sigs.k8s.io/yaml` for the documents; the comment lines cf2cnp adds (`# To allow all traffic to -world …`) stay text-based, as today; `merge` unchanged (YAML nodes). Golden files: every policy under the PoC's +**Rendering — researched.** `sigs.k8s.io/yaml` marshals Kubernetes types through JSON and orders keys alphabetically +(it "does not respect original mapping key order"); tools that write Kubernetes YAML for humans own a canonical order +instead — kustomize/kpt's `kyaml` formatter orders "commonly encountered Resource fields" by a precedence list and +sorts the rest lexicographically. cf2cnp does the same: marshal with `sigs.k8s.io/yaml`, load the JSON into a +`yaml.v3` node tree, re-order by a cf2cnp-owned precedence table (`apiVersion, kind, metadata{name, namespace, labels, +annotations}, spec{description, endpointSelector, nodeSelector, enableDefaultDeny, ingress, egress, ingressDeny, +egressDeny, labels, log}`; a rule's `from*`/`to*` before `toPorts`, `icmps`, `authentication`; a port's `port, endPort, +protocol`, then `rules`; unknown keys lexicographically), add the comment lines as node comments, encode with a 2-space +indent. Today's layout is preserved exactly, so the golden tests demand byte identity; `merge` is unchanged (nodes). Golden files: every policy under the PoC's `demos/2[6-9]*/policies` and `demos/3*/policies` regenerated from its saved flows and diffed — the only accepted differences are key order within `metadata` and the fields the new generator adds. @@ -184,7 +191,26 @@ becomes a cf2cnp release with a known diff. - **Behaviour changes on the swap.** Caught by the golden tests over 40+ real policies from the demos; the review brief for E12 lists them as claims. -## 6. Out of scope, noted +## 6. The DNS resolver rule — derived from flows, with platform profiles + +Cilium's DNS-policy guide gives the resolver rule for Kubernetes and adds, verbatim: "OpenShift users will need to +modify the policies to match the namespace `openshift-dns` (instead of `kube-system`), remove the match on the +`k8s:k8s-app=kube-dns` label, and change the port to 5353." OpenShift's DNS operator runs CoreDNS as the DaemonSet +`dns-default` in `openshift-dns` listening on **5353** (`dns`/UDP, `dns-tcp`/TCP) behind the Service `dns-default:53`, +with the operator's own labels. cf2cnp hard-codes one rule (`kube-system`, `k8s-app: kube-dns`, `53/UDP`) in +`dnsVisibilityRule()` for both the `toFQDNs` path and `--dns-visibility`; on OpenShift that rule selects nothing +and the FQDN policy cuts the pod off from DNS. + +Design: the resolver rule is **derived from the observed DNS flows** when the input has them (the destination pod's +namespace and identifying labels, the destination port and protocol as Hubble reports them; `ANY` when both UDP +and TCP were seen), and from a **profile** otherwise — `--dns-profile auto` (default: from the flows, else +`kubernetes`), `kubernetes` (the docs' rule, `53/ANY`), `openshift` (`openshift-dns`, no `k8s-app` label, `5353/ANY`), +and `--dns-resolver [/
@@ -638,6 +640,7 @@ curl -X POST "http://localhost:8080/generate?name=shop-from-pos" -d @flow.json - const params = []; if (name) params.push('name=' + encodeURIComponent(name)); if (document.getElementById('l7').checked) params.push('l7=true'); if (document.getElementById('dnsVisibility').checked) params.push('dnsVisibility=true'); + const dnsProfile = document.getElementById('dnsProfile').value; if (dnsProfile && dnsProfile !== 'auto') params.push('dnsProfile=' + dnsProfile); for (const p of excludedPeers()) params.push('exclude=' + encodeURIComponent(p)); const url = '/generate' + (params.length ? '?' + params.join('&') : ''); try { @@ -768,6 +771,18 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("l7") == "true" { generator = generator.WithL7() // E2: opt-in layer-7 rules } + if p := r.URL.Query().Get("dnsProfile"); p != "" { + if _, err := generator.WithDNSProfile(p); err != nil { // 0.7.0: auto | kubernetes | openshift + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if spec := r.URL.Query().Get("dnsResolver"); spec != "" { + if _, err := generator.WithDNSResolver(spec); err != nil { // 0.7.0: [/