diff --git a/ACCOUNTS.md b/ACCOUNTS.md index 49cb85f..e935864 100644 --- a/ACCOUNTS.md +++ b/ACCOUNTS.md @@ -29,6 +29,8 @@ node name, chrome-box profile, API `--account`, and the agent's display name. | muse | muse | muse | email_otp | ltd.pixels.ltd@gmail.com | ltd.pixels.ltd@gmail.com | no | unknown | active | 104.28.195.181 | 9410 | muse | Main dev agent. Logged in 2026-10-03 via email OTP on bl. | | pip | pip | pip | phone_otp | piparada | - | yes | yes | active | 104.28.195.181 | 9420 | pip | Phone OTP login 2026-10-03. Renamed from 'Muse' to 'pip'. Session lost on browser restart 2026-10-03; needs re-auth. IG avatar visible in account selector. | | 646 | 646 | 646 | phone_otp | Meta Account | - | yes | unknown | active | 104.28.195.181 | 9430 | 646 | Shares phone number with piparada's account. Logged in 2026-10-03 via phone OTP (first Meta Account option). Node created 2026-10-03 (warp-646, CDP 9430). Browser up, session active (verified 2026-10-03). | +| def | def | def | email_otp | defnotabotnet@gmail.com | defnotabotnet@gmail.com | no | yes | active | 104.28.195.181 | 9450 | def | Full onboarding completed 2026-10-04; age verification cleared via Instagram linking (paradahub). Active chat session. | +| opm | opm | opm | email_otp | Nico Parada | yourfriendnico@proton.me | no | unknown | active | 104.28.195.181 | 9440 | opm | Node created 2026-10-03 (warp-opm, CDP 9440). Browser up, session active. | ## Login Type Details diff --git a/CLIENT-ONBOARDING-RUNBOOK.md b/CLIENT-ONBOARDING-RUNBOOK.md new file mode 100644 index 0000000..9122430 --- /dev/null +++ b/CLIENT-ONBOARDING-RUNBOOK.md @@ -0,0 +1,109 @@ +# Client Onboarding & Fleet Runbook (`CLIENT-ONBOARDING-RUNBOOK.md`) + +## 1. Overview & Agency Context +This document defines the complete standard operating procedure (SOP) and automated runbook for provisioning, authenticating, and onboarding client agency profiles (nodes) into the NetVM multi-tenant fleet on `bl`. + +In accordance with the NetVM Ethics Charter (`https://start.muse-dev.online/ethics.html`): +- Managed services are strictly run for consenting clients with explicit authority. +- Every client receives a completely isolated network namespace (`warp-`), dedicated WireGuard tunnel identity, isolated Chrome profile, and separate credentials. +- Canonical Naming Convention: `node == agent == profile == API account`. + +--- + +## 2. Fleet Architecture & Port Allocation + +The fleet uses a deterministic `94x0` CDP port and `warp-` naming convention: + +| Node | CDP Port | Netns | Egress IP | Purpose / Profile | +|------|----------|-------|-----------|-------------------| +| `muse` | `9410` | `warp-muse` | Dedicated WARP | Primary Dev / Orchestrator | +| `pip` | `9420` | `warp-pip` | Dedicated WARP | Production Agent | +| `646` | `9430` | `warp-646` | Dedicated WARP | Production Agent | +| `opm` | `9440` | `warp-opm` | Dedicated WARP | Production Agent | +| `def` | `9450` | `warp-def` | Dedicated WARP | Production Agent | +| `` | `9460+` | `warp-`| Dedicated WARP | Next provisioned client node | + +--- + +## 3. Step-by-Step Client Onboarding SOP + +### Phase 1: Infrastructure Provisioning (Automated) +Run the idempotent node provisioning script to generate the WireGuard identity, network namespace, CDP relay, and chrome-box profile: + +```bash +# Example: Provisioning node 'dev1' +./bin/netvm-provision-node.sh dev1 +``` +*Verification:* +- Namespace created: `ip netns list | grep warp-dev1` +- Registry updated in `NODES.md` and `ACCOUNTS.md`. + +--- + +### Phase 2: Sign-in Initiation (`super cred initiate`) +Launch the client login flow without handling raw passwords or secrets: + +```bash +# For email OTP login: +super cred initiate --node dev1 --email client@domain.com + +# Or via Python Agent API: +python3 bin/cred-client.py initiate --node dev1 --email client@domain.com +``` + +- If already authenticated, exits `0` (`active`). +- If awaiting verification code, exits `2` (`awaiting_otp`). + +--- + +### Phase 3: Submitting Transient OTP (`super cred submit-otp`) +When the client or operator receives the 6-digit email OTP: + +```bash +super cred submit-otp --node dev1 --otp 123456 +``` + +- The code is submitted transiently and is never persisted to disk or logs. +- If the account directly enters chat, status transitions to `active`. +- If the account requires age verification, it advances to Phase 4. + +--- + +### Phase 4: Resolving the Age Verification Gate (`/access/verification`) +When a brand-new or unlinked client profile reaches the Muse age verification gate: + +#### Method A: Instagram Linking (Recommended) +1. Run: + ```bash + super cred link-instagram --node dev1 [--notify] + ``` +2. The system provides a one-tap Tailscale portal URL: + `http://bl.tailfb5960.ts.net:8765/verify/dev1` +3. **Crucial Rule**: The operator or client must link an **established / aged Instagram profile** (not created within minutes). Brand-new Instagram accounts lack mature age signals, causing Meta Accounts Center to disable the Confirm button. +4. If completed via mobile/desktop browser, use an Incognito/Private window to prevent ambient Meta cookie bleed. +5. If executing automated RPA in-browser, inject the Instagram credentials and security code directly into the container's CDP session. + +#### Method B: Credit Card Verification (Fallback) +If Instagram linking is not available, operator can complete the verification using a client payment card on `/access/verification`. + +--- + +### Phase 5: Vitality & Status Monitoring +Query individual or fleet-wide health: + +```bash +# Check single node +super cred status --node dev1 + +# Check entire fleet +super cred list +``` + +--- + +## 4. Rate-Limiting & Operational Safety Rules + +To avoid platform anti-automation challenges and maintain high reputation: +1. **Pacing / Spacing**: Space new node creations and Instagram authorizations by **at least 15–20 minutes** per IP/session. +2. **Namespace Isolation**: Never attempt multi-account auth inside the same browser profile. Always execute inside the client's dedicated `warp-` netns. +3. **No Credential Logging**: Never print plain text passwords or authentication tokens to stdout, git-tracked markdown, or plain text logs. diff --git a/INSTAGRAM-CRED-POOL.md b/INSTAGRAM-CRED-POOL.md new file mode 100644 index 0000000..0a3b259 --- /dev/null +++ b/INSTAGRAM-CRED-POOL.md @@ -0,0 +1,114 @@ +# Instagram Credential Pool Specification (`INSTAGRAM-CRED-POOL.md`) + +## 1. Overview & Agency Context +When onboarding agency client profiles ("nodes") to Muse (`muse.ai`), new accounts and certain unconfirmed email accounts encounter the post-OTP Age Verification gate (`/access/verification`). + +While credit-card verification is not suitable for autonomous multi-tenant operations, **Meta OAuth / Instagram Linking** is the highest-reliability verification route. + +Currently, the system uses a **Human-in-the-Loop** model: +- An operator receives an authorization notification via Tailscale / email. +- The operator signs into Instagram via a one-tap link from their mobile device or laptop. + +This specification details the future architecture for **Automated Instagram Credential Pooling** to remove human intervention entirely while strictly complying with the NetVM Ethics Charter (`https://start.muse-dev.online/ethics.html`). + +--- + +## 2. Architecture & Design Principles + +### 2.1 Isolation & Multi-Tenancy (Ethics Charter Compliant) +- **1-to-1 Node Mapping**: Each client node (`node == agent == profile == API account`) maintains dedicated browser state, WireGuard netns isolation (`warp-`), and separate credentials. +- **Dedicated IG Identities**: Instagram accounts in the pool are provisioned specifically for age-verification linking, never shared concurrently across different active client profiles. +- **Encrypted Secret Storage**: Instagram credentials (username, password, 2FA TOTP secret, session cookies) are stored in an encrypted credential vault (e.g., `age`-encrypted `/etc/netvm/meta-credentials/store.age`), never committed in plain text to git or unencrypted markdown. + +### 2.2 Pool States & Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Available : Provisioned & Verified + Available --> Assigned : Reserved for Node Onboarding + Assigned --> Linking : Navigating Meta OAuth in netns + Linking --> Linked : Age Gate Cleared on Muse + Linking --> Cooloff : Checkpoint / Rate-limit Hit + Cooloff --> Available : Cooldown Elapsed + Linked --> InUse : Node Active in Fleet +``` + +- **`available`**: Account is verified, healthy, and not currently tied to any active Muse profile. +- **`assigned`**: Temporarily reserved by `super cred` for onboarding node ``. +- **`linking`**: Automated driver navigating the Meta Accounts Center flow inside the isolated namespace. +- **`linked`**: Successfully bound to Muse account. +- **`cooloff`**: Encountered challenge or cooldown; resting before re-qualification. + +### 2.3 Meta Account Center Constraints & Edge Cases +- **1-to-1 Linking Constraint**: Meta Accounts Center rejects linking if the target Instagram account is already associated with an existing Meta / Muse profile (`auth_flow=ig_linking` drops to `add_accounts` error page with `token` and `blob` parameters). +- **Brand New Account Age Gate Limitation**: + - Newly created Instagram accounts without a mature age/identity verification tier or age signal trigger Meta Accounts Center to disable the **"Confirm"** action (`aria-disabled="true"` on `/add_accounts/?flow=HATCH_AGE_VERIFICATION_IG_UPSELL`). + - Meta Accounts Center uses Instagram accounts for age verification by checking that the linked Instagram profile itself has established age signals. A freshly minted account created minutes prior lacks this profile history, leaving the age verification unsatisfied. + - **Agency Recommendation**: Pre-aged or verified Instagram identities in the pool with established age badges/profiles, or using established client identities, rather than accounts created in the immediate transaction. +- **Session Bleed & OIDC Secondary Auth Trip (`auth.meta.com`)**: + - When the link is opened in a browser that has existing Meta session cookies (e.g. from Facebook, Oculus, or another Meta account), selecting the new Instagram identity triggers a secondary OpenID Connect reconciliation trip (`https://auth.meta.com/?waterfall_id=...&redirect_uri=auth.meta.com/oidc/...&source_app_id=633385687760560`). + - This prompts the user with **"Log in with your Meta account"** because the browser's ambient Meta session does not match the freshly authenticated Instagram identity. + - If the user confirms with their cached personal Meta credentials, Meta attempts to merge/link across two disparate account graphs, creating an authorization loop or conflict. + - **Resolution**: The link must strictly be opened in an **Incognito / Private window** or a completely clean browser profile with zero cached Meta/Facebook/Instagram cookies. +- **In-Namespace Isolation**: Automated pool linking runs in Chromium directly inside `warp-` with an isolated profile, avoiding cross-session cookie collisions entirely. + +--- + +## 3. Automated Driver Mechanics + +### 3.1 Fetching Authorization Payload +From the node's running browser tab sitting on `/access/verification`: +```javascript +const res = await fetch('/api/hatch/age-confirmation/linking-web-auth?account_type=instagram', { + headers: { 'Accept': 'application/json' } +}).then(r => r.json()); +// res.url: https://www.instagram.com/fxcal/auth/login/?app_id=...&next=... +``` + +### 3.2 Automated Headless Linking Flow +1. Rather than opening a blocked popup, the driver navigates a dedicated worker tab inside the node's namespace (`warp-`) to `res.url`. +2. Inspects form fields: + - Username: `input[name="username"]` + - Password: `input[name="password"]` + - Submit: `button[type="submit"]` +3. If 2FA prompt appears (`input[name="verificationCode"]` or email security code `auth_platform/codeentry`), handles code entry. +4. Handles Meta Accounts Center confirmation button: `"Confirm"`, `"Allow"`, or `"Continue as "`. +5. Upon redirect back to `https://muse.ai/`, checks for DOM chat markers (`"Connected"`, `"Chats"`, or URL `/`). +6. Updates node status in `ACCOUNTS.md` to `active`. + +### 3.3 Singular Email Multi-Client Onboarding via RPA +- **The Concept**: For agency onboarding efficiency, an RPA pipeline can provision and link accounts for multiple consenting client nodes backed by sub-addressing / plus-addressing (e.g., `agency+client_node@domain.com`) or a managed singular operator email inbox. +- **RPA Capabilities**: + - Automatically spins up the Instagram registration flow (submitting username, password, birthdate). + - Listens to the incoming email stream via IMAP / Gmail API / maildrop to ingest the Instagram security code / OTP without human roundtrips. + - Automatically submits the received code into the waiting Instagram code entry screen (`auth_platform/codeentry`). + - Solves any automated challenges/captchas through authorized agency captcha-solving harnesses. + - Passes the linked identity to Meta Accounts Center to clear the Muse age gate in seconds per node. + +--- + +## 4. Pool CLI Surface (`super cred pool`) + +Planned CLI commands to be exposed once implemented: + +```bash +# Check status of the credential pool +super cred pool status + +# Add a provisioned Instagram credential to the encrypted pool +super cred pool add --username --password-file [--totp-secret ] + +# Trigger automated linking for a node in verification status +super cred link-instagram --node --auto + +# Human-in-the-loop manual fallback (current default) +super cred link-instagram --node --human +``` + +--- + +## 5. Security & Risk Mitigations + +1. **Anti-Fingerprinting**: All Meta navigation occurs strictly inside the client's assigned `warp-` network namespace to ensure consistent egress IP and prevent cross-node contamination. +2. **Audit Logging**: Every pool acquisition and release event is recorded with timestamps in `job-log.jsonl` with credentials scrubbed/redacted. +3. **Graceful Human Escalation**: If Meta serves an anti-automation challenge (e.g., CAPTCHA, SMS checkpoint), the automated pool driver immediately falls back to the Human-in-the-Loop Tailscale portal notification. diff --git a/NODES.md b/NODES.md index 820b3d0..3c7c193 100644 --- a/NODES.md +++ b/NODES.md @@ -12,3 +12,4 @@ Unified naming: node == agent == profile == API account. Profiles preserved, sessions persisted (muse). WireGuard identities renamed. | 646 | warp-646 | 104.28.195.181 | 9430 | active | 646 (phone_otp, first Meta Account option) | | opm | warp-opm | 104.28.195.181 | 9440 | active | opm (yourfriendnico@proton.me, email_otp, Nico Parada) | +| def | warp-def | 104.28.195.181 | 9450 | active | (unassigned) | diff --git a/bin/accounts-health.py b/bin/accounts-health.py index abbf93f..87a54bd 100644 --- a/bin/accounts-health.py +++ b/bin/accounts-health.py @@ -68,8 +68,12 @@ try: ws.close() dom = json.loads(resp["result"]["result"]["value"]) # Heuristic: login buttons present + no avatar => logged out. + # access/verification gate => needs verification. # No login buttons (or avatar present) => likely logged in. - if dom["loginButtons"] and not dom["hasAvatar"]: + if "access/verification" in dom["url"]: + result["session_alive"] = False + result["detail"] = "age verification gate (access/verification)" + elif dom["loginButtons"] and not dom["hasAvatar"]: result["session_alive"] = False result["detail"] = f"login wall visible: {dom['loginButtons'][:2]}" else: diff --git a/bin/cred-client.py b/bin/cred-client.py new file mode 100755 index 0000000..6cab12d --- /dev/null +++ b/bin/cred-client.py @@ -0,0 +1,378 @@ +#!/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 --email [--service muse] [--account-name ] + cred-client.py submit-otp --node --otp [--email ] + cred-client.py status --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 \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 + } + + +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") + + # list + sub.add_parser("list", parents=[common], help="List all registered nodes and vitality statuses") + + args = p.parse_args() + client = CredClient() + + 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 ") + 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() diff --git a/bin/cred_client.py b/bin/cred_client.py new file mode 120000 index 0000000..afca5b7 --- /dev/null +++ b/bin/cred_client.py @@ -0,0 +1 @@ +cred-client.py \ No newline at end of file diff --git a/bin/muse-signin.py b/bin/muse-signin.py index 2980d30..d307d6c 100755 --- a/bin/muse-signin.py +++ b/bin/muse-signin.py @@ -95,50 +95,71 @@ def main(): page = get_page(port) ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=15) - # Step 1: Check if already logged in + # Step 1: Check if already logged in or blocked at verification gate title = ev(ws, "document.title") - body = ev(ws, "document.body.innerText.slice(0,200)") - if "Connected" in body or "Chats" in body: + body = ev(ws, "document.body.innerText.slice(0,500)") + url = ev(ws, "location.href") or "" + if "access/verification" in url or "confirm your age" in body.lower(): + ig_link = ev(ws, """(async()=>{ + try { + const r = await fetch('/api/hatch/age-confirmation/linking-web-auth?account_type=instagram', { + headers: {'Accept': 'application/json'} + }); + const j = await r.json(); + return j && j.url ? j.url : null; + } catch(e) { return null; } + })()""", True) + if ig_link: + print(f"LINK_INSTAGRAM_URL: {ig_link}") + print(f"NEEDS_HUMAN: Age verification required for {args.email} at {url}. Client intervention required to confirm age or link Instagram/Facebook.", file=sys.stderr) + ws.close() + sys.exit(3) + + if ("Connected" in body or "Chats" in body) and "Enter your code" not in body and "access/verification" not in url: print(f"Already logged in (title: {title})") ws.close() return 0 - # Step 2: Click Log in (if on landing page) - if "Log in" in body: - print("Clicking Log in...") + already_on_otp = ("Enter your code" in body or "code we sent" in body) and args.email in body + if not already_on_otp: + # Step 2: Click Log in (if on landing page) + if "Log in" in body: + print("Clicking Log in...") + ev(ws, """(async()=>{ + const b=[...document.querySelectorAll('button')].find(x=>x.innerText.includes('Log in')); + if(b) b.click(); return !!b; + })()""", True) + time.sleep(3) + + # Step 3: Enter email + print("Entering email: [redacted]") + result = ev(ws, f"""(async()=>{{ + const inp=[...document.querySelectorAll('input')].find(i=> + (i.placeholder&&i.placeholder.toLowerCase().includes('email'))|| + (i.getAttribute('aria-label')&&i.getAttribute('aria-label').toLowerCase().includes('email')) + ); + if(!inp) return 'NOINPUT'; + inp.focus(); + document.execCommand('insertText',false,'{args.email}'); + await new Promise(r=>setTimeout(r,500)); + return 'entered:'+inp.value; + }})()""", True) + print("Email: [redacted]") + if result == 'NOINPUT': + print("ERROR: Email input not found", file=sys.stderr) + ws.close() + sys.exit(1) + time.sleep(1) + + # Step 4: Click Continue + print("Clicking Continue...") ev(ws, """(async()=>{ - const b=[...document.querySelectorAll('button')].find(x=>x.innerText.includes('Log in')); + const b=[...document.querySelectorAll('button')].find(x=>x.innerText.includes('Continue')); if(b) b.click(); return !!b; })()""", True) - time.sleep(3) - - # Step 3: Enter email - print("Entering email: [redacted]") - result = ev(ws, f"""(async()=>{{ - const inp=[...document.querySelectorAll('input')].find(i=> - (i.placeholder&&i.placeholder.toLowerCase().includes('email'))|| - (i.getAttribute('aria-label')&&i.getAttribute('aria-label').toLowerCase().includes('email')) - ); - if(!inp) return 'NOINPUT'; - inp.focus(); - document.execCommand('insertText',false,'{args.email}'); - await new Promise(r=>setTimeout(r,500)); - return 'entered:'+inp.value; - }})()""", True) - print("Email: [redacted]") - if result == 'NOINPUT': - print("ERROR: Email input not found", file=sys.stderr) - ws.close() - sys.exit(1) - time.sleep(1) - - # Step 4: Click Continue - print("Clicking Continue...") - ev(ws, """(async()=>{ - const b=[...document.querySelectorAll('button')].find(x=>x.innerText.includes('Continue')); - if(b) b.click(); return !!b; - })()""", True) - time.sleep(4) + time.sleep(4) + else: + print("Page is already on OTP prompt for this email, skipping email entry.") # Step 5: Check for OTP prompt body = ev(ws, "document.body.innerText.slice(0,500)") @@ -176,7 +197,26 @@ def main(): # Verify login body = ev(ws, "document.body.innerText.slice(0,500)") title = ev(ws, "document.title") or "" - if "Connected" in body or "Chats" in body or "Muse" in title or "Chat" in title: + url = ev(ws, "location.href") or "" + + # Check for post-login gates requiring human/client intervention + if "access/verification" in url or "confirm your age" in body.lower(): + ig_link = ev(ws, """(async()=>{ + try { + const r = await fetch('/api/hatch/age-confirmation/linking-web-auth?account_type=instagram', { + headers: {'Accept': 'application/json'} + }); + const j = await r.json(); + return j && j.url ? j.url : null; + } catch(e) { return null; } + })()""", True) + if ig_link: + print(f"LINK_INSTAGRAM_URL: {ig_link}") + print(f"NEEDS_HUMAN: Age verification required for {args.email} at {url}. Client intervention required to confirm age or link Instagram/Facebook.", file=sys.stderr) + ws.close() + sys.exit(3) + + if "Connected" in body or "Chats" in body or (("Muse" in title or "Chat" in title) and "access/verification" not in url): print(f"SUCCESS: Logged in as [redacted] (title: {title})") ws.close() return 0 diff --git a/bin/super-cli.py b/bin/super-cli.py index 167ba49..a5cba79 100755 --- a/bin/super-cli.py +++ b/bin/super-cli.py @@ -1878,11 +1878,11 @@ def cmd_cred_initiate(args): else: st = res.get("status") if st == "awaiting_otp": - print("\n" + c_warn(f"[APPROVAL_NEEDED] Code sent to {args.email} for node '{args.node}'.") + "\n") + print("\n" + c_yellow(f"[APPROVAL_NEEDED] Code sent to {args.email} for node '{args.node}'.") + "\n") print(f" Submit OTP with: super cred submit-otp --node {args.node} --otp \n") sys.exit(2) elif st == "active": - print("\n" + c_ok(f"[SUCCESS] Node '{args.node}' is already authenticated and active.") + "\n") + print("\n" + c_green(f"[SUCCESS] Node '{args.node}' is already authenticated and active.") + "\n") else: print(f"[{st.upper()}] {res.get('message') or res.get('detail')}") sys.exit(res.get("code", 1)) @@ -1896,7 +1896,7 @@ def cmd_cred_submit_otp(args): else: st = res.get("status") if st == "active": - print("\n" + c_ok(f"[SUCCESS] Node '{args.node}' successfully signed in!") + "\n") + print("\n" + c_green(f"[SUCCESS] Node '{args.node}' successfully signed in!") + "\n") else: print(f"[{st.upper()}] {res.get('message') or res.get('detail')}") sys.exit(res.get("code", 1)) @@ -1919,6 +1919,26 @@ def cmd_cred_status(args): print(f" Detail : {c_dim(res['detail'])}") print() +def cmd_cred_link_instagram(args): + import cred_client + client = cred_client.CredClient() + res = client.link_instagram(args.node, notify=getattr(args, "notify", False)) + if getattr(args, "json", False): + print(json.dumps(res, indent=2)) + else: + print("\n" + c_bold(f"=== INSTAGRAM LINKING: {args.node} ===") + "\n") + if res.get("error"): + print(c_err(f" Error: {res['error']}")) + sys.exit(1) + print(f" Tailscale Portal: {c_cyan(res['portal_url'])}") + print(f" Direct IP Portal: {c_cyan(res['portal_ip_url'])}") + oauth_preview = res['direct_oauth_url'][:75] + "..." if res.get('direct_oauth_url') else "-" + print(f" OAuth URL : {c_dim(oauth_preview)}") + if getattr(args, "notify", False): + n_badge = badge_ok("SENT") if res['email_notified'] else badge_err("FAILED") + print(f" Email Alert : {n_badge}") + print("\n" + c_yellow(" → Tap either Tailscale link from your phone/browser to complete Meta age verification.") + "\n") + def cmd_cred_list(args): import cred_client client = cred_client.CredClient() @@ -3082,6 +3102,10 @@ def build_parser(): p_c_stat = cred_sub.add_parser("status", parents=[common], help="Query onboarding & vitality status for a node") p_c_stat.add_argument("--node", required=True, help="Node label") + p_c_link = cred_sub.add_parser("link-instagram", parents=[common], help="Generate Tailscale one-tap portal & OAuth link for age verification") + p_c_link.add_argument("--node", required=True, help="Node label") + p_c_link.add_argument("--notify", action="store_true", help="Send email alert to operator via local MTA") + cred_sub.add_parser("list", parents=[common], help="List all registered nodes and vitality statuses") # Domain: HARVEST @@ -3356,6 +3380,8 @@ def main(): cmd_cred_submit_otp(args) elif act == "status": cmd_cred_status(args) + elif act == "link-instagram": + cmd_cred_link_instagram(args) else: parser.print_help() elif args.domain == "harvest": diff --git a/bin/tailscale-verify-portal.py b/bin/tailscale-verify-portal.py new file mode 100755 index 0000000..3bec684 --- /dev/null +++ b/bin/tailscale-verify-portal.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +tailscale-verify-portal.py: Tailscale verification portal for Human-in-the-Loop age verification. + +Serves on Tailscale IP (100.123.153.75:8765) and/or localhost. +Endpoints: + GET /status - JSON status of nodes awaiting verification + GET /verify/ - Generates fresh Instagram OAuth linking URL from the node's browser and 302 redirects + GET /check/ - Polls node to see if age gate has cleared into active chat session +""" + +import http.server +import json +import os +import re +import socketserver +import subprocess +import sys +import time +import urllib.parse + +PORT = 8765 +BIND_HOST = "0.0.0.0" +NETVM_DIR = "/home/super/Projects/NetVM" + +def get_tailscale_ip(): + try: + out = subprocess.check_output(["tailscale", "ip", "-4"], text=True).strip().splitlines() + for line in out: + line = line.strip() + if re.match(r"^\d+\.\d+\.\d+\.\d+$", line): + return line + except Exception: + pass + return "100.123.153.75" + +def get_tailscale_dns(): + try: + out = subprocess.check_output(["tailscale", "status", "--json"], text=True) + data = json.loads(out) + self_dns = data.get("Self", {}).get("DNSName") + if self_dns: + return self_dns.rstrip(".") + except Exception: + pass + return "bl.tailfb5960.ts.net" + +def fetch_node_ig_link(node): + """Query CDP within node netns to get fresh Meta Accounts Center OAuth URL.""" + script = """ +import json, websocket, sys, urllib.request + +try: + tabs = json.loads(urllib.request.urlopen("http://127.0.0.1:9450/json").read()) +except Exception as e: + print(json.dumps({"error": f"CDP unreachable: {e}"})) + sys.exit(0) + +muse_tab = next((t for t in tabs if "muse.ai" in t.get("url", "") and t.get("type") == "page"), None) +if not muse_tab: + print(json.dumps({"error": "No muse.ai tab open"})) + sys.exit(0) + +try: + ws = websocket.create_connection(muse_tab["webSocketDebuggerUrl"], timeout=5) + expr = '''(async()=>{ + try { + const r = await fetch('/api/hatch/age-confirmation/linking-web-auth?account_type=instagram', { + headers: {'Accept': 'application/json'} + }); + return await r.json(); + } catch(e) { return {error: String(e)}; } + })()''' + ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expr, "awaitPromise": True, "returnByValue": True}})) + while True: + msg = json.loads(ws.recv()) + if msg.get("id") == 1: + val = msg.get("result", {}).get("result", {}).get("value", {}) + print(json.dumps(val)) + break + ws.close() +except Exception as e: + print(json.dumps({"error": f"CDP evaluate failed: {e}"})) +""" + cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, "-c", script] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + lines = res.stdout.strip().splitlines() + if lines: + data = json.loads(lines[-1]) + return data + except Exception as e: + return {"error": str(e)} + return {"error": "No response from node"} + +def check_node_cleared(node): + """Check if node has transitioned past access/verification into active chat.""" + checker = os.path.join(NETVM_DIR, "bin", "accounts-health.py") + # Lookup port from registry or default to 9450 for def + port = 9450 + cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, checker, str(port)] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + lines = res.stdout.strip().splitlines() + if lines: + data = json.loads(lines[-1]) + return data + except Exception as e: + return {"error": str(e)} + return {"error": "No response from checker"} + +class VerifyHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + parts = [p for p in parsed.path.split("/") if p] + + if not parts or parts[0] == "": + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + html = """ + +NetVM Operator Portal + + + +

NetVM Client Verification Portal

+

Nodes pending verification:

+

Tap to Link Instagram for Node 'def'

+""" + self.wfile.write(html.encode("utf-8")) + return + + if parts[0] == "verify" and len(parts) >= 2: + node = parts[1] + data = fetch_node_ig_link(node) + url = data.get("url") + if url: + # 302 redirect directly to Instagram OAuth login + self.send_response(302) + self.send_header("Location", url) + self.end_headers() + return + else: + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "Failed to get Instagram link", "detail": data}).encode("utf-8")) + return + + if parts[0] == "check" and len(parts) >= 2: + node = parts[1] + st = check_node_cleared(node) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(st, indent=2).encode("utf-8")) + return + + if parts[0] == "status": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"portal": "online", "ts_ip": get_tailscale_ip(), "ts_dns": get_tailscale_dns()}).encode("utf-8")) + return + + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + # Concise logging + sys.stderr.write(f"[PORTAL] {self.address_string()} - {format % args}\n") + +def run(): + with socketserver.TCPServer((BIND_HOST, PORT), VerifyHandler) as httpd: + print(f"Tailscale Verification Portal serving on http://{get_tailscale_ip()}:{PORT}/") + print(f"Tailscale DNS: http://{get_tailscale_dns()}:{PORT}/") + sys.stdout.flush() + httpd.serve_forever() + +if __name__ == "__main__": + run()