215 lines
8.6 KiB
Bash
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"
|