mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 18:41:30 +02:00
[client] Add netbird agent-network ls and env commands
Surface the caller-scoped Agent Network setup on the CLI. Both commands dial management directly with the active profile's WireGuard key — the same path foreground login uses — so no daemon proto or engine wiring is needed for the proof of concept. netbird agent-network ls prints the proxy endpoint, the authorized providers, and the allowed models (--json for the raw response). netbird agent-network env prints POSIX export lines for Anthropic-compatible tools such as Claude Code, applied with eval "$(netbird agent-network env)": ANTHROPIC_BASE_URL points at the account's proxy endpoint and ANTHROPIC_AUTH_TOKEN carries a placeholder (the proxy authenticates by tunnel peer and injects the real upstream credentials). A model is never guessed: ANTHROPIC_MODEL is exported only when exactly one model is allowed or --model pins one; anything ambiguous is printed as comment lines instead. "Not available for this peer" is an answer, not an error: both commands exit 0 with a plain message (on stderr for env, keeping the eval a harmless no-op). Linear: NET-1399
This commit is contained in:
274
client/cmd/agentnetwork.go
Normal file
274
client/cmd/agentnetwork.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// agentNetworkAuthToken is the placeholder credential exported for
|
||||
// AI-tool CLIs: the Agent Network proxy authenticates callers by tunnel
|
||||
// peer and injects the real upstream credentials itself, so the
|
||||
// client-side token only needs to satisfy the tool's non-empty check.
|
||||
const agentNetworkAuthToken = "netbird"
|
||||
|
||||
var (
|
||||
agentNetworkModelFlag string
|
||||
agentNetworkJSONFlag bool
|
||||
)
|
||||
|
||||
var agentNetworkCmd = &cobra.Command{
|
||||
Use: "agent-network",
|
||||
Short: "Show the Agent Network setup available to this peer",
|
||||
Long: `Commands to inspect the Agent Network (AI provider proxy) setup this peer's groups authorize:
|
||||
the proxy endpoint, the reachable providers, and the allowed models.`,
|
||||
}
|
||||
|
||||
var agentNetworkLsCmd = &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List the Agent Network endpoint, providers, and allowed models",
|
||||
Example: " netbird agent-network ls",
|
||||
RunE: agentNetworkLs,
|
||||
}
|
||||
|
||||
var agentNetworkEnvCmd = &cobra.Command{
|
||||
Use: "env",
|
||||
Short: "Print shell export lines that point AI tools at the Agent Network",
|
||||
Long: `Print POSIX shell export lines (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and
|
||||
ANTHROPIC_MODEL when unambiguous) that configure Anthropic-compatible AI tools, such as
|
||||
Claude Code, to use the Agent Network proxy. Apply them to the current shell with:
|
||||
|
||||
eval "$(netbird agent-network env)"
|
||||
|
||||
When several models are allowed, none is exported — pass --model to pin one.`,
|
||||
Example: " eval \"$(netbird agent-network env)\"\n eval \"$(netbird agent-network env --model claude-sonnet-4-5)\"",
|
||||
RunE: agentNetworkEnv,
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentNetworkLsCmd.PersistentFlags().BoolVar(&agentNetworkJSONFlag, "json", false, "output the setup as JSON")
|
||||
agentNetworkEnvCmd.PersistentFlags().StringVar(&agentNetworkModelFlag, "model", "", "model to export as ANTHROPIC_MODEL (required when several models are allowed)")
|
||||
}
|
||||
|
||||
// fetchAgentNetworkSetup dials the management server directly with the
|
||||
// active profile's WireGuard key — the same credential and path every
|
||||
// other peer RPC uses — and asks for the caller-scoped setup. No daemon
|
||||
// involvement: the request is read-only and needs no tunnel state.
|
||||
func fetchAgentNetworkSetup(ctx context.Context) (*mgmProto.AgentNetworkSetupResponse, error) {
|
||||
pm := profilemanager.NewProfileManager()
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %v", err)
|
||||
}
|
||||
if activeProf == nil {
|
||||
return nil, fmt.Errorf("active profile not found, please run 'netbird up' first")
|
||||
}
|
||||
|
||||
configFilePath, err := activeProf.FilePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile file path: %v", err)
|
||||
}
|
||||
config, err := profilemanager.ReadConfig(configFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file %s: %v (run 'netbird up' first)", configFilePath, err)
|
||||
}
|
||||
|
||||
privateKey, err := wgtypes.ParseKey(config.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse profile private key: %v", err)
|
||||
}
|
||||
|
||||
mgmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tlsEnabled := config.ManagementURL.Scheme == "https"
|
||||
mgmClient, err := mgm.NewClient(mgmCtx, config.ManagementURL.Host, privateKey, tlsEnabled)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to management service %s: %v", config.ManagementURL.String(), err)
|
||||
}
|
||||
defer func() {
|
||||
_ = mgmClient.Close()
|
||||
}()
|
||||
|
||||
setup, err := mgmClient.GetAgentNetworkSetup(mgmCtx)
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied {
|
||||
return nil, fmt.Errorf("this peer is not registered with the management service — run 'netbird up' first")
|
||||
}
|
||||
return nil, fmt.Errorf("get agent network setup: %v", err)
|
||||
}
|
||||
return setup, nil
|
||||
}
|
||||
|
||||
func agentNetworkLs(cmd *cobra.Command, _ []string) error {
|
||||
setup, err := fetchAgentNetworkSetup(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentNetworkJSONFlag {
|
||||
out, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(setup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal setup: %v", err)
|
||||
}
|
||||
cmd.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
if !setup.Configured {
|
||||
cmd.Println("Agent Network is not available for this peer. Ask your administrator.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Printf("Agent Network endpoint: %s\n", setup.Endpoint)
|
||||
cmd.Println("(reachable while connected to NetBird)")
|
||||
for _, p := range setup.Providers {
|
||||
cmd.Println()
|
||||
cmd.Printf("%s (%s)\n", sanitizeOutput(p.Name), sanitizeOutput(providerFlavorLabel(p)))
|
||||
switch {
|
||||
case p.AllModelsAllowed && len(p.Models) == 0:
|
||||
cmd.Println(" All models allowed")
|
||||
case p.AllModelsAllowed:
|
||||
cmd.Println(" All models allowed, including:")
|
||||
printModels(cmd, p.Models)
|
||||
default:
|
||||
cmd.Println(" Allowed models:")
|
||||
printModels(cmd, p.Models)
|
||||
}
|
||||
}
|
||||
cmd.Println()
|
||||
cmd.Println("To configure Anthropic-compatible tools in the current shell: eval \"$(netbird agent-network env)\"")
|
||||
return nil
|
||||
}
|
||||
|
||||
func printModels(cmd *cobra.Command, models []string) {
|
||||
if len(models) == 0 {
|
||||
cmd.Println(" (none)")
|
||||
return
|
||||
}
|
||||
for _, m := range models {
|
||||
cmd.Printf(" %s\n", sanitizeOutput(m))
|
||||
}
|
||||
}
|
||||
|
||||
func providerFlavorLabel(p *mgmProto.AgentNetworkProviderInfo) string {
|
||||
if p.ApiFlavor == "" {
|
||||
return p.CatalogId
|
||||
}
|
||||
return fmt.Sprintf("%s · %s-flavor API", p.CatalogId, p.ApiFlavor)
|
||||
}
|
||||
|
||||
func agentNetworkEnv(cmd *cobra.Command, _ []string) error {
|
||||
setup, err := fetchAgentNetworkSetup(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !setup.Configured {
|
||||
// An answer, not an error: print nothing eval-able and say why on
|
||||
// stderr so `eval "$(...)"` stays a harmless no-op.
|
||||
cmd.PrintErrln("Agent Network is not available for this peer. Ask your administrator.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Printf("export ANTHROPIC_BASE_URL=%s\n", shellQuote(setup.Endpoint))
|
||||
cmd.Printf("export ANTHROPIC_AUTH_TOKEN=%s\n", shellQuote(agentNetworkAuthToken))
|
||||
|
||||
model, note, err := resolveAgentNetworkModel(setup, agentNetworkModelFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if model != "" {
|
||||
cmd.Printf("export ANTHROPIC_MODEL=%s\n", shellQuote(model))
|
||||
}
|
||||
for _, line := range note {
|
||||
cmd.Printf("# %s\n", sanitizeOutput(line))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentNetworkModel picks the model to export from the
|
||||
// Anthropic-flavor providers' effective model sets. A model is never
|
||||
// guessed: --model wins (validated against the allowed set), a single
|
||||
// allowed model is used, and anything ambiguous is returned as comment
|
||||
// lines instead of an export.
|
||||
func resolveAgentNetworkModel(setup *mgmProto.AgentNetworkSetupResponse, flagModel string) (string, []string, error) {
|
||||
allowAny := false
|
||||
var models []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range setup.Providers {
|
||||
if p.ApiFlavor != "anthropic" {
|
||||
continue
|
||||
}
|
||||
if p.AllModelsAllowed {
|
||||
allowAny = true
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
key := strings.ToLower(strings.TrimSpace(m))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
models = append(models, strings.TrimSpace(m))
|
||||
}
|
||||
}
|
||||
|
||||
if flagModel != "" {
|
||||
if allowAny {
|
||||
return flagModel, nil, nil
|
||||
}
|
||||
if _, ok := seen[strings.ToLower(strings.TrimSpace(flagModel))]; !ok {
|
||||
return "", nil, fmt.Errorf("model %q is not in the allowed model list — run 'netbird agent-network ls' to see it", flagModel)
|
||||
}
|
||||
return flagModel, nil, nil
|
||||
}
|
||||
|
||||
if len(models) == 0 && !allowAny {
|
||||
return "", []string{"No Anthropic-flavor provider is authorized for this peer; ANTHROPIC_MODEL not exported."}, nil
|
||||
}
|
||||
if len(models) == 1 && !allowAny {
|
||||
return models[0], nil, nil
|
||||
}
|
||||
|
||||
note := []string{"Multiple models are allowed — none exported. Re-run with --model to pin one:"}
|
||||
for _, m := range models {
|
||||
note = append(note, " "+m)
|
||||
}
|
||||
if allowAny {
|
||||
note = append(note, " (any other model the provider serves)")
|
||||
}
|
||||
return "", note, nil
|
||||
}
|
||||
|
||||
// shellQuote single-quotes a value for safe use in an eval'd export
|
||||
// line, escaping embedded single quotes.
|
||||
func shellQuote(v string) string {
|
||||
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// sanitizeOutput strips control characters (including newlines) from
|
||||
// server-supplied strings so operator-typed values can't break the
|
||||
// line-oriented output or smuggle lines past a `# ` comment prefix.
|
||||
func sanitizeOutput(v string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, v)
|
||||
}
|
||||
@@ -171,6 +171,9 @@ func init() {
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
rootCmd.AddCommand(exposeCmd)
|
||||
rootCmd.AddCommand(agentNetworkCmd)
|
||||
agentNetworkCmd.AddCommand(agentNetworkLsCmd)
|
||||
agentNetworkCmd.AddCommand(agentNetworkEnvCmd)
|
||||
|
||||
networksCMD.AddCommand(routesListCmd)
|
||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||
|
||||
@@ -34,4 +34,8 @@ type Client interface {
|
||||
CreateExpose(ctx context.Context, req ExposeRequest) (*ExposeResponse, error)
|
||||
RenewExpose(ctx context.Context, domain string) error
|
||||
StopExpose(ctx context.Context, domain string) error
|
||||
// GetAgentNetworkSetup returns the Agent Network connection info the
|
||||
// calling peer's groups authorize: proxy endpoint plus effective
|
||||
// providers and models.
|
||||
GetAgentNetworkSetup(ctx context.Context) (*proto.AgentNetworkSetupResponse, error)
|
||||
}
|
||||
|
||||
@@ -914,6 +914,38 @@ func (c *GrpcClient) StopExpose(ctx context.Context, domain string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAgentNetworkSetup asks the management server for the Agent Network
|
||||
// connection info the calling peer's groups authorize.
|
||||
func (c *GrpcClient) GetAgentNetworkSetup(ctx context.Context) (*proto.AgentNetworkSetupResponse, error) {
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encReq, err := encryption.EncryptMessage(*serverPubKey, c.key, &proto.AgentNetworkSetupRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt agent network setup request: %w", err)
|
||||
}
|
||||
|
||||
mgmCtx, cancel := context.WithTimeout(ctx, ConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.realClient.GetAgentNetworkSetup(mgmCtx, &proto.EncryptedMessage{
|
||||
WgPubKey: c.key.PublicKey().String(),
|
||||
Body: encReq,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setupResp := &proto.AgentNetworkSetupResponse{}
|
||||
if err := encryption.DecryptMessage(*serverPubKey, c.key, resp.Body, setupResp); err != nil {
|
||||
return nil, fmt.Errorf("decrypt agent network setup response: %w", err)
|
||||
}
|
||||
|
||||
return setupResp, nil
|
||||
}
|
||||
|
||||
func fromProtoExposeResponse(resp *proto.ExposeServiceResponse) *ExposeResponse {
|
||||
return &ExposeResponse{
|
||||
ServiceName: resp.ServiceName,
|
||||
|
||||
@@ -25,6 +25,7 @@ type MockClient struct {
|
||||
CreateExposeFunc func(ctx context.Context, req ExposeRequest) (*ExposeResponse, error)
|
||||
RenewExposeFunc func(ctx context.Context, domain string) error
|
||||
StopExposeFunc func(ctx context.Context, domain string) error
|
||||
GetAgentNetworkSetupFunc func(ctx context.Context) (*proto.AgentNetworkSetupResponse, error)
|
||||
}
|
||||
|
||||
func (m *MockClient) IsHealthy() bool {
|
||||
@@ -136,3 +137,10 @@ func (m *MockClient) StopExpose(ctx context.Context, domain string) error {
|
||||
}
|
||||
return m.StopExposeFunc(ctx, domain)
|
||||
}
|
||||
|
||||
func (m *MockClient) GetAgentNetworkSetup(ctx context.Context) (*proto.AgentNetworkSetupResponse, error) {
|
||||
if m.GetAgentNetworkSetupFunc == nil {
|
||||
return &proto.AgentNetworkSetupResponse{}, nil
|
||||
}
|
||||
return m.GetAgentNetworkSetupFunc(ctx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user