Created
October 20, 2025 09:24
-
-
Save raihankhan/81a3b072a2ebcc32a23ab5a70e4b3720 to your computer and use it in GitHub Desktop.
Netbird_resource_ingestor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "bytes" | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "log" | |
| "net" | |
| "net/http" | |
| "os" | |
| "strings" | |
| "time" | |
| ) | |
| /* | |
| Production-ready example: create a Netbird network, create a resource with a specific IP, | |
| and assign that resource to a group. | |
| Notes: | |
| - The exact control-plane API paths and payloads may differ depending on the Netbird deployment/version. | |
| This program uses sensible defaults for REST endpoints: | |
| - POST {baseURL}/api/v1/networks | |
| - POST {baseURL}/api/v1/resources | |
| - GET {baseURL}/api/v1/groups | |
| - POST {baseURL}/api/v1/groups/{group_id}/resources (to assign) | |
| If your control plane uses different endpoints or GraphQL/gRPC, adapt the paths/payloads | |
| by setting the *_PATH environment variables below or changing the payload composition. | |
| - Authenticate using a bearer token provided in NETBIRD_API_TOKEN environment variable. | |
| - Use the environment variables documented in `main()` for configuration. | |
| - The code uses retries with exponential backoff for idempotent operations (configurable). | |
| - The code does basic validation (e.g., IP address). | |
| */ | |
| // Default endpoints (adjust if your control plane differs) | |
| const ( | |
| defaultNetworksPath = "/api/v1/networks" | |
| defaultResourcesPath = "/api/v1/resources" | |
| defaultGroupsPath = "/api/v1/groups" | |
| defaultGroupAssignFormat = "/api/v1/groups/%s/resources" // POST with payload to assign resource | |
| ) | |
| // Config holds runtime configuration loaded from environment | |
| type Config struct { | |
| BaseURL string | |
| APIToken string | |
| NetworkName string | |
| NetworkDesc string | |
| ResourceName string | |
| ResourceIP string | |
| GroupName string // used to lookup group | |
| GroupID string // can be provided directly; if set, will be used | |
| NetworksPath string | |
| ResourcesPath string | |
| GroupsPath string | |
| GroupAssign string | |
| HTTPTimeout time.Duration | |
| MaxRetries int | |
| } | |
| func main() { | |
| ctx := context.Background() | |
| // Load config from environment | |
| cfg := loadConfigFromEnv() | |
| logger := log.New(os.Stdout, "", log.LstdFlags|log.Lmsgprefix) | |
| logger.SetPrefix("[netbird-client] ") | |
| // Basic validation | |
| if cfg.BaseURL == "" || cfg.APIToken == "" { | |
| logger.Fatal("NETBIRD_BASE_URL and NETBIRD_API_TOKEN MUST be set") | |
| } | |
| if cfg.NetworkName == "" || cfg.ResourceName == "" || cfg.ResourceIP == "" || (cfg.GroupName == "" && cfg.GroupID == "") { | |
| logger.Fatal("NETWORK_NAME, RESOURCE_NAME, RESOURCE_IP and either GROUP_NAME or GROUP_ID must be provided") | |
| } | |
| if net.ParseIP(cfg.ResourceIP) == nil { | |
| logger.Fatalf("RESOURCE_IP (%s) is not a valid IP address", cfg.ResourceIP) | |
| } | |
| httpClient := &http.Client{ | |
| Timeout: cfg.HTTPTimeout, | |
| } | |
| // Create network | |
| logger.Printf("Creating network %q...", cfg.NetworkName) | |
| networkID, err := createNetwork(ctx, httpClient, cfg, logger) | |
| if err != nil { | |
| logger.Fatalf("failed to create network: %v", err) | |
| } | |
| logger.Printf("Network created with ID: %s", networkID) | |
| // Create resource in the network with the specific IP | |
| logger.Printf("Creating resource %q with IP %s in network %s...", cfg.ResourceName, cfg.ResourceIP, networkID) | |
| resourceID, err := createResource(ctx, httpClient, cfg, networkID, logger) | |
| if err != nil { | |
| logger.Fatalf("failed to create resource: %v", err) | |
| } | |
| logger.Printf("Resource created with ID: %s", resourceID) | |
| // Determine group ID (if not provided) | |
| groupID := cfg.GroupID | |
| if groupID == "" { | |
| logger.Printf("Looking up group %q...", cfg.GroupName) | |
| foundID, err := getGroupIDByName(ctx, httpClient, cfg, cfg.GroupName, logger) | |
| if err != nil { | |
| logger.Fatalf("failed to find group: %v", err) | |
| } | |
| groupID = foundID | |
| logger.Printf("Found group ID: %s", groupID) | |
| } | |
| // Assign resource to group | |
| logger.Printf("Assigning resource %s to group %s...", resourceID, groupID) | |
| if err := assignResourceToGroup(ctx, httpClient, cfg, groupID, resourceID, logger); err != nil { | |
| logger.Fatalf("failed to assign resource to group: %v", err) | |
| } | |
| logger.Printf("Resource %s assigned to group %s successfully", resourceID, groupID) | |
| } | |
| // loadConfigFromEnv reads configuration from environment and returns a Config with defaults applied. | |
| func loadConfigFromEnv() *Config { | |
| base := os.Getenv("NETBIRD_BASE_URL") | |
| if base != "" { | |
| // trim trailing slash for consistent path building | |
| base = strings.TrimRight(base, "/") | |
| } | |
| networksPath := getEnvOrDefault("NETBIRD_NETWORKS_PATH", defaultNetworksPath) | |
| resourcesPath := getEnvOrDefault("NETBIRD_RESOURCES_PATH", defaultResourcesPath) | |
| groupsPath := getEnvOrDefault("NETBIRD_GROUPS_PATH", defaultGroupsPath) | |
| groupAssign := getEnvOrDefault("NETBIRD_GROUP_ASSIGN_PATH", defaultGroupAssignFormat) | |
| // HTTP timeout and retry defaults (production-friendly) | |
| timeout := 20 * time.Second | |
| retries := 5 | |
| if v := os.Getenv("NETBIRD_HTTP_TIMEOUT_SECONDS"); v != "" { | |
| if t, err := time.ParseDuration(v + "s"); err == nil { | |
| timeout = t | |
| } | |
| } | |
| if v := os.Getenv("NETBIRD_MAX_RETRIES"); v != "" { | |
| if n, err := parseInt(v); err == nil && n >= 0 { | |
| retries = n | |
| } | |
| } | |
| return &Config{ | |
| BaseURL: base, | |
| APIToken: os.Getenv("NETBIRD_API_TOKEN"), | |
| NetworkName: os.Getenv("NETWORK_NAME"), | |
| NetworkDesc: os.Getenv("NETWORK_DESCRIPTION"), | |
| ResourceName: os.Getenv("RESOURCE_NAME"), | |
| ResourceIP: os.Getenv("RESOURCE_IP"), | |
| GroupName: os.Getenv("GROUP_NAME"), | |
| GroupID: os.Getenv("GROUP_ID"), | |
| NetworksPath: networksPath, | |
| ResourcesPath: resourcesPath, | |
| GroupsPath: groupsPath, | |
| GroupAssign: groupAssign, | |
| HTTPTimeout: timeout, | |
| MaxRetries: retries, | |
| } | |
| } | |
| func getEnvOrDefault(key, def string) string { | |
| if v := os.Getenv(key); v != "" { | |
| return v | |
| } | |
| return def | |
| } | |
| func parseInt(s string) (int, error) { | |
| var n int | |
| _, err := fmt.Sscanf(s, "%d", &n) | |
| return n, err | |
| } | |
| // doJSONRequest sends an HTTP request with JSON body and decodes the JSON response into out (if non-nil). | |
| // Implements a simple retry with exponential backoff for idempotent operations. | |
| func doJSONRequest(ctx context.Context, client *http.Client, method, url string, token string, body interface{}, out interface{}, maxRetries int) error { | |
| var reqBody []byte | |
| var err error | |
| if body != nil { | |
| reqBody, err = json.Marshal(body) | |
| if err != nil { | |
| return fmt.Errorf("marshal request body: %w", err) | |
| } | |
| } | |
| backoffBase := 300 * time.Millisecond | |
| attempt := 0 | |
| for { | |
| attempt++ | |
| req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(reqBody)) | |
| if err != nil { | |
| return err | |
| } | |
| req.Header.Set("Authorization", "Bearer "+token) | |
| if body != nil { | |
| req.Header.Set("Content-Type", "application/json") | |
| } | |
| req.Header.Set("Accept", "application/json") | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| // Retry on transient transport errors | |
| if attempt <= maxRetries { | |
| sleep := backoffBase * time.Duration(1<<(attempt-1)) | |
| time.Sleep(sleep) | |
| continue | |
| } | |
| return fmt.Errorf("http request failed: %w", err) | |
| } | |
| defer resp.Body.Close() | |
| if resp.StatusCode >= 200 && resp.StatusCode <= 299 { | |
| if out != nil { | |
| dec := json.NewDecoder(resp.Body) | |
| if err := dec.Decode(out); err != nil { | |
| return fmt.Errorf("decode response JSON: %w", err) | |
| } | |
| } | |
| return nil | |
| } | |
| // Non-successful status codes | |
| var errResp map[string]interface{} | |
| dec := json.NewDecoder(resp.Body) | |
| _ = dec.Decode(&errResp) // try to decode error payload, ignore decode errors | |
| // Retry on server-side errors (5xx) | |
| if resp.StatusCode >= 500 && attempt <= maxRetries { | |
| sleep := backoffBase * time.Duration(1<<(attempt-1)) | |
| time.Sleep(sleep) | |
| continue | |
| } | |
| // Build helpful error message | |
| return fmt.Errorf("request failed: %s %s status=%d body=%v", method, url, resp.StatusCode, errResp) | |
| } | |
| } | |
| // createNetwork attempts to create a network and returns the created network ID. | |
| func createNetwork(ctx context.Context, client *http.Client, cfg *Config, logger *log.Logger) (string, error) { | |
| url := cfg.BaseURL + cfg.NetworksPath | |
| // Example payload - adjust fields to match your control plane API. | |
| payload := map[string]interface{}{ | |
| "name": cfg.NetworkName, | |
| "description": cfg.NetworkDesc, | |
| } | |
| var resp map[string]interface{} | |
| if err := doJSONRequest(ctx, client, http.MethodPost, url, cfg.APIToken, payload, &resp, cfg.MaxRetries); err != nil { | |
| return "", err | |
| } | |
| id := extractID(resp) | |
| if id == "" { | |
| // Sometimes the API returns { "network": { "id": "..." } } | |
| if nested, ok := resp["network"].(map[string]interface{}); ok { | |
| id = extractID(nested) | |
| } | |
| } | |
| if id == "" { | |
| logger.Printf("warning: response didn't include an id field; full response: %+v", resp) | |
| return "", errors.New("no id in network creation response") | |
| } | |
| return id, nil | |
| } | |
| // createResource creates a resource attached to the given network and returns the resource ID. | |
| // The payload includes an IP address for the resource, mapped according to a typical control-plane schema. | |
| func createResource(ctx context.Context, client *http.Client, cfg *Config, networkID string, logger *log.Logger) (string, error) { | |
| url := cfg.BaseURL + cfg.ResourcesPath | |
| // Payload – adapt as necessary for your control plane. Many control planes accept "network_id" or "network" relation. | |
| payload := map[string]interface{}{ | |
| "name": cfg.ResourceName, | |
| "network_id": networkID, | |
| "addresses": []string{cfg.ResourceIP}, // common field name: addresses or ips | |
| "metadata": map[string]string{ | |
| "created_by": "netbird-go-client", | |
| "time": time.Now().UTC().Format(time.RFC3339), | |
| }, | |
| } | |
| var resp map[string]interface{} | |
| if err := doJSONRequest(ctx, client, http.MethodPost, url, cfg.APIToken, payload, &resp, cfg.MaxRetries); err != nil { | |
| return "", err | |
| } | |
| id := extractID(resp) | |
| // Fallback: resp["resource"].(map)["id"] | |
| if id == "" { | |
| if nested, ok := resp["resource"].(map[string]interface{}); ok { | |
| id = extractID(nested) | |
| } | |
| } | |
| if id == "" { | |
| logger.Printf("warning: response didn't include an id field; full response: %+v", resp) | |
| return "", errors.New("no id in resource creation response") | |
| } | |
| return id, nil | |
| } | |
| // getGroupIDByName retrieves the list of groups and returns the ID for the provided group name. | |
| // This function paginates if necessary (basic single-page implementation). | |
| func getGroupIDByName(ctx context.Context, client *http.Client, cfg *Config, groupName string, logger *log.Logger) (string, error) { | |
| url := cfg.BaseURL + cfg.GroupsPath | |
| var resp []map[string]interface{} | |
| if err := doJSONRequest(ctx, client, http.MethodGet, url, cfg.APIToken, nil, &resp, cfg.MaxRetries); err != nil { | |
| return "", err | |
| } | |
| for _, g := range resp { | |
| // try common name keys | |
| if name, ok := g["name"].(string); ok && name == groupName { | |
| if id := extractID(g); id != "" { | |
| return id, nil | |
| } | |
| } | |
| // sometimes group payloads use "display_name" | |
| if name, ok := g["display_name"].(string); ok && name == groupName { | |
| if id := extractID(g); id != "" { | |
| return id, nil | |
| } | |
| } | |
| } | |
| return "", fmt.Errorf("group %q not found", groupName) | |
| } | |
| // assignResourceToGroup associates the resource with the given group. | |
| // Expected endpoint: POST {baseURL}/api/v1/groups/{group_id}/resources | |
| func assignResourceToGroup(ctx context.Context, client *http.Client, cfg *Config, groupID, resourceID string, logger *log.Logger) error { | |
| // Compose assign URL. cfg.GroupAssign can be a format string with %s for group id. | |
| assignPath := cfg.GroupAssign | |
| if strings.Contains(assignPath, "%s") { | |
| assignPath = fmt.Sprintf(assignPath, groupID) | |
| } else { | |
| // If user supplied a direct path, try to replace {group_id} | |
| assignPath = strings.ReplaceAll(assignPath, "{group_id}", groupID) | |
| } | |
| url := cfg.BaseURL + assignPath | |
| // Common payload: { "resource_id": "..." } or { "resources": ["id1", ...] } | |
| payload := map[string]interface{}{ | |
| "resource_id": resourceID, | |
| } | |
| var resp map[string]interface{} | |
| if err := doJSONRequest(ctx, client, http.MethodPost, url, cfg.APIToken, payload, &resp, cfg.MaxRetries); err != nil { | |
| return err | |
| } | |
| // Optionally validate response for success | |
| // If your API returns 204 No Content, doJSONRequest would have returned success already with no decoding. | |
| return nil | |
| } | |
| // extractID extracts an "id" or "ID" or "Id" string from a generic JSON object. | |
| func extractID(obj map[string]interface{}) string { | |
| for _, key := range []string{"id", "ID", "Id"} { | |
| if v, ok := obj[key]; ok { | |
| switch t := v.(type) { | |
| case string: | |
| return t | |
| case float64: | |
| // some APIs encode numeric IDs | |
| return fmt.Sprintf("%.0f", t) | |
| } | |
| } | |
| } | |
| return "" | |
| } | |
| /* | |
| - A single, production-minded Go program (main.go) that: | |
| - Reads configuration (endpoints, token, network/resource/group names) from environment variables. | |
| - Creates a network via a POST to the control-plane networks endpoint. | |
| - Creates a resource attached to that network with a specified IP address. | |
| - Looks up the named group (or uses a provided group ID) and assigns the resource to that group. | |
| - Uses a real HTTP client with timeout, retries and exponential backoff, and provides helpful logging. | |
| - Includes defensive checks (IP validation, response ID extraction fallbacks) and clear comments. | |
| How to run | |
| 1. Set environment variables (example): | |
| - NETBIRD_BASE_URL=https://controlplane.example.com | |
| - NETBIRD_API_TOKEN=eyJ... | |
| - NETWORK_NAME="my-network" | |
| - NETWORK_DESCRIPTION="Managed-by-go-client" | |
| - RESOURCE_NAME="resource-01" | |
| - RESOURCE_IP="10.100.0.5" | |
| - GROUP_NAME="workers" (or set GROUP_ID directly) | |
| - Optional: NETBIRD_NETWORKS_PATH, NETBIRD_RESOURCES_PATH, NETBIRD_GROUPS_PATH, NETBIRD_GROUP_ASSIGN_PATH | |
| 2. Build and run: | |
| go build -o nb-client main.go | |
| ./nb-client | |
| Notes and next steps | |
| - Verify the control-plane API schema used by your Netbird installation. If it uses different field names or GraphQL/gRPC, adapt payloads and endpoints accordingly. | |
| - For production, consider: | |
| - Using a structured logger (e.g., zap, zerolog). | |
| - Secrets management for the API token (not plain env vars). | |
| - Better pagination/filters for group lookup if you have many groups. | |
| - TLS config and CA verification customization if your control plane uses private certs. | |
| - If you want, I can adapt this example to the exact Netbird control-plane schema (show me sample API request/response examples or the control-plane OpenAPI/Swagger schema and I'll generate exact payloads). | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment