瀏覽代碼

Merge pull request #3657 from gravitl/release-v1.1.0

Release v1.1.0
Abhishek K 18 小時之前
父節點
當前提交
fbc0f6969b
共有 12 個文件被更改,包括 404 次插入86 次删除
  1. 6 24
      controllers/ext_client.go
  2. 1 1
      controllers/middleware.go
  3. 21 2
      controllers/network.go
  4. 47 8
      logic/acls.go
  5. 19 0
      logic/dns.go
  6. 3 14
      logic/extpeers.go
  7. 5 4
      logic/peers.go
  8. 13 2
      migrate/migrate.go
  9. 16 4
      pro/controllers/users.go
  10. 64 27
      pro/idp/okta/okta.go
  11. 20 0
      pro/logic/dns.go
  12. 189 0
      scripts/netmaker-ci-runner.sh

+ 6 - 24
controllers/ext_client.go

@@ -466,23 +466,6 @@ func getExtClientHAConf(w http.ResponseWriter, r *http.Request) {
 	extclient.IngressGatewayID = targetGwID
 	extclient.Network = networkid
 	extclient.Tags = make(map[models.TagID]struct{})
-	// extclient.Tags[models.TagID(fmt.Sprintf("%s.%s", extclient.Network,
-	// 	models.RemoteAccessTagName))] = struct{}{}
-	// set extclient dns to ingressdns if extclient dns is not explicitly set
-	if (extclient.DNS == "") && (gwnode.IngressDNS != "") {
-		network, _ := logic.GetNetwork(gwnode.Network)
-		dns := gwnode.IngressDNS
-		if len(network.NameServers) > 0 {
-			if dns == "" {
-				dns = strings.Join(network.NameServers, ",")
-			} else {
-				dns += "," + strings.Join(network.NameServers, ",")
-			}
-
-		}
-		extclient.DNS = dns
-
-	}
 
 	listenPort := logic.GetPeerListenPort(host)
 	extclient.IngressGatewayEndpoint = fmt.Sprintf("%s:%d", host.EndpointIP.String(), listenPort)
@@ -506,6 +489,11 @@ func getExtClientHAConf(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+	logic.SetDNSOnWgConfig(&gwnode, &client)
+	defaultDNS := ""
+	if client.DNS != "" {
+		defaultDNS = "DNS = " + client.DNS
+	}
 	addrString := client.Address
 	if addrString != "" {
 		addrString += "/32"
@@ -551,13 +539,6 @@ func getExtClientHAConf(w http.ResponseWriter, r *http.Request) {
 	} else {
 		gwendpoint = fmt.Sprintf("%s:%d", host.EndpointIP.String(), host.ListenPort)
 	}
-	defaultDNS := ""
-	if client.DNS != "" {
-		defaultDNS = "DNS = " + client.DNS
-	} else if gwnode.IngressDNS != "" {
-		defaultDNS = "DNS = " + gwnode.IngressDNS
-	}
-
 	defaultMTU := 1420
 	if host.MTU != 0 {
 		defaultMTU = host.MTU
@@ -630,6 +611,7 @@ Endpoint = %s
 
 	name := client.ClientID + ".conf"
 	w.Header().Set("Content-Type", "application/config")
+	w.Header().Set("Client-ID", client.ClientID)
 	w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
 	w.WriteHeader(http.StatusOK)
 	_, err = fmt.Fprint(w, config)

+ 1 - 1
controllers/middleware.go

@@ -71,7 +71,7 @@ func userMiddleWare(handler http.Handler) http.Handler {
 		if strings.Contains(route, "tags") {
 			r.Header.Set("TARGET_RSRC", models.TagRsrc.String())
 		}
-		if strings.Contains(route, "extclients") {
+		if strings.Contains(route, "extclients") || strings.Contains(route, "client_conf") {
 			r.Header.Set("TARGET_RSRC", models.ExtClientsRsrc.String())
 		}
 		if strings.Contains(route, "enrollment-keys") {

+ 21 - 2
controllers/network.go

@@ -575,21 +575,40 @@ func createNetwork(w http.ResponseWriter, r *http.Request) {
 
 	// validate address ranges: must be private
 	if network.AddressRange != "" {
-		_, _, err := net.ParseCIDR(network.AddressRange)
+		_, cidr, err := net.ParseCIDR(network.AddressRange)
 		if err != nil {
 			logger.Log(0, r.Header.Get("user"), "failed to create network: ",
 				err.Error())
 			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 			return
+		} else {
+			ones, bits := cidr.Mask.Size()
+			if bits-ones <= 1 {
+				err = fmt.Errorf("cannot create network with /31 or /32 cidr")
+				logger.Log(0, r.Header.Get("user"), "failed to create network: ",
+					err.Error())
+				logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+				return
+			}
 		}
 	}
+
 	if network.AddressRange6 != "" {
-		_, _, err := net.ParseCIDR(network.AddressRange6)
+		_, cidr, err := net.ParseCIDR(network.AddressRange6)
 		if err != nil {
 			logger.Log(0, r.Header.Get("user"), "failed to create network: ",
 				err.Error())
 			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 			return
+		} else {
+			ones, bits := cidr.Mask.Size()
+			if bits-ones <= 1 {
+				err = fmt.Errorf("cannot create network with /127 or /128 cidr")
+				logger.Log(0, r.Header.Get("user"), "failed to create network: ",
+					err.Error())
+				logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+				return
+			}
 		}
 	}
 

+ 47 - 8
logic/acls.go

@@ -50,16 +50,31 @@ func GetFwRulesOnIngressGateway(node models.Node) (rules []models.FwRule) {
 	if defaultDevicePolicy.Enabled {
 		return
 	}
+	defer func() {
+		if len(rules) == 0 && IsNodeAllowedToCommunicateWithAllRsrcs(node) {
+			if node.NetworkRange.IP != nil {
+				rules = append(rules, models.FwRule{
+					SrcIP: node.NetworkRange,
+					Allow: true,
+				})
+			}
+			if node.NetworkRange6.IP != nil {
+				rules = append(rules, models.FwRule{
+					SrcIP: node.NetworkRange6,
+					Allow: true,
+				})
+			}
+			return
+		}
+	}()
+
 	for _, nodeI := range nodes {
 		if !nodeI.IsStatic || nodeI.IsUserNode {
 			continue
 		}
-		if !node.StaticNode.Enabled {
+		if !nodeI.StaticNode.Enabled {
 			continue
 		}
-		// if nodeI.StaticNode.IngressGatewayID != node.ID.String() {
-		// 	continue
-		// }
 		if IsNodeAllowedToCommunicateWithAllRsrcs(nodeI) {
 			if nodeI.Address.IP != nil {
 				rules = append(rules, models.FwRule{
@@ -525,7 +540,18 @@ func GetAclRulesForNode(targetnodeI *models.Node) (rules map[string]models.AclRu
 						continue
 					}
 					if _, ok := eI.Nodes[targetnode.ID.String()]; ok {
-						if eI.Range != "" {
+						if servercfg.IsPro && eI.Domain != "" && len(eI.DomainAns) > 0 {
+							for _, domainAnsI := range eI.DomainAns {
+								ip, cidr, err := net.ParseCIDR(domainAnsI)
+								if err == nil {
+									if ip.To4() != nil {
+										egressRanges4 = append(egressRanges4, *cidr)
+									} else {
+										egressRanges6 = append(egressRanges6, *cidr)
+									}
+								}
+							}
+						} else if eI.Range != "" {
 							_, cidr, err := net.ParseCIDR(eI.Range)
 							if err == nil {
 								if cidr.IP.To4() != nil {
@@ -535,6 +561,7 @@ func GetAclRulesForNode(targetnodeI *models.Node) (rules map[string]models.AclRu
 								}
 							}
 						}
+						dstTags[targetnode.ID.String()] = struct{}{}
 					}
 				}
 				break
@@ -544,7 +571,18 @@ func GetAclRulesForNode(targetnodeI *models.Node) (rules map[string]models.AclRu
 				err := e.Get(db.WithContext(context.TODO()))
 				if err == nil && e.Status && len(e.Nodes) > 0 {
 					if _, ok := e.Nodes[targetnode.ID.String()]; ok {
-						if e.Range != "" {
+						if servercfg.IsPro && e.Domain != "" && len(e.DomainAns) > 0 {
+							for _, domainAnsI := range e.DomainAns {
+								ip, cidr, err := net.ParseCIDR(domainAnsI)
+								if err == nil {
+									if ip.To4() != nil {
+										egressRanges4 = append(egressRanges4, *cidr)
+									} else {
+										egressRanges6 = append(egressRanges6, *cidr)
+									}
+								}
+							}
+						} else if e.Range != "" {
 							_, cidr, err := net.ParseCIDR(e.Range)
 							if err == nil {
 								if cidr.IP.To4() != nil {
@@ -554,6 +592,7 @@ func GetAclRulesForNode(targetnodeI *models.Node) (rules map[string]models.AclRu
 								}
 							}
 						}
+						dstTags[targetnode.ID.String()] = struct{}{}
 					}
 
 				}
@@ -800,10 +839,10 @@ func GetEgressRulesForNode(targetnode models.Node) (rules map[string]models.AclR
 						if node.ID == targetnode.ID {
 							continue
 						}
-						if node.Address.IP != nil {
+						if !node.IsStatic && node.Address.IP != nil {
 							aclRule.IPList = append(aclRule.IPList, node.AddressIPNet4())
 						}
-						if node.Address6.IP != nil {
+						if !node.IsStatic && node.Address6.IP != nil {
 							aclRule.IP6List = append(aclRule.IP6List, node.AddressIPNet6())
 						}
 						if node.IsStatic && node.StaticNode.Address != "" {

+ 19 - 0
logic/dns.go

@@ -434,6 +434,25 @@ func validateNameserverReq(ns schema.Nameserver) error {
 	if len(ns.Servers) == 0 {
 		return errors.New("atleast one nameserver should be specified")
 	}
+	network, err := GetNetwork(ns.NetworkID)
+	if err != nil {
+		return errors.New("invalid network id")
+	}
+	_, cidr, err4 := net.ParseCIDR(network.AddressRange)
+	_, cidr6, err6 := net.ParseCIDR(network.AddressRange6)
+	for _, nsIPStr := range ns.Servers {
+		nsIP := net.ParseIP(nsIPStr)
+		if nsIP == nil {
+			return errors.New("invalid nameserver " + nsIPStr)
+		}
+		if err4 == nil && nsIP.To4() != nil {
+			if cidr.Contains(nsIP) {
+				return errors.New("cannot use netmaker IP as nameserver")
+			}
+		} else if err6 == nil && cidr6.Contains(nsIP) {
+			return errors.New("cannot use netmaker IP as nameserver")
+		}
+	}
 	if !ns.MatchAll && len(ns.MatchDomains) == 0 {
 		return errors.New("atleast one match domain is required")
 	}

+ 3 - 14
logic/extpeers.go

@@ -70,23 +70,12 @@ func storeExtClientInCache(key string, extclient models.ExtClient) {
 func GetEgressRangesOnNetwork(client *models.ExtClient) ([]string, error) {
 
 	var result []string
-	networkNodes, err := GetNetworkNodes(client.Network)
-	if err != nil {
-		return []string{}, err
-	}
 	eli, _ := (&schema.Egress{Network: client.Network}).ListByNetwork(db.WithContext(context.TODO()))
-	acls, _ := ListAclsByNetwork(models.NetworkID(client.Network))
-	// clientNode := client.ConvertToStaticNode()
-	for _, currentNode := range networkNodes {
-		if currentNode.Network != client.Network {
+	for _, eI := range eli {
+		if !eI.Status || eI.Range == "" {
 			continue
 		}
-		GetNodeEgressInfo(&currentNode, eli, acls)
-		if currentNode.EgressDetails.IsEgressGateway { // add the egress gateway range(s) to the result
-			if len(currentNode.EgressDetails.EgressGatewayRanges) > 0 {
-				result = append(result, currentNode.EgressDetails.EgressGatewayRanges...)
-			}
-		}
+		result = append(result, eI.Range)
 	}
 	extclients, _ := GetNetworkExtClients(client.Network)
 	for _, extclient := range extclients {

+ 5 - 4
logic/peers.go

@@ -149,10 +149,11 @@ func GetPeerUpdateForHost(network string, host *models.Host, allNodes []models.N
 	}
 	defer func() {
 		if !hostPeerUpdate.FwUpdate.AllowAll {
-
-			hostPeerUpdate.FwUpdate.EgressInfo["allowed-network-rules"] = models.EgressInfo{
-				EgressID:      "allowed-network-rules",
-				EgressFwRules: make(map[string]models.AclRule),
+			if len(hostPeerUpdate.FwUpdate.AllowedNetworks) > 0 {
+				hostPeerUpdate.FwUpdate.EgressInfo["allowed-network-rules"] = models.EgressInfo{
+					EgressID:      "allowed-network-rules",
+					EgressFwRules: make(map[string]models.AclRule),
+				}
 			}
 			for _, aclRule := range hostPeerUpdate.FwUpdate.AllowedNetworks {
 				hostPeerUpdate.FwUpdate.AclRules[aclRule.ID] = aclRule

+ 13 - 2
migrate/migrate.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"log"
+	"net"
 	"os"
 	"time"
 
@@ -63,6 +64,10 @@ func migrateNameservers() {
 	}
 
 	for _, netI := range nets {
+		_, cidr, err := net.ParseCIDR(netI.AddressRange)
+		if err != nil {
+			continue
+		}
 		if len(netI.NameServers) > 0 {
 			ns := schema.Nameserver{
 				ID:           uuid.NewString(),
@@ -78,8 +83,14 @@ func migrateNameservers() {
 				Status:    true,
 				CreatedBy: user.UserName,
 			}
-			for _, ip := range netI.NameServers {
-				ns.Servers = append(ns.Servers, ip)
+
+			for _, nsIP := range netI.NameServers {
+				if net.ParseIP(nsIP) == nil {
+					continue
+				}
+				if !cidr.Contains(net.ParseIP(nsIP)) {
+					ns.Servers = append(ns.Servers, nsIP)
+				}
 			}
 			ns.Create(db.WithContext(context.TODO()))
 			netI.NameServers = []string{}

+ 16 - 4
pro/controllers/users.go

@@ -1498,6 +1498,10 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			continue
 		}
 
+		if extClient.RemoteAccessClientID == "" {
+			continue
+		}
+
 		_, ok := userExtClients[extClient.IngressGatewayID]
 		if !ok {
 			userExtClients[extClient.IngressGatewayID] = []models.ExtClient{}
@@ -1526,13 +1530,21 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
-		if !found {
-			// TODO: prevent ip clashes.
-			if len(extClients) > 0 {
-				gwClient = extClients[0]
+		if !found && req.RemoteAccessClientID != "" {
+			for _, extClient := range extClients {
+				if extClient.RemoteAccessClientID == req.RemoteAccessClientID {
+					gwClient = extClient
+					found = true
+					break
+				}
 			}
 		}
 
+		if !found && len(extClients) > 0 {
+			// TODO: prevent ip clashes.
+			gwClient = extClients[0]
+		}
+
 		host, err := logic.GetHost(node.HostID.String())
 		if err != nil {
 			continue

+ 64 - 27
pro/idp/okta/okta.go

@@ -17,6 +17,7 @@ func NewOktaClient(oktaOrgURL, oktaAPIToken string) (*Client, error) {
 	config, err := okta.NewConfiguration(
 		okta.WithOrgUrl(oktaOrgURL),
 		okta.WithToken(oktaAPIToken),
+		okta.WithRateLimitPrevent(true),
 	)
 	if err != nil {
 		return nil, err
@@ -45,18 +46,17 @@ func (o *Client) Verify() error {
 
 func (o *Client) GetUsers(filters []string) ([]idp.User, error) {
 	var retval []idp.User
-	var allUsersFetched bool
-
-	for !allUsersFetched {
-		users, resp, err := o.client.UserAPI.ListUsers(context.TODO()).
-			Search(buildPrefixFilter("profile.login", filters)).
-			Execute()
-		if err != nil {
-			return nil, err
-		}
 
-		allUsersFetched = !resp.HasNextPage()
+	users, resp, err := o.client.UserAPI.ListUsers(context.TODO()).
+		Search(buildPrefixFilter("profile.login", filters)).
+		Execute()
+	if err != nil {
+		return nil, err
+	}
 
+	usersProcessingPending := len(users) > 0 || resp.HasNextPage()
+
+	for usersProcessingPending {
 		for _, user := range users {
 			id := *user.Id
 			username := *user.Profile.Login
@@ -79,6 +79,19 @@ func (o *Client) GetUsers(filters []string) ([]idp.User, error) {
 				AccountArchived: false,
 			})
 		}
+
+		if resp.HasNextPage() {
+			users = make([]okta.User, 0)
+
+			resp, err = resp.Next(&users)
+			if err != nil {
+				return nil, err
+			}
+
+			usersProcessingPending = len(users) > 0 || resp.HasNextPage()
+		} else {
+			usersProcessingPending = false
+		}
 	}
 
 	return retval, nil
@@ -86,35 +99,46 @@ func (o *Client) GetUsers(filters []string) ([]idp.User, error) {
 
 func (o *Client) GetGroups(filters []string) ([]idp.Group, error) {
 	var retval []idp.Group
-	var allGroupsFetched bool
-
-	for !allGroupsFetched {
-		groups, resp, err := o.client.GroupAPI.ListGroups(context.TODO()).
-			Search(buildPrefixFilter("profile.name", filters)).
-			Execute()
-		if err != nil {
-			return nil, err
-		}
 
-		allGroupsFetched = !resp.HasNextPage()
+	groups, resp, err := o.client.GroupAPI.ListGroups(context.TODO()).
+		Search(buildPrefixFilter("profile.name", filters)).
+		Execute()
+	if err != nil {
+		return nil, err
+	}
+
+	groupsProcessingPending := len(groups) > 0 || resp.HasNextPage()
 
+	for groupsProcessingPending {
 		for _, group := range groups {
-			var allMembersFetched bool
 			id := *group.Id
 			name := *group.Profile.Name
 
 			var members []string
-			for !allMembersFetched {
-				groupUsers, resp, err := o.client.GroupAPI.ListGroupUsers(context.TODO(), id).Execute()
-				if err != nil {
-					return nil, err
-				}
+			groupUsers, groupUsersResp, err := o.client.GroupAPI.ListGroupUsers(context.TODO(), id).Execute()
+			if err != nil {
+				return nil, err
+			}
 
-				allMembersFetched = !resp.HasNextPage()
+			groupUsersProcessingPending := len(groupUsers) > 0 || groupUsersResp.HasNextPage()
 
+			for groupUsersProcessingPending {
 				for _, groupUser := range groupUsers {
 					members = append(members, *groupUser.Id)
 				}
+
+				if groupUsersResp.HasNextPage() {
+					groupUsers = make([]okta.GroupMember, 0)
+
+					groupUsersResp, err = groupUsersResp.Next(&groupUsers)
+					if err != nil {
+						return nil, err
+					}
+
+					groupUsersProcessingPending = len(groupUsers) > 0 || groupUsersResp.HasNextPage()
+				} else {
+					groupUsersProcessingPending = false
+				}
 			}
 
 			retval = append(retval, idp.Group{
@@ -123,6 +147,19 @@ func (o *Client) GetGroups(filters []string) ([]idp.Group, error) {
 				Members: members,
 			})
 		}
+
+		if resp.HasNextPage() {
+			groups = make([]okta.Group, 0)
+
+			resp, err = resp.Next(&groups)
+			if err != nil {
+				return nil, err
+			}
+
+			groupsProcessingPending = len(groups) > 0 || resp.HasNextPage()
+		} else {
+			groupsProcessingPending = false
+		}
 	}
 
 	return retval, nil

+ 20 - 0
pro/logic/dns.go

@@ -3,6 +3,7 @@ package logic
 import (
 	"context"
 	"errors"
+	"net"
 
 	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/logic"
@@ -20,6 +21,25 @@ func ValidateNameserverReq(ns schema.Nameserver) error {
 	if len(ns.Servers) == 0 {
 		return errors.New("atleast one nameserver should be specified")
 	}
+	network, err := logic.GetNetwork(ns.NetworkID)
+	if err != nil {
+		return errors.New("invalid network id")
+	}
+	_, cidr, err4 := net.ParseCIDR(network.AddressRange)
+	_, cidr6, err6 := net.ParseCIDR(network.AddressRange6)
+	for _, nsIPStr := range ns.Servers {
+		nsIP := net.ParseIP(nsIPStr)
+		if nsIP == nil {
+			return errors.New("invalid nameserver " + nsIPStr)
+		}
+		if err4 == nil && nsIP.To4() != nil {
+			if cidr.Contains(nsIP) {
+				return errors.New("cannot use netmaker IP as nameserver")
+			}
+		} else if err6 == nil && cidr6.Contains(nsIP) {
+			return errors.New("cannot use netmaker IP as nameserver")
+		}
+	}
 	if !ns.MatchAll && len(ns.MatchDomains) == 0 {
 		return errors.New("atleast one match domain is required")
 	}

+ 189 - 0
scripts/netmaker-ci-runner.sh

@@ -0,0 +1,189 @@
+#!/usr/bin/env bash
+# Netmaker CI helper: bring WireGuard up/down and manage ephemeral client lifecycle.
+# Subcommands:
+#   up   - fetch config, capture Client-ID, bring interface up, save state
+#   down - bring interface down, delete local conf, delete client via API
+#
+# Env vars (can be overridden by flags):
+#   NETMAKER_BASE_URL   (required)  e.g. https://nm.example.com   or pass --base-url
+#   NETMAKER_NETWORK    (required)  e.g. corpnet                  or pass --network
+#   NETMAKER_API_JWT    (required)  Bearer token                  or pass --jwt
+#   WG_IFACE            (default netmaker)                           or pass --iface
+#   WG_CONF_DIR         (default /etc/wireguard)                  or pass --confdir
+#   NETMAKER_STATE_FILE (default RUNNER_TEMP or /tmp)
+# You may also pass --client-id on `down` to avoid relying on the state file.
+
+set -euo pipefail
+
+# ---------- defaults ----------
+WG_IFACE="${WG_IFACE:-netmaker}"
+WG_CONF_DIR="${WG_CONF_DIR:-/etc/wireguard}"
+SUBCMD=""
+CLIENT_ID_OVERRIDE=""
+
+usage() {
+  cat <<USAGE
+Usage:
+  $0 up   [--iface IFACE] [--confdir DIR] [--base-url URL] [--network NET] [--jwt TOKEN]
+  $0 down [--iface IFACE] [--confdir DIR] [--base-url URL] [--network NET] [--jwt TOKEN] [--client-id ID]
+
+Flags override env vars. Env vars documented at top of the script.
+Examples:
+  NETMAKER_BASE_URL=https://nm.example.com NETMAKER_NETWORK=corpnet NETMAKER_API_JWT=... $0 up
+  $0 down --base-url https://nm.example.com --network corpnet --jwt ... --client-id icy-water
+USAGE
+}
+
+# ---------- arg parse ----------
+if [[ $# -lt 1 ]]; then usage; exit 2; fi
+SUBCMD="$1"; shift || true
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --iface)      WG_IFACE="$2"; shift 2;;
+    --confdir)    WG_CONF_DIR="$2"; shift 2;;
+    --base-url)   NETMAKER_BASE_URL="$2"; shift 2;;
+    --network)    NETMAKER_NETWORK="$2"; shift 2;;
+    --jwt)        NETMAKER_API_JWT="$2"; shift 2;;
+    --client-id)  CLIENT_ID_OVERRIDE="$2"; shift 2;;
+    -h|--help)    usage; exit 0;;
+    *) echo "Unknown arg: $1" >&2; usage; exit 2;;
+  esac
+done
+
+STATE_FILE="${NETMAKER_STATE_FILE:-${RUNNER_TEMP:-/tmp}/netmaker_ci_${WG_IFACE}.env}"
+
+require_env() {
+  : "${NETMAKER_BASE_URL:?ERROR: NETMAKER_BASE_URL not set}"
+  : "${NETMAKER_NETWORK:?ERROR: NETMAKER_NETWORK not set}"
+  : "${NETMAKER_API_JWT:?ERROR: NETMAKER_API_JWT not set}"
+}
+
+install_deps() {
+  echo "[*] Checking dependencies ..."
+  local need=(curl jq wg-quick ip)
+  local miss=()
+  for b in "${need[@]}"; do command -v "$b" >/dev/null 2>&1 || miss+=("$b"); done
+  if [[ ${#miss[@]} -eq 0 ]]; then
+    echo "[*] All dependencies present."
+    return
+  fi
+  echo "[*] Installing missing deps: ${miss[*]}"
+  if command -v apt-get >/dev/null 2>&1; then
+    sudo apt-get update -y
+    sudo apt-get install -y wireguard-tools jq curl iproute2 resolvconf
+  elif command -v yum >/dev/null 2>&1; then
+    sudo yum install -y wireguard-tools jq curl iproute iproute-tc
+  elif command -v dnf >/dev/null 2>&1; then
+    sudo dnf install -y wireguard-tools jq curl iproute
+  else
+    echo "ERROR: no supported package manager found; install: curl jq wireguard-tools iproute" >&2
+    exit 1
+  fi
+}
+
+do_up() {
+  require_env
+  install_deps
+
+  local ep="${NETMAKER_BASE_URL}/api/v1/client_conf/${NETMAKER_NETWORK}"
+  local tmp_conf="/tmp/${WG_IFACE}.conf"
+  local tmp_hdr="/tmp/${WG_IFACE}.headers"
+
+  echo "[*] Requesting client config: ${ep}"
+  # Optional headers
+  declare -a hdrs
+  hdrs=(-H "Authorization: Bearer ${NETMAKER_API_JWT}")
+  [[ -n "${NM_CLIENT_LABEL:-}"   ]] && hdrs+=(-H "X-NM-Client-Label: ${NM_CLIENT_LABEL}")
+  [[ -n "${NM_REQUESTED_NAME:-}" ]] && hdrs+=(-H "X-NM-Requested-Name: ${NM_REQUESTED_NAME}")
+
+  local code
+  code="$(curl -sS -L --dump-header "${tmp_hdr}" -w '%{http_code}' -o "${tmp_conf}" "${hdrs[@]}" "${ep}")"
+  if [[ "${code}" != "200" ]]; then
+    echo "ERROR: client_conf HTTP ${code}" >&2
+    curl -sS -L "${hdrs[@]}" "${ep}" | head -c 400 >&2 || true
+    exit 1
+  fi
+  grep -q "^\[Interface\]" "${tmp_conf}" || { echo "ERROR: not a WireGuard conf"; head -n 20 "${tmp_conf}"; exit 1; }
+
+  # --- Extract Client-ID (one-liner, trim spaces/quotes) ---
+  local client_id
+  client_id="$(grep -i '^Client-ID:' "${tmp_hdr}" | head -n1 | cut -d: -f2- | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"//; s/"$//' -e "s/^'//; s/'$//")"
+  if [[ -z "${client_id}" ]]; then
+    echo "ERROR: Client-ID header missing in response; cannot manage lifecycle." >&2
+    exit 1
+  fi
+  echo "[*] Client-ID: ${client_id}"
+
+  # Optional marker
+  if ! grep -q "^#interface-name=" "${tmp_conf}"; then
+    echo "#interface-name=${WG_IFACE}" | cat - "${tmp_conf}" > "${tmp_conf}.tmp" && mv "${tmp_conf}.tmp" "${tmp_conf}"
+  fi
+
+  # Install & bring up
+  sudo mkdir -p "${WG_CONF_DIR}"
+  sudo mv "${tmp_conf}" "${WG_CONF_DIR}/${WG_IFACE}.conf"
+  sudo chmod 600 "${WG_CONF_DIR}/${WG_IFACE}.conf"
+  echo "[*] Bringing up ${WG_IFACE} ..."
+  sudo wg-quick up "${WG_IFACE}"
+
+  echo "==== ${WG_IFACE} is up ===="
+  ip addr show "${WG_IFACE}" || true
+  wg show "${WG_IFACE}" || true
+
+  # Persist state
+  cat > "${STATE_FILE}" <<EOF
+NETMAKER_BASE_URL='${NETMAKER_BASE_URL}'
+NETMAKER_NETWORK='${NETMAKER_NETWORK}'
+NETMAKER_API_JWT='${NETMAKER_API_JWT}'
+WG_IFACE='${WG_IFACE}'
+WG_CONF_DIR='${WG_CONF_DIR}'
+CLIENT_ID='${client_id}'
+EOF
+  chmod 600 "${STATE_FILE}"
+  echo "[*] Saved state: ${STATE_FILE}"
+}
+
+do_down() {
+  # Load state if present; flags/env can still override
+  if [[ -f "${STATE_FILE}" ]]; then
+    # shellcheck disable=SC1090
+    source "${STATE_FILE}"
+  fi
+
+  require_env
+
+  local client_id="${CLIENT_ID_OVERRIDE:-${CLIENT_ID:-}}"
+  echo "[*] Bringing down ${WG_IFACE} ..."
+  sudo wg-quick down "${WG_IFACE}" || echo "WARN: wg-quick down failed (already down?)."
+
+  # Remove local conf
+  if [[ -f "${WG_CONF_DIR}/${WG_IFACE}.conf" ]]; then
+    sudo shred -u "${WG_CONF_DIR}/${WG_IFACE}.conf" 2>/dev/null || sudo rm -f "${WG_CONF_DIR}/${WG_IFACE}.conf"
+  fi
+
+  # Delete ephemeral client on server (if we know its ID)
+  if [[ -n "${client_id}" ]]; then
+    local del_ep="${NETMAKER_BASE_URL}/api/extclients/${NETMAKER_NETWORK}/${client_id}"
+    echo "[*] Deleting client: DELETE ${del_ep}"
+    local http
+    http="$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: Bearer ${NETMAKER_API_JWT}" "${del_ep}")"
+    if [[ "${http}" =~ ^20[0-9]$ ]]; then
+      echo "[*] Client deleted (HTTP ${http})."
+    else
+      echo "WARN: deletion returned HTTP ${http}; verify server state."
+    fi
+  else
+    echo "WARN: client id not known (missing --client-id and state file); skipping server delete."
+  fi
+
+  rm -f "${STATE_FILE}" || true
+  echo "[*] Teardown finished."
+}
+
+case "${SUBCMD}" in
+  up)   do_up ;;
+  down) do_down ;;
+  *)    usage; exit 2 ;;
+esac
+