Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/in-toto/attestation v1.2.0
github.com/kyverno/chainsaw v0.2.15
github.com/miekg/dns v1.1.72
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/prometheus/client_golang v1.23.2
Expand Down Expand Up @@ -148,6 +149,7 @@ require (
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/grpc v1.81.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
Expand Down
59 changes: 59 additions & 0 deletions internal/testdns/testdns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package testdns provides shared test utilities for spinning up an in-process
// DNS server. It lives in internal/ so it is only importable from this module
// and can be reused across pkg/discovery, pkg/api, and any future consumers
// without each package keeping its own copy.
package testdns

import (
"context"
"net"
"testing"

"github.com/miekg/dns"
)

// Start launches a UDP DNS server on 127.0.0.1 with an ephemeral port that
// dispatches to the supplied handler. It returns the host:port the caller
// should configure their DNS client with. The server is shut down via
// t.Cleanup when the test finishes.
func Start(t *testing.T, handler dns.HandlerFunc) string {
t.Helper()

// noctx lint requires the context-aware ListenConfig variant rather
// than the bare net.ListenPacket. Background() is fine here — test
// servers don't need a real deadline.
lc := &net.ListenConfig{}
pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("testdns: listen: %v", err)
}

server := &dns.Server{
PacketConn: pc,
Handler: handler,
}

go func() {
_ = server.ActivateAndServe()
}()

t.Cleanup(func() {
_ = server.Shutdown()
})

return pc.LocalAddr().String()
}
159 changes: 159 additions & 0 deletions pkg/api/agents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"context"
"net/http"
"regexp"

"log/slog"

"golang.org/x/sync/errgroup"

"github.com/NVIDIA/aicr/pkg/defaults"
"github.com/NVIDIA/aicr/pkg/discovery"
"github.com/NVIDIA/aicr/pkg/errors"
"github.com/NVIDIA/aicr/pkg/openshell"
"github.com/NVIDIA/aicr/pkg/serializer"
"github.com/NVIDIA/aicr/pkg/server"
)

// maxPolicyCheckConcurrency bounds the number of in-flight policy fetches
// per /v1/agents request. Sequential fetches with a 3s PolicyFetchTimeout
// would otherwise risk N×3s latency under fanout.
const maxPolicyCheckConcurrency = 8

// dnsNameRegex validates DNS domain names: labels separated by dots, optional trailing dot.
// Each label: 1-63 chars of [a-z0-9_-], total <= 253 chars.
Comment thread
IngmarVG-IB marked this conversation as resolved.
var dnsNameRegex = regexp.MustCompile(`^[a-z0-9_][a-z0-9_\-]{0,62}(\.[a-z0-9_][a-z0-9_\-]{0,62})*\.?$`)
Comment on lines +40 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Clarify underscore allowance in DNS label validation.

The regex permits underscores ([a-z0-9_]) in DNS labels, which is non-standard per RFC 1035 (labels should be [a-z0-9-] only). Underscores are valid in SRV-style prefixed names (e.g., _svc._tcp) but not in general domain labels. Since this validates the user-supplied domain query parameter (a domain suffix, not an underscore-prefixed service name), the leniency may be unintentional. If this is deliberate for Kubernetes compatibility or another reason, a brief inline comment would clarify the intent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/api/agents.go` around lines 40 - 41, The dnsNameRegex currently allows
underscores in labels (character class `[a-z0-9_]`) which is non‑standard;
update the regex named dnsNameRegex so label character classes use `[a-z0-9-]`
(and ensure the leading-character class likewise disallows `_`) to enforce RFC
1035 rules for domain labels, or if underscores were intentionally permitted
(e.g., for Kubernetes SRV/prefixed names), add a concise inline comment above
dnsNameRegex explaining that deliberate deviation and why; then run/update any
unit tests that validate domain parsing to reflect the change.


// agentResponse represents a single agent in the /v1/agents JSON response.
type agentResponse struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
Endpoint string `json:"endpoint"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Params agentParamsSummary `json:"params,omitempty"`
PolicyResult *policyResultSummary `json:"policyResult,omitempty"`
}

// agentParamsSummary is a subset of SvcParams relevant for the API response.
type agentParamsSummary struct {
Realm string `json:"realm,omitempty"`
BAP []string `json:"bap,omitempty"`
Policy string `json:"policy,omitempty"`
ConnectClass string `json:"connectClass,omitempty"`
}

// policyResultSummary is the OpenShell policy evaluation result for an agent.
type policyResultSummary struct {
Allowed bool `json:"allowed"`
Violations []string `json:"violations,omitempty"`
}

// handleAgents returns an http.HandlerFunc that discovers all agents in a domain,
// evaluates OpenShell policy for each, and returns them as JSON. The domain is
// taken from the "domain" query parameter and defaults to defaultDomain.
// If guard is nil, policy evaluation is skipped.
func handleAgents(disc *discovery.Discoverer, guard *openshell.Guard, defaultDomain string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
server.WriteError(w, r, http.StatusMethodNotAllowed, errors.ErrCodeMethodNotAllowed,
"Method not allowed", false, nil)
return
}

ctx, cancel := context.WithTimeout(r.Context(), defaults.DiscoveryHandlerTimeout)
defer cancel()

domain := r.URL.Query().Get("domain")
if domain == "" {
domain = defaultDomain
}
if len(domain) > 253 || !dnsNameRegex.MatchString(domain) {
server.WriteError(w, r, http.StatusBadRequest, errors.ErrCodeInvalidRequest,
Comment thread
IngmarVG-IB marked this conversation as resolved.
"invalid domain parameter", false, map[string]any{"domain": domain})
return
}

records, err := disc.DiscoverAll(ctx, domain)
if err != nil {
server.WriteErrorFromErr(w, r, err, "agent discovery failed", nil)
return
}

// Build the response shells first so per-agent policy checks can fan
// out concurrently into stable indices.
agents := make([]agentResponse, len(records))
for i := range records {
rec := &records[i]
agents[i] = agentResponse{
Name: rec.Name,
Protocol: string(rec.Protocol),
Endpoint: rec.Endpoint,
Port: rec.Port,
Priority: rec.Priority,
Params: agentParamsSummary{
Realm: rec.Params.Realm,
BAP: rec.Params.BAP,
Policy: rec.Params.Policy,
ConnectClass: rec.Params.ConnectClass,
},
}
}

// Evaluate OpenShell policy concurrently. Sequential checks would
// scale as N×PolicyFetchTimeout (3s) on a cold cache and risk
// blowing past DiscoveryHandlerTimeout.
if guard != nil {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(maxPolicyCheckConcurrency)
for i := range records {
if records[i].Params.Policy == "" {
continue
}
g.Go(func() error {
rec := &records[i]
result, err := guard.Check(gctx, rec)
if err != nil {
slog.Warn("openshell guard check failed",
"agent", rec.Name, "error", err)
return nil // skip; do not fail the whole request
}
summary := policyResultSummary{Allowed: result.Allowed}
for _, v := range result.Violations {
summary.Violations = append(summary.Violations, v.Rule+": "+v.Detail)
}
agents[i].PolicyResult = &summary
return nil
})
}
// Goroutines never return errors today, but Wait() also surfaces
// gctx cancellation if the request deadline trips first.
_ = g.Wait()
}

resp := map[string]any{
"domain": domain,
"agents": agents,
"count": len(agents),
}

serializer.RespondJSON(w, http.StatusOK, resp)
}
}
Loading