mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 19:45:14 -04:00
The agent-network commands dial management directly with the profile's WireGuard key, and the default profile config is root-owned — running unprivileged fails reading it. Surface a clear "re-run with sudo" message instead of a bare permission error. Linear: NET-1399
291 lines
9.5 KiB
Go
291 lines
9.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"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 {
|
|
// The default profile config (and its WireGuard key) is owned by
|
|
// root; dialing management directly therefore needs the same
|
|
// elevation the daemon has. Point at sudo instead of surfacing a
|
|
// bare permission error.
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
return nil, fmt.Errorf("reading profile %s requires elevated permissions — re-run with sudo", configFilePath)
|
|
}
|
|
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 {
|
|
switch s.Code() {
|
|
case codes.PermissionDenied:
|
|
return nil, fmt.Errorf("this peer is not registered with the management service at %s — run 'netbird up' first", config.ManagementURL.String())
|
|
case codes.Unimplemented:
|
|
return nil, fmt.Errorf("the management server at %s does not implement the agent-network setup RPC — the process answering runs a build without it.\n"+
|
|
"Verify the running binary contains the RPC: grep -ac GetAgentNetworkSetup <path-to-server-binary> (0 = built without it),\n"+
|
|
"and that this URL actually reaches the server you rebuilt", config.ManagementURL.String())
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("get agent network setup from %s: %v", config.ManagementURL.String(), 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)
|
|
}
|