diff --git a/commands/add.command.sh b/commands/add.command.sh index 26f58b2..18f1d84 100644 --- a/commands/add.command.sh +++ b/commands/add.command.sh @@ -147,100 +147,71 @@ function cmd::add::run() { esac done - # --guest shorthand - if $guest; then - type="guest" - fi + $guest && type="guest" - # Resolve guest subtype - local effective_type="$type" - if [[ "$type" == "guest" && -n "$subtype" ]]; then - # Validate subtype - local valid_subtypes="desktop laptop phone tablet" - if ! echo "$valid_subtypes" | grep -qw "$subtype"; then - log::error "Invalid subtype: ${subtype} (valid: desktop, laptop, phone, tablet)" - return 1 - fi - effective_type="guest-${subtype}" - fi + local effective_type + effective_type=$(cmd::add::_resolve_type "$type" "$subtype") || return 1 - # Build full client name local full_name="${type}-${name}" - - # Validate cmd::add::validate "$name" "$type" "$ip" "$tunnel" || return 1 - # Resolve tunnel mode — flag > device default - if [[ -z "$tunnel" ]]; then - tunnel=$(config::default_tunnel_for "$type") - fi + [[ -z "$tunnel" ]] && tunnel=$(config::default_tunnel_for "$type") + [[ -z "$rule" ]] && rule=$(cmd::add::_default_rule "$type") - # Determine rule — explicit flag > type default - if [[ -z "$rule" ]]; then - if config::is_guest_type "$type"; then - rule="guest" - else - rule="user" - fi - fi - - # Validate rule if specified - if ! rule::exists "$rule"; then - log::error "Rule not found: ${rule}" - return 1 - fi + rule::exists "$rule" || { log::error "Rule not found: ${rule}"; return 1; } local allowed_ips allowed_ips=$(config::allowed_ips_for "$effective_type" "$tunnel") || return 1 log::section "Adding client: ${full_name}" - - # Auto-assign IP if not provided - if [[ -z "$ip" ]]; then - ip=$(ip::next_for_type "$effective_type") || return 1 - fi + [[ -z "$ip" ]] && ip=$(ip::next_for_type "$effective_type") || return 1 log::wg_add "Name: ${full_name}" log::wg_add "Type: ${type}" log::wg_add "IP: ${ip}" log::wg_add "Tunnel: ${tunnel} (${allowed_ips})" - - # Generate keys - keys::generate_pair "$full_name" || return 1 - - # Create client config - peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1 - - # Store subtype in meta - if [[ -n "$subtype" ]]; then - peers::set_meta "$full_name" "subtype" "$subtype" - fi - - # Add peer to server config - local public_key - public_key=$(keys::public "$full_name") || return 1 - peers::add_to_server "$full_name" "$public_key" "$ip" || return 1 - log::wg_add "Rule: ${rule:-none}" - # Apply presets + keys::generate_pair "$full_name" || return 1 + peers::create_client_config "$full_name" "$effective_type" "$ip" "$allowed_ips" || return 1 + [[ -n "$subtype" ]] && peers::set_meta "$full_name" "subtype" "$subtype" + + local public_key + public_key=$(keys::public "$full_name") || return 1 + peers::add_to_server "$full_name" "$public_key" "$ip" || return 1 + for preset in "${presets[@]}"; do - fw::apply_preset "$preset" "${ip}" || return 1 + fw::apply_preset "$preset" "$ip" || return 1 done - # Apply rule - if [[ -n "$rule" ]]; then - rule::apply "$rule" "$ip" || return 1 - fi - - # Reload WireGuard - peers::reload || return 1 + [[ -n "$rule" ]] && rule::apply "$rule" "$ip" || return 1 + peers::reload || return 1 log::wg_success "Client added successfully: ${full_name} (${ip}) [${tunnel} tunnel]" + cmd::add::_show_result "$full_name" "${subtype:-$type}" +} - # Show QR for mobile by default, config for desktop/laptop - local display_type="${subtype:-$type}" - +function cmd::add::_resolve_type() { + local type="$1" subtype="$2" + if [[ "$type" == "guest" && -n "$subtype" ]]; then + local valid_subtypes="desktop laptop phone tablet" + if ! echo "$valid_subtypes" | grep -qw "$subtype"; then + log::error "Invalid subtype: ${subtype} (valid: desktop, laptop, phone, tablet)" + return 1 + fi + echo "guest-${subtype}" + else + echo "$type" + fi +} + +function cmd::add::_default_rule() { + local type="$1" + config::is_guest_type "$type" && echo "guest" || echo "user" +} + +function cmd::add::_show_result() { + local full_name="$1" display_type="$2" if cmd::add::is_mobile "$display_type"; then keys::qr "$full_name" else diff --git a/commands/group.command.sh b/commands/group.command.sh index 395c75f..bdbd7a8 100644 --- a/commands/group.command.sh +++ b/commands/group.command.sh @@ -127,9 +127,13 @@ function cmd::group::list() { local short_desc="${desc:0:33}" [[ ${#desc} -gt 33 ]] && short_desc="${short_desc}..." - printf " %-20s %-35s %-8s %b\n" \ + local desc_col_width=35 + [[ "$desc" == "—" || -z "$desc" ]] && desc_col_width=37 + + printf " %-20s %-${desc_col_width}s %-8s %b\n" \ "$name" "${short_desc:-—}" "$total" \ "${status_color}${status_str}\033[0m" + done < <(json::group_list_data "$groups_dir" "$(ctx::blocks)") printf "\n" @@ -521,7 +525,11 @@ function cmd::group::block() { local peers_list=() mapfile -t peers_list < <(group::peers "$name") - [[ -z "${peers_list[0]}" ]] && log::wg_warning "Group '${name}' has no peers" && return 0 + + if [[ ${#peers_list[@]} -eq 0 ]] || [[ -z "${peers_list[0]:-}" ]]; then + log::wg_warning "Group '${name}' has no peers" + return 0 + fi # log::section "Blocking group: ${name}" diff --git a/commands/list.command.sh b/commands/list.command.sh index f631660..dab49c2 100644 --- a/commands/list.command.sh +++ b/commands/list.command.sh @@ -169,7 +169,7 @@ function cmd::list::_format_status() { echo -e "${color}${conn_str}${modifier}\033[0m" } -function cmd::list::_get_type() { +function peers::get_type() { local ip="$1" local type="unknown" for t in $(config::device_types); do @@ -277,7 +277,7 @@ function cmd::list::show_client() { public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") local type - type=$(cmd::list::_get_type "$ip") + type=$(peers::get_type "$ip") local endpoint="—" if peers::is_blocked "$name"; then @@ -487,7 +487,7 @@ function cmd::list::_iter_confs() { ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) fi local type - type=$(cmd::list::_get_type "$ip") + type=$(peers::get_type "$ip") [[ -n "$filter_type" && "$type" != "$filter_type" ]] && continue "$callback" "$client_name" "$ip" "$type" done diff --git a/commands/list.command.sh.bak b/commands/list.command.sh.bak deleted file mode 100644 index 0a44950..0000000 --- a/commands/list.command.sh.bak +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/env bash - -# ============================================ -# Lifecycle -# ============================================ - -function cmd::list::on_load() { - flag::register --type - flag::register --online - flag::register --offline - flag::register --restricted - flag::register --blocked - flag::register --allowed - flag::register --detailed - flag::register --name -} - -# ============================================ -# Help -# ============================================ - -function cmd::list::help() { - cat < Filter by device type - --online Show only connected clients - --offline Show only disconnected clients - --allowed Show only fully allowed clients - --restricted Show only restricted clients - --blocked Show only blocked clients - --detailed Show full detail cards for all clients - --name Show detail card for a single client - -Examples: - wgctl list - wgctl ls --type phone - wgctl list --online - wgctl list --blocked - wgctl list --allowed - wgctl list --restricted - wgctl list --detailed - wgctl list --name phone-nuno -EOF -} - -# ============================================ -# Status Helpers -# ============================================ - -function cmd::list::last_handshake_ts() { - local public_key="$1" - wg show "$(config::interface)" latest-handshakes 2>/dev/null \ - | grep "^${public_key}" \ - | awk '{print $2}' -} - -function cmd::list::last_dropped_ts() { - local client_ip="$1" - journalctl -k --grep "wgctl-dropped: " 2>/dev/null \ - | grep "SRC=${client_ip}" \ - | tail -1 \ - | awk '{print $1, $2, $3}' -} - -function cmd::list::is_connected() { - local public_key="$1" - local ts - ts=$(cmd::list::last_handshake_ts "$public_key") - [[ -z "$ts" || "$ts" == "0" ]] && return 1 - local now diff - now=$(date +%s) - diff=$(( now - ts )) - (( diff < 180 )) -} - -function cmd::list::is_attempting() { - local name="$1" - local ts - ts=$(monitor::last_attempt "$name") - [[ -z "$ts" ]] && return 1 - - local now attempt_ts diff - now=$(date +%s) - attempt_ts=$(python3 -c " -from datetime import datetime, timezone -dt = datetime.fromisoformat('${ts}') -if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) -print(int(dt.timestamp())) -" 2>/dev/null || echo 0) - - diff=$(( now - attempt_ts )) - (( diff < 180 )) -} - -function cmd::list::is_blocked() { - local name="$1" - peers::is_blocked "$name" -} - -function cmd::list::is_restricted() { - local name="$1" - [[ -f "$(ctx::block::path "${name}.block")" ]] -} - -function cmd::list::format_last_seen() { - local name="$1" - local public_key="$2" - local ip="$3" - - if cmd::list::is_blocked "$name"; then - local ts - ts=$(monitor::last_attempt "$name") - if [[ -n "$ts" ]]; then - # Format ISO timestamp - local formatted - formatted=$(python3 -c " -from datetime import datetime, timezone -dt = datetime.fromisoformat('${ts}') -print(dt.strftime('%Y-%m-%d %H:%M')) -" 2>/dev/null || echo "$ts") - echo "${formatted} (dropped)" - else - echo "—" - fi - else - local ts - ts=$(cmd::list::last_handshake_ts "$public_key") - if [[ -z "$ts" || "$ts" == "0" ]]; then - echo "—" - else - local formatted - formatted=$(date -d "@${ts}" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts") - echo "${formatted} (handshake)" - fi - fi -} - -function cmd::list::format_status() { - local name="$1" - local public_key="$2" - local ip="$3" # new - - local connected=false - local blocked=false - local restricted=false - - cmd::list::is_blocked "$name" && blocked=true - cmd::list::is_restricted "$name" && restricted=true - - if $blocked; then - cmd::list::is_attempting "$name" && connected=true - modifier=" (blocked)" - elif $restricted; then - cmd::list::is_connected "$public_key" && connected=true - modifier=" (restricted)" - else - cmd::list::is_connected "$public_key" && connected=true - modifier="" - fi - - local conn_str - $connected && conn_str="online" || conn_str="offline" - - local status="${conn_str}${modifier}" - - local color - if $blocked; then - color="\033[1;31m" - elif $restricted; then - color="\033[1;33m" - elif $connected; then - color="\033[1;32m" - else - color="\033[0;37m" - fi - - echo -e "${color}${status}\033[0m" -} - -# ============================================ -# Display Type -# ============================================ - -function cmd::list::display_type() { - local name="$1" - local type="$2" - - # log::debug "$(config::is_guest_type "$type")" - if config::is_guest_type "$type"; then - local subtype - subtype=$(peers::get_meta "$name" "subtype") - # log::debug "$subtype" - - if [[ -n "$subtype" ]]; then - echo "guest/${subtype}" - else - echo "guest" - fi - else - echo "$type" - fi -} - -# ============================================ -# Detail Card -# ============================================ - -function cmd::list::show_client() { - local name="$1" - local dir - dir="$(ctx::clients)" - local conf="${dir}/${name}.conf" - - if [[ ! -f "$conf" ]]; then - log::error "Client not found: ${name}" - return 1 - fi - - local ip - ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - - local allowed_ips - allowed_ips=$(grep "^AllowedIPs" "$conf" | awk '{print $3}') - - local public_key - public_key=$(keys::public "$name" 2>/dev/null || echo "unknown") - - # Get endpoint - local endpoint="—" - if cmd::list::is_blocked "$name"; then - local ep - ep=$(monitor::last_endpoint "$name") - [[ -n "$ep" ]] && endpoint="$ep" - else - local ep - ep=$(monitor::endpoint_for_key "$public_key") - [[ -n "$ep" ]] && endpoint="$ep" - fi - - # Determine type - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet."; then - type="$t" - break - fi - done - - local status - status=$(cmd::list::format_status "$name" "$public_key" "$ip") - - local last_seen - last_seen=$(cmd::list::format_last_seen "$name" "$public_key" "$ip") - - # Block rules - local block_file - block_file="$(ctx::block::path "${name}.block")" - local blocks="" - -if [[ -f "$block_file" ]] && [[ -s "$block_file" ]]; then - while IFS=" " read -r client_ip target port proto; do - if [[ -z "$target" ]]; then - blocks+=" all traffic blocked\n" - else - local rule=" ${target}" - [[ -n "$port" ]] && rule+=":${port}/${proto}" - blocks+="${rule}\n" - fi - done < "$block_file" - fi - - local sep - sep="$(printf '─%.0s' {1..50})" - - echo "" - echo " ${sep}" - printf " \033[1;34m%-20s\033[0m %s\n" "Client:" "$name" - echo " ${sep}" - printf " %-20s %s\n" "IP:" "$ip" - printf " %-20s %s\n" "Type:" "$type" - printf " %-20s %b\n" "Status:" "$status" - printf " %-20s %s\n" "Endpoint:" "$endpoint" - printf " %-20s %s\n" "Last seen:" "$last_seen" - printf " %-20s %s\n" "Allowed IPs:" "$allowed_ips" - printf " %-20s %s\n" "Public key:" "$public_key" - - if [[ -z "$blocks" ]]; then - printf " %-20s %s\n" "Blocks:" "none" - elif [[ "$blocks" == *"all traffic blocked"* ]]; then - printf " %-20s \033[1;31mAll\033[0m\n" "Blocks:" - else - printf " %-20s\n" "Blocks:" - echo -e "$blocks" - fi - - echo " ${sep}" - echo "" -} - -# ============================================ -# Run -# ============================================ - -function cmd::list::run() { - local filter_type="" - local online_only=false - local offline_only=false - local restricted_only=false - local blocked_only=false - local allowed_only=false - local detailed=false - local single_name="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --type) filter_type="$2"; shift 2 ;; - --online) online_only=true; shift ;; - --offline) offline_only=true; shift ;; - --restricted) restricted_only=true; shift ;; - --blocked) blocked_only=true; shift ;; - --allowed) allowed_only=true; shift ;; - --detailed) detailed=true; shift ;; - --name) single_name="$2"; shift 2 ;; - --help) cmd::list::help; return ;; - *) - log::error "Unknown flag: $1" - cmd::list::help - return 1 - ;; - esac - done - - # Single client detail card - if [[ -n "$single_name" ]]; then - cmd::list::show_client "$single_name" - return - fi - - local dir - dir="$(ctx::clients)" - local confs=("${dir}"/*.conf) - - if [[ ! -f "${confs[0]}" ]]; then - log::wg_list "No clients configured" - return 0 - fi - - # START - GROUP SECTION - # Check if any groups exist - local has_groups=false - local groups_dir - groups_dir="$(ctx::groups)" - local group_files=("${groups_dir}"/*.group) - [[ -f "${group_files[0]}" ]] && has_groups=true - - # Precompute peer->group map - declare -A peer_group_map - if $has_groups; then - while IFS=":" read -r peer_name group_name; do - [[ -n "$peer_name" ]] && peer_group_map["$peer_name"]="$group_name" - done < <(json::peer_group_map "$groups_dir") - fi - # END - GROUP SECTION - - # Detailed mode — cards only, no table - if $detailed; then - log::section "WireGuard Clients" - for conf in "${dir}"/*.conf; do - [[ -f "$conf" ]] || continue - local client_name - client_name=$(basename "$conf" .conf) - - # Apply type filter - if [[ -n "$filter_type" ]]; then - local ip - ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet."; then - type="$t" - break - fi - done - [[ "$type" != "$filter_type" ]] && continue - fi - - cmd::list::show_client "$client_name" - done - return - fi - - # Normal table view - log::section "WireGuard Clients" - - if $has_groups; then - printf "\n %-28s %-15s %-13s %-12s %-12s %-22s %s\n" \ - "NAME" "IP" "TYPE" "RULE" "GROUP" "STATUS" "LAST SEEN" - printf " %s\n" "$(printf '─%.0s' {1..135})" - else - printf "\n %-28s %-15s %-13s %-12s %-22s %s\n" \ - "NAME" "IP" "TYPE" "RULE" "STATUS" "LAST SEEN" - printf " %s\n" "$(printf '─%.0s' {1..107})" - fi - - for conf in "${dir}"/*.conf; do - [[ -f "$conf" ]] || continue - - local client_name - client_name=$(basename "$conf" .conf) - - local ip - ip=$(grep "^Address" "$conf" | awk '{print $3}' | cut -d'/' -f1) - - # Determine type - local type="unknown" - for t in $(config::device_types); do - local subnet - subnet=$(config::subnet_for "$t") - if string::starts_with "$ip" "$subnet."; then - type="$t" - break - fi - done - - # Apply type filter - if [[ -n "$filter_type" && "$type" != "$filter_type" ]]; then - continue - fi - - local public_key - public_key=$(keys::public "$client_name" 2>/dev/null || echo "") - - # Apply filters - if $online_only && ! cmd::list::is_connected "$public_key"; then - continue - fi - - if $offline_only && cmd::list::is_connected "$public_key"; then - continue - fi - - if $restricted_only && ! cmd::list::is_restricted "$client_name"; then - continue - fi - - if $blocked_only && ! cmd::list::is_blocked "$client_name"; then - continue - fi - - if $allowed_only && { cmd::list::is_blocked "$client_name" || cmd::list::is_restricted "$client_name"; }; then - continue - fi - - local status - status=$(cmd::list::format_status "$client_name" "$public_key" "$ip") - - local last_seen - last_seen=$(cmd::list::format_last_seen "$client_name" "$public_key" "$ip") - - local display_type - display_type=$(cmd::list::display_type "$client_name" "$type") - # log::debug "display_type called with name=$client_name type=$type" - - local rule - rule=$(peers::effective_rule "$client_name") - rule="${rule:-—}" - - local padded_status - padded_status=$(cmd::list::pad_status "$status" 25) - local group_display="—" - if $has_groups; then - group_display="${peer_group_map[$client_name]:-—}" - fi - - local rule_col_width=12 - [[ "$rule" == "—" ]] && rule_col_width=14 - - local group_col_width=12 - [[ "$group_display" == "—" ]] && group_col_width=14 - - if $has_groups; then - printf " %-28s %-15s %-13s %-${rule_col_width}s %-${group_col_width}s %s %s\n" \ - "$client_name" "$ip" "$display_type" "$rule" \ - "$group_display" "$padded_status" "$last_seen" - else - printf " %-28s %-15s %-13s %-12s %s %s\n" \ - "$client_name" "$ip" "$display_type" "$rule" \ - "$padded_status" "$last_seen" - fi - done - - if $has_groups; then - printf " %s\n" "$(printf '─%.0s' {1..135})" - else - printf " %s\n" "$(printf '─%.0s' {1..107})" - fi - -local group_summary="" -if $has_groups; then - declare -A group_counts - for peer in "${!peer_group_map[@]}"; do - local g="${peer_group_map[$peer]}" - group_counts["$g"]=$(( ${group_counts["$g"]:-0} + 1 )) || true - done - for g in "${!group_counts[@]}"; do - group_summary+="${group_counts[$g]} in ${g}, " - done - group_summary="${group_summary%, }" -fi - - cmd::list::_render_summary "$group_summary" - - printf "\n" -} - -function cmd::list::_render_summary() { - local group_summary="${1:-}" - # Summary line - local total online_count - total=$(peers::all | wc -l) - - # Count by rule - declare -A rule_summary - while IFS= read -r peer_name; do - local r - r=$(peers::effective_rule "$peer_name") - rule_summary["$r"]=$(( ${rule_summary["$r"]:-0} + 1 )) - done < <(peers::all) - - local summary="" - for r in "${!rule_summary[@]}"; do - summary+="${rule_summary[$r]} ${r}, " - done - summary="${summary%, }" # remove trailing comma - - if [[ -n "$group_summary" ]]; then - printf "\n Showing %s peers [%s] — %s\n\n" "$total" "$summary" "$group_summary" - else - printf "\n Showing %s peers [%s]\n\n" "$total" "$summary" - fi -} - -# Strip ANSI codes to measure visible length, then pad manually -function cmd::list::pad_status() { - local status="$1" - local width="${2:-20}" - local visible - visible=$(echo -e "$status" | sed 's/\x1b\[[0-9;]*m//g') - local pad=$(( width - ${#visible} )) - printf "%b%${pad}s" "$status" "" -} \ No newline at end of file diff --git a/commands/test.command.sh b/commands/test.command.sh index a7e1926..7ef35a5 100644 --- a/commands/test.command.sh +++ b/commands/test.command.sh @@ -179,39 +179,43 @@ function cmd::test::section_fw() { function cmd::test::section_destructive() { test::section "Destructive (modifying state)" + # Cleanup from any previous failed run + /usr/local/bin/wgctl remove --name phone-testunit --force > /dev/null 2>&1 || true + /usr/local/bin/wgctl group remove --name testgroup --force > /dev/null 2>&1 || true + # Add test peer cmd::test::run_cmd "add phone peer" "added successfully" \ - wgctl add --name testunit --type phone --force + add --name testunit --type phone # Block/unblock cmd::test::run_cmd "block peer" "blocked" \ - wgctl block --name phone-testunit --force + block --name phone-testunit cmd::test::run_cmd "list shows blocked" "blocked" \ - wgctl list --blocked + list --blocked cmd::test::run_cmd "unblock peer" "unblocked" \ - wgctl unblock --name phone-testunit --force + unblock --name phone-testunit # Rule assign/unassign cmd::test::run_cmd "rule assign" "Assigned" \ - wgctl rule assign --name admin --peer phone-testunit + rule assign --name admin --peer phone-testunit cmd::test::run_cmd "rule unassign" "Unassigned" \ - wgctl rule unassign --peer phone-testunit + rule unassign --peer phone-testunit # Group operations cmd::test::run_cmd "group add" "created" \ - wgctl group add --name testgroup --desc "Test group" + group add --name testgroup --desc "Test group" cmd::test::run_cmd "group peer add" "Added" \ - wgctl group peer add --name testgroup --peer phone-testunit - cmd::test::run_cmd "group block" "Blocked" \ - wgctl group block --name testgroup - cmd::test::run_cmd "group unblock" "Unblocked" \ - wgctl group unblock --name testgroup + group peer add --name testgroup --peer phone-testunit + cmd::test::run_cmd "group block" "have been blocked" \ + group block --name testgroup + cmd::test::run_cmd "group unblock" "have been unblocked" \ + group unblock --name testgroup cmd::test::run_cmd "group remove" "removed" \ - wgctl group remove --name testgroup --force + group remove --name testgroup --force # Remove test peer cmd::test::run_cmd "remove phone peer" "removed" \ - wgctl remove --name phone-testunit --force + remove --name phone-testunit --force } # ============================================ diff --git a/daemon/endpoint_cache.json b/daemon/endpoint_cache.json index 664cf36..5eeeb91 100644 --- a/daemon/endpoint_cache.json +++ b/daemon/endpoint_cache.json @@ -1,9 +1,9 @@ { "phone-fred": "94.63.0.129", - "phone-helena": "148.69.38.203", - "phone-nuno": "148.69.48.87", + "phone-helena": "148.69.38.9", + "phone-nuno": "94.63.0.129", "tablet-nuno": "148.69.202.5", - "guest-zephyr": "86.120.152.105", + "guest-zephyr": "5.13.82.5", "guest-zephyr-test": "94.63.0.129", "desktop-roboclean": "46.189.215.231" } \ No newline at end of file