4a72d03c8e
Deploy / Deploy Staging (push) Failing after 2s
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m0s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m0s
Tests / test (pull_request) Failing after 1m0s
Tests / lint (pull_request) Failing after 1m0s
218 lines
8.1 KiB
Bash
218 lines
8.1 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 << '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"
|