diff --git a/packages/ns-api/README.md b/packages/ns-api/README.md index 880ca3eca..37b7068a8 100644 --- a/packages/ns-api/README.md +++ b/packages/ns-api/README.md @@ -4240,19 +4240,57 @@ Response example: { "id": "ns_81df3995", "name": "tun1", + "enabled": "1", + "status": "ESTABLISHED", + "connected": "yes", + "children": [ + { + "name": "ns_81df3995_tunnel_1", + "installed": true, + "local_subnet": [ + "192.168.100.0/24" + ], + "remote_subnet": [ + "192.168.200.0/24" + ] + }, + { + "name": "ns_81df3995_tunnel_2", + "installed": true, + "local_subnet": [ + "192.168.100.0/24" + ], + "remote_subnet": [ + "192.168.210.0/24" + ] + } + ], + "raw_output": "ns_81df3995: #1, ESTABLISHED, IKEv1, 8aa580a8a0b5edf1_i* e9fd7cd6550ed7a9_r\n local 'tun1.local' @ 192.168.122.49[500]\n remote 'tun1.remote' @ 192.168.122.50[500]\n AES_CBC-256/HMAC_SHA2_256_128/PRF_HMAC_SHA2_256/MODP_2048\n established 336s ago, rekeying in 3221s\n ns_81df3995_tunnel_1: #1, reqid 1, INSTALLED, TUNNEL, ESP:AES_CBC-256/HMAC_SHA2_256_128/MODP_2048\n installed 336s ago, rekeying in 2951s, expires in 3624s\n in c08d184d (-|0x00000001), 0 bytes, 0 packets\n out c35d0c6c (-|0x00000001), 0 bytes, 0 packets\n local 192.168.100.0/24\n remote 192.168.200.0/24\n ns_81df3995_tunnel_2: #2, reqid 2, INSTALLED, TUNNEL, ESP:AES_CBC-256/HMAC_SHA2_256_128/MODP_2048\n installed 336s ago, rekeying in 3050s, expires in 3624s\n in cddc9495 (-|0x00000001), 0 bytes, 0 packets\n out c019eb7b (-|0x00000001), 0 bytes, 0 packets\n local 192.168.100.0/24\n remote 192.168.210.0/24\n", "local": [ "192.168.100.0/24" ], "remote": [ - "192.168.200.0/24" - ], - "enabled": "1", - "connected": false + "192.168.200.0/24", + "192.168.210.0/24" + ] } ] } ``` +Fields: +- `status`: IKE status from swanctl (e.g., "ESTABLISHED", "CONNECTING", etc.) +- `connected`: Connection state with three possible values: + - `"yes"`: All child tunnels are installed and status is ESTABLISHED + - `"warning"`: Not all child tunnels are installed but status is ESTABLISHED + - `"no"`: Status is not ESTABLISHED +- `children`: Array of child SAs with installation status: + - `name`: Tunnel identifier + - `installed`: `true` if tunnel has INSTALLED child SA, `false` otherwise + - `local_subnet`: List of local subnets for this specific tunnel child + - `remote_subnet`: List of remote subnets for this specific tunnel child +- `raw_output`: Raw output from `swanctl --list-sas` for this tunnel, including detailed status of IKE and child SAs (useful for debugging) + ### list-wans List available wans: @@ -4320,6 +4358,7 @@ Response example: }, "ipcomp": "false", "dpdaction": "restart", + "closeaction": "trap", "remote_subnet": "192.168.200.0/24", "local_subnet": "192.168.100.0/24", "ns_name": "tun1", @@ -4337,7 +4376,7 @@ Response example: Create a tunnel: ``` -api-cli ns.ipsectunnel add-tunnel --data '{"ns_name": "tun1", "ike": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "esp": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "pre_shared_key": "xxxxxxxxxxxxxxxxxxx", "local_identifier": "@ipsec1.local", "remote_identifier": "@ipsec1.remote", "local_subnet": ["192.168.100.0/24"], "remote_subnet": ["192.168.200.0/24"], "enabled": "1", "local_ip": "192.168.122.49", "keyexchange": "ike", "ipcomp": "false", "dpdaction": "restart", "gateway": "10.10.0.172"}' +api-cli ns.ipsectunnel add-tunnel --data '{"ns_name": "tun1", "ike": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "esp": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "pre_shared_key": "xxxxxxxxxxxxxxxxxxx", "local_identifier": "@ipsec1.local", "remote_identifier": "@ipsec1.remote", "local_subnet": ["192.168.100.0/24"], "remote_subnet": ["192.168.200.0/24"], "enabled": "1", "local_ip": "192.168.122.49", "keyexchange": "ike", "ipcomp": "false", "dpdaction": "restart", "closeaction": "trap", "gateway": "10.10.0.172"}' ``` Response example: @@ -4349,7 +4388,7 @@ Response example: Edit a tunnel: ``` -api-cli ns.ipsectunnel add-tunnel --data '{"id": "ns_81df3995", "ns_name": "tun1", "ike": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "esp": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "pre_shared_key": "xxxxxxxxxxxxxxxxxxx", "local_identifier": "@ipsec1.local", "remote_identifier": "@ipsec1.remote", "local_subnet": ["192.168.100.0/24"], "remote_subnet": ["192.168.200.0/24"], "enabled": "1", "local_ip": "192.168.122.49", "keyexchange": "ike", "ipcomp": "false", "dpdaction": "restart", "gateway": "10.10.0.172"}' +api-cli ns.ipsectunnel edit-tunnel --data '{"id": "ns_81df3995", "ns_name": "tun1", "ike": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "esp": {"hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "modp1024", "rekeytime": "3600"}, "pre_shared_key": "xxxxxxxxxxxxxxxxxxx", "local_identifier": "@ipsec1.local", "remote_identifier": "@ipsec1.remote", "local_subnet": ["192.168.100.0/24"], "remote_subnet": ["192.168.200.0/24"], "enabled": "1", "local_ip": "192.168.122.49", "keyexchange": "ike", "ipcomp": "false", "dpdaction": "restart", "closeaction": "trap", "gateway": "10.10.0.172"}' ``` Response example: @@ -4436,6 +4475,24 @@ Result example: } ``` +### restart + +Restart the swanctl daemon. Use this endpoint after adding or removing networks from an IPSec tunnel. **Not needed when modifying an existing tunnel's configuration.** + +``` +api-cli ns.ipsectunnel restart +``` + +Response example: +```json +{"result": "success"} +``` + +Error response example: +```json +{"error": "restart_failed"} +``` + ## ns.netdata Configure netdata reporting daemon. diff --git a/packages/ns-api/files/ns.ipsectunnel b/packages/ns-api/files/ns.ipsectunnel index c243371ab..7f67577d2 100755 --- a/packages/ns-api/files/ns.ipsectunnel +++ b/packages/ns-api/files/ns.ipsectunnel @@ -47,14 +47,97 @@ def next_id(): continue return max_id + 1 -def is_connected(id): +def get_tunnel_status(id): + """Get tunnel status from swanctl output. Returns dict with status, installed count, tunnel details, and raw output.""" + result = { + 'status': None, + 'installed_count': 0, + 'tunnel_count': 0, + 'tunnels': {}, # Map of tunnel_name -> installed (bool) + 'raw_output': '' # Raw swanctl output for this remote + } + try: + p = subprocess.run(["swanctl", "--list-sas"], capture_output=True, text=True, check=True) + lines = p.stdout.split("\n") + in_remote = False + remote_lines = [] + + for i, l in enumerate(lines): + # First line contains the status: "id: #n, STATUS, IKEv1/IKEv2, ..." + if f'{id}:' in l: + parts = l.split(',') + if len(parts) >= 2: + result['status'] = parts[1].strip() + in_remote = True + remote_lines = [l] + continue + + if in_remote: + # Stop parsing when we hit the next remote or end + if l and not l[0].isspace() and ':' in l: + break + + remote_lines.append(l) + + # Parse INSTALLED child SAs (they are indented and contain tunnel name) + if 'INSTALLED' in l and l.strip().startswith(f'{id}_tunnel'): + # Extract tunnel name from line like: "ns_92bfe076_tunnel_2: #1, reqid 1, INSTALLED, ..." + tunnel_name = l.strip().split(':')[0] + result['tunnels'][tunnel_name] = True + result['installed_count'] += 1 + + # Store raw output for this remote + result['raw_output'] = '\n'.join(remote_lines) + except: + pass + + return result + +def is_connected(id, u): + """Check tunnel connection status with three states: yes, warning, no. + - yes: all children installed and status is ESTABLISHED + - warning: not all children installed but status is ESTABLISHED + - no: status is not ESTABLISHED + """ + status_info = get_tunnel_status(id) + + # Get count of configured tunnels from UCI + try: + tunnels = u.get_all('ipsec', id, 'tunnel') + tunnel_count = len(tunnels) if tunnels else 0 + except: + tunnel_count = 0 + + installed_count = status_info['installed_count'] + + if status_info['status'] != 'ESTABLISHED': + return 'no' + + # Status is ESTABLISHED + if installed_count == tunnel_count and tunnel_count > 0: + return 'yes' + else: + return 'warning' + +def uci_set_if_changed(u, config, section, option, value): + """Set UCI option only if it differs from current value to minimize changes.""" try: - p = subprocess.run(["swanctl", "--list-sas", "--ike", id], capture_output=True, text=True, check=True) - for l in p.stdout.split("\n"): - if 'ESTABLISHED' in l: + current = u.get(config, section, option, default=None) + # Handle list values + if isinstance(value, list): + try: + current_list = set(sorted(list(u.get_all(config, section, option)))) + except: + current_list = None + if current_list != set(sorted(value)): + u.set(config, section, option, value) return True + elif str(current) != str(value): + u.set(config, section, option, value) + return True except: - return False + u.set(config, section, option, value) + return True return False @@ -66,13 +149,40 @@ def list_tunnels(): for r in utils.get_all_by_type(u, 'ipsec', 'remote'): local = set() remote = set() + status_info = get_tunnel_status(r) + + # Build children array with tunnel details + children = [] + tunnels = u.get_all('ipsec', r, 'tunnel') + for t in tunnels: + child_local = [] + child_remote = [] + try: + child_local = u.get_all('ipsec', t, 'local_subnet') + except: + pass + try: + child_remote = u.get_all('ipsec', t, 'remote_subnet') + except: + pass + child = { + 'name': t, + 'installed': status_info['tunnels'].get(t, False), + 'local_subnet': child_local, + 'remote_subnet': child_remote + } + children.append(child) + tunnel = { 'id': r, 'name': u.get('ipsec', r, 'ns_name', default=r), 'enabled': u.get('ipsec', r, 'enabled', default='1'), - 'connected': is_connected(r) + 'status': status_info['status'], + 'connected': is_connected(r, u), + 'children': children, + 'raw_output': status_info['raw_output'] } - tunnels = u.get_all('ipsec', r, 'tunnel') + for t in tunnels: t_config = u.get_all('ipsec', t) try: @@ -85,11 +195,10 @@ def list_tunnels(): remote = remote | set(tmp) except: continue - tunnel['local'] = list(local) - tunnel['remote'] = list(remote) + tunnel['local'] = sorted(list(local)) + tunnel['remote'] = sorted(list(remote)) ret.append(tunnel) - return {"tunnels": ret} def add_tunnel(args): @@ -128,7 +237,7 @@ def setup_tunnel(u, iname, args): u.set('ipsec', tunnel, 'rekeytime', args['esp']['rekeytime']) u.set('ipsec', tunnel, 'crypto_proposal', [esp_p]) - u.set('ipsec', tunnel, 'closeaction', 'none') + u.set('ipsec', tunnel, 'closeaction', args.get('closeaction', 'none')) u.set('ipsec', tunnel, 'startaction', 'start') u.set('ipsec', tunnel, 'if_id', if_id) u.set('ipsec', tunnel, 'ns_link', link) @@ -181,11 +290,180 @@ def setup_tunnel(u, iname, args): return {"id": iname} def edit_tunnel(args): - ret = delete_tunnel(args['id']) - if 'result' in ret: - return add_tunnel(args) - else: - return utils.generic_error('cant_edit_tunnel') + u = EUci() + id = args['id'] + + # Verify tunnel exists + try: + u.get("ipsec", id) + except: + return utils.validation_error("tunnel_not_found") + + ike_p = f'{id}_ike' + esp_p = f'{id}_esp' + tunnel_base = f'{id}_tunnel' + link = f'ipsec/{id}' + + # Update IKE proposal (only if changed) + for opt in ['encryption_algorithm', 'hash_algorithm', 'dh_group']: + uci_set_if_changed(u, 'ipsec', ike_p, opt, args['ike'][opt]) + + # Update ESP proposal (only if changed) + for opt in ['encryption_algorithm', 'hash_algorithm', 'dh_group']: + uci_set_if_changed(u, 'ipsec', esp_p, opt, args['esp'][opt]) + + # Build map of existing tunnels: (local_subnet, remote_subnet) -> tunnel_name + # Keep a deterministic order of tunnel ids to reuse them when pairs change + existing_tunnel_map = {} + ordered_tunnels = [] + if_id = None + max_tunnel_index = 0 + for t in utils.get_all_by_type(u, 'ipsec', 'tunnel'): + if t.startswith(tunnel_base): + if if_id is None: + if_id = u.get('ipsec', t, 'if_id', default=None) + # Extract tunnel index from name (e.g., ns_xxx_tunnel_1 -> 1) + try: + t_index = int(t.split('_')[-1]) + max_tunnel_index = max(max_tunnel_index, t_index) + except: + t_index = 0 + ordered_tunnels.append((t_index, t)) + try: + local_subnets = u.get_all('ipsec', t, 'local_subnet') + remote_subnets = u.get_all('ipsec', t, 'remote_subnet') + # Each tunnel has one local and one remote subnet + for ls in local_subnets: + for rs in remote_subnets: + existing_tunnel_map[(ls, rs)] = t + except: + continue + ordered_tunnels.sort() + + # Build set of desired subnet pairs + desired_pairs = set() + for ls in args['local_subnet']: + for rs in args['remote_subnet']: + desired_pairs.add((ls, rs)) + + existing_pairs = set(existing_tunnel_map.keys()) + + # Determine what to keep and what is unmatched + pairs_to_keep = existing_pairs & desired_pairs + pairs_to_add = list(desired_pairs - existing_pairs) + pairs_to_reassign = list(existing_pairs - desired_pairs) # candidates to reuse ids + + # Update existing tunnels that we're keeping (only if changed) + for pair in pairs_to_keep: + t = existing_tunnel_map[pair] + for opt in ['ipcomp', 'dpdaction']: + uci_set_if_changed(u, 'ipsec', t, opt, args[opt]) + uci_set_if_changed(u, 'ipsec', t, 'rekeytime', args['esp']['rekeytime']) + uci_set_if_changed(u, 'ipsec', t, 'closeaction', args.get('closeaction', 'none')) + + # Reuse tunnel ids when changing subnet pairs: assign existing tunnel sections to new pairs before creating new ones + available_tunnels = [t for (_idx, t) in ordered_tunnels if t not in [existing_tunnel_map[p] for p in pairs_to_keep]] + ti = max_tunnel_index + 1 + for (ls, rs) in pairs_to_add: + if available_tunnels: + tunnel = available_tunnels.pop(0) + # Reassign this tunnel to the new pair instead of deleting/creating + uci_set_if_changed(u, 'ipsec', tunnel, 'local_subnet', [ls]) + uci_set_if_changed(u, 'ipsec', tunnel, 'remote_subnet', [rs]) + for opt in ['ipcomp', 'dpdaction']: + uci_set_if_changed(u, 'ipsec', tunnel, opt, args[opt]) + uci_set_if_changed(u, 'ipsec', tunnel, 'rekeytime', args['esp']['rekeytime']) + uci_set_if_changed(u, 'ipsec', tunnel, 'crypto_proposal', [esp_p]) + uci_set_if_changed(u, 'ipsec', tunnel, 'closeaction', args.get('closeaction', 'none')) + uci_set_if_changed(u, 'ipsec', tunnel, 'startaction', 'start') + uci_set_if_changed(u, 'ipsec', tunnel, 'if_id', if_id) + uci_set_if_changed(u, 'ipsec', tunnel, 'ns_link', link) + # Remove from pairs_to_reassign since it's now reused + if pairs_to_reassign: + pairs_to_reassign.pop(0) + else: + # No reusable tunnel ids left, create a new one + tunnel = f'{tunnel_base}_{ti}' + u.set('ipsec', tunnel, 'tunnel') + for opt in ['ipcomp', 'dpdaction']: + u.set('ipsec', tunnel, opt, args[opt]) + u.set('ipsec', tunnel, 'local_subnet', [ls]) + u.set('ipsec', tunnel, 'remote_subnet', [rs]) + u.set('ipsec', tunnel, 'rekeytime', args['esp']['rekeytime']) + u.set('ipsec', tunnel, 'crypto_proposal', [esp_p]) + u.set('ipsec', tunnel, 'closeaction', args.get('closeaction', 'none')) + u.set('ipsec', tunnel, 'startaction', 'start') + u.set('ipsec', tunnel, 'if_id', if_id) + u.set('ipsec', tunnel, 'ns_link', link) + ti = ti + 1 + + # Delete tunnels that were not reused + for pair in pairs_to_reassign: + t = existing_tunnel_map[pair] + u.delete('ipsec', t) + + # Update remote's tunnel list if tunnels changed + if pairs_to_add or pairs_to_reassign: + # Rebuild tunnel list from current state + final_tunnels = [] + for t in utils.get_all_by_type(u, 'ipsec', 'tunnel'): + if t.startswith(tunnel_base): + final_tunnels.append(t) + uci_set_if_changed(u, 'ipsec', id, 'tunnel', final_tunnels) + + # Update routes if remote subnets changed + current_remote = set(rs for (ls, rs) in existing_pairs) + new_remote = set(args['remote_subnet']) + dname = f'ipsec{if_id}' + + if current_remote != new_remote: + # Build map of existing routes: target -> route_name + existing_routes = {} + for r in utils.get_all_by_type(u, 'network', 'route'): + if u.get('network', r, 'ns_link', default='') == link: + target = u.get('network', r, 'target', default='') + existing_routes[target] = r + + # Remove routes for removed remote subnets + for net in (current_remote - new_remote): + if net in existing_routes: + u.delete('network', existing_routes[net]) + + # Add routes for new remote subnets + # Find max route index + max_route_index = 0 + for r in existing_routes.values(): + try: + r_index = int(r.split('_')[-1]) + max_route_index = max(max_route_index, r_index) + except: + pass + + ri = max_route_index + 1 + for net in (new_remote - current_remote): + rname = f'{id}_route_{ri}' + u.set('network', rname, 'route') + u.set('network', rname, 'target', net) + u.set('network', rname, 'interface', dname) + u.set('network', rname, 'ns_link', link) + u.set('network', rname, 'disabled', '0' if args['enabled'] == '1' else '1') + ri = ri + 1 + + u.save('network') + + # Update remote section (only if changed) + uci_set_if_changed(u, 'ipsec', id, 'ns_name', args['ns_name']) + for opt in ['gateway', 'keyexchange', 'local_identifier', 'local_ip', 'enabled', 'remote_identifier', 'pre_shared_key']: + uci_set_if_changed(u, 'ipsec', id, opt, args[opt]) + uci_set_if_changed(u, 'ipsec', id, 'rekeytime', args['ike']['rekeytime']) + + # Update network interface tunlink if local_ip changed + uci_set_if_changed(u, 'network', dname, 'tunlink', get_wan_by_ip(u, args['local_ip'])) + u.save('network') + + u.save('ipsec') + + return {"id": id} def delete_tunnel(id): u = EUci() @@ -269,6 +547,8 @@ def get_tunnel(id): ret['ipcomp'] = u.get('ipsec', t, 'ipcomp', default="") if 'dpdaction' not in ret: ret['dpdaction'] = u.get('ipsec', t, 'dpdaction', default="") + if 'closeaction' not in ret: + ret['closeaction'] = u.get('ipsec', t, 'closeaction', default="none") if 'rekeytime' not in ret['esp']: ret['esp']['rekeytime'] = u.get('ipsec', t, 'rekeytime', default='3600') tmpl = u.get_all('ipsec', t, 'local_subnet') @@ -297,6 +577,14 @@ def get_defaults(): key = subprocess.run(["openssl", "rand", "-base64", "66"], capture_output=True, text=True).stdout.rstrip().replace('\n','') return {"pre_shared_key": key, "local_identifier": local, "remote_identifier": remote, "local_networks": ovpn.get_local_networks(u)} +def restart_swanctl(): + """Restart the swanctl daemon.""" + try: + subprocess.run(["/etc/init.d/swanctl", "restart"], check=True, capture_output=True, text=True) + return {"result": "success"} + except: + return utils.generic_error("restart_failed") + def list_algs(): ret = { "encryption" : [ @@ -345,6 +633,7 @@ if cmd == 'list': "get-defaults": {}, "list-wans": {}, "list-algs": {}, + "restart": {}, "add-tunnel": { "ns_name": "tun1", "ike": {"encryption_algorithm": "3des", "hash_algorithm": "md5", "encryption_algorithm": "3des", "dh_group": "mod1024", "rekeytime": "3600"}, @@ -359,6 +648,7 @@ if cmd == 'list': "keyexchange": "ike", # ike, ikev1, ikev2 "ipcomp": "false", # compression "dpdaction": "restart", + "closeaction": "none", # optional, default none "gateway": "1.2.3.4" # remote server }, "edit-tunnel": { @@ -376,6 +666,7 @@ if cmd == 'list': "keyexchange": "ike", # ike, ikev1, ikev2 "ipcomp": "false", # compression "dpdaction": "restart", + "closeaction": "none", # optional, default none "gateway": "1.2.3.4" # remote server }, "enable-tunnel": {"id": "ns_tun1"}, @@ -393,6 +684,8 @@ else: ret = {"wans": utils.get_all_wan_ips(EUci())} elif action == "list-algs": ret = list_algs() + elif action == "restart": + ret = restart_swanctl() else: args = json.loads(sys.stdin.read())