592 lines
18 KiB
Python
Executable File
592 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
from collections import deque
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
import psutil
|
|
import uvicorn
|
|
from fastapi import Depends, FastAPI, HTTPException, Response
|
|
from fastapi.responses import PlainTextResponse, RedirectResponse
|
|
from fastapi.security.api_key import APIKeyHeader
|
|
from pydantic import BaseModel
|
|
|
|
# ---------------------- Constants ----------------------
|
|
DB_PATH = "/4server/data/contracts.db"
|
|
BIN_PATH = "/4server/sbin"
|
|
API_KEY = os.getenv("API_KEY", "your-secret-api-key")
|
|
VERSION = "API: 0.0.9"
|
|
|
|
# ---------------------- FastAPI App ----------------------
|
|
app = FastAPI()
|
|
api_key_header = APIKeyHeader(name="X-API-Key")
|
|
|
|
|
|
def verify_api_key(key: str = Depends(api_key_header)):
|
|
if key != API_KEY:
|
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
|
|
|
|
|
# ---------------------- Helpers ----------------------
|
|
def run_command(cmd: list[str]) -> str:
|
|
"""Run a shell command and return stdout or raise HTTPException on error."""
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
error_msg = (
|
|
f"Command failed\n"
|
|
f"Command: {' '.join(cmd)}\n"
|
|
f"Return code: {result.returncode}\n"
|
|
f"Stdout: {result.stdout.strip()}\n"
|
|
f"Stderr: {result.stderr.strip() or 'None'}"
|
|
)
|
|
raise HTTPException(status_code=500, detail=error_msg)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def init_db():
|
|
"""Initialize the database with containers table."""
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS containers (
|
|
ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
UUID CHAR(50) UNIQUE,
|
|
email CHAR(100),
|
|
expires DATE,
|
|
tags TEXT,
|
|
env TEXT,
|
|
affiliate CHAR(30),
|
|
image CHAR(50),
|
|
history TEXT,
|
|
comment TEXT,
|
|
domains TEXT,
|
|
status CHAR(20),
|
|
created DATE,
|
|
bump DATE,
|
|
secret TEXT,
|
|
contract TEXT,
|
|
git integer,
|
|
backup_slots integer,
|
|
hdd integer,
|
|
workers integer,
|
|
utm_source text,
|
|
utm_medium text,
|
|
utm_campaign text,
|
|
domains_slots integer,
|
|
secret_list TEXT,
|
|
env_list TEXT
|
|
)
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def execute_db(query: str, params: tuple = (), fetch: bool = False):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
cursor.execute(query, params)
|
|
conn.commit()
|
|
data = cursor.fetchall() if fetch else None
|
|
conn.close()
|
|
if data and fetch:
|
|
return [dict(row) for row in data]
|
|
return None
|
|
|
|
|
|
# ---------------------- Models ----------------------
|
|
class ContractItem(BaseModel):
|
|
quantity: int
|
|
name: str
|
|
product_id: int
|
|
features: Dict[str, Any]
|
|
|
|
|
|
class ContainerModel(BaseModel):
|
|
UUID: str
|
|
email: Optional[str] = None
|
|
expires: Optional[str] = None
|
|
tags: Optional[str] = None
|
|
image: Optional[str] = None
|
|
history: Optional[str] = None
|
|
comment: Optional[str] = None
|
|
domains: Optional[str] = None
|
|
status: Optional[str] = None
|
|
created: Optional[str] = None
|
|
bump: Optional[str] = None
|
|
git: Optional[int] = None
|
|
backup_slots: Optional[int] = None
|
|
hdd: Optional[int] = None
|
|
workers: Optional[int] = None
|
|
utm_source: Optional[str] = None
|
|
utm_medium: Optional[str] = None
|
|
utm_campaign: Optional[str] = None
|
|
domains_slots: Optional[int] = None
|
|
<<<<<<< HEAD
|
|
secret_list: Optional[str] = None
|
|
=======
|
|
>>>>>>> c583649 (hmm)
|
|
env_list: Optional[str] = None
|
|
|
|
|
|
class UUIDRequest(BaseModel):
|
|
UUID: str
|
|
|
|
|
|
class CommandRequest(BaseModel):
|
|
uuid: str
|
|
method: int
|
|
|
|
|
|
class ImportRequest(BaseModel):
|
|
filename: str
|
|
|
|
|
|
class MoveRequest(BaseModel):
|
|
source: str
|
|
destination: str
|
|
|
|
|
|
class AddKeyRequest(BaseModel):
|
|
key: str
|
|
uuid: str
|
|
|
|
|
|
class ImageRequest(BaseModel):
|
|
image: str
|
|
|
|
|
|
# ---------------------- Routes ----------------------
|
|
@app.get("/", include_in_schema=False)
|
|
def redirect_to_odoo():
|
|
return RedirectResponse(url="https://ODOO4PROJECTS.com")
|
|
|
|
|
|
import json
|
|
from typing import Any, Dict, Optional
|
|
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
# ---------------------- Routes ----------------------
|
|
@app.get("/", include_in_schema=False)
|
|
def redirect_to_odoo():
|
|
return RedirectResponse(url="https://ODOO4PROJECTS.com")
|
|
|
|
|
|
@app.post("/container/update", dependencies=[Depends(verify_api_key)])
|
|
def update_container(request: ContainerModel):
|
|
# Fetch existing record
|
|
existing = execute_db(
|
|
"SELECT * FROM containers WHERE UUID = ?", (request.UUID,), fetch=True
|
|
)
|
|
if not existing:
|
|
# If record does not exist, insert a new one with all given fields
|
|
execute_db(
|
|
"""
|
|
INSERT INTO containers (UUID, email, expires, tags, image, history,
|
|
comment, domains, status, created, bump, git, backup_slots, hdd, workers,
|
|
<<<<<<< HEAD
|
|
utm_source, utm_medium, utm_campaign, domains_slots, secret_list, env_list)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
=======
|
|
utm_source, utm_medium, utm_campaign, domains_slots, env_list)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
>>>>>>> c583649 (hmm)
|
|
""",
|
|
(
|
|
request.UUID,
|
|
request.email,
|
|
request.expires,
|
|
request.tags,
|
|
request.image,
|
|
request.history,
|
|
request.comment,
|
|
request.domains,
|
|
request.status,
|
|
request.created,
|
|
request.bump,
|
|
request.git,
|
|
request.backup_slots,
|
|
request.hdd,
|
|
request.workers,
|
|
request.utm_source,
|
|
request.utm_medium,
|
|
request.utm_campaign,
|
|
request.domains_slots,
|
|
<<<<<<< HEAD
|
|
request.secret_list,
|
|
=======
|
|
>>>>>>> c583649 (hmm)
|
|
request.env_list,
|
|
),
|
|
)
|
|
return {"UUID": request.UUID, "status": "created"}
|
|
|
|
# Existing record found, do partial update
|
|
existing = existing[0] # Assuming fetch returns list of dicts
|
|
updates = {}
|
|
params = []
|
|
|
|
# Only add fields that are not None in the request
|
|
for field in ContainerModel.__fields__:
|
|
if field == "UUID":
|
|
continue
|
|
value = getattr(request, field)
|
|
if value is not None:
|
|
updates[field] = value
|
|
params.append(value)
|
|
|
|
if updates:
|
|
# Build SQL dynamically
|
|
set_clause = ", ".join(f"{k}=?" for k in updates.keys())
|
|
params.append(request.UUID) # UUID for WHERE clause
|
|
execute_db(f"UPDATE containers SET {set_clause} WHERE UUID=?", tuple(params))
|
|
return {
|
|
"UUID": request.UUID,
|
|
"status": "updated",
|
|
"fields_updated": list(updates.keys()),
|
|
}
|
|
|
|
return {"UUID": request.UUID, "status": "no_change"}
|
|
|
|
|
|
@app.post("/container/start", dependencies=[Depends(verify_api_key)])
|
|
def start_container(request: UUIDRequest):
|
|
return {"message": run_command([f"{BIN_PATH}/startContainer", request.UUID])}
|
|
|
|
|
|
@app.post("/container/restartAllContainers", dependencies=[Depends(verify_api_key)])
|
|
def start_container(request: ImageRequest):
|
|
return {"message": run_command([f"{BIN_PATH}/restartContainers", request.image])}
|
|
|
|
|
|
@app.post("/container/stop", dependencies=[Depends(verify_api_key)])
|
|
def stop_container(request: UUIDRequest):
|
|
try:
|
|
return {"message": run_command([f"{BIN_PATH}/stopContainer", request.UUID])}
|
|
except HTTPException:
|
|
# Command failed, but continue without error to the API
|
|
return {"message": "Container stop command executed (may have failed)"}
|
|
|
|
|
|
@app.post("/container/nuke", dependencies=[Depends(verify_api_key)])
|
|
def nuke_container(request: UUIDRequest):
|
|
status = execute_db(
|
|
"SELECT status FROM containers WHERE UUID=?", (request.UUID,), fetch=True
|
|
)
|
|
if not status or status[0]["status"] != "nuke":
|
|
raise HTTPException(400, "Container status is not 'nuke'")
|
|
return {"message": run_command([f"{BIN_PATH}/nukeContainer", request.UUID])}
|
|
|
|
|
|
@app.post("/container/info", dependencies=[Depends(verify_api_key)])
|
|
def info_container(request: Optional[UUIDRequest] = None):
|
|
# Fields to select
|
|
fields = [
|
|
"ID",
|
|
"UUID",
|
|
"email",
|
|
"expires",
|
|
"tags",
|
|
"image",
|
|
"history",
|
|
"comment",
|
|
"domains",
|
|
"status",
|
|
"created",
|
|
"git",
|
|
"backup_slots",
|
|
"hdd",
|
|
"workers",
|
|
"utm_source",
|
|
"utm_medium",
|
|
"utm_campaign",
|
|
"domains_slots",
|
|
"env_list",
|
|
]
|
|
field_str = ", ".join(fields)
|
|
|
|
# Execute query
|
|
if request:
|
|
rows = execute_db(
|
|
f"SELECT {field_str} FROM containers WHERE UUID=?",
|
|
(str(request.UUID),),
|
|
fetch=True,
|
|
)
|
|
else:
|
|
rows = execute_db(f"SELECT {field_str} FROM containers", fetch=True)
|
|
|
|
# Map rows to dicts safely
|
|
containers = []
|
|
for row in rows:
|
|
if isinstance(row, dict):
|
|
# Already a dict (e.g., some DB wrappers)
|
|
containers.append(row)
|
|
else:
|
|
# Tuple/list -> map with fields
|
|
containers.append(dict(zip(fields, row)))
|
|
|
|
# Wrap in n8n JSON format
|
|
n8n_items = [{"json": container} for container in containers]
|
|
|
|
return n8n_items
|
|
|
|
|
|
@app.post("/container/bump", dependencies=[Depends(verify_api_key)])
|
|
def bump_container(request: UUIDRequest):
|
|
today = datetime.utcnow().strftime("%Y-%m-%d")
|
|
execute_db("UPDATE containers SET bump=? WHERE UUID=?", (today, request.UUID))
|
|
msg = run_command([f"{BIN_PATH}/bumpContainer", request.UUID])
|
|
return {"message": msg, "bump_date": today}
|
|
|
|
|
|
@app.post("/container/quota", dependencies=[Depends(verify_api_key)])
|
|
def container_quota(request: UUIDRequest):
|
|
output = run_command(
|
|
[
|
|
"docker",
|
|
"stats",
|
|
request.UUID,
|
|
"--no-stream",
|
|
"--format",
|
|
"{{.MemUsage}},{{.BlockIO}}",
|
|
]
|
|
)
|
|
mem_usage, disk_usage = output.split(",")
|
|
return {"memory_usage": mem_usage, "disk_io": disk_usage}
|
|
|
|
|
|
# ---------------------- SYSTEM ----------------------
|
|
@app.get("/system/containers", dependencies=[Depends(verify_api_key)])
|
|
def get_containers():
|
|
return Response(
|
|
content=run_command([f"{BIN_PATH}/getContainers"]),
|
|
media_type="application/json",
|
|
)
|
|
|
|
|
|
@app.get("/system/images", dependencies=[Depends(verify_api_key)])
|
|
def list_images():
|
|
images = run_command(["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"])
|
|
return {"images": images.split("\n")}
|
|
|
|
|
|
@app.get("/system/cpu", dependencies=[Depends(verify_api_key)])
|
|
def get_cpu_log():
|
|
CPU_LOG_PATH = Path("/4server/data/log/cpu.log")
|
|
if not CPU_LOG_PATH.exists():
|
|
raise HTTPException(status_code=404, detail="CPU log file not found")
|
|
|
|
try:
|
|
with CPU_LOG_PATH.open("r") as f:
|
|
content = f.read()
|
|
return PlainTextResponse(content)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error reading CPU log: {e}")
|
|
|
|
|
|
@app.get("/system/info", dependencies=[Depends(verify_api_key)])
|
|
def get_system_info():
|
|
try:
|
|
alpine_version = None
|
|
last_update = None
|
|
bump_dates = execute_db(
|
|
"SELECT MAX(bump) AS latest_bump FROM containers", fetch=True
|
|
)[0]["latest_bump"]
|
|
if os.path.exists("/4server/data/update"):
|
|
with open("/4server/data/update") as f:
|
|
last_update = f.read().strip()
|
|
if os.path.exists("/etc/alpine-release"):
|
|
with open("/etc/alpine-release") as f:
|
|
alpine_version = f.read().strip()
|
|
mem = psutil.virtual_memory()
|
|
disk = psutil.disk_usage("/")
|
|
cpu_count = psutil.cpu_count(logical=True)
|
|
return {
|
|
"alpine_version": alpine_version,
|
|
"last_update": last_update,
|
|
"latest_bump": bump_dates,
|
|
"version": VERSION,
|
|
"resources": {
|
|
"memory": {
|
|
"total": mem.total,
|
|
"available": mem.available,
|
|
"used": mem.used,
|
|
},
|
|
"disk": {"total": disk.total, "used": disk.used, "free": disk.free},
|
|
"cpu_count": cpu_count,
|
|
},
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/system/pull", dependencies=[Depends(verify_api_key)])
|
|
def pull_all_images():
|
|
return {"message": run_command([f"{BIN_PATH}/pullAllContainers"])}
|
|
|
|
|
|
@app.post("/system/restartAllContainers", dependencies=[Depends(verify_api_key)])
|
|
def restart_all_containers(request: ImageRequest):
|
|
if not request.image:
|
|
raise HTTPException(status_code=400, detail="Image name is required")
|
|
return {"message": run_command([f"{BIN_PATH}/restartContainers", request.image])}
|
|
|
|
|
|
@app.post("/client/git", dependencies=[Depends(verify_api_key)])
|
|
def git_tool(request: CommandRequest):
|
|
if request.method == 1:
|
|
command = [f"{BIN_PATH}/gitPull", request.uuid]
|
|
elif request.method == 2:
|
|
command = [f"{BIN_PATH}/gitRevert", request.uuid]
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Invalid method")
|
|
|
|
output = run_command(command)
|
|
return {"message": output}
|
|
|
|
|
|
@app.post("/client/addGitKey", dependencies=[Depends(verify_api_key)])
|
|
def add_git_key(request: AddKeyRequest):
|
|
if not request.key or not request.uuid:
|
|
raise HTTPException(status_code=400, detail="Key and uuid are required")
|
|
|
|
file_path = f"/4server/data/{request.uuid}/git-server/keys/id_rsa.pub"
|
|
|
|
try:
|
|
# Ensure the directory exists
|
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
# Write the key to the file
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
f.write(request.key)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to save key: {str(e)}")
|
|
|
|
return {"message": f"Key saved for container {request.uuid}", "file": file_path}
|
|
|
|
|
|
@app.get("/client/audit/{uuid}", dependencies=[Depends(verify_api_key)])
|
|
def audit_odoo(uuid: str):
|
|
return {"message": run_command([f"{BIN_PATH}/ODOO_19/listModules", uuid])}
|
|
|
|
|
|
@app.get("/client/logs/{uuid}", dependencies=[Depends(verify_api_key)])
|
|
async def get_odoo_log_summary(uuid: str):
|
|
if not re.fullmatch(r"[0-9a-fA-F\-]+", uuid):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid UUID format. Only numbers, letters a-f, and '-' are allowed.",
|
|
)
|
|
|
|
BASE_LOG_DIR = "/4server/data"
|
|
|
|
# Build file paths as strings
|
|
project_dir = os.path.join(BASE_LOG_DIR, uuid)
|
|
odoo_log_file = os.path.join(project_dir, "logs", "odoo.log")
|
|
git_log_file = os.path.join(project_dir, "logs", "git.log")
|
|
|
|
if not os.path.isfile(odoo_log_file):
|
|
raise HTTPException(status_code=404, detail="Odoo log file not found")
|
|
|
|
# --- Helper variables and functions ---
|
|
IMPORTANT_PATTERNS = [
|
|
re.compile(r"odoo\.addons\."), # Any Odoo addon log
|
|
re.compile(r"Job '.*' starting"), # Cron job start
|
|
re.compile(r"Job '.*' fully done"), # Cron job end
|
|
re.compile(r"ERROR"), # Errors
|
|
re.compile(r"WARNING"), # Warnings
|
|
re.compile(r"Traceback"), # Tracebacks
|
|
re.compile(r"Error"),
|
|
]
|
|
|
|
def is_important_line(line: str) -> bool:
|
|
return any(p.search(line) for p in IMPORTANT_PATTERNS)
|
|
|
|
def read_last_lines(file_path: str, max_lines: int) -> list[str]:
|
|
"""
|
|
Read the last `max_lines` from the file efficiently.
|
|
"""
|
|
if not os.path.isfile(file_path):
|
|
return []
|
|
last_lines = deque(maxlen=max_lines)
|
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
for line in f:
|
|
last_lines.append(line.strip())
|
|
return list(last_lines)
|
|
|
|
# --- Main logic ---
|
|
try:
|
|
# Last 500 lines from Odoo log
|
|
last_500_lines = read_last_lines(odoo_log_file, 500)
|
|
important_odoo_lines = [
|
|
line for line in last_500_lines if is_important_line(line)
|
|
]
|
|
|
|
# Last 50 lines from git.log
|
|
last_50_git_lines = read_last_lines(git_log_file, 50)
|
|
|
|
return {
|
|
"uuid": str(uuid),
|
|
"important_odoo_log_lines": important_odoo_lines,
|
|
"last_git_log_lines": last_50_git_lines,
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error reading log files: {e}")
|
|
|
|
|
|
# ------------------------ BACKUP HANDLING -------------------------------------
|
|
@app.post("/backup/import", dependencies=[Depends(verify_api_key)])
|
|
def backup_import(request: ImportRequest):
|
|
if not request.filename:
|
|
raise HTTPException(status_code=400, detail="Filename is required")
|
|
|
|
command = [f"{BIN_PATH}/ODOO_19/import", request.filename]
|
|
output = run_command(command)
|
|
return {"message": output}
|
|
|
|
|
|
@app.post("/backup/move", dependencies=[Depends(verify_api_key)])
|
|
def backup_move(request: MoveRequest):
|
|
if not request.source or not request.destination:
|
|
raise HTTPException(
|
|
status_code=400, detail="Source and destination are required"
|
|
)
|
|
|
|
if not os.path.exists(request.source):
|
|
raise HTTPException(status_code=404, detail="Source file does not exist")
|
|
|
|
# Use shell command to move the file
|
|
command = ["mv", request.source, request.destination] # Linux/macOS
|
|
# For Windows, use: command = ["move", request.source, request.destination]
|
|
|
|
output = run_command(command)
|
|
return {
|
|
"message": f"Moved {request.source} to {request.destination}",
|
|
"output": output,
|
|
}
|
|
|
|
|
|
@app.get("/backup/borgpush", dependencies=[Depends(verify_api_key)])
|
|
def backup_borgpush():
|
|
return {"message": run_command(["doas", "-u", "4server", f"{BIN_PATH}/borgpush"])}
|
|
|
|
|
|
# ---------------------- Entry Point ----------------------
|
|
if __name__ == "__main__":
|
|
print(VERSION)
|
|
init_db()
|
|
time.sleep(25)
|
|
uvicorn.run(app, host="10.5.0.1", port=8888)
|