add.command.sh
· 9.6 KiB · Bash
Ham
#!/usr/bin/env bash
# ============================================
# Lifecycle
# ============================================
function cmd::add::on_load() {
load_module subnet
load_module identity
load_module policy
flag::register --name
flag::register --identity
flag::register --type
flag::register --subnet
flag::register --rule
flag::register --group
flag::register --ip
flag::register --tunnel
flag::register --show-qr
# Dynamically register --<subnet_name> as shorthand flags
local subnet_name
while IFS= read -r subnet_name; do
[[ -n "$subnet_name" ]] && flag::register "--${subnet_name}"
done < <(subnet::list_names)
}
# ============================================
# Help
# ============================================
function cmd::add::help() {
cat <<EOF
Usage: wgctl add --name <name> --type <type> [options]
or: wgctl add --identity <identity> --type <type> [options]
Add a new WireGuard client.
Options:
--name <name> Client name (e.g. nuno) — combined with type: phone-nuno
--identity <name> Identity name — auto-names peer with next available index
--type <type> Device type: desktop, laptop, phone, tablet, server, iot
--subnet <subnet> Subnet to allocate from (default: type-native)
--ip <ip> Override auto-assigned IP (optional)
--tunnel <mode> Tunnel mode: split|full (overrides policy)
--rule <rule> Peer rule (default: from policy default_rule or none)
--group <group> Add to group on creation (group must exist)
--show-qr Show the WireGuard config as a QR code after creation
Subnet shorthands (equivalent to --subnet <name>):
--guests, --servers, --iot, ... (see: wgctl subnet list)
Examples:
wgctl add --name nuno --type phone
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
}
# ============================================
# Validation
# ============================================
function cmd::add::_validate() {
local name="$1" identity="$2" type="$3" ip="$4" tunnel="$5"
if [[ -z "$name" && -z "$identity" ]]; then
log::error "Missing required flag: --name or --identity"
return 1
fi
if [[ -z "$type" ]]; then
log::error "Missing required flag: --type"
return 1
fi
if ! json::subnet_exists "$(ctx::subnets)" "$type" 2>/dev/null; then
log::error "Unknown device type: '${type}'"
log::info "Use 'wgctl subnet list' to see valid types and subnets"
return 1
fi
if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then
log::error "Invalid tunnel mode: '${tunnel}' (use 'split' or 'full')"
return 1
fi
if [[ -n "$ip" ]]; then
ip::require_valid "$ip"
fi
}
function cmd::add::_validate_not_exists() {
local full_name="$1"
if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then
log::error "Client already exists: ${full_name}"
return 1
fi
}
# ============================================
# Display helpers
# ============================================
function cmd::add::is_mobile() {
local type="$1"
[[ "$type" == "phone" || "$type" == "tablet" ]]
}
# ============================================
# Run
# ============================================
function cmd::add::run() {
local name="" identity="" type="" subnet_name="" rule="" \
group="" ip="" tunnel="" show_qr=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--identity) identity="$2"; shift 2 ;;
--type) type="$2"; shift 2 ;;
--subnet) subnet_name="$2"; shift 2 ;;
--rule) rule="$2"; shift 2 ;;
--group) group="$2"; shift 2 ;;
--ip) ip="$2"; shift 2 ;;
--tunnel) tunnel="$2"; shift 2 ;;
--show-qr) show_qr=true; shift ;;
--help) cmd::add::help; return ;;
--*)
local flag_name="${1#--}"
if subnet::exists "$flag_name" 2>/dev/null; then
subnet_name="$flag_name"
shift
else
log::error "Unknown flag: $1"
cmd::add::help
return 1
fi
;;
*)
log::error "Unknown flag: $1"
cmd::add::help
return 1
;;
esac
done
cmd::add::_validate "$name" "$identity" "$type" "$ip" "$tunnel" || return 1
# Resolve full peer name
local full_name
if [[ -n "$identity" ]]; then
full_name=$(identity::next_peer_name "$identity" "$type") || return 1
log::info "Auto-named: ${full_name}"
else
full_name="${type}-${name}"
fi
cmd::add::_validate_not_exists "$full_name" || return 1
# Resolve subnet CIDR and canonical type
local resolved_cidr resolved_type
resolved_cidr=$(subnet::resolve_for_add "$type" "$subnet_name") || return 1
resolved_type=$(subnet::type_for_add "$type" "$subnet_name") || return 1
# 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 tunnel mode — flag overrides policy
if [[ -z "$tunnel" ]]; then
tunnel=$(policy::tunnel_mode "$effective_policy")
fi
# 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
log::section "Adding client: ${full_name}"
# Allocate IP
if [[ -n "$ip" ]]; then
subnet::require_ip_valid_for "$resolved_cidr" "$ip" || return 1
else
ip=$(ip::next_for_subnet "$resolved_cidr") || return 1
fi
cmd::add::_log_plan "$full_name" "$type" "$resolved_type" \
"$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
# Write meta — type, subnet, rule (if set)
peers::set_meta "$full_name" "type" "$resolved_type"
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
# 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"
}
# ============================================
# Internal helpers
# ============================================
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:-}" policy="${10:-}"
log::wg_add "Name: ${full_name}"
log::wg_add "Type: ${resolved_type}"
[[ -n "$subnet_name" ]] && log::wg_add "Subnet: ${subnet_name} (${resolved_cidr})"
log::wg_add "IP: ${ip}"
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() {
local full_name="${1:-}" group="${2:-}"
[[ -z "$group" ]] && return 0
if ! group::exists "$group"; then
log::wg_warning "Group '${group}' not found — skipping group assignment"
return 0
fi
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 rules
rules=$(identity::rules "$identity_name")
if [[ -z "$rules" ]]; then
# No identity rules — warn if no peer rule either
if [[ -z "$peer_rule" ]]; then
policy::warn_no_rule "$full_name"
fi
return 0
fi
# Apply all identity rules
rule::_apply_identity_rule "$full_name" "$ip"
# Warn based on strict_rule
local strict
strict=$(identity::rule_flags "$identity_name" "strict_rule")
if [[ "$strict" == "true" ]]; then
local rule_list
rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//')
policy::warn_strict_rule "$identity_name" "$effective_policy" "$rule_list"
elif [[ -n "$peer_rule" ]]; then
local rule_list
rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//')
policy::warn_additive_rule "$identity_name" "$rule_list" "$peer_rule"
fi
}
function cmd::add::_show_result() {
local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}"
if $show_qr || cmd::add::is_mobile "$type"; then
log::section "Client QR"
keys::qr "$full_name"
else
log::section "Client Config"
cat "$(ctx::clients)/${full_name}.conf"
fi
}
| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | # ============================================ |
| 4 | # Lifecycle |
| 5 | # ============================================ |
| 6 | |
| 7 | function cmd::add::on_load() { |
| 8 | load_module subnet |
| 9 | load_module identity |
| 10 | load_module policy |
| 11 | |
| 12 | flag::register --name |
| 13 | flag::register --identity |
| 14 | flag::register --type |
| 15 | flag::register --subnet |
| 16 | flag::register --rule |
| 17 | flag::register --group |
| 18 | flag::register --ip |
| 19 | flag::register --tunnel |
| 20 | flag::register --show-qr |
| 21 | |
| 22 | # Dynamically register --<subnet_name> as shorthand flags |
| 23 | local subnet_name |
| 24 | while IFS= read -r subnet_name; do |
| 25 | [[ -n "$subnet_name" ]] && flag::register "--${subnet_name}" |
| 26 | done < <(subnet::list_names) |
| 27 | } |
| 28 | |
| 29 | # ============================================ |
| 30 | # Help |
| 31 | # ============================================ |
| 32 | |
| 33 | function cmd::add::help() { |
| 34 | cat <<EOF |
| 35 | Usage: wgctl add --name <name> --type <type> [options] |
| 36 | or: wgctl add --identity <identity> --type <type> [options] |
| 37 | |
| 38 | Add a new WireGuard client. |
| 39 | |
| 40 | Options: |
| 41 | --name <name> Client name (e.g. nuno) — combined with type: phone-nuno |
| 42 | --identity <name> Identity name — auto-names peer with next available index |
| 43 | --type <type> Device type: desktop, laptop, phone, tablet, server, iot |
| 44 | --subnet <subnet> Subnet to allocate from (default: type-native) |
| 45 | --ip <ip> Override auto-assigned IP (optional) |
| 46 | --tunnel <mode> Tunnel mode: split|full (overrides policy) |
| 47 | --rule <rule> Peer rule (default: from policy default_rule or none) |
| 48 | --group <group> Add to group on creation (group must exist) |
| 49 | --show-qr Show the WireGuard config as a QR code after creation |
| 50 | |
| 51 | Subnet shorthands (equivalent to --subnet <name>): |
| 52 | --guests, --servers, --iot, ... (see: wgctl subnet list) |
| 53 | |
| 54 | Examples: |
| 55 | wgctl add --name nuno --type phone |
| 56 | wgctl add --identity nuno --type phone |
| 57 | wgctl add --name zephyr --type desktop --guests |
| 58 | wgctl add --identity zephyr --type desktop --guests |
| 59 | wgctl add --name visitor --type phone --guests --show-qr |
| 60 | wgctl add --name dev --type laptop --rule dev-01 |
| 61 | EOF |
| 62 | } |
| 63 | |
| 64 | # ============================================ |
| 65 | # Validation |
| 66 | # ============================================ |
| 67 | |
| 68 | function cmd::add::_validate() { |
| 69 | local name="$1" identity="$2" type="$3" ip="$4" tunnel="$5" |
| 70 | |
| 71 | if [[ -z "$name" && -z "$identity" ]]; then |
| 72 | log::error "Missing required flag: --name or --identity" |
| 73 | return 1 |
| 74 | fi |
| 75 | |
| 76 | if [[ -z "$type" ]]; then |
| 77 | log::error "Missing required flag: --type" |
| 78 | return 1 |
| 79 | fi |
| 80 | |
| 81 | if ! json::subnet_exists "$(ctx::subnets)" "$type" 2>/dev/null; then |
| 82 | log::error "Unknown device type: '${type}'" |
| 83 | log::info "Use 'wgctl subnet list' to see valid types and subnets" |
| 84 | return 1 |
| 85 | fi |
| 86 | |
| 87 | if [[ -n "$tunnel" && "$tunnel" != "split" && "$tunnel" != "full" ]]; then |
| 88 | log::error "Invalid tunnel mode: '${tunnel}' (use 'split' or 'full')" |
| 89 | return 1 |
| 90 | fi |
| 91 | |
| 92 | if [[ -n "$ip" ]]; then |
| 93 | ip::require_valid "$ip" |
| 94 | fi |
| 95 | } |
| 96 | |
| 97 | function cmd::add::_validate_not_exists() { |
| 98 | local full_name="$1" |
| 99 | if [[ -f "$(ctx::clients)/${full_name}.conf" ]]; then |
| 100 | log::error "Client already exists: ${full_name}" |
| 101 | return 1 |
| 102 | fi |
| 103 | } |
| 104 | |
| 105 | # ============================================ |
| 106 | # Display helpers |
| 107 | # ============================================ |
| 108 | |
| 109 | function cmd::add::is_mobile() { |
| 110 | local type="$1" |
| 111 | [[ "$type" == "phone" || "$type" == "tablet" ]] |
| 112 | } |
| 113 | |
| 114 | # ============================================ |
| 115 | # Run |
| 116 | # ============================================ |
| 117 | |
| 118 | function cmd::add::run() { |
| 119 | local name="" identity="" type="" subnet_name="" rule="" \ |
| 120 | group="" ip="" tunnel="" show_qr=false |
| 121 | |
| 122 | while [[ $# -gt 0 ]]; do |
| 123 | case "$1" in |
| 124 | --name) name="$2"; shift 2 ;; |
| 125 | --identity) identity="$2"; shift 2 ;; |
| 126 | --type) type="$2"; shift 2 ;; |
| 127 | --subnet) subnet_name="$2"; shift 2 ;; |
| 128 | --rule) rule="$2"; shift 2 ;; |
| 129 | --group) group="$2"; shift 2 ;; |
| 130 | --ip) ip="$2"; shift 2 ;; |
| 131 | --tunnel) tunnel="$2"; shift 2 ;; |
| 132 | --show-qr) show_qr=true; shift ;; |
| 133 | --help) cmd::add::help; return ;; |
| 134 | --*) |
| 135 | local flag_name="${1#--}" |
| 136 | if subnet::exists "$flag_name" 2>/dev/null; then |
| 137 | subnet_name="$flag_name" |
| 138 | shift |
| 139 | else |
| 140 | log::error "Unknown flag: $1" |
| 141 | cmd::add::help |
| 142 | return 1 |
| 143 | fi |
| 144 | ;; |
| 145 | *) |
| 146 | log::error "Unknown flag: $1" |
| 147 | cmd::add::help |
| 148 | return 1 |
| 149 | ;; |
| 150 | esac |
| 151 | done |
| 152 | |
| 153 | cmd::add::_validate "$name" "$identity" "$type" "$ip" "$tunnel" || return 1 |
| 154 | |
| 155 | # Resolve full peer name |
| 156 | local full_name |
| 157 | if [[ -n "$identity" ]]; then |
| 158 | full_name=$(identity::next_peer_name "$identity" "$type") || return 1 |
| 159 | log::info "Auto-named: ${full_name}" |
| 160 | else |
| 161 | full_name="${type}-${name}" |
| 162 | fi |
| 163 | |
| 164 | cmd::add::_validate_not_exists "$full_name" || return 1 |
| 165 | |
| 166 | # Resolve subnet CIDR and canonical type |
| 167 | local resolved_cidr resolved_type |
| 168 | resolved_cidr=$(subnet::resolve_for_add "$type" "$subnet_name") || return 1 |
| 169 | resolved_type=$(subnet::type_for_add "$type" "$subnet_name") || return 1 |
| 170 | |
| 171 | # Resolve effective policy |
| 172 | local identity_name="${identity:-$(identity::get_name "$full_name")}" |
| 173 | local effective_policy |
| 174 | effective_policy=$(policy::effective "$subnet_name" "$resolved_type" "$identity_name") |
| 175 | |
| 176 | # Resolve tunnel mode — flag overrides policy |
| 177 | if [[ -z "$tunnel" ]]; then |
| 178 | tunnel=$(policy::tunnel_mode "$effective_policy") |
| 179 | fi |
| 180 | |
| 181 | # Resolve peer rule — explicit flag overrides policy default_rule |
| 182 | if [[ -z "$rule" ]]; then |
| 183 | rule=$(policy::default_rule "$effective_policy") |
| 184 | fi |
| 185 | |
| 186 | # Validate rule if set |
| 187 | if [[ -n "$rule" ]]; then |
| 188 | rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } |
| 189 | fi |
| 190 | |
| 191 | local allowed_ips |
| 192 | allowed_ips=$(config::allowed_ips_for "$tunnel") || return 1 |
| 193 | |
| 194 | log::section "Adding client: ${full_name}" |
| 195 | |
| 196 | # Allocate IP |
| 197 | if [[ -n "$ip" ]]; then |
| 198 | subnet::require_ip_valid_for "$resolved_cidr" "$ip" || return 1 |
| 199 | else |
| 200 | ip=$(ip::next_for_subnet "$resolved_cidr") || return 1 |
| 201 | fi |
| 202 | |
| 203 | cmd::add::_log_plan "$full_name" "$type" "$resolved_type" \ |
| 204 | "$subnet_name" "$resolved_cidr" "$ip" "$tunnel" \ |
| 205 | "$allowed_ips" "${rule:---}" "$effective_policy" |
| 206 | |
| 207 | keys::generate_pair "$full_name" || return 1 |
| 208 | peers::create_client_config "$full_name" "$resolved_type" "$ip" "$allowed_ips" || return 1 |
| 209 | |
| 210 | # Write meta — type, subnet, rule (if set) |
| 211 | peers::set_meta "$full_name" "type" "$resolved_type" |
| 212 | if [[ -n "$subnet_name" ]]; then |
| 213 | peers::set_meta "$full_name" "subnet" "$subnet_name" |
| 214 | fi |
| 215 | if [[ -n "$rule" ]]; then |
| 216 | peers::set_meta "$full_name" "rule" "$rule" |
| 217 | fi |
| 218 | |
| 219 | cmd::add::_assign_group "$full_name" "$group" |
| 220 | |
| 221 | local public_key |
| 222 | public_key=$(keys::public "$full_name") || return 1 |
| 223 | peers::add_to_server "$full_name" "$public_key" "$ip" || return 1 |
| 224 | |
| 225 | # Apply peer rule if set |
| 226 | if [[ -n "$rule" ]]; then |
| 227 | rule::apply "$rule" "$ip" "$full_name" || return 1 |
| 228 | fi |
| 229 | |
| 230 | # Auto-attach to identity and apply identity rule if set |
| 231 | identity::auto_attach "$full_name" "$resolved_type" |
| 232 | cmd::add::_apply_identity_rule "$full_name" "$ip" "$identity_name" "$effective_policy" "$rule" |
| 233 | |
| 234 | peers::reload || return 1 |
| 235 | |
| 236 | log::wg_success "Client added: ${full_name} (${ip}) [${tunnel} tunnel]" |
| 237 | cmd::add::_show_result "$full_name" "$resolved_type" "$show_qr" |
| 238 | } |
| 239 | |
| 240 | # ============================================ |
| 241 | # Internal helpers |
| 242 | # ============================================ |
| 243 | |
| 244 | function cmd::add::_log_plan() { |
| 245 | local full_name="${1:-}" type="${2:-}" resolved_type="${3:-}" \ |
| 246 | subnet_name="${4:-}" resolved_cidr="${5:-}" ip="${6:-}" \ |
| 247 | tunnel="${7:-}" allowed_ips="${8:-}" rule="${9:-}" policy="${10:-}" |
| 248 | |
| 249 | log::wg_add "Name: ${full_name}" |
| 250 | log::wg_add "Type: ${resolved_type}" |
| 251 | [[ -n "$subnet_name" ]] && log::wg_add "Subnet: ${subnet_name} (${resolved_cidr})" |
| 252 | log::wg_add "IP: ${ip}" |
| 253 | log::wg_add "Tunnel: ${tunnel} (${allowed_ips})" |
| 254 | log::wg_add "Endpoint: $(config::endpoint)" |
| 255 | log::wg_add "Rule: ${rule}" |
| 256 | log::wg_add "Policy: ${policy}" |
| 257 | } |
| 258 | |
| 259 | function cmd::add::_assign_group() { |
| 260 | local full_name="${1:-}" group="${2:-}" |
| 261 | [[ -z "$group" ]] && return 0 |
| 262 | if ! group::exists "$group"; then |
| 263 | log::wg_warning "Group '${group}' not found — skipping group assignment" |
| 264 | return 0 |
| 265 | fi |
| 266 | group::add_peer "$group" "$full_name" |
| 267 | log::wg "Added to group: ${group}" |
| 268 | } |
| 269 | |
| 270 | function cmd::add::_apply_identity_rule() { |
| 271 | local full_name="${1:-}" ip="${2:-}" identity_name="${3:-}" \ |
| 272 | effective_policy="${4:-}" peer_rule="${5:-}" |
| 273 | |
| 274 | [[ -z "$identity_name" ]] && return 0 |
| 275 | |
| 276 | local rules |
| 277 | rules=$(identity::rules "$identity_name") |
| 278 | |
| 279 | if [[ -z "$rules" ]]; then |
| 280 | # No identity rules — warn if no peer rule either |
| 281 | if [[ -z "$peer_rule" ]]; then |
| 282 | policy::warn_no_rule "$full_name" |
| 283 | fi |
| 284 | return 0 |
| 285 | fi |
| 286 | |
| 287 | # Apply all identity rules |
| 288 | rule::_apply_identity_rule "$full_name" "$ip" |
| 289 | |
| 290 | # Warn based on strict_rule |
| 291 | local strict |
| 292 | strict=$(identity::rule_flags "$identity_name" "strict_rule") |
| 293 | if [[ "$strict" == "true" ]]; then |
| 294 | local rule_list |
| 295 | rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//') |
| 296 | policy::warn_strict_rule "$identity_name" "$effective_policy" "$rule_list" |
| 297 | elif [[ -n "$peer_rule" ]]; then |
| 298 | local rule_list |
| 299 | rule_list=$(echo "$rules" | tr '\n' ',' | sed 's/,$//') |
| 300 | policy::warn_additive_rule "$identity_name" "$rule_list" "$peer_rule" |
| 301 | fi |
| 302 | } |
| 303 | |
| 304 | function cmd::add::_show_result() { |
| 305 | local full_name="${1:-}" type="${2:-}" show_qr="${3:-false}" |
| 306 | if $show_qr || cmd::add::is_mobile "$type"; then |
| 307 | log::section "Client QR" |
| 308 | keys::qr "$full_name" |
| 309 | else |
| 310 | log::section "Client Config" |
| 311 | cat "$(ctx::clients)/${full_name}.conf" |
| 312 | fi |
| 313 | } |
| 314 |