Implements Meshname DNS for Yggdrasil mesh networks with gossip-based service discovery and dnsmasq integration. New packages: - secubox-app-meshname-dns: Core service with meshnamectl CLI - luci-app-meshname-dns: LuCI dashboard for service management Features: - Services announce .ygg domains via gossip protocol (meshname_announce) - dnsmasq integration via /tmp/hosts/meshname dynamic hosts file - Cross-node resolution through gossip message propagation - RPCD handler with 8 methods for LuCI integration CLI commands: announce, revoke, resolve, list, sync, status, daemon Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
549 lines
13 KiB
Bash
549 lines
13 KiB
Bash
#!/bin/sh
|
|
# SPDX-License-Identifier: MIT
|
|
# Meshname DNS Controller - Decentralized .ygg domain resolution
|
|
#
|
|
# Copyright (C) 2026 CyberMind.fr
|
|
|
|
. /lib/functions.sh
|
|
. /usr/share/libubox/jshn.sh
|
|
|
|
CONFIG="meshname-dns"
|
|
LIB_DIR="/usr/lib/meshname-dns"
|
|
STATE_DIR="/var/lib/meshname-dns"
|
|
HOSTS_FILE="/tmp/hosts/meshname"
|
|
DOMAINS_FILE="$STATE_DIR/domains.json"
|
|
LOCAL_FILE="$STATE_DIR/local.json"
|
|
LOG_TAG="meshname-dns"
|
|
|
|
# Source libraries
|
|
[ -f "$LIB_DIR/announcer.sh" ] && . "$LIB_DIR/announcer.sh"
|
|
[ -f "$LIB_DIR/resolver.sh" ] && . "$LIB_DIR/resolver.sh"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: meshnamectl <command> [options]
|
|
|
|
Commands:
|
|
announce <name> [port] Announce service as <name>.ygg
|
|
revoke <name> Stop announcing service
|
|
resolve <name>.ygg Resolve domain to IPv6
|
|
list List known .ygg domains
|
|
sync Force sync with mesh peers
|
|
status Show meshname DNS status
|
|
daemon Run sync daemon (internal)
|
|
|
|
Examples:
|
|
meshnamectl announce myapp 8080 # Announces myapp.ygg -> local Ygg IPv6
|
|
meshnamectl resolve peer.ygg # Returns peer's Ygg IPv6
|
|
meshnamectl list # Shows all known .ygg domains
|
|
|
|
Configuration: /etc/config/meshname-dns
|
|
EOF
|
|
}
|
|
|
|
log_info() { logger -t "$LOG_TAG" "$*"; echo "[INFO] $*"; }
|
|
log_warn() { logger -t "$LOG_TAG" -p warning "$*"; echo "[WARN] $*" >&2; }
|
|
log_error() { logger -t "$LOG_TAG" -p err "$*"; echo "[ERROR] $*" >&2; }
|
|
|
|
uci_get() { uci -q get "${CONFIG}.$1"; }
|
|
uci_set() { uci set "${CONFIG}.$1=$2"; }
|
|
|
|
# Get local Yggdrasil IPv6 address
|
|
get_ygg_ipv6() {
|
|
# Try tun0 first (standard Yggdrasil interface)
|
|
local ipv6=$(ip -6 addr show tun0 2>/dev/null | grep -oP '(?<=inet6 )2[0-9a-f:]+(?=/)')
|
|
|
|
# If not found, try any interface with 200: prefix
|
|
if [ -z "$ipv6" ]; then
|
|
ipv6=$(ip -6 addr show 2>/dev/null | grep -oP '(?<=inet6 )2[0-9a-f][0-9a-f]:[0-9a-f:]+(?=/)' | head -1)
|
|
fi
|
|
|
|
echo "$ipv6"
|
|
}
|
|
|
|
# Initialize state files
|
|
init_state() {
|
|
mkdir -p "$STATE_DIR"
|
|
mkdir -p "$(dirname "$HOSTS_FILE")"
|
|
|
|
[ -f "$DOMAINS_FILE" ] || echo '{}' > "$DOMAINS_FILE"
|
|
[ -f "$LOCAL_FILE" ] || echo '{}' > "$LOCAL_FILE"
|
|
[ -f "$HOSTS_FILE" ] || touch "$HOSTS_FILE"
|
|
}
|
|
|
|
# Load configuration
|
|
load_config() {
|
|
enabled=$(uci_get main.enabled)
|
|
auto_announce=$(uci_get main.auto_announce)
|
|
sync_interval=$(uci_get main.sync_interval)
|
|
hosts_file=$(uci_get main.hosts_file)
|
|
|
|
resolver_enabled=$(uci_get resolver.enabled)
|
|
cache_ttl=$(uci_get resolver.cache_ttl)
|
|
|
|
gossip_enabled=$(uci_get gossip.enabled)
|
|
announce_priority=$(uci_get gossip.announce_priority)
|
|
|
|
[ -z "$sync_interval" ] && sync_interval=60
|
|
[ -z "$cache_ttl" ] && cache_ttl=300
|
|
[ -z "$announce_priority" ] && announce_priority=50
|
|
[ -n "$hosts_file" ] && HOSTS_FILE="$hosts_file"
|
|
}
|
|
|
|
# =============================================================================
|
|
# STATUS
|
|
# =============================================================================
|
|
|
|
cmd_status() {
|
|
init_state
|
|
load_config
|
|
|
|
local ygg_ipv6=$(get_ygg_ipv6)
|
|
local local_count=0
|
|
local domain_count=0
|
|
|
|
if [ -f "$LOCAL_FILE" ] && [ -s "$LOCAL_FILE" ]; then
|
|
local_count=$(jsonfilter -i "$LOCAL_FILE" -e '@' 2>/dev/null | grep -c '"name"' || echo 0)
|
|
fi
|
|
|
|
if [ -f "$DOMAINS_FILE" ] && [ -s "$DOMAINS_FILE" ]; then
|
|
domain_count=$(jsonfilter -i "$DOMAINS_FILE" -e '@' 2>/dev/null | grep -c '"ipv6"' || echo 0)
|
|
fi
|
|
|
|
echo "=== Meshname DNS Status ==="
|
|
echo ""
|
|
echo "Enabled: $([ "$enabled" = "1" ] && echo "Yes" || echo "No")"
|
|
echo "Yggdrasil IPv6: ${ygg_ipv6:-not available}"
|
|
echo "Sync Interval: ${sync_interval}s"
|
|
echo ""
|
|
echo "=== Services ==="
|
|
echo "Local Announced: $local_count"
|
|
echo "Known Domains: $domain_count"
|
|
echo ""
|
|
echo "=== Files ==="
|
|
echo "Hosts File: $HOSTS_FILE"
|
|
[ -f "$HOSTS_FILE" ] && echo "Hosts Entries: $(wc -l < "$HOSTS_FILE")"
|
|
|
|
if [ -f "$STATE_DIR/last_sync" ]; then
|
|
echo "Last Sync: $(cat "$STATE_DIR/last_sync")"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# ANNOUNCE
|
|
# =============================================================================
|
|
|
|
cmd_announce() {
|
|
local name="$1"
|
|
local port="${2:-0}"
|
|
|
|
[ -z "$name" ] && {
|
|
echo "Usage: meshnamectl announce <name> [port]"
|
|
echo "Example: meshnamectl announce myservice 8080"
|
|
exit 1
|
|
}
|
|
|
|
# Strip .ygg suffix if present
|
|
name="${name%.ygg}"
|
|
|
|
init_state
|
|
load_config
|
|
|
|
local ygg_ipv6=$(get_ygg_ipv6)
|
|
[ -z "$ygg_ipv6" ] && {
|
|
log_error "Yggdrasil IPv6 not available. Is Yggdrasil running?"
|
|
exit 1
|
|
}
|
|
|
|
local fqdn="${name}.ygg"
|
|
local timestamp=$(date +%s)
|
|
|
|
log_info "Announcing $fqdn -> $ygg_ipv6 (port: $port)"
|
|
|
|
# Store locally
|
|
local tmp_file="/tmp/meshname_local_$$.json"
|
|
if [ -s "$LOCAL_FILE" ] && [ "$(cat "$LOCAL_FILE")" != "{}" ]; then
|
|
# Update existing entry or add new
|
|
python3 -c "
|
|
import json, sys
|
|
name, ipv6, port, ts = sys.argv[1:5]
|
|
try:
|
|
with open('$LOCAL_FILE') as f:
|
|
data = json.load(f)
|
|
except:
|
|
data = {}
|
|
data[name] = {'name': name, 'fqdn': name + '.ygg', 'ipv6': ipv6, 'port': int(port), 'timestamp': int(ts)}
|
|
with open('$tmp_file', 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
" "$name" "$ygg_ipv6" "$port" "$timestamp"
|
|
else
|
|
python3 -c "
|
|
import json, sys
|
|
name, ipv6, port, ts = sys.argv[1:5]
|
|
data = {name: {'name': name, 'fqdn': name + '.ygg', 'ipv6': ipv6, 'port': int(port), 'timestamp': int(ts)}}
|
|
with open('$tmp_file', 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
" "$name" "$ygg_ipv6" "$port" "$timestamp"
|
|
fi
|
|
mv "$tmp_file" "$LOCAL_FILE"
|
|
|
|
# Update local hosts file
|
|
update_hosts_entry "$fqdn" "$ygg_ipv6"
|
|
|
|
# Broadcast via gossip
|
|
if [ "$gossip_enabled" = "1" ]; then
|
|
broadcast_announce "$name" "$fqdn" "$ygg_ipv6" "$port"
|
|
fi
|
|
|
|
log_info "Announced $fqdn"
|
|
}
|
|
|
|
# =============================================================================
|
|
# REVOKE
|
|
# =============================================================================
|
|
|
|
cmd_revoke() {
|
|
local name="$1"
|
|
|
|
[ -z "$name" ] && {
|
|
echo "Usage: meshnamectl revoke <name>"
|
|
exit 1
|
|
}
|
|
|
|
# Strip .ygg suffix if present
|
|
name="${name%.ygg}"
|
|
|
|
init_state
|
|
load_config
|
|
|
|
local fqdn="${name}.ygg"
|
|
|
|
log_info "Revoking $fqdn"
|
|
|
|
# Remove from local file
|
|
if [ -f "$LOCAL_FILE" ]; then
|
|
python3 -c "
|
|
import json, sys
|
|
name = sys.argv[1]
|
|
try:
|
|
with open('$LOCAL_FILE') as f:
|
|
data = json.load(f)
|
|
if name in data:
|
|
del data[name]
|
|
with open('$LOCAL_FILE', 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
print('Removed:', name)
|
|
except Exception as e:
|
|
print('Error:', e)
|
|
" "$name"
|
|
fi
|
|
|
|
# Remove from hosts file
|
|
remove_hosts_entry "$fqdn"
|
|
|
|
# Broadcast revocation via gossip (with empty IPv6)
|
|
if [ "$gossip_enabled" = "1" ]; then
|
|
broadcast_revoke "$name" "$fqdn"
|
|
fi
|
|
|
|
log_info "Revoked $fqdn"
|
|
}
|
|
|
|
# =============================================================================
|
|
# RESOLVE
|
|
# =============================================================================
|
|
|
|
cmd_resolve() {
|
|
local domain="$1"
|
|
|
|
[ -z "$domain" ] && {
|
|
echo "Usage: meshnamectl resolve <name>.ygg"
|
|
exit 1
|
|
}
|
|
|
|
# Add .ygg suffix if not present
|
|
echo "$domain" | grep -q '\.ygg$' || domain="${domain}.ygg"
|
|
|
|
init_state
|
|
|
|
# Check hosts file first
|
|
local ipv6=$(grep -w "$domain" "$HOSTS_FILE" 2>/dev/null | awk '{print $1}')
|
|
|
|
if [ -n "$ipv6" ]; then
|
|
echo "$ipv6"
|
|
return 0
|
|
fi
|
|
|
|
# Check domains cache
|
|
local name="${domain%.ygg}"
|
|
if [ -f "$DOMAINS_FILE" ]; then
|
|
ipv6=$(python3 -c "
|
|
import json, sys
|
|
name = sys.argv[1]
|
|
try:
|
|
with open('$DOMAINS_FILE') as f:
|
|
data = json.load(f)
|
|
if name in data:
|
|
print(data[name].get('ipv6', ''))
|
|
except:
|
|
pass
|
|
" "$name")
|
|
|
|
if [ -n "$ipv6" ]; then
|
|
echo "$ipv6"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
log_error "Cannot resolve $domain"
|
|
return 1
|
|
}
|
|
|
|
# =============================================================================
|
|
# LIST
|
|
# =============================================================================
|
|
|
|
cmd_list() {
|
|
init_state
|
|
|
|
echo "=== Local Services ==="
|
|
if [ -f "$LOCAL_FILE" ] && [ -s "$LOCAL_FILE" ] && [ "$(cat "$LOCAL_FILE")" != "{}" ]; then
|
|
python3 -c "
|
|
import json
|
|
try:
|
|
with open('$LOCAL_FILE') as f:
|
|
data = json.load(f)
|
|
for name, info in data.items():
|
|
port = info.get('port', 0)
|
|
port_str = f' (port {port})' if port > 0 else ''
|
|
print(f\" {info['fqdn']} -> {info['ipv6']}{port_str}\")
|
|
except:
|
|
print(' (none)')
|
|
"
|
|
else
|
|
echo " (none)"
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== Known Domains ==="
|
|
if [ -f "$DOMAINS_FILE" ] && [ -s "$DOMAINS_FILE" ] && [ "$(cat "$DOMAINS_FILE")" != "{}" ]; then
|
|
python3 -c "
|
|
import json
|
|
try:
|
|
with open('$DOMAINS_FILE') as f:
|
|
data = json.load(f)
|
|
for name, info in data.items():
|
|
origin = info.get('origin', 'unknown')[:16]
|
|
print(f\" {info['fqdn']} -> {info['ipv6']} (from {origin})\")
|
|
except:
|
|
print(' (none)')
|
|
"
|
|
else
|
|
echo " (none)"
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== Hosts File ($HOSTS_FILE) ==="
|
|
if [ -f "$HOSTS_FILE" ] && [ -s "$HOSTS_FILE" ]; then
|
|
sed 's/^/ /' "$HOSTS_FILE"
|
|
else
|
|
echo " (empty)"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# SYNC
|
|
# =============================================================================
|
|
|
|
cmd_sync() {
|
|
init_state
|
|
load_config
|
|
|
|
log_info "Syncing with mesh peers..."
|
|
|
|
# Re-announce all local services
|
|
if [ -f "$LOCAL_FILE" ] && [ "$gossip_enabled" = "1" ]; then
|
|
python3 -c "
|
|
import json
|
|
try:
|
|
with open('$LOCAL_FILE') as f:
|
|
data = json.load(f)
|
|
for name, info in data.items():
|
|
print(f\"{name}|{info['fqdn']}|{info['ipv6']}|{info.get('port', 0)}\")
|
|
except:
|
|
pass
|
|
" | while IFS='|' read -r name fqdn ipv6 port; do
|
|
[ -n "$name" ] && broadcast_announce "$name" "$fqdn" "$ipv6" "$port"
|
|
done
|
|
fi
|
|
|
|
# Regenerate hosts file from domains cache
|
|
regenerate_hosts
|
|
|
|
date -Iseconds > "$STATE_DIR/last_sync"
|
|
log_info "Sync complete"
|
|
}
|
|
|
|
# =============================================================================
|
|
# DAEMON
|
|
# =============================================================================
|
|
|
|
cmd_daemon() {
|
|
load_config
|
|
|
|
[ "$enabled" = "1" ] || {
|
|
log_error "Meshname DNS is disabled"
|
|
exit 1
|
|
}
|
|
|
|
log_info "Starting Meshname DNS daemon (interval: ${sync_interval}s)..."
|
|
|
|
# Initial sync
|
|
cmd_sync 2>&1 | while read -r line; do
|
|
logger -t "$LOG_TAG" "$line"
|
|
done
|
|
|
|
# Periodic sync loop
|
|
while true; do
|
|
sleep "$sync_interval"
|
|
|
|
cmd_sync 2>&1 | while read -r line; do
|
|
logger -t "$LOG_TAG" "$line"
|
|
done
|
|
done
|
|
}
|
|
|
|
# =============================================================================
|
|
# HELPER FUNCTIONS
|
|
# =============================================================================
|
|
|
|
# Update a single entry in hosts file
|
|
update_hosts_entry() {
|
|
local fqdn="$1"
|
|
local ipv6="$2"
|
|
|
|
[ -z "$fqdn" ] || [ -z "$ipv6" ] && return 1
|
|
|
|
# Remove existing entry if present
|
|
sed -i "/ ${fqdn}$/d" "$HOSTS_FILE" 2>/dev/null
|
|
|
|
# Add new entry
|
|
echo "$ipv6 $fqdn" >> "$HOSTS_FILE"
|
|
|
|
# Signal dnsmasq to reload
|
|
killall -HUP dnsmasq 2>/dev/null
|
|
}
|
|
|
|
# Remove entry from hosts file
|
|
remove_hosts_entry() {
|
|
local fqdn="$1"
|
|
|
|
sed -i "/ ${fqdn}$/d" "$HOSTS_FILE" 2>/dev/null
|
|
killall -HUP dnsmasq 2>/dev/null
|
|
}
|
|
|
|
# Regenerate entire hosts file from caches
|
|
regenerate_hosts() {
|
|
local tmp_hosts="/tmp/meshname_hosts_$$.tmp"
|
|
: > "$tmp_hosts"
|
|
|
|
# Add local services
|
|
if [ -f "$LOCAL_FILE" ]; then
|
|
python3 -c "
|
|
import json
|
|
try:
|
|
with open('$LOCAL_FILE') as f:
|
|
data = json.load(f)
|
|
for name, info in data.items():
|
|
print(f\"{info['ipv6']} {info['fqdn']}\")
|
|
except:
|
|
pass
|
|
" >> "$tmp_hosts"
|
|
fi
|
|
|
|
# Add remote domains
|
|
if [ -f "$DOMAINS_FILE" ]; then
|
|
python3 -c "
|
|
import json
|
|
try:
|
|
with open('$DOMAINS_FILE') as f:
|
|
data = json.load(f)
|
|
for name, info in data.items():
|
|
print(f\"{info['ipv6']} {info['fqdn']}\")
|
|
except:
|
|
pass
|
|
" >> "$tmp_hosts"
|
|
fi
|
|
|
|
# Deduplicate and sort
|
|
sort -u "$tmp_hosts" > "$HOSTS_FILE"
|
|
rm -f "$tmp_hosts"
|
|
|
|
# Signal dnsmasq
|
|
killall -HUP dnsmasq 2>/dev/null
|
|
}
|
|
|
|
# Broadcast announcement via gossip
|
|
broadcast_announce() {
|
|
local name="$1"
|
|
local fqdn="$2"
|
|
local ipv6="$3"
|
|
local port="${4:-0}"
|
|
|
|
# Use mirrornet gossip if available
|
|
if [ -f /usr/lib/mirrornet/gossip.sh ]; then
|
|
. /usr/lib/mirrornet/gossip.sh
|
|
|
|
local data="{\"name\":\"$name\",\"fqdn\":\"$fqdn\",\"ipv6\":\"$ipv6\",\"port\":$port,\"type\":\"announce\"}"
|
|
gossip_broadcast "meshname_announce" "$data" "$announce_priority"
|
|
fi
|
|
}
|
|
|
|
# Broadcast revocation via gossip
|
|
broadcast_revoke() {
|
|
local name="$1"
|
|
local fqdn="$2"
|
|
|
|
if [ -f /usr/lib/mirrornet/gossip.sh ]; then
|
|
. /usr/lib/mirrornet/gossip.sh
|
|
|
|
local data="{\"name\":\"$name\",\"fqdn\":\"$fqdn\",\"ipv6\":\"\",\"type\":\"revoke\"}"
|
|
gossip_broadcast "meshname_announce" "$data" "$announce_priority"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# MAIN
|
|
# =============================================================================
|
|
|
|
case "$1" in
|
|
status)
|
|
cmd_status
|
|
;;
|
|
announce)
|
|
cmd_announce "$2" "$3"
|
|
;;
|
|
revoke)
|
|
cmd_revoke "$2"
|
|
;;
|
|
resolve)
|
|
cmd_resolve "$2"
|
|
;;
|
|
list)
|
|
cmd_list
|
|
;;
|
|
sync)
|
|
cmd_sync
|
|
;;
|
|
daemon)
|
|
cmd_daemon
|
|
;;
|
|
-h|--help|help)
|
|
usage
|
|
;;
|
|
*)
|
|
usage
|
|
exit 1
|
|
;;
|
|
esac
|