From 92d829e1844d8a41799c09704e22895de3ab027e Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 21 May 2026 02:16:32 +0000 Subject: [PATCH] implement policy system --- commands/add.command.sh | 90 +++++++++--- commands/identity.command.sh | 197 ++++++++++++++++++++++++-- commands/policy.command.sh | 238 ++++++++++++++++++++++++++++++++ core/json.sh | 12 +- core/json_helper.py | 258 ++++++++++++++++++++++++++++++++++- modules/identity.module.sh | 120 ++++++++++++++++ modules/policy.module.sh | 200 +++++++++++++++++++++++++++ modules/rule.module.sh | 27 +++- modules/subnet.module.sh | 18 ++- 9 files changed, 1120 insertions(+), 40 deletions(-) create mode 100644 commands/policy.command.sh create mode 100644 modules/policy.module.sh diff --git a/commands/add.command.sh b/commands/add.command.sh index 450881b..139b1ab 100644 --- a/commands/add.command.sh +++ b/commands/add.command.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # ============================================ # Lifecycle # ============================================ @@ -5,6 +7,7 @@ function cmd::add::on_load() { load_module subnet load_module identity + load_module policy flag::register --name flag::register --identity @@ -40,8 +43,8 @@ Options: --type Device type: desktop, laptop, phone, tablet, server, iot --subnet Subnet to allocate from (default: type-native) --ip Override auto-assigned IP (optional) - --tunnel Tunnel mode: split|full (default: from subnet) - --rule Assign rule on creation (default: from subnet or user) + --tunnel Tunnel mode: split|full (overrides policy) + --rule Peer rule (default: from policy default_rule or none) --group Add to group on creation (group must exist) --show-qr Show the WireGuard config as a QR code after creation @@ -50,11 +53,11 @@ Subnet shorthands (equivalent to --subnet ): Examples: wgctl add --name nuno --type phone - wgctl add --identity nuno --type phone # auto-names phone-nuno (or phone-nuno-2) - wgctl add --identity nuno --type laptop + wgctl add --identity nuno --type phone wgctl add --name zephyr --type desktop --guests wgctl add --identity zephyr --type desktop --guests wgctl add --name visitor --type phone --guests --show-qr + wgctl add --name dev --type laptop --rule dev-01 EOF } @@ -165,16 +168,25 @@ function cmd::add::run() { resolved_cidr=$(subnet::resolve_for_add "$type" "$subnet_name") || return 1 resolved_type=$(subnet::type_for_add "$type" "$subnet_name") || return 1 - # Resolve tunnel mode - [[ -z "$tunnel" ]] && tunnel=$(subnet::tunnel_mode "${subnet_name:-$type}" "$type") + # Resolve effective policy + local identity_name="${identity:-$(identity::get_name "$full_name")}" + local effective_policy + effective_policy=$(policy::effective "$subnet_name" "$resolved_type" "$identity_name") - # Resolve rule — subnet default_rule, then global default - if [[ -z "$rule" ]]; then - rule=$(subnet::default_rule "$subnet_name" "$resolved_type") - [[ -z "$rule" ]] && rule="user" + # Resolve tunnel mode — flag overrides policy + if [[ -z "$tunnel" ]]; then + tunnel=$(policy::tunnel_mode "$effective_policy") fi - rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } + # Resolve peer rule — explicit flag overrides policy default_rule + if [[ -z "$rule" ]]; then + rule=$(policy::default_rule "$effective_policy") + fi + + # Validate rule if set + if [[ -n "$rule" ]]; then + rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } + fi local allowed_ips allowed_ips=$(config::allowed_ips_for "$tunnel") || return 1 @@ -189,28 +201,37 @@ function cmd::add::run() { fi cmd::add::_log_plan "$full_name" "$type" "$resolved_type" \ - "$subnet_name" "$resolved_cidr" "$ip" "$tunnel" "$allowed_ips" "$rule" + "$subnet_name" "$resolved_cidr" "$ip" "$tunnel" \ + "$allowed_ips" "${rule:---}" "$effective_policy" - keys::generate_pair "$full_name" || return 1 - peers::create_client_config "$full_name" "$resolved_type" "$ip" "$allowed_ips" || return 1 + keys::generate_pair "$full_name" || return 1 + peers::create_client_config "$full_name" "$resolved_type" "$ip" "$allowed_ips" || return 1 - # Write meta + # Write meta — type, subnet, rule (if set) peers::set_meta "$full_name" "type" "$resolved_type" - peers::set_meta "$full_name" "rule" "$rule" if [[ -n "$subnet_name" ]]; then peers::set_meta "$full_name" "subnet" "$subnet_name" fi + if [[ -n "$rule" ]]; then + peers::set_meta "$full_name" "rule" "$rule" + fi cmd::add::_assign_group "$full_name" "$group" local public_key public_key=$(keys::public "$full_name") || return 1 peers::add_to_server "$full_name" "$public_key" "$ip" || return 1 - rule::apply "$rule" "$ip" || return 1 - peers::reload || return 1 - # Auto-attach to identity + # Apply peer rule if set + if [[ -n "$rule" ]]; then + rule::apply "$rule" "$ip" "$full_name" || return 1 + fi + + # Auto-attach to identity and apply identity rule if set identity::auto_attach "$full_name" "$resolved_type" + cmd::add::_apply_identity_rule "$full_name" "$ip" "$identity_name" "$effective_policy" "$rule" + + peers::reload || return 1 log::wg_success "Client added: ${full_name} (${ip}) [${tunnel} tunnel]" cmd::add::_show_result "$full_name" "$resolved_type" "$show_qr" @@ -223,7 +244,7 @@ function cmd::add::run() { function cmd::add::_log_plan() { local full_name="${1:-}" type="${2:-}" resolved_type="${3:-}" \ subnet_name="${4:-}" resolved_cidr="${5:-}" ip="${6:-}" \ - tunnel="${7:-}" allowed_ips="${8:-}" rule="${9:-}" + tunnel="${7:-}" allowed_ips="${8:-}" rule="${9:-}" policy="${10:-}" log::wg_add "Name: ${full_name}" log::wg_add "Type: ${resolved_type}" @@ -232,6 +253,7 @@ function cmd::add::_log_plan() { log::wg_add "Tunnel: ${tunnel} (${allowed_ips})" log::wg_add "Endpoint: $(config::endpoint)" log::wg_add "Rule: ${rule}" + log::wg_add "Policy: ${policy}" } function cmd::add::_assign_group() { @@ -244,6 +266,34 @@ function cmd::add::_assign_group() { group::add_peer "$group" "$full_name" log::wg "Added to group: ${group}" } + +function cmd::add::_apply_identity_rule() { + local full_name="${1:-}" ip="${2:-}" identity_name="${3:-}" \ + effective_policy="${4:-}" peer_rule="${5:-}" + + [[ -z "$identity_name" ]] && return 0 + + local identity_rule + identity_rule=$(identity::rule "$identity_name") || true + + [[ -z "$identity_rule" ]] && { + # No identity rule — warn if no peer rule either + if [[ -z "$peer_rule" ]]; then + policy::warn_no_rule "$full_name" + fi + return 0 + } + + # Apply identity rule + rule::exists "$identity_rule" && rule::apply "$identity_rule" "$ip" "$full_name" || true + + # Warn based on strict_rule + if policy::strict_rule "$effective_policy"; then + policy::warn_strict_rule "$identity_name" "$effective_policy" "$identity_rule" + elif [[ -n "$peer_rule" && "$peer_rule" != "$identity_rule" ]]; then + policy::warn_additive_rule "$identity_name" "$identity_rule" "$peer_rule" + fi +} function cmd::add::_show_result() { local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}" diff --git a/commands/identity.command.sh b/commands/identity.command.sh index 4b2643f..0b8a0b8 100644 --- a/commands/identity.command.sh +++ b/commands/identity.command.sh @@ -13,10 +13,23 @@ # ============================================ function cmd::identity::on_load() { + load_module identity + load_module policy + flag::register --name flag::register --peer flag::register --dry-run flag::register --force + # rule subcommand flags + flag::register --rule + # options subcommand flags + flag::register --policy + flag::register --set-strict-rule + flag::register --unset-strict-rule + flag::register --set-auto-apply + flag::register --unset-auto-apply + flag::register --field + flag::register --value } # ============================================ @@ -26,9 +39,9 @@ function cmd::identity::on_load() { function cmd::identity::help() { cat < [options] - + Manage peer identities. - + Subcommands: list List all identities show --name Show identity details and device status @@ -36,14 +49,24 @@ Subcommands: --peer remove --name Remove identity and all associated peers migrate [--dry-run] Create identities from existing peer names - + + rule assign --name Assign a rule to an identity + --rule + rule unassign --name Remove rule from an identity + rule show --name Show current identity rule + + options --name Set identity options + [--policy ] + [--set-strict-rule | --unset-strict-rule] + [--set-auto-apply | --unset-auto-apply] + Examples: wgctl identity list wgctl identity show --name nuno - wgctl identity add --name nuno --peer roboclean - wgctl identity remove --name zephyr - wgctl identity migrate --dry-run - wgctl identity migrate + wgctl identity rule assign --name nuno --rule admin + wgctl identity rule unassign --name nuno + wgctl identity options --name guests-identity --policy guest + wgctl identity options --name nuno --set-strict-rule EOF } @@ -61,9 +84,11 @@ function cmd::identity::run() { add) cmd::identity::_add "$@" ;; remove) cmd::identity::_remove "$@" ;; migrate) cmd::identity::_migrate "$@" ;; - --help) cmd::identity::help ;; + rule) cmd::identity::_rule "$@" ;; + options) cmd::identity::_options "$@" ;; + --help) cmd::identity::help ;; *) - log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, remove, migrate" + log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, remove, migrate, rule, options" return 1 ;; esac @@ -281,4 +306,158 @@ function cmd::identity::_migrate() { done <<< "$output" ui::identity::migrate_summary "$created" "$skipped" "$dry_run" +} + +function cmd::identity::_rule() { + local subcmd="${1:-show}" + shift || true + + case "$subcmd" in + assign) cmd::identity::_rule_assign "$@" ;; + unassign) cmd::identity::_rule_unassign "$@" ;; + show) cmd::identity::_rule_show "$@" ;; + *) + log::error "Unknown rule subcommand '${subcmd}'. Available: assign, unassign, show" + return 1 + ;; + esac +} + +function cmd::identity::_rule_assign() { + local name="" rule="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --rule) rule="$2"; shift 2 ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$rule" ]] && { log::error "Missing required flag: --rule"; return 1; } + identity::require_exists "$name" || return 1 + rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } + + identity::set_rule "$name" "$rule" + log::ok "Rule '${rule}' assigned to identity '${name}'" + + # Reapply rules for all peers if auto_apply is set + local auto + auto=$(identity::rule_flags "$name" "auto_apply") + if [[ "$auto" != "false" ]]; then + log::info "Reapplying rules for all peers in identity '${name}'..." + identity::reapply_rules "$name" + log::ok "Rules reapplied" + fi + + # Warn about strict_rule impact + if policy::strict_rule "$(identity::policy "$name")"; then + log::warn "Strict rule is enabled for identity '${name}' — peer rules will not be additive" + fi +} + +function cmd::identity::_rule_unassign() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local current_rule + current_rule=$(identity::rule "$name") + if [[ -z "$current_rule" ]]; then + log::warn "Identity '${name}' has no rule assigned" + return 0 + fi + + identity::clear_rule "$name" + log::ok "Rule removed from identity '${name}'" + log::info "Note: existing fw rules from '${current_rule}' are not automatically removed — run 'wgctl rule reapply' if needed" +} + +function cmd::identity::_rule_show() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local rule policy + rule=$(identity::rule "$name") + policy=$(identity::policy "$name") + + local strict auto + strict=$(identity::rule_flags "$name" "strict_rule") + auto=$(identity::rule_flags "$name" "auto_apply") + + echo "" + ui::row "Identity" "$name" + ui::row "Rule" "${rule:-—}" + ui::row "Policy" "$policy" + ui::row "Strict rule" "$( [[ "$strict" == "true" ]] && echo "yes" || echo "no" )" + ui::row "Auto apply" "$( [[ "$auto" != "false" ]] && echo "yes" || echo "no" )" + echo "" +} + +function cmd::identity::_options() { + local name="" new_policy="" + local set_strict="" set_auto="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --policy) new_policy="$2"; shift 2 ;; + --set-strict-rule) set_strict="true"; shift ;; + --unset-strict-rule) set_strict="false"; shift ;; + --set-auto-apply) set_auto="true"; shift ;; + --unset-auto-apply) set_auto="false"; shift ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + identity::require_exists "$name" || return 1 + + local changed=false + + if [[ -n "$new_policy" ]]; then + policy::require_exists "$new_policy" || return 1 + identity::set_policy "$name" "$new_policy" + log::ok "Policy set to '${new_policy}' for identity '${name}'" + changed=true + fi + + if [[ -n "$set_strict" ]]; then + identity::set_rule_flag "$name" "strict_rule" "$set_strict" + if [[ "$set_strict" == "true" ]]; then + log::ok "Strict rule enabled for identity '${name}' — peer rules will not be additive" + else + log::ok "Strict rule disabled for identity '${name}' — peer rules will be additive" + fi + changed=true + fi + + if [[ -n "$set_auto" ]]; then + identity::set_rule_flag "$name" "auto_apply" "$set_auto" + if [[ "$set_auto" == "true" ]]; then + log::ok "Auto apply enabled for identity '${name}'" + else + log::ok "Auto apply disabled for identity '${name}'" + fi + changed=true + fi + + if ! $changed; then + cmd::identity::_rule_show --name "$name" + fi } \ No newline at end of file diff --git a/commands/policy.command.sh b/commands/policy.command.sh new file mode 100644 index 0000000..98a1d42 --- /dev/null +++ b/commands/policy.command.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# policy.command.sh — manage policies +# +# Subcommands: +# wgctl policy list +# wgctl policy show --name +# wgctl policy add --name [--tunnel-mode split|full] +# [--default-rule ] [--strict-rule] [--no-auto-apply] +# [--desc ] +# wgctl policy rm --name +# wgctl policy set --name --field --value + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::policy::on_load() { + load_module policy + + flag::register --name + flag::register --tunnel-mode + flag::register --default-rule + flag::register --strict-rule + flag::register --no-strict-rule + flag::register --auto-apply + flag::register --no-auto-apply + flag::register --desc + flag::register --field + flag::register --value +} + +# ============================================ +# Help +# ============================================ + +function cmd::policy::help() { + cat < [options] + +Manage policies. Policies define behavioral flags for subnets and identities. + +Subcommands: + list List all policies + show --name Show policy details + add --name Add a new policy + [--tunnel-mode split|full] + [--default-rule ] + [--strict-rule] + [--no-auto-apply] + [--desc ] + rm --name Remove a policy (built-ins cannot be removed) + set --name Set a single field on a policy + --field + --value + +Fields: + tunnel_mode split|full + default_rule rule name (or empty to clear) + strict_rule true|false + auto_apply true|false + desc description string + +Built-in policies (cannot be removed): default, guest, trusted, server, iot + +Examples: + wgctl policy list + wgctl policy show --name guest + wgctl policy add --name contractor --default-rule contractor --strict-rule + wgctl policy set --name contractor --field tunnel-mode --value full + wgctl policy rm --name contractor +EOF +} + +# ============================================ +# Run +# ============================================ + +function cmd::policy::run() { + local subcmd="${1:-list}" + shift || true + + case "$subcmd" in + list) cmd::policy::_list "$@" ;; + show) cmd::policy::_show "$@" ;; + add) cmd::policy::_add "$@" ;; + rm) cmd::policy::_rm "$@" ;; + set) cmd::policy::_set "$@" ;; + --help) cmd::policy::help ;; + *) + log::error "Unknown subcommand '${subcmd}'. Available: list, show, add, rm, set" + return 1 + ;; + esac +} + +# ============================================ +# Subcommands +# ============================================ + +function cmd::policy::_list() { + local data + data=$(policy::list_data) + + if [[ -z "$data" ]]; then + log::info "No policies defined." + return 0 + fi + + printf " %-14s %-8s %-14s %-12s %-12s %s\n" \ + "NAME" "TUNNEL" "DEFAULT RULE" "STRICT RULE" "AUTO APPLY" "DESCRIPTION" + ui::divider 84 + + while IFS='|' read -r name tunnel default_rule strict auto desc; do + local rule_display="${default_rule:-—}" + local strict_display auto_display + [[ "$strict" == "true" ]] && strict_display="$(color::red yes)" || strict_display="no" + [[ "$auto" == "true" ]] && auto_display="yes" || auto_display="no" + printf " %-14s %-8s %-14s %-12s %-12s %s\n" \ + "$name" "$tunnel" "$rule_display" "$strict_display" "$auto_display" "$desc" + done <<< "$data" +} + +function cmd::policy::_show() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + policy::require_exists "$name" || return 1 + + echo "" + ui::row "Name" "$name" + ui::row "Tunnel mode" "$(policy::tunnel_mode "$name")" + local dr + dr=$(policy::default_rule "$name") + ui::row "Default rule" "${dr:-—}" + ui::row "Strict rule" "$(policy::strict_rule "$name" && echo "yes" || echo "no")" + ui::row "Auto apply" "$(policy::auto_apply "$name" && echo "yes" || echo "no")" + echo "" +} + +function cmd::policy::_add() { + local name="" tunnel_mode="split" default_rule="" \ + strict_rule="false" auto_apply="true" desc="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --tunnel-mode) tunnel_mode="$2"; shift 2 ;; + --default-rule) default_rule="$2"; shift 2 ;; + --strict-rule) strict_rule="true"; shift ;; + --no-strict-rule) strict_rule="false"; shift ;; + --no-auto-apply) auto_apply="false"; shift ;; + --auto-apply) auto_apply="true"; shift ;; + --desc) desc="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + + case "$tunnel_mode" in + split|full) ;; + *) log::error "Invalid --tunnel-mode '${tunnel_mode}'. Use: split, full"; return 1 ;; + esac + + json::policy_add "$(ctx::policies)" "$name" "$tunnel_mode" \ + "$default_rule" "$strict_rule" "$auto_apply" "$desc" + log::ok "Policy '${name}' added" +} + +function cmd::policy::_rm() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + policy::require_exists "$name" || return 1 + + json::policy_remove "$(ctx::policies)" "$name" + log::ok "Policy '${name}' removed" +} + +function cmd::policy::_set() { + local name="" field="" value="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --field) field="$2"; shift 2 ;; + --value) value="$2"; shift 2 ;; + --help) cmd::policy::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + [[ -z "$name" ]] && { log::error "Missing required flag: --name"; return 1; } + [[ -z "$field" ]] && { log::error "Missing required flag: --field"; return 1; } + [[ -z "$value" ]] && { log::error "Missing required flag: --value"; return 1; } + + policy::require_exists "$name" || return 1 + + # Normalise field name (allow tunnel-mode as well as tunnel_mode) + field="${field//-/_}" + + case "$field" in + tunnel_mode) + case "$value" in + split|full) ;; + *) log::error "Invalid value '${value}' for tunnel_mode. Use: split, full"; return 1 ;; + esac + ;; + strict_rule|auto_apply) + case "$value" in + true|false) ;; + *) log::error "Invalid value '${value}' for ${field}. Use: true, false"; return 1 ;; + esac + ;; + default_rule|desc) ;; + *) + log::error "Unknown field '${field}'. Valid: tunnel_mode, default_rule, strict_rule, auto_apply, desc" + return 1 + ;; + esac + + json::policy_set_field "$(ctx::policies)" "$name" "$field" "$value" + log::ok "Policy '${name}': ${field} = ${value}" +} \ No newline at end of file diff --git a/core/json.sh b/core/json.sh index 49232a1..96c2508 100644 --- a/core/json.sh +++ b/core/json.sh @@ -60,6 +60,8 @@ function json::net_resolve() { python3 "$JSON_HELPER" net_resolve function json::net_reverse_lookup() { python3 "$JSON_HELPER" net_reverse_lookup "$@" prints all fields as key|value lines + policy_get(file, "guest", "tunnel_mode") -> prints "split" + policy_get(file, "guest", "strict_rule") -> prints "true" or "false" + """ + data = _policy_read(file) + entry = _policy_get_entry(data, name) + + if field: + val = entry.get(field) + if val is None: + print('') + elif isinstance(val, bool): + print('true' if val else 'false') + else: + print(val) + else: + for k, v in entry.items(): + if isinstance(v, bool): + print(f"{k}|{'true' if v else 'false'}") + elif v is None: + print(f"{k}|") + else: + print(f"{k}|{v}") + +def policy_list(file): + """ + List all policies. + Output per line: name|tunnel_mode|default_rule|strict_rule|auto_apply|desc + """ + data = _policy_read(file) + for name, raw_entry in data.items(): + entry = _policy_get_entry(data, name) + tunnel_mode = entry.get('tunnel_mode', 'split') + default_rule = entry.get('default_rule') or '' + strict_rule = 'true' if entry.get('strict_rule', False) else 'false' + auto_apply = 'true' if entry.get('auto_apply', True) else 'false' + desc = entry.get('desc', '') + print(f"{name}|{tunnel_mode}|{default_rule}|{strict_rule}|{auto_apply}|{desc}") + +def policy_exists(file, name): + """Exit 0 if policy exists, 1 otherwise.""" + data = _policy_read(file) + sys.exit(0 if name in data else 1) + +def policy_add(file, name, tunnel_mode, default_rule, strict_rule, auto_apply, desc): + """Add or update a policy entry.""" + data = _policy_read(file) + data[name] = { + 'tunnel_mode': tunnel_mode or 'split', + 'default_rule': default_rule if default_rule else None, + 'strict_rule': strict_rule == 'true', + 'auto_apply': auto_apply != 'false', + 'desc': desc or '' + } + _policy_write(file, data) + +def policy_remove(file, name): + """Remove a policy. Refuses to remove hardcoded defaults.""" + if name in _POLICY_DEFAULTS: + print(f"Error: Cannot remove built-in policy '{name}'", file=sys.stderr) + sys.exit(1) + data = _policy_read(file) + if name not in data: + print(f"Error: Policy '{name}' not found", file=sys.stderr) + sys.exit(1) + del data[name] + _policy_write(file, data) + +def policy_set_field(file, name, field, value): + """Set a single field on an existing policy.""" + data = _policy_read(file) + if name not in data: + print(f"Error: Policy '{name}' not found", file=sys.stderr) + sys.exit(1) + entry = data[name] + if field in ('strict_rule', 'auto_apply'): + entry[field] = value == 'true' + elif field == 'default_rule': + entry[field] = value if value else None + else: + entry[field] = value + data[name] = entry + _policy_write(file, data) + +def subnet_policy(subnets_file, subnet_name, type_key=''): + """ + Get the policy name for a subnet entry. + Falls back to 'default' if no policy set. + """ + data = _subnet_read(subnets_file) + if subnet_name not in data: + print('default') + return + entry = data[subnet_name] + if _subnet_is_group(entry): + key = type_key if type_key else 'none' + child = entry.get(key, {}) + print(child.get('policy', 'default')) + else: + print(entry.get('policy', 'default')) + +def json_get_nested(file, *keys): + """ + Get a nested field from a JSON file. + json_get_nested(file, "rule_flags", "strict_rule") + Output: the value as a string, or empty string if not found. + """ + try: + with open(file) as f: + data = json.load(f) + val = data + for key in keys: + if not isinstance(val, dict): + print('') + return + val = val.get(key) + if val is None: + print('') + return + if isinstance(val, bool): + print('true' if val else 'false') + elif val is None: + print('') + else: + print(val) + except Exception: + print('') + +def json_set_nested(file, *args): + """ + Set a nested field in a JSON file. + Args: file, key1, key2, ..., value + json_set_nested(file, "rule_flags", "strict_rule", "true") + Creates intermediate dicts as needed. + """ + if len(args) < 2: + return + keys = args[:-1] + value = args[-1] + + try: + if os.path.exists(file): + with open(file) as f: + data = json.load(f) + else: + data = {} + + # Navigate/create nested structure + target = data + for key in keys[:-1]: + if key not in target or not isinstance(target[key], dict): + target[key] = {} + target = target[key] + + # Coerce value types + final_key = keys[-1] + if value == 'true': + target[final_key] = True + elif value == 'false': + target[final_key] = False + else: + target[final_key] = value + + with open(file, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) commands = { 'get': lambda args: get(args[0], args[1]), @@ -2069,7 +2314,7 @@ commands = { 'audit_fw_counts': lambda args: audit_fw_counts(args[0]), 'peer_group_map': lambda args: peer_group_map(args[0]), 'peer_groups': lambda args: peer_groups(args[0], args[1]), - 'peer_data': lambda args: peer_data(args[0], args[1], args[2]), + 'peer_data': lambda args: peer_data(args[0], args[1], args[2]), 'iso_to_ts': lambda args: iso_to_ts(args[0]), 'rule_list_data': lambda args: rule_list_data(args[0], args[1]), 'group_list_data': lambda args: group_list_data(args[0], args[1]), @@ -2170,6 +2415,17 @@ commands = { 'identity_exists': lambda args: identity_exists(args[0]), 'subnet_default_rule': lambda args: subnet_default_rule(args[0], args[1], args[2] if len(args) > 2 else ''), 'subnet_list_names': lambda args: subnet_list_names(args[0]), + + # Policy commands: + 'policy_get': lambda args: policy_get(args[0], args[1], args[2] if len(args) > 2 else ''), + 'policy_list': lambda args: policy_list(args[0]), + 'policy_exists': lambda args: policy_exists(args[0], args[1]), + 'policy_add': lambda args: policy_add(args[0], args[1], args[2], args[3], args[4], args[5], args[6] if len(args) > 6 else ''), + 'policy_remove': lambda args: policy_remove(args[0], args[1]), + 'policy_set_field': lambda args: policy_set_field(args[0], args[1], args[2], args[3]), + 'subnet_policy': lambda args: subnet_policy(args[0], args[1], args[2] if len(args) > 2 else ''), + 'get_nested': lambda args: json_get_nested(args[0], *args[1:]), + 'set_nested': lambda args: json_set_nested(args[0], *args[1:]), } if __name__ == '__main__': diff --git a/modules/identity.module.sh b/modules/identity.module.sh index 5ce1e3d..8da7d3d 100644 --- a/modules/identity.module.sh +++ b/modules/identity.module.sh @@ -253,4 +253,124 @@ function identity::rename_peer() { rm -f "$id_file" fi fi +} + +# identity::rule +# Returns the rule assigned to an identity, or empty string if none. +function identity::rule() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + [[ ! -f "$id_file" ]] && return 0 + json::get "$id_file" "rule" 2>/dev/null || true +} + +# identity::set_rule +# Sets the rule on an identity file. +function identity::set_rule() { + local identity_name="${1:-}" rule_name="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + if [[ ! -f "$id_file" ]]; then + log::error "Identity '${identity_name}' not found" + return 1 + fi + json::set "$id_file" "rule" "$rule_name" +} + +# identity::clear_rule +# Removes the rule from an identity file. +function identity::clear_rule() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + [[ ! -f "$id_file" ]] && return 0 + json::delete "$id_file" "rule" 2>/dev/null || true +} + +# identity::policy +# Returns the policy name assigned to an identity, or "default". +function identity::policy() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + [[ ! -f "$id_file" ]] && echo "default" && return 0 + local result + result=$(json::get "$id_file" "policy" 2>/dev/null) || true + echo "${result:-default}" +} + +# identity::set_policy +# Sets the policy on an identity file. +function identity::set_policy() { + local identity_name="${1:-}" policy_name="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + if [[ ! -f "$id_file" ]]; then + log::error "Identity '${identity_name}' not found" + return 1 + fi + json::set "$id_file" "policy" "$policy_name" +} + +# identity::rule_flags +# Returns a specific rule_flag from the identity file. +# Falls back to the policy's value if not explicitly set on the identity. +function identity::rule_flags() { + local identity_name="${1:-}" flag="${2:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + + local result + result=$(json::get_nested "$id_file" "rule_flags" "$flag" 2>/dev/null) || true + + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + + # Fall back to policy value + local policy_name + policy_name=$(identity::policy "$identity_name") + policy::get "$policy_name" "$flag" +} + +# identity::set_rule_flag +# Sets a rule_flag directly on the identity file. +function identity::set_rule_flag() { + local identity_name="${1:-}" flag="${2:-}" value="${3:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + if [[ ! -f "$id_file" ]]; then + log::error "Identity '${identity_name}' not found" + return 1 + fi + json::set_nested "$id_file" "rule_flags" "$flag" "$value" +} + +# identity::reapply_rules +# Reapply the identity rule to all peers in this identity. +# Respects auto_apply flag — if false, does nothing. +function identity::reapply_rules() { + local identity_name="${1:-}" + + # Check auto_apply + local auto + auto=$(identity::rule_flags "$identity_name" "auto_apply") + [[ "$auto" == "false" ]] && return 0 + + local identity_rule + identity_rule=$(identity::rule "$identity_name") + [[ -z "$identity_rule" ]] && return 0 + + local peers + peers=$(identity::peers "$identity_name") + [[ -z "$peers" ]] && return 0 + + while IFS= read -r peer_name; do + [[ -z "$peer_name" ]] && continue + local client_ip + client_ip=$(peers::get_ip "$peer_name") || continue + rule::full_restore_peer "$peer_name" "$client_ip" + done <<< "$peers" } \ No newline at end of file diff --git a/modules/policy.module.sh b/modules/policy.module.sh new file mode 100644 index 0000000..4e71fce --- /dev/null +++ b/modules/policy.module.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# policy.module.sh — policy system +# Policies define behavioral flags for subnets, identities, and future contexts. +# Chain: Subnet → Policy → Identity → Peer + +# ====================================================== +# Hardcoded Fallbacks +# Mirror of policies.json built-in policies. +# Used when policies.json lookup fails. +# ====================================================== + +declare -gA _POLICY_TUNNEL_MODE=( + [default]="split" + [guest]="split" + [trusted]="split" + [server]="split" + [iot]="split" +) + +declare -gA _POLICY_DEFAULT_RULE=( + [default]="" + [guest]="guest" + [trusted]="" + [server]="" + [iot]="" +) + +declare -gA _POLICY_STRICT_RULE=( + [default]="false" + [guest]="true" + [trusted]="false" + [server]="false" + [iot]="false" +) + +declare -gA _POLICY_AUTO_APPLY=( + [default]="true" + [guest]="true" + [trusted]="true" + [server]="true" + [iot]="true" +) + +function policy::_hardcoded_field() { + local name="${1:-}" field="${2:-}" + case "$field" in + tunnel_mode) echo "${_POLICY_TUNNEL_MODE[$name]:-split}" ;; + default_rule) echo "${_POLICY_DEFAULT_RULE[$name]:-}" ;; + strict_rule) echo "${_POLICY_STRICT_RULE[$name]:-false}" ;; + auto_apply) echo "${_POLICY_AUTO_APPLY[$name]:-true}" ;; + *) echo "" ;; + esac +} + +# ====================================================== +# Core Accessors +# ====================================================== + +function ctx::policies() { echo "${_CTX_DATA}/policies.json"; } + +function policy::exists() { + local name="${1:-}" + json::policy_exists "$(ctx::policies)" "$name" 2>/dev/null +} + +function policy::require_exists() { + local name="${1:-}" + if ! policy::exists "$name"; then + log::error "Policy '${name}' not found. Use 'wgctl policy list' to see available policies." + return 1 + fi +} + +function policy::get() { + local name="${1:-}" field="${2:-}" + local result + result=$(json::policy_get "$(ctx::policies)" "$name" "$field" 2>/dev/null) || true + if [[ -n "$result" ]]; then + echo "$result" + return 0 + fi + # Fallback to hardcoded + [[ -n "$field" ]] && policy::_hardcoded_field "$name" "$field" +} + +function policy::tunnel_mode() { + local name="${1:-default}" + policy::get "$name" "tunnel_mode" +} + +function policy::default_rule() { + local name="${1:-default}" + policy::get "$name" "default_rule" +} + +function policy::strict_rule() { + local name="${1:-default}" + local val + val=$(policy::get "$name" "strict_rule") + [[ "$val" == "true" ]] +} + +function policy::auto_apply() { + local name="${1:-default}" + local val + val=$(policy::get "$name" "auto_apply") + [[ "$val" != "false" ]] +} + +function policy::list_data() { + json::policy_list "$(ctx::policies)" 2>/dev/null || true +} + +# ====================================================== +# Subnet Policy Resolution +# ====================================================== + +# policy::for_subnet [type_key] +# Returns the policy name for a subnet entry. +# Falls back to "default" if no policy set on the subnet. +function policy::for_subnet() { + local subnet_name="${1:-}" type_key="${2:-}" + local result + result=$(json::subnet_policy "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true + echo "${result:-default}" +} + +# policy::resolve_for_add [type_key] +# Returns the fully resolved policy for use during wgctl add. +# Output: policy_name +function policy::resolve_for_add() { + local subnet_name="${1:-}" type_key="${2:-}" + if [[ -z "$subnet_name" ]]; then + echo "default" + return 0 + fi + policy::for_subnet "$subnet_name" "$type_key" +} + +# ====================================================== +# Identity Policy Resolution +# ====================================================== + +# policy::for_identity +# Returns the policy name stored in an identity file. +# Falls back to "default". +function policy::for_identity() { + local identity_name="${1:-}" + local id_file + id_file=$(ctx::identity::path "${identity_name}.identity") + local result + result=$(json::get "$id_file" "policy" 2>/dev/null) || true + echo "${result:-default}" +} + +# policy::effective +# Returns the effective policy name for a peer being added. +# Priority: identity policy > subnet policy > default +function policy::effective() { + local subnet_name="${1:-}" type_key="${2:-}" identity_name="${3:-}" + + # Identity policy takes precedence if explicitly set + if [[ -n "$identity_name" ]]; then + local id_policy + id_policy=$(policy::for_identity "$identity_name") + if [[ "$id_policy" != "default" ]]; then + echo "$id_policy" + return 0 + fi + fi + + # Subnet policy next + if [[ -n "$subnet_name" ]]; then + local subnet_policy + subnet_policy=$(policy::for_subnet "$subnet_name" "$type_key") + echo "$subnet_policy" + return 0 + fi + + echo "default" +} + +# ====================================================== +# Warning Helpers (called from add.command.sh) +# ====================================================== + +function policy::warn_strict_rule() { + local identity_name="${1:-}" policy_name="${2:-}" identity_rule="${3:-}" + log::warn "Identity '${identity_name}' has policy '${policy_name}' with strict rule enabled — peer rule will not be applied, '${identity_rule}' is the only active rule" +} + +function policy::warn_additive_rule() { + local identity_name="${1:-}" identity_rule="${1:-}" peer_rule="${2:-}" + log::info "Identity '${identity_name}' has strict rule disabled — '${identity_rule}' and '${peer_rule}' will both apply" +} + +function policy::warn_no_rule() { + local peer_name="${1:-}" + log::warn "'${peer_name}' has no rule assigned — peer has unrestricted access" +} diff --git a/modules/rule.module.sh b/modules/rule.module.sh index 18a0e37..fa276fc 100644 --- a/modules/rule.module.sh +++ b/modules/rule.module.sh @@ -234,6 +234,26 @@ function rule::unapply() { # Bulk Operations # ============================================ +function rule::_apply_identity_rule() { + local peer_name="${1:-}" client_ip="${2:-}" + local identity_name + identity_name=$(identity::get_name "$peer_name") + [[ -z "$identity_name" ]] && return 0 + + local identity_rule strict + identity_rule=$(identity::rule "$identity_name") + [[ -z "$identity_rule" ]] && return 0 + + strict=$(identity::rule_flags "$identity_name" "strict_rule") + + if [[ "$strict" == "true" ]]; then + fw::flush_peer "$client_ip" + rule::apply "$identity_rule" "$client_ip" "$peer_name" + else + rule::apply "$identity_rule" "$client_ip" "$peer_name" + fi +} + # rule::full_restore_peer # Flush and fully restore all fw rules for a peer — rule rules + block rules. # Use this instead of calling rule::apply + block::restore_rules_for separately @@ -244,10 +264,11 @@ function rule::full_restore_peer() { fw::flush_peer "$client_ip" - local rule_name - rule_name=$(peers::get_meta "$peer_name" "rule") - [[ -n "$rule_name" ]] && rule::apply "$rule_name" "$client_ip" "$peer_name" + local peer_rule + peer_rule=$(peers::get_meta "$peer_name" "rule") + [[ -n "$peer_rule" ]] && rule::apply "$peer_rule" "$client_ip" "$peer_name" + rule::_apply_identity_rule "$peer_name" "$client_ip" block::restore_rules_for "$peer_name" "$client_ip" } diff --git a/modules/subnet.module.sh b/modules/subnet.module.sh index 9144f61..b02e94f 100644 --- a/modules/subnet.module.sh +++ b/modules/subnet.module.sh @@ -199,6 +199,11 @@ function subnet::_hardcoded_tunnel_mode() { # Core Resolution # ====================================================== +function subnet::policy() { + local subnet_name="${1:-}" type_key="${2:-}" + policy::for_subnet "$subnet_name" "$type_key" +} + # subnet::lookup [type_key] # Returns the CIDR for a given subnet name and optional type. # Falls back to hardcoded map on failure. @@ -258,10 +263,9 @@ function subnet::type_for_add() { # Returns "split" or "full" for the given subnet. function subnet::tunnel_mode() { local subnet_name="${1:-}" type_key="${2:-}" - local result - result=$(json::subnet_tunnel_mode "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null) || true - [[ -n "$result" ]] && { echo "$result"; return 0; } - subnet::_hardcoded_tunnel_mode + local policy_name + policy_name=$(policy::for_subnet "$subnet_name" "$type_key") + policy::tunnel_mode "$policy_name" } function subnet::type_from_ip() { @@ -302,7 +306,9 @@ function subnet::name_from_ip() { function subnet::default_rule() { local subnet_name="${1:-}" type_key="${2:-}" [[ -z "$subnet_name" ]] && return 0 - json::subnet_default_rule "$(ctx::subnets)" "$subnet_name" "$type_key" 2>/dev/null || true + local policy_name + policy_name=$(policy::for_subnet "$subnet_name" "$type_key") + policy::default_rule "$policy_name" } # subnet::list_names @@ -353,4 +359,4 @@ function subnet::list_data() { function subnet::show_data() { local name="${1:-}" json::subnet_show "$(ctx::subnets)" "$name" -} \ No newline at end of file +}