#!/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 << 'SERVEREOF' #!/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(): global AUTH_TOKEN installed_flag = "/etc/xiaoxia-cmd-agent.installed" if not os.path.exists(installed_flag): with open("/etc/xiaoxia-cmd-agent.token","w") as f: f.write(AUTH_TOKEN) os.chmod("/etc/xiaoxia-cmd-agent.token", 0o600) svc = "[Unit]\nDescription=Xiaoxia Command Agent\nAfter=network.target\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /opt/xiaoxia-cmd-agent/server.py\nRestart=always\nRestartSec=5\n[Install]\nWantedBy=multi-user.target\n" 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") with open(installed_flag,"w") as f: f.write("1") print(f"Service installed. TOKEN: {AUTH_TOKEN}") else: with open("/etc/xiaoxia-cmd-agent.token") as f: AUTH_TOKEN = f.read().strip() print(f"Using existing token: {AUTH_TOKEN}") server = http.server.HTTPServer(("127.0.0.1", LISTEN_PORT), Handler) print(f"Listening: 127.0.0.1:{LISTEN_PORT}") server.serve_forever() if __name__ == "__main__": main() SERVEREOF echo "[3/5] Starting agent service..." python3 /opt/xiaoxia-cmd-agent/server.py & sleep 2 TOKEN=$(cat /etc/xiaoxia-cmd-agent.token) echo "TOKEN: $TOKEN" echo "[4/5] Configuring Nginx reverse proxy..." # Add /cmd-agent/ location to existing 443 server block python3 -c " import os conf_path = '/etc/nginx/sites-enabled/git-xiaoxia.conf' if not os.path.exists(conf_path): # Try default config for d in ['/etc/nginx/conf.d/', '/etc/nginx/sites-enabled/']: for f in os.listdir(d): if f.endswith('.conf'): conf_path = os.path.join(d, f) break with open(conf_path) as f: content = f.read() if '/cmd-agent/' not in content: location_block = """ location /cmd-agent/ { proxy_pass http://127.0.0.1:18888/; proxy_http_version 1.1; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; proxy_read_timeout 120s; } """ content = content.replace(' location / {', location_block + '\n location / {') with open(conf_path, 'w') as f: f.write(content) print('Nginx config updated') else: print('Already configured') " nginx -t && systemctl reload nginx echo "[5/5] Testing..." curl -s -H "Authorization: $TOKEN" http://127.0.0.1:18888/status echo "" echo "DONE. Token: $TOKEN"