2026-10-04 21:43:32 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""cred-client.py — Agent API client & CLI for cred.muse-dev.online.
|
|
|
|
|
|
|
|
|
|
Provides programmatic and CLI access for agents and operators to:
|
|
|
|
|
- initiate: start onboarding for a client email/node
|
|
|
|
|
- submit-otp: submit verification code transiently
|
|
|
|
|
- status: query onboarding & vitality state for a node
|
|
|
|
|
- list: list fleet accounts, emails, and statuses
|
|
|
|
|
|
|
|
|
|
Authentication:
|
|
|
|
|
- Machine identity signature via ~/.ssh/muse-health (machine bl)
|
|
|
|
|
- Or Bearer token from CRED_TOKEN / OPERATOR_TOKEN environment variable.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
cred-client.py initiate --node <node> --email <email> [--service muse] [--account-name <name>]
|
|
|
|
|
cred-client.py submit-otp --node <node> --otp <code> [--email <email>]
|
|
|
|
|
cred-client.py status --node <node> [--json]
|
|
|
|
|
cred-client.py list [--json]
|
|
|
|
|
"""
|
|
|
|
|
import argparse
|
|
|
|
|
import datetime
|
|
|
|
|
import importlib.util
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
NETVM_DIR = os.environ.get("NETVM_DIR", "/home/super/Projects/NetVM")
|
|
|
|
|
KEY_DEFAULT = os.path.expanduser("~/.ssh/muse-health")
|
|
|
|
|
CRED_API_DEFAULT = os.environ.get("CRED_API_URL", "https://cred.muse-dev.online/api/cred")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_registry():
|
|
|
|
|
path = os.path.join(NETVM_DIR, "bin", "netvm-registry.py")
|
|
|
|
|
try:
|
|
|
|
|
spec = importlib.util.spec_from_file_location("netvm_registry", path)
|
|
|
|
|
mod = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(mod)
|
|
|
|
|
return mod
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_accounts():
|
|
|
|
|
"""Parse ACCOUNTS.md into a structured list of accounts."""
|
|
|
|
|
path = os.path.join(NETVM_DIR, "ACCOUNTS.md")
|
|
|
|
|
accounts = []
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
return accounts
|
|
|
|
|
with open(path) as f:
|
|
|
|
|
for line in f:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line.startswith("|") or line.startswith("| agent") or line.startswith("|-------"):
|
|
|
|
|
continue
|
|
|
|
|
cols = [c.strip() for c in line.strip("|").split("|")]
|
|
|
|
|
if len(cols) >= 9:
|
|
|
|
|
accounts.append({
|
|
|
|
|
"agent": cols[0],
|
|
|
|
|
"node": cols[1],
|
|
|
|
|
"profile": cols[2],
|
|
|
|
|
"login_type": cols[3],
|
|
|
|
|
"meta_label": cols[4],
|
|
|
|
|
"email": cols[5],
|
|
|
|
|
"phone_otp": cols[6],
|
|
|
|
|
"instagram_linked": cols[7],
|
|
|
|
|
"status": cols[8],
|
|
|
|
|
"egress_ip": cols[9] if len(cols) > 9 else "",
|
|
|
|
|
"cdp_port": cols[10] if len(cols) > 10 else "",
|
|
|
|
|
"display_name": cols[11] if len(cols) > 11 else "",
|
|
|
|
|
"notes": cols[12] if len(cols) > 12 else ""
|
|
|
|
|
})
|
|
|
|
|
return accounts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sign_payload(payload_bytes, key_path=KEY_DEFAULT, namespace="cred"):
|
|
|
|
|
"""Sign payload using SSH ed25519 key (zero secret across wire)."""
|
|
|
|
|
if not os.path.exists(key_path):
|
|
|
|
|
return None
|
|
|
|
|
tmp = tempfile.mkdtemp()
|
|
|
|
|
try:
|
|
|
|
|
data_file = os.path.join(tmp, "data")
|
|
|
|
|
with open(data_file, "wb") as f:
|
|
|
|
|
f.write(payload_bytes)
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
["ssh-keygen", "-Y", "sign", "-f", key_path, "-n", namespace, data_file],
|
|
|
|
|
capture_output=True, text=True
|
|
|
|
|
)
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
return None
|
|
|
|
|
with open(data_file + ".sig") as f:
|
|
|
|
|
return f.read().strip()
|
|
|
|
|
finally:
|
|
|
|
|
subprocess.run(["rm", "-rf", tmp], capture_output=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CredClient:
|
|
|
|
|
def __init__(self, api_url=CRED_API_DEFAULT, key_path=KEY_DEFAULT):
|
|
|
|
|
self.api_url = api_url.rstrip("/")
|
|
|
|
|
self.key_path = key_path
|
|
|
|
|
self.token = os.environ.get("CRED_TOKEN") or os.environ.get("OPERATOR_TOKEN")
|
|
|
|
|
|
|
|
|
|
def run_local_driver(self, node, step, email, otp=None, account_name=None):
|
|
|
|
|
"""Execute local onboard-driver.py inside the node's netns."""
|
|
|
|
|
exec_script = os.path.join(NETVM_DIR, "bin", "netvm-exec.sh")
|
|
|
|
|
driver_script = os.path.join(NETVM_DIR, "bin", "onboard-driver.py")
|
|
|
|
|
cmd = [exec_script, node, "--", sys.executable, driver_script,
|
|
|
|
|
"--node", node, "--service", "muse", "--id-type", "email", "--step", step]
|
|
|
|
|
if account_name:
|
|
|
|
|
cmd += ["--account-name", account_name]
|
|
|
|
|
|
|
|
|
|
input_data = email + "\n"
|
|
|
|
|
if otp:
|
|
|
|
|
input_data += str(otp) + "\n"
|
|
|
|
|
|
|
|
|
|
p = subprocess.run(cmd, input=input_data, capture_output=True, text=True, timeout=240)
|
|
|
|
|
return p.returncode, p.stdout.strip(), p.stderr.strip()
|
|
|
|
|
|
|
|
|
|
def initiate(self, node, email, service="muse", account_name=None):
|
|
|
|
|
"""Initiate client onboarding."""
|
|
|
|
|
ret, stdout, stderr = self.run_local_driver(node, "initiate", email, account_name=account_name)
|
|
|
|
|
if ret == 0:
|
|
|
|
|
return {"status": "active", "node": node, "email": email, "message": "Already authenticated and session active."}
|
|
|
|
|
elif ret == 2:
|
|
|
|
|
return {"status": "awaiting_otp", "node": node, "email": email, "message": "OTP verification code sent. Awaiting input."}
|
|
|
|
|
elif ret == 3:
|
|
|
|
|
return {"status": "needs_human", "node": node, "email": email, "message": "Multiple accounts match identifier. Manual selection required."}
|
|
|
|
|
else:
|
|
|
|
|
return {"status": "error", "node": node, "email": email, "code": ret, "detail": stderr or stdout}
|
|
|
|
|
|
|
|
|
|
def submit_otp(self, node, otp, email=None, service="muse"):
|
|
|
|
|
"""Submit transient verification code."""
|
|
|
|
|
if not email:
|
|
|
|
|
# Look up email from ACCOUNTS.md
|
|
|
|
|
for acct in get_accounts():
|
|
|
|
|
if acct["node"] == node and acct["email"] not in ("-", ""):
|
|
|
|
|
email = acct["email"]
|
|
|
|
|
break
|
|
|
|
|
if not email:
|
|
|
|
|
email = "unknown"
|
|
|
|
|
|
|
|
|
|
ret, stdout, stderr = self.run_local_driver(node, "submit", email, otp=otp)
|
|
|
|
|
if ret == 0:
|
|
|
|
|
return {"status": "active", "node": node, "email": email, "message": "Authentication successful. Chat session active."}
|
|
|
|
|
elif ret == 3:
|
|
|
|
|
return {"status": "needs_human", "node": node, "email": email, "message": "Multiple accounts match identifier."}
|
|
|
|
|
else:
|
|
|
|
|
return {"status": "error", "node": node, "email": email, "code": ret, "detail": stderr or stdout}
|
|
|
|
|
|
|
|
|
|
def status(self, node):
|
|
|
|
|
"""Check status of a node."""
|
|
|
|
|
accounts = get_accounts()
|
|
|
|
|
target = next((a for a in accounts if a["node"] == node), None)
|
|
|
|
|
port = None
|
|
|
|
|
reg = load_registry()
|
|
|
|
|
if reg:
|
|
|
|
|
port = reg.port_for(node)
|
|
|
|
|
|
|
|
|
|
# Vitality check
|
|
|
|
|
alive = False
|
|
|
|
|
detail = ""
|
|
|
|
|
if port:
|
|
|
|
|
try:
|
|
|
|
|
checker = os.path.join(NETVM_DIR, "bin", "accounts-health.py")
|
|
|
|
|
cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, checker, str(port)]
|
|
|
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
data = json.loads(r.stdout.strip().splitlines()[-1])
|
|
|
|
|
alive = data.get("session_alive", False)
|
|
|
|
|
detail = data.get("detail", "")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
detail = str(e)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"node": node,
|
|
|
|
|
"status": target["status"] if target else "unregistered",
|
|
|
|
|
"email": target["email"] if target else "-",
|
|
|
|
|
"cdp_port": port,
|
|
|
|
|
"session_alive": alive,
|
|
|
|
|
"detail": detail
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def list_all(self):
|
|
|
|
|
"""List all accounts and live statuses."""
|
|
|
|
|
res = []
|
|
|
|
|
for a in get_accounts():
|
|
|
|
|
st = self.status(a["node"])
|
|
|
|
|
res.append(st)
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def notify_operator(self, subject, body, to_email="defnotabotnet@gmail.com"):
|
|
|
|
|
"""Send notification to operator via local MTA (msmtp or mail)."""
|
|
|
|
|
sent = False
|
|
|
|
|
err = None
|
|
|
|
|
# Try msmtp first
|
|
|
|
|
try:
|
|
|
|
|
p = subprocess.Popen(["msmtp", to_email], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
|
|
|
msg = f"Subject: {subject}\nTo: {to_email}\nFrom: NetVM Automation <root@bl>\n\n{body}\n"
|
|
|
|
|
stdout, stderr = p.communicate(input=msg)
|
|
|
|
|
if p.returncode == 0:
|
|
|
|
|
sent = True
|
|
|
|
|
else:
|
|
|
|
|
err = stderr.strip() or stdout.strip()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
err = str(e)
|
|
|
|
|
|
|
|
|
|
if not sent:
|
|
|
|
|
# Fallback to mail / s-nail
|
|
|
|
|
try:
|
|
|
|
|
p = subprocess.Popen(["mail", "-s", subject, to_email], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
|
|
|
stdout, stderr = p.communicate(input=body)
|
|
|
|
|
if p.returncode == 0:
|
|
|
|
|
sent = True
|
|
|
|
|
else:
|
|
|
|
|
err = stderr.strip() or stdout.strip()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
err = str(e)
|
|
|
|
|
|
|
|
|
|
return {"sent": sent, "recipient": to_email, "error": err if not sent else None}
|
|
|
|
|
|
|
|
|
|
def link_instagram(self, node, notify=False):
|
|
|
|
|
"""Fetch the Meta Accounts Center OAuth URL and provide one-tap Tailscale link."""
|
|
|
|
|
portal_script = os.path.join(NETVM_DIR, "bin", "tailscale-verify-portal.py")
|
|
|
|
|
ts_ip = "100.123.153.75"
|
|
|
|
|
ts_dns = "bl.tailfb5960.ts.net"
|
|
|
|
|
try:
|
|
|
|
|
import importlib.util
|
|
|
|
|
spec = importlib.util.spec_from_file_location("portal", portal_script)
|
|
|
|
|
portal_mod = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(portal_mod)
|
|
|
|
|
ts_ip = portal_mod.get_tailscale_ip()
|
|
|
|
|
ts_dns = portal_mod.get_tailscale_dns()
|
|
|
|
|
ig_data = portal_mod.fetch_node_ig_link(node)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
ig_data = {"error": str(e)}
|
|
|
|
|
|
|
|
|
|
direct_url = ig_data.get("url") if isinstance(ig_data, dict) else None
|
|
|
|
|
portal_url = f"http://{ts_dns}:8765/verify/{node}"
|
|
|
|
|
portal_ip_url = f"http://{ts_ip}:8765/verify/{node}"
|
|
|
|
|
|
|
|
|
|
email_result = None
|
|
|
|
|
if notify and direct_url:
|
|
|
|
|
subject = f"[NetVM Action Required] Link Instagram for Node '{node}'"
|
|
|
|
|
body = (
|
|
|
|
|
f"Node '{node}' is waiting at the Muse age verification gate.\n\n"
|
|
|
|
|
f"Tap the Tailscale portal link from your device to approve:\n"
|
|
|
|
|
f" {portal_url}\n"
|
|
|
|
|
f" (or {portal_ip_url})\n\n"
|
|
|
|
|
f"Direct OAuth URL:\n"
|
|
|
|
|
f" {direct_url}\n\n"
|
|
|
|
|
f"After approving in Instagram, NetVM will automatically transition node '{node}' to active."
|
|
|
|
|
)
|
|
|
|
|
email_result = self.notify_operator(subject, body)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"node": node,
|
|
|
|
|
"status": "needs_verification",
|
|
|
|
|
"portal_url": portal_url,
|
|
|
|
|
"portal_ip_url": portal_ip_url,
|
|
|
|
|
"direct_oauth_url": direct_url,
|
|
|
|
|
"error": ig_data.get("error") if isinstance(ig_data, dict) else None,
|
|
|
|
|
"email_notified": email_result.get("sent") if email_result else False
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 23:02:04 +00:00
|
|
|
def audit_meta(self, node):
|
|
|
|
|
"""Query Meta Accounts Center for linked profiles and security status."""
|
|
|
|
|
meta_script = os.path.join(NETVM_DIR, "bin", "meta-acct.py")
|
|
|
|
|
cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, meta_script, "list-linked", node]
|
|
|
|
|
try:
|
|
|
|
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
2026-10-04 23:03:27 +00:00
|
|
|
out = res.stdout.strip()
|
|
|
|
|
if out:
|
|
|
|
|
# Find outermost JSON object
|
|
|
|
|
start = out.find("{")
|
|
|
|
|
end = out.rfind("}")
|
|
|
|
|
if start >= 0 and end >= start:
|
|
|
|
|
return json.loads(out[start:end+1])
|
|
|
|
|
return json.loads(out)
|
2026-10-04 23:02:04 +00:00
|
|
|
return {"error": res.stderr.strip() or "No output from meta-acct.py"}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"error": str(e)}
|
|
|
|
|
|
2026-10-04 21:43:32 +00:00
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
|
|
|
common.add_argument("--json", action="store_true", help="Output JSON response")
|
|
|
|
|
|
|
|
|
|
p = argparse.ArgumentParser(description="cred-client: Agent API client for cred onboarding", parents=[common])
|
|
|
|
|
sub = p.add_subparsers(dest="command", required=True)
|
|
|
|
|
|
|
|
|
|
# initiate
|
|
|
|
|
p_init = sub.add_parser("initiate", parents=[common], help="Initiate onboarding flow for a node")
|
|
|
|
|
p_init.add_argument("--node", required=True, help="Node label (e.g. opm, pip, client1)")
|
|
|
|
|
p_init.add_argument("--email", required=True, help="Client login email")
|
|
|
|
|
p_init.add_argument("--service", default="muse", help="Service name (default: muse)")
|
|
|
|
|
p_init.add_argument("--account-name", default=None, help="Display name hint for multi-account selector")
|
|
|
|
|
|
|
|
|
|
# submit-otp
|
|
|
|
|
p_otp = sub.add_parser("submit-otp", parents=[common], help="Submit transient OTP verification code")
|
|
|
|
|
p_otp.add_argument("--node", required=True, help="Node label")
|
|
|
|
|
p_otp.add_argument("--otp", required=True, help="6-digit verification code")
|
|
|
|
|
p_otp.add_argument("--email", default=None, help="Client email (optional, auto-detected from registry)")
|
|
|
|
|
|
|
|
|
|
# status
|
|
|
|
|
p_stat = sub.add_parser("status", parents=[common], help="Query onboarding and session status for a node")
|
|
|
|
|
p_stat.add_argument("--node", required=True, help="Node label")
|
|
|
|
|
|
|
|
|
|
# link-instagram
|
|
|
|
|
p_link = sub.add_parser("link-instagram", parents=[common], help="Generate Tailscale one-tap portal & OAuth link for age verification")
|
|
|
|
|
p_link.add_argument("--node", required=True, help="Node label")
|
|
|
|
|
p_link.add_argument("--notify", action="store_true", help="Send email alert to operator via local MTA")
|
|
|
|
|
|
2026-10-04 23:02:04 +00:00
|
|
|
# meta-audit
|
|
|
|
|
p_meta = sub.add_parser("meta-audit", parents=[common], help="Query Meta Accounts Center for linked profiles and security status")
|
|
|
|
|
p_meta.add_argument("--node", required=True, help="Node label")
|
|
|
|
|
|
2026-10-04 21:43:32 +00:00
|
|
|
# list
|
|
|
|
|
sub.add_parser("list", parents=[common], help="List all registered nodes and vitality statuses")
|
|
|
|
|
|
|
|
|
|
args = p.parse_args()
|
|
|
|
|
client = CredClient()
|
|
|
|
|
|
2026-10-04 23:02:04 +00:00
|
|
|
if args.command == "meta-audit":
|
|
|
|
|
res = client.audit_meta(args.node)
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(res, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
print(f"\n=== META ACCOUNTS CENTER AUDIT: {args.node} ===")
|
|
|
|
|
if res.get("error"):
|
|
|
|
|
print(f"Error: {res['error']}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
print(f"Meta Account Email: {res.get('email') or '(none / phone-only)'}")
|
|
|
|
|
profiles = res.get("profiles", [])
|
|
|
|
|
print(f"Linked Profiles ({len(profiles)}):")
|
|
|
|
|
for p_info in profiles:
|
|
|
|
|
print(f" - [{p_info.get('type')}] {p_info.get('name')}")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
2026-10-04 21:43:32 +00:00
|
|
|
if args.command == "link-instagram":
|
|
|
|
|
res = client.link_instagram(args.node, notify=args.notify)
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(res, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
print(f"\n=== INSTAGRAM LINKING: {args.node} ===")
|
|
|
|
|
if res.get("error"):
|
|
|
|
|
print(f"Error: {res['error']}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
print(f"Tailscale Portal: {res['portal_url']}")
|
|
|
|
|
print(f"Direct IP Portal: {res['portal_ip_url']}")
|
|
|
|
|
print(f"OAuth URL: {res['direct_oauth_url'][:80]}...")
|
|
|
|
|
if args.notify:
|
|
|
|
|
print(f"Email Notified: {'YES' if res['email_notified'] else 'FAILED'}")
|
|
|
|
|
print("\nTap either Tailscale link from your phone/browser to complete Meta age verification.")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
if args.command == "initiate":
|
|
|
|
|
res = client.initiate(args.node, args.email, service=args.service, account_name=args.account_name)
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(res, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
st = res.get("status")
|
|
|
|
|
if st == "awaiting_otp":
|
|
|
|
|
print(f"[APPROVAL_NEEDED] Code sent to [redacted] for node '{args.node}'.")
|
|
|
|
|
print(f"Submit OTP with: super cred submit-otp --node {args.node} --otp <code>")
|
|
|
|
|
sys.exit(2)
|
|
|
|
|
elif st == "active":
|
|
|
|
|
print(f"[SUCCESS] Node '{args.node}' is already authenticated and active.")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
else:
|
|
|
|
|
print(f"[{st.upper()}] {res.get('message') or res.get('detail')}")
|
|
|
|
|
sys.exit(res.get("code", 1))
|
|
|
|
|
|
|
|
|
|
elif args.command == "submit-otp":
|
|
|
|
|
res = client.submit_otp(args.node, args.otp, email=args.email)
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(res, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
st = res.get("status")
|
|
|
|
|
if st == "active":
|
|
|
|
|
print(f"[SUCCESS] Node '{args.node}' successfully signed in!")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
else:
|
|
|
|
|
print(f"[{st.upper()}] {res.get('message') or res.get('detail')}")
|
|
|
|
|
sys.exit(res.get("code", 1))
|
|
|
|
|
|
|
|
|
|
elif args.command == "status":
|
|
|
|
|
res = client.status(args.node)
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(res, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
print(f"Node: {res['node']}")
|
|
|
|
|
print(f"Email: {res['email']}")
|
|
|
|
|
print(f"Status: {res['status']}")
|
|
|
|
|
print(f"CDP Port: {res['cdp_port'] or '-'}")
|
|
|
|
|
print(f"Session Alive: {'YES' if res['session_alive'] else 'NO'}")
|
|
|
|
|
if res["detail"]:
|
|
|
|
|
print(f"Detail: {res['detail']}")
|
|
|
|
|
|
|
|
|
|
elif args.command == "list":
|
|
|
|
|
items = client.list_all()
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(items, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
print(f"{'NODE':<10} {'STATUS':<14} {'CDP':<6} {'ALIVE':<7} {'EMAIL'}")
|
|
|
|
|
print("-" * 65)
|
|
|
|
|
for it in items:
|
|
|
|
|
alive_str = "YES" if it["session_alive"] else "NO"
|
|
|
|
|
print(f"{it['node']:<10} {it['status']:<14} {str(it['cdp_port'] or '-'):<6} {alive_str:<7} {it['email']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|