added netbird-watcher script
All checks were successful
Terraform / terraform (push) Successful in 7s
All checks were successful
Terraform / terraform (push) Successful in 7s
This commit is contained in:
104
watcher/README.md
Normal file
104
watcher/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# NetBird Peer Renamer Watcher
|
||||
|
||||
Automatically renames NetBird peers after enrollment based on setup key names.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Engineer creates setup key named after the desired peer name (e.g., `pilot-ivanov`)
|
||||
2. Operator enrolls using the setup key
|
||||
3. Peer appears with random hostname (e.g., `DESKTOP-ABC123`)
|
||||
4. **Watcher detects the consumed setup key and renames peer to `pilot-ivanov`**
|
||||
|
||||
## Logic
|
||||
|
||||
The watcher polls NetBird API every 30 seconds:
|
||||
|
||||
1. Fetches all setup keys
|
||||
2. Finds keys with `used_times > 0` that haven't been processed
|
||||
3. For each consumed key:
|
||||
- Looks up `last_used` timestamp
|
||||
- Finds peer created around that time (within 60 seconds)
|
||||
- Renames peer to match setup key name
|
||||
4. Marks key as processed to avoid re-processing
|
||||
|
||||
## Installation
|
||||
|
||||
### Via Ansible
|
||||
|
||||
```bash
|
||||
cd ansible/netbird-watcher
|
||||
ansible-playbook -i poc-inventory.yml playbook.yml -e vault_netbird_token=<TOKEN>
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
# Copy script
|
||||
sudo cp netbird_watcher.py /opt/netbird-watcher/
|
||||
sudo chmod +x /opt/netbird-watcher/netbird_watcher.py
|
||||
|
||||
# Create config
|
||||
sudo mkdir -p /etc/netbird-watcher
|
||||
sudo cat > /etc/netbird-watcher/config.json << EOF
|
||||
{
|
||||
"url": "https://netbird-poc.networkmonitor.cc",
|
||||
"token": "nbp_YOUR_TOKEN"
|
||||
}
|
||||
EOF
|
||||
sudo chmod 600 /etc/netbird-watcher/config.json
|
||||
|
||||
# Create state directory
|
||||
sudo mkdir -p /var/lib/netbird-watcher
|
||||
|
||||
# Install service
|
||||
sudo cp netbird-watcher.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now netbird-watcher
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Check status
|
||||
|
||||
```bash
|
||||
systemctl status netbird-watcher
|
||||
journalctl -u netbird-watcher -f
|
||||
```
|
||||
|
||||
### Run manually (one-shot)
|
||||
|
||||
```bash
|
||||
./netbird_watcher.py \
|
||||
--url https://netbird-poc.networkmonitor.cc \
|
||||
--token nbp_xxx \
|
||||
--once \
|
||||
--verbose
|
||||
```
|
||||
|
||||
### State file
|
||||
|
||||
Processed keys are tracked in `/var/lib/netbird-watcher/state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"processed_keys": ["key-id-1", "key-id-2"]
|
||||
}
|
||||
```
|
||||
|
||||
To reprocess a key, remove its ID from this file.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Peer not renamed
|
||||
|
||||
1. Check if setup key was consumed: `used_times > 0`
|
||||
2. Check watcher logs: `journalctl -u netbird-watcher`
|
||||
3. Ensure peer enrolled within 60 seconds of key consumption
|
||||
4. Check if key was already processed (in state.json)
|
||||
|
||||
### Reset state
|
||||
|
||||
```bash
|
||||
sudo rm /var/lib/netbird-watcher/state.json
|
||||
sudo systemctl restart netbird-watcher
|
||||
```
|
||||
4
watcher/config.json.example
Normal file
4
watcher/config.json.example
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "https://netbird-poc.networkmonitor.cc",
|
||||
"token": "nbp_YOUR_TOKEN_HERE"
|
||||
}
|
||||
19
watcher/netbird-watcher.service
Normal file
19
watcher/netbird-watcher.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=NetBird Peer Renamer Watcher
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/netbird-watcher/netbird_watcher.py --config /etc/netbird-watcher/config.json
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/netbird-watcher
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
348
watcher/netbird_watcher.py
Normal file
348
watcher/netbird_watcher.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NetBird Peer Renamer Watcher
|
||||
|
||||
Polls NetBird API for consumed setup keys and automatically renames
|
||||
the enrolled peers to match the setup key name.
|
||||
|
||||
Setup key name = desired peer name.
|
||||
|
||||
Usage:
|
||||
./netbird_watcher.py --config /etc/netbird-watcher/config.json
|
||||
./netbird_watcher.py --url https://netbird.example.com --token nbp_xxx
|
||||
|
||||
Environment variables (alternative to flags):
|
||||
NETBIRD_URL - NetBird management URL
|
||||
NETBIRD_TOKEN - NetBird API token (PAT)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_STATE_FILE = "/var/lib/netbird-watcher/state.json"
|
||||
DEFAULT_POLL_INTERVAL = 30 # seconds
|
||||
PEER_MATCH_WINDOW = 60 # seconds - how close peer creation must be to key usage
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
return logging.getLogger("netbird-watcher")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# State Management
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class State:
|
||||
"""Tracks which setup keys have been processed."""
|
||||
|
||||
def __init__(self, state_file: str):
|
||||
self.state_file = Path(state_file)
|
||||
self.processed_keys: set[str] = set()
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
data = json.loads(self.state_file.read_text())
|
||||
self.processed_keys = set(data.get("processed_keys", []))
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logging.warning(f"Failed to load state file: {e}")
|
||||
self.processed_keys = set()
|
||||
|
||||
def save(self):
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"processed_keys": list(self.processed_keys)}
|
||||
self.state_file.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def is_processed(self, key_id: str) -> bool:
|
||||
return key_id in self.processed_keys
|
||||
|
||||
def mark_processed(self, key_id: str):
|
||||
self.processed_keys.add(key_id)
|
||||
self.save()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# NetBird API Client
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class NetBirdAPI:
|
||||
"""Simple NetBird API client."""
|
||||
|
||||
def __init__(self, url: str, token: str):
|
||||
self.base_url = url.rstrip("/")
|
||||
self.token = token
|
||||
self.logger = logging.getLogger("netbird-api")
|
||||
|
||||
def _request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict:
|
||||
url = f"{self.base_url}/api{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except HTTPError as e:
|
||||
self.logger.error(f"HTTP {e.code} for {method} {endpoint}: {e.read().decode()}")
|
||||
raise
|
||||
except URLError as e:
|
||||
self.logger.error(f"URL error for {method} {endpoint}: {e}")
|
||||
raise
|
||||
|
||||
def get_setup_keys(self) -> list[dict]:
|
||||
result = self._request("GET", "/setup-keys")
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def get_peers(self) -> list[dict]:
|
||||
result = self._request("GET", "/peers")
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def rename_peer(self, peer_id: str, new_name: str) -> dict:
|
||||
return self._request("PUT", f"/peers/{peer_id}", {"name": new_name})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Watcher Logic
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def parse_timestamp(ts: str) -> Optional[datetime]:
|
||||
"""Parse NetBird timestamp to datetime."""
|
||||
if not ts or ts.startswith("0001-01-01"):
|
||||
return None
|
||||
try:
|
||||
# Handle various formats
|
||||
ts = ts.replace("Z", "+00:00")
|
||||
if "." in ts:
|
||||
# Truncate nanoseconds to microseconds
|
||||
parts = ts.split(".")
|
||||
frac = parts[1]
|
||||
tz_idx = frac.find("+") if "+" in frac else frac.find("-") if "-" in frac else len(frac)
|
||||
frac_sec = frac[:tz_idx][:6] # max 6 digits for microseconds
|
||||
tz_part = frac[tz_idx:] if tz_idx < len(frac) else "+00:00"
|
||||
ts = f"{parts[0]}.{frac_sec}{tz_part}"
|
||||
return datetime.fromisoformat(ts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def find_matching_peer(
|
||||
key_name: str,
|
||||
key_last_used: datetime,
|
||||
key_auto_groups: list[str],
|
||||
peers: list[dict],
|
||||
logger: logging.Logger,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Find the peer that enrolled using this setup key.
|
||||
|
||||
Strategy:
|
||||
1. Peer must be in one of the key's auto_groups
|
||||
2. Peer must have been created within PEER_MATCH_WINDOW of key usage
|
||||
3. Peer name should NOT already match key name (not yet renamed)
|
||||
4. Pick the closest match by creation time
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
for peer in peers:
|
||||
peer_name = peer.get("name", "")
|
||||
peer_id = peer.get("id", "")
|
||||
peer_groups = [g.get("id") for g in peer.get("groups", [])]
|
||||
|
||||
# Skip if already renamed to target name
|
||||
if peer_name == key_name:
|
||||
logger.debug(f"Peer {peer_id} already named '{key_name}', skipping")
|
||||
continue
|
||||
|
||||
# Check group membership
|
||||
if not any(g in peer_groups for g in key_auto_groups):
|
||||
continue
|
||||
|
||||
# Check creation time
|
||||
# Note: NetBird peer object doesn't have 'created_at' directly accessible
|
||||
# We use 'last_seen' or 'connected' status as proxy
|
||||
# Actually, let's use the peer's 'connected' first time or 'last_seen'
|
||||
|
||||
# Looking at NetBird API, peers have 'last_seen' but not always 'created_at'
|
||||
# For newly enrolled peers, last_seen should be very recent
|
||||
peer_last_seen = parse_timestamp(peer.get("last_seen", ""))
|
||||
|
||||
if peer_last_seen:
|
||||
time_diff = abs((peer_last_seen - key_last_used).total_seconds())
|
||||
if time_diff <= PEER_MATCH_WINDOW:
|
||||
candidates.append((peer, time_diff))
|
||||
logger.debug(
|
||||
f"Candidate peer: {peer_name} ({peer_id}), "
|
||||
f"last_seen={peer_last_seen}, diff={time_diff:.1f}s"
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Return closest match
|
||||
candidates.sort(key=lambda x: x[1])
|
||||
return candidates[0][0]
|
||||
|
||||
|
||||
def process_consumed_keys(
|
||||
api: NetBirdAPI,
|
||||
state: State,
|
||||
logger: logging.Logger,
|
||||
) -> int:
|
||||
"""
|
||||
Process all consumed setup keys and rename their peers.
|
||||
Returns count of peers renamed.
|
||||
"""
|
||||
renamed_count = 0
|
||||
|
||||
try:
|
||||
setup_keys = api.get_setup_keys()
|
||||
peers = api.get_peers()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch data from NetBird API: {e}")
|
||||
return 0
|
||||
|
||||
for key in setup_keys:
|
||||
key_id = key.get("id", "")
|
||||
key_name = key.get("name", "")
|
||||
used_times = key.get("used_times", 0)
|
||||
last_used_str = key.get("last_used", "")
|
||||
auto_groups = key.get("auto_groups", [])
|
||||
|
||||
# Skip if not consumed
|
||||
if used_times == 0:
|
||||
continue
|
||||
|
||||
# Skip if already processed
|
||||
if state.is_processed(key_id):
|
||||
continue
|
||||
|
||||
# Skip system/default keys (optional - adjust pattern as needed)
|
||||
if key_name.lower() in ("default", "all"):
|
||||
state.mark_processed(key_id)
|
||||
continue
|
||||
|
||||
last_used = parse_timestamp(last_used_str)
|
||||
if not last_used:
|
||||
logger.warning(f"Key '{key_name}' ({key_id}) has invalid last_used: {last_used_str}")
|
||||
state.mark_processed(key_id)
|
||||
continue
|
||||
|
||||
logger.info(f"Processing consumed key: '{key_name}' (used_times={used_times})")
|
||||
|
||||
# Find matching peer
|
||||
peer = find_matching_peer(key_name, last_used, auto_groups, peers, logger)
|
||||
|
||||
if peer:
|
||||
peer_id = peer.get("id", "")
|
||||
old_name = peer.get("name", "unknown")
|
||||
|
||||
if not peer_id:
|
||||
logger.error(f"Peer has no ID, cannot rename")
|
||||
state.mark_processed(key_id)
|
||||
continue
|
||||
|
||||
try:
|
||||
api.rename_peer(peer_id, key_name)
|
||||
logger.info(f"Renamed peer '{old_name}' -> '{key_name}' (id={peer_id})")
|
||||
renamed_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rename peer {peer_id}: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"No matching peer found for key '{key_name}'. "
|
||||
f"Peer may not have enrolled yet or was already renamed."
|
||||
)
|
||||
|
||||
# Mark as processed regardless (to avoid infinite retries)
|
||||
state.mark_processed(key_id)
|
||||
|
||||
return renamed_count
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="NetBird Peer Renamer Watcher")
|
||||
parser.add_argument("--url", help="NetBird management URL")
|
||||
parser.add_argument("--token", help="NetBird API token")
|
||||
parser.add_argument("--config", help="Path to config JSON file")
|
||||
parser.add_argument("--state-file", default=DEFAULT_STATE_FILE, help="Path to state file")
|
||||
parser.add_argument("--interval", type=int, default=DEFAULT_POLL_INTERVAL, help="Poll interval in seconds")
|
||||
parser.add_argument("--once", action="store_true", help="Run once and exit (for cron)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
logger = setup_logging(args.verbose)
|
||||
|
||||
# Load config
|
||||
url = args.url or os.environ.get("NETBIRD_URL")
|
||||
token = args.token or os.environ.get("NETBIRD_TOKEN")
|
||||
|
||||
if args.config:
|
||||
try:
|
||||
config = json.loads(Path(args.config).read_text())
|
||||
url = url or config.get("url")
|
||||
token = token or config.get("token")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if not url or not token:
|
||||
logger.error("NetBird URL and token are required. Use --url/--token, env vars, or --config")
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize
|
||||
api = NetBirdAPI(url, token)
|
||||
state = State(args.state_file)
|
||||
|
||||
logger.info(f"NetBird Watcher started (url={url}, interval={args.interval}s)")
|
||||
|
||||
if args.once:
|
||||
# Single run mode (for cron)
|
||||
count = process_consumed_keys(api, state, logger)
|
||||
logger.info(f"Processed {count} peer(s)")
|
||||
else:
|
||||
# Daemon mode
|
||||
while True:
|
||||
try:
|
||||
count = process_consumed_keys(api, state, logger)
|
||||
if count > 0:
|
||||
logger.info(f"Processed {count} peer(s) this cycle")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in processing cycle: {e}")
|
||||
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user