Files
xiaoxia-saas/scripts/deploy_cmd_agent.sh
T

215 lines
8.6 KiB
Bash

#!/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"