diff --git a/scripts/deploy_cmd_agent.sh b/scripts/deploy_cmd_agent.sh new file mode 100644 index 000000000..430bb5ef1 --- /dev/null +++ b/scripts/deploy_cmd_agent.sh @@ -0,0 +1,217 @@ +#!/bin/bash +set -e + +echo "[1/5] Creating agent directory..." +mkdir -p /opt/xiaoxia-cmd-agent + +echo "[2/5] Writing server.py..." +cat > /opt/xiaoxia-cmd-agent/server.py << 'PYEOF' +#!/usr/bin/env python3 +import http.server, subprocess, json, os, sys, secrets +from urllib.parse import urlparse, parse_qs + +LISTEN_PORT = 18888 +AUTH_TOKEN = "xsa-" + secrets.token_hex(16) +LOG_FILE = "/var/log/xiaoxia-cmd-agent.log" + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + try: + with open(LOG_FILE, "a") as f: + f.write("%s - %s\n" % (self.log_date_time_string(), fmt % args)) + except: pass + + def check_auth(self): + t = self.headers.get("Authorization", "") + if t != AUTH_TOKEN: + self._j({"error": "unauthorized"}, 401); return False + return True + + def _j(self, data, code=200): + body = json.dumps(data, ensure_ascii=False).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if not self.check_auth(): return + if self.path.startswith("/download"): + self._download(); return + if self.path == "/status": + self._j({"status":"ok","hostname":os.uname().nodename,"port":LISTEN_PORT}) + else: + self._j({"error":"use /status or /download?path=xxx"}, 404) + + def do_POST(self): + if not self.check_auth(): return + try: length = int(self.headers.get("Content-Length", 0)) + except: self._j({"error":"bad content-length"},400); return + if self.path == "/exec": self._exec(length) + elif self.path == "/list": self._list(length) + elif self.path == "/upload": self._upload(length) + elif self.path == "/mkdir": self._mkdir(length) + else: self._j({"error":"unknown"}, 404) + + def do_DELETE(self): + if not self.check_auth(): return + try: length = int(self.headers.get("Content-Length", 0)) + except: self._j({"error":"bad"},400); return + body = json.loads(self.rfile.read(length)) if length > 0 else {} + fp = body.get("path","") + if not fp: self._j({"error":"path required"},400); return + try: + import shutil + if os.path.isdir(fp): shutil.rmtree(fp) + else: os.remove(fp) + self._j({"ok":True,"deleted":fp}) + except Exception as e: self._j({"error":str(e)},500) + + def _exec(self, length): + try: data = json.loads(self.rfile.read(length)) if length>0 else {} + except: self._j({"error":"invalid json"},400); return + cmd = data.get("command","") + timeout = min(data.get("timeout",30), 120) + if not cmd: self._j({"error":"command required"},400); return + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + self._j({"exit_code":r.returncode,"stdout":r.stdout[-100000:] if r.stdout else "","stderr":r.stderr[-10000:] if r.stderr else ""}) + except subprocess.TimeoutExpired: self._j({"error":f"timeout {timeout}s"},408) + except Exception as e: self._j({"error":str(e)},500) + + def _list(self, length): + try: data = json.loads(self.rfile.read(length)) if length>0 else {} + except: self._j({"error":"invalid json"},400); return + path = data.get("path",".") + try: + entries = [] + for name in sorted(os.listdir(path)): + fp2 = os.path.join(path, name) + try: + st = os.stat(fp2) + entries.append({"name":name,"is_dir":os.path.isdir(fp2),"size":st.st_size,"mtime":st.st_mtime}) + except: entries.append({"name":name,"is_dir":False,"size":0,"mtime":0}) + self._j({"path":path,"entries":entries}) + except Exception as e: self._j({"error":str(e)},500) + + def _upload(self, length): + ct = self.headers.get("Content-Type","") + if "multipart/form-data" not in ct: + self._j({"error":"multipart required"},400); return + try: + boundary = ct.split("boundary=")[1].strip() + body = self.rfile.read(length) + parts = body.split(b"--" + boundary.encode()) + fname = fdata = None + for part in parts: + if b"filename=" in part: + h, _, content = part.partition(b"\r\n\r\n") + for line in h.decode(errors="replace").split("\r\n"): + if 'filename="' in line: fname = line.split('filename="')[1].split('"')[0] + fdata = content.rstrip(b"\r\n") + if fname is None or fdata is None: + self._j({"error":"no file"},400); return + dest_dir = "/tmp" + dest = os.path.join(dest_dir, fname) + os.makedirs(dest_dir, exist_ok=True) + with open(dest,"wb") as f: f.write(fdata) + self._j({"ok":True,"path":dest,"size":len(fdata)}) + except Exception as e: self._j({"error":str(e)},500) + + def _mkdir(self, length): + try: data = json.loads(self.rfile.read(length)) if length>0 else {} + except: self._j({"error":"invalid json"},400); return + path = data.get("path","") + if not path: self._j({"error":"path required"},400); return + try: os.makedirs(path, exist_ok=True); self._j({"ok":True,"created":path}) + except Exception as e: self._j({"error":str(e)},500) + + def _download(self): + if not self.check_auth(): return + qs = parse_qs(urlparse(self.path).query) + fp = qs.get("path",[""])[0] + if not fp or not os.path.isfile(fp): + self._j({"error":"not found"},404); return + try: + with open(fp,"rb") as f: data = f.read() + self.send_response(200) + self.send_header("Content-Type","application/octet-stream") + self.send_header("Content-Length",str(len(data))) + self.end_headers() + self.wfile.write(data) + except Exception as e: self._j({"error":str(e)},500) + +def main(): + server = http.server.HTTPServer(("127.0.0.1", LISTEN_PORT), Handler) + with open("/etc/xiaoxia-cmd-agent.token","w") as f: f.write(AUTH_TOKEN) + os.chmod("/etc/xiaoxia-cmd-agent.token", 0o600) + svc = """[Unit] +Description=Xiaoxia Command Agent +After=network.target +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/xiaoxia-cmd-agent/server.py +Restart=always +RestartSec=5 +[Install] +WantedBy=multi-user.target +""" + with open("/etc/systemd/system/xiaoxia-cmd-agent.service","w") as f: f.write(svc) + os.system("systemctl daemon-reload && systemctl enable xiaoxia-cmd-agent && systemctl restart xiaoxia-cmd-agent") + print(f"TOKEN: {AUTH_TOKEN}") + print(f"Listening: 127.0.0.1:{LISTEN_PORT}") + print(f"Service started") + +if __name__ == "__main__": + main() +PYEOF + +echo "[3/5] Starting agent service..." +cd /opt/xiaoxia-cmd-agent +python3 server.py & +sleep 2 +TOKEN=$(cat /etc/xiaoxia-cmd-agent.token) +echo "Agent running. Token: $TOKEN" + +echo "[4/5] Configuring Nginx reverse proxy..." +mkdir -p /etc/nginx/ssl +openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/nginx/ssl/cmd-agent.key \ + -out /etc/nginx/ssl/cmd-agent.crt \ + -subj "/CN=cmd-agent" 2>/dev/null + +# Check nginx sites-available structure +if [ -d /etc/nginx/sites-available ]; then + CONF=/etc/nginx/sites-available/cmd-agent + LINK=/etc/nginx/sites-enabled/cmd-agent +else + CONF=/etc/nginx/conf.d/cmd-agent.conf + LINK="" +fi + +cat > "$CONF" << NGINXEOF +server { + listen 8443 ssl; + server_name _; + ssl_certificate /etc/nginx/ssl/cmd-agent.crt; + ssl_certificate_key /etc/nginx/ssl/cmd-agent.key; + location / { + proxy_pass http://127.0.0.1:18888; + proxy_set_header Host \$host; + proxy_read_timeout 120s; + client_max_body_size 50M; + } +} +NGINXEOF + +[ -n "$LINK" ] && ln -sf "$CONF" "$LINK" +nginx -t 2>&1 && systemctl reload nginx + +echo "[5/5] Testing..." +curl -s -H "Authorization: $TOKEN" http://127.0.0.1:18888/status +echo "" +echo "=== DEPLOY COMPLETE ===" +echo "Token: $TOKEN" +echo "Local: http://127.0.0.1:18888" +echo "Nginx: https://127.0.0.1:8443"