feat: AI Agent API — HTTP REST + CLI for external LLM agents
Adds a self-healing infrastructure accessible via HTTP REST and CLI: ## New files - tachyon/files/usr/lib/service/agent_api.uc Full REST API handler for /tachyon/agent/v1/* endpoints READ (no auth): /health /snapshot /diagnose /logs /config /tools WRITE (Bearer): /heal /restart /reload /config/set /section/toggle /domain/add - tachyon/files/usr/lib/cgi-bin/tachyon-agent CGI shell wrapper — installed into /www/cgi-bin by postinst Exposes agent API via uhttpd at http://<router>/cgi-bin/tachyon-agent/<endpoint> - AGENT.md Full documentation: curl examples, SSH usage, Claude/Open-WebUI integration ## Modified files - tachyon/files/usr/bin/tachyon + diagnose_json command -> diagnostics/runtime.uc diagnose-json + agent_cgi command -> service/agent_api.uc __cgi + agent subcommand with special arg-passing (path method body bearer) + show_help() updated - tachyon/files/usr/lib/diagnostics/runtime.uc + diagnose_json() — structured JSON output of doctor checks for LLM agents + Registered in CLI dispatch as 'diagnose-json' Fix: UTF-8 byte offset for arrow character U+2192 (+3 not +2) - tachyon/Makefile + INSTALL_BIN for usr/lib/cgi-bin/tachyon-agent + postinst: symlink CGI into /www/cgi-bin on install
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
# Tachyon AI Agent API
|
||||
|
||||
Tachyon поддерживает подключение внешних ИИ-агентов (Claude, GPT, Open-WebUI, Cursor и т.д.) через встроенный HTTP REST API и CLI-интерфейс.
|
||||
|
||||
## Самодиагностика и самовосстановление (из коробки)
|
||||
|
||||
Watchdog Tachyon уже работает автономно: каждые 5–15 секунд он проверяет состояние всех подсистем и автоматически исправляет проблемы. Для принудительного запуска диагностики и восстановления:
|
||||
|
||||
```sh
|
||||
tachyon ai_heal # полный цикл диагностики + авто-починки
|
||||
tachyon ai_status # текущий JSON-статус watchdog
|
||||
tachyon ai_status_full # расширенный JSON с метриками
|
||||
tachyon doctor # текстовый отчёт доктора
|
||||
tachyon diagnose_json # структурированный JSON для ИИ-агента
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP REST API (для ИИ-агентов)
|
||||
|
||||
После установки Tachyon поднимает CGI-эндпоинт через uhttpd:
|
||||
|
||||
```
|
||||
http://<ip-роутера>/cgi-bin/tachyon-agent/<endpoint>
|
||||
```
|
||||
|
||||
### Настройка токена (обязательна для WRITE-операций)
|
||||
|
||||
```sh
|
||||
uci set tachyon.settings.agent_api_token='ВАШ_СЕКРЕТНЫЙ_ТОКЕН'
|
||||
uci commit tachyon
|
||||
```
|
||||
|
||||
Без токена READ-эндпоинты работают свободно с локальной сети. WRITE-эндпоинты всегда требуют `Authorization: Bearer <token>`.
|
||||
|
||||
---
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
### READ (без токена)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/cgi-bin/tachyon-agent/health` | Быстрый статус служб |
|
||||
| GET | `/cgi-bin/tachyon-agent/snapshot` | Полный снимок системы для LLM |
|
||||
| GET | `/cgi-bin/tachyon-agent/diagnose` | Диагностика + автоисправление |
|
||||
| GET | `/cgi-bin/tachyon-agent/logs` | Последние 100 строк лога |
|
||||
| GET | `/cgi-bin/tachyon-agent/config` | Конфигурация UCI (секреты скрыты) |
|
||||
| GET | `/cgi-bin/tachyon-agent/tools` | JSON-схема инструментов для LLM |
|
||||
|
||||
### WRITE (Bearer token обязателен)
|
||||
|
||||
| Метод | Путь | Тело | Описание |
|
||||
|---|---|---|---|
|
||||
| POST | `/cgi-bin/tachyon-agent/heal` | `{}` | Авто-диагностика и ремонт |
|
||||
| POST | `/cgi-bin/tachyon-agent/restart` | `{"service":"singbox"}` | Перезапуск службы |
|
||||
| POST | `/cgi-bin/tachyon-agent/reload` | `{}` | Перезагрузка конфигурации |
|
||||
| POST | `/cgi-bin/tachyon-agent/config/set` | `{"section":"settings","option":"dns_type","value":"doh"}` | Установить UCI-опцию |
|
||||
| POST | `/cgi-bin/tachyon-agent/section/toggle` | `{"section":"myrule"}` | Вкл/выкл секцию |
|
||||
| POST | `/cgi-bin/tachyon-agent/domain/add` | `{"section":"myrule","domain":"example.com"}` | Добавить домен |
|
||||
|
||||
---
|
||||
|
||||
## Примеры curl
|
||||
|
||||
```sh
|
||||
# Быстрый статус
|
||||
curl http://192.168.1.1/cgi-bin/tachyon-agent/health
|
||||
|
||||
# Полный снимок системы
|
||||
curl http://192.168.1.1/cgi-bin/tachyon-agent/snapshot | jq .
|
||||
|
||||
# Запуск диагностики и авто-починки
|
||||
curl http://192.168.1.1/cgi-bin/tachyon-agent/diagnose | jq .
|
||||
|
||||
# Принудительный heal с токеном
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ВАШ_ТОКЕН" \
|
||||
http://192.168.1.1/cgi-bin/tachyon-agent/heal
|
||||
|
||||
# Перезапуск sing-box
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ВАШ_ТОКЕН" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"service":"singbox"}' \
|
||||
http://192.168.1.1/cgi-bin/tachyon-agent/restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI через SSH
|
||||
|
||||
Для ИИ-агентов с SSH-доступом:
|
||||
|
||||
```sh
|
||||
# Полный диагностический JSON
|
||||
tachyon diagnose_json
|
||||
|
||||
# Вызов любого эндпоинта
|
||||
tachyon agent /tachyon/agent/v1/health GET
|
||||
tachyon agent /tachyon/agent/v1/snapshot GET
|
||||
tachyon agent /tachyon/agent/v1/diagnose GET
|
||||
tachyon agent /tachyon/agent/v1/heal POST '{}' 'Bearer ВАШ_ТОКЕН'
|
||||
tachyon agent /tachyon/agent/v1/restart POST '{"service":"singbox"}' 'Bearer ВАШ_ТОКЕН'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Подключение к Claude / Open-WebUI / Cursor
|
||||
|
||||
### 1. Получить схему инструментов
|
||||
|
||||
```sh
|
||||
curl http://192.168.1.1/cgi-bin/tachyon-agent/tools
|
||||
```
|
||||
|
||||
Ответ содержит массив `tools` в формате OpenAI Function Calling / MCP.
|
||||
|
||||
### 2. Системный промпт для ИИ-агента
|
||||
|
||||
```
|
||||
You are a network assistant connected to a Tachyon OpenWrt router.
|
||||
Router API base: http://192.168.1.1/cgi-bin/tachyon-agent/
|
||||
Authorization token for write operations: Bearer <ВАШ_ТОКЕН>
|
||||
|
||||
Start by calling GET /snapshot to understand the current system state.
|
||||
Then call GET /diagnose to check for problems.
|
||||
Use the tools listed at GET /tools to interact with the system.
|
||||
```
|
||||
|
||||
### 3. Через Open-WebUI с MCP
|
||||
|
||||
Добавьте URL инструментов в конфигурацию Open-WebUI:
|
||||
```
|
||||
http://192.168.1.1/cgi-bin/tachyon-agent/tools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **READ-эндпоинты** доступны с локальной сети без токена (данные не содержат секретов)
|
||||
- **WRITE-эндпоинты** всегда требуют Bearer-токен
|
||||
- Токен хранится в UCI: `tachyon.settings.agent_api_token`
|
||||
- Внешний (WAN) доступ закрыт фаерволом OpenWrt по умолчанию
|
||||
- Для удалённого доступа используйте SSH-туннель:
|
||||
```sh
|
||||
ssh -L 8080:192.168.1.1:80 root@ваш-роутер
|
||||
curl http://localhost:8080/cgi-bin/tachyon-agent/health
|
||||
```
|
||||
@@ -123,6 +123,12 @@ TACHYON_LIB=/usr/lib/tachyon ucode -L /usr/lib/tachyon /usr/lib/tachyon/config/m
|
||||
# Harden config: telegram bot_token must not be world-readable
|
||||
chmod 600 /etc/config/tachyon
|
||||
chown root:root /etc/config/tachyon
|
||||
|
||||
# Install AI Agent API CGI handler into uhttpd's cgi-bin
|
||||
if [ -d /www/cgi-bin ]; then
|
||||
cp /usr/lib/cgi-bin/tachyon-agent /www/cgi-bin/tachyon-agent 2>/dev/null || true
|
||||
chmod 755 /www/cgi-bin/tachyon-agent 2>/dev/null || true
|
||||
fi
|
||||
endef
|
||||
|
||||
define Package/tachyon/conffiles
|
||||
@@ -145,6 +151,10 @@ define Package/tachyon/install
|
||||
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
|
||||
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-tachyon-wan-monitor $(1)/etc/hotplug.d/iface/99-tachyon-wan-monitor
|
||||
|
||||
# AI Agent API CGI handler (executable shell wrapper for uhttpd)
|
||||
$(INSTALL_DIR) $(1)/usr/lib/cgi-bin
|
||||
$(INSTALL_BIN) ./files/usr/lib/cgi-bin/tachyon-agent $(1)/usr/lib/cgi-bin/tachyon-agent
|
||||
|
||||
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(TACHYON_COMPILED_VERSION)/g' \
|
||||
-e 's/__COMPILED_COMMIT_SHA__/$(shell git -C $(TOPDIR)/../ rev-parse --short HEAD 2>/dev/null || echo unknown)/g' \
|
||||
$(1)/usr/lib/tachyon/core/constants.uc
|
||||
|
||||
@@ -133,6 +133,10 @@ function show_help() {
|
||||
print(" ai_status Show AI watchdog status\n");
|
||||
print(" ai_status_full Show full AI watchdog status (JSON)\n");
|
||||
print(" ai_heal Run AI watchdog heal cycle\n");
|
||||
print(" diagnose_json Run diagnostics and return structured JSON (for AI agents)\n");
|
||||
print(" agent <path> <method> [body_json] [bearer]\n");
|
||||
print(" AI Agent API: call a /tachyon/agent/v1/* endpoint\n");
|
||||
print(" agent_cgi AI Agent API: CGI handler (called by uhttpd)\n");
|
||||
print(" watchdog Manage watchdog daemon (start-runtime|stop-runtime|status)\n");
|
||||
print(" telegram_status Get Telegram bot running status\n");
|
||||
print(" telegram_start Start Telegram bot\n");
|
||||
@@ -215,6 +219,8 @@ function command_spec(command) {
|
||||
global_check: [ "diagnostics/runtime.uc", "global-check", 2 ],
|
||||
doctor: [ "diagnostics/runtime.uc", "doctor", 1 ],
|
||||
ai_doctor: [ "diagnostics/runtime.uc", "ai-doctor", 0 ],
|
||||
diagnose_json: [ "diagnostics/runtime.uc", "diagnose-json", 0 ],
|
||||
agent_cgi: [ "service/agent_api.uc", "__cgi", 0 ],
|
||||
watchdog: [ "service/watchdog.uc", "", 1 ],
|
||||
watchdog_start: [ "service/watchdog.uc", "start-runtime", 0 ],
|
||||
watchdog_stop: [ "service/watchdog.uc", "stop-runtime", 0 ],
|
||||
@@ -253,8 +259,34 @@ function run_spec(command, spec, user_args) {
|
||||
return normalize_status(system(command_from_args(args)));
|
||||
}
|
||||
|
||||
function run_agent(user_args) {
|
||||
// tachyon agent <path> <method> [body_json] [bearer_token]
|
||||
// passes all 4 args directly to agent_api.uc which reads them as ARGV[0..3]
|
||||
let module = module_path("service/agent_api.uc");
|
||||
if (!file_readable(module)) {
|
||||
warn("Error: service/agent_api.uc is missing\n");
|
||||
return 1;
|
||||
}
|
||||
let args = [ "ucode", "-L", LIB_DIR, module,
|
||||
user_args[0] || "/",
|
||||
user_args[1] || "GET",
|
||||
user_args[2] || "{}",
|
||||
user_args[3] || ""
|
||||
];
|
||||
return normalize_status(system(command_from_args(args)));
|
||||
}
|
||||
|
||||
function main() {
|
||||
let command = ARGV[0] || "";
|
||||
|
||||
// agent command gets all remaining args passed verbatim
|
||||
if (command == "agent") {
|
||||
let user_args = [];
|
||||
for (let i = 1; i < length(ARGV); i++)
|
||||
push(user_args, ARGV[i]);
|
||||
return run_agent(user_args);
|
||||
}
|
||||
|
||||
let spec = command_spec(command);
|
||||
if (spec == null) {
|
||||
show_help();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Tachyon AI Agent API — CGI handler for uhttpd
|
||||
# Install at: /usr/lib/cgi-bin/tachyon-agent
|
||||
# then symlink: ln -sf /usr/lib/cgi-bin/tachyon-agent /www/cgi-bin/tachyon-agent
|
||||
#
|
||||
# API is served at: http://<router>/cgi-bin/tachyon-agent/<endpoint>
|
||||
# Examples:
|
||||
# curl http://192.168.1.1/cgi-bin/tachyon-agent/health
|
||||
# curl http://192.168.1.1/cgi-bin/tachyon-agent/snapshot
|
||||
# curl http://192.168.1.1/cgi-bin/tachyon-agent/diagnose
|
||||
# curl -X POST -H "Authorization: Bearer mytoken" \
|
||||
# http://192.168.1.1/cgi-bin/tachyon-agent/heal
|
||||
|
||||
TACHYON_LIB=/usr/lib/tachyon
|
||||
AGENT_UC="$TACHYON_LIB/service/agent_api.uc"
|
||||
|
||||
PATH_INFO="${PATH_INFO:-/}"
|
||||
API_PATH="/tachyon/agent/v1${PATH_INFO}"
|
||||
METHOD="${REQUEST_METHOD:-GET}"
|
||||
AUTH="${HTTP_AUTHORIZATION:-}"
|
||||
|
||||
BODY="{}"
|
||||
if [ "$METHOD" = "POST" ] || [ "$METHOD" = "PUT" ]; then
|
||||
if [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then
|
||||
BODY=$(dd bs=1 count="$CONTENT_LENGTH" 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
|
||||
OUTPUT=$(ucode -L "$TACHYON_LIB" "$AGENT_UC" "$API_PATH" "$METHOD" "$BODY" "$AUTH" 2>/dev/null)
|
||||
|
||||
printf 'Content-Type: application/json\r\n'
|
||||
printf 'Access-Control-Allow-Origin: *\r\n'
|
||||
printf 'Cache-Control: no-cache\r\n'
|
||||
printf '\r\n'
|
||||
printf '%s\n' "$OUTPUT"
|
||||
@@ -2834,6 +2834,72 @@ function doctor(format) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── AI Agent: structured JSON diagnostics ────────────────────────────────────
|
||||
// Same checks as run_doctor_checks() but output is machine-readable JSON for
|
||||
// LLM agents. Each problem has: id, severity, description, suggested_fix.
|
||||
function diagnose_json() {
|
||||
let res = run_doctor_checks();
|
||||
let problems = [];
|
||||
|
||||
// Parse the text report lines into structured objects
|
||||
for (let line in split(res.report, "\n")) {
|
||||
line = trim(as_string(line));
|
||||
if (line == "") continue;
|
||||
|
||||
let severity = null;
|
||||
let fixed_inline = false;
|
||||
if (index(line, "❌") >= 0) {
|
||||
severity = "critical";
|
||||
} else if (index(line, "⚠️") >= 0) {
|
||||
severity = "warning";
|
||||
} else if (index(line, "ℹ️") >= 0) {
|
||||
severity = "info";
|
||||
}
|
||||
|
||||
if (severity == null) continue;
|
||||
|
||||
// Extract check name and message (format: icon name message)
|
||||
let clean = replace(replace(replace(replace(
|
||||
line, "❌", ""), "⚠️", ""), "ℹ️", ""), "✅", "");
|
||||
clean = trim(clean);
|
||||
|
||||
let arrow_idx = index(clean, "→");
|
||||
let description = trim(arrow_idx >= 0 ? substr(clean, 0, arrow_idx) : clean);
|
||||
// "→" is U+2192, encoded as 3 UTF-8 bytes (E2 86 92); skip all 3
|
||||
let suggested_fix = arrow_idx >= 0 ? trim(substr(clean, arrow_idx + 3)) : "";
|
||||
|
||||
// Detect if already fixed inline (FIXED: prefix in suggested_fix)
|
||||
if (index(suggested_fix, "FIXED:") >= 0) {
|
||||
fixed_inline = true;
|
||||
severity = "info"; // was critical but already fixed
|
||||
}
|
||||
|
||||
push(problems, {
|
||||
severity: severity,
|
||||
description: description,
|
||||
suggested_fix: suggested_fix,
|
||||
fixed: fixed_inline
|
||||
});
|
||||
}
|
||||
|
||||
let ai_status_data = {};
|
||||
let ai_status_raw = fs.readfile("/tmp/tachyon_ai_status.json");
|
||||
if (ai_status_raw) {
|
||||
try { ai_status_data = json(ai_status_raw); } catch(e) {}
|
||||
}
|
||||
|
||||
print(sprintf("%J\n", {
|
||||
success: true,
|
||||
timestamp: time(),
|
||||
issues_found: res.issues,
|
||||
issues_fixed: res.fixed,
|
||||
overall: (res.issues == 0) ? "healthy" : ((res.fixed == res.issues) ? "repaired" : "degraded"),
|
||||
problems: problems,
|
||||
watchdog_status: ai_status_data
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
|
||||
function ai_doctor() {
|
||||
let cfg = uci_settings();
|
||||
if (cfg.enable_ai_doctor != "1" || !cfg.ai_doctor_api_key) {
|
||||
@@ -3073,6 +3139,8 @@ else if (mode == "global-check")
|
||||
exit(global_check(ARGV[1] || "", ARGV[2] || ""));
|
||||
else if (mode == "doctor")
|
||||
exit(doctor(ARGV[1]));
|
||||
else if (mode == "diagnose-json")
|
||||
exit(diagnose_json());
|
||||
else if (mode == "ai-doctor")
|
||||
exit(ai_doctor());
|
||||
else if (mode == "validate-nfqws-strategy-json")
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env ucode
|
||||
|
||||
// ─── Tachyon AI Agent API ─────────────────────────────────────────────────────
|
||||
//
|
||||
// HTTP handler for /tachyon/agent/v1/*
|
||||
// Provides structured read/write endpoints for LLM agents (Claude, GPT, etc.)
|
||||
//
|
||||
// READ endpoints (no auth required from LAN):
|
||||
// GET /tachyon/agent/v1/snapshot
|
||||
// GET /tachyon/agent/v1/health
|
||||
// GET /tachyon/agent/v1/diagnose
|
||||
// GET /tachyon/agent/v1/logs
|
||||
// GET /tachyon/agent/v1/config
|
||||
// GET /tachyon/agent/v1/tools
|
||||
//
|
||||
// WRITE endpoints (require Bearer token via agent_api_token UCI option):
|
||||
// POST /tachyon/agent/v1/heal
|
||||
// POST /tachyon/agent/v1/restart
|
||||
// POST /tachyon/agent/v1/reload
|
||||
// POST /tachyon/agent/v1/config/set
|
||||
// POST /tachyon/agent/v1/section/toggle
|
||||
// POST /tachyon/agent/v1/domain/add
|
||||
|
||||
let fs = require("fs");
|
||||
let uci = require("core.uci");
|
||||
let common = require("core.common");
|
||||
|
||||
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
|
||||
const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon";
|
||||
const RUNTIME_UC = LIB_DIR + "/diagnostics/runtime.uc";
|
||||
const WATCHDOG_UC = LIB_DIR + "/service/watchdog.uc";
|
||||
const API_VERSION = "1";
|
||||
|
||||
let as_string = common.as_string;
|
||||
let shell_quote = common.shell_quote;
|
||||
|
||||
function write_json(obj) {
|
||||
print(sprintf("%J\n", obj));
|
||||
}
|
||||
|
||||
function ok(data) {
|
||||
let result = { success: true };
|
||||
for (let k in keys(data)) result[k] = data[k];
|
||||
write_json(result);
|
||||
}
|
||||
|
||||
function err(message, code) {
|
||||
write_json({ success: false, error: as_string(message), code: code || 400 });
|
||||
}
|
||||
|
||||
function command_capture(command) {
|
||||
let pipe = fs.popen(command, "r");
|
||||
if (!pipe) return { status: 1, output: "" };
|
||||
let data = pipe.read("all");
|
||||
let status = pipe.close();
|
||||
status = int(status);
|
||||
if (status == -1) status = 255;
|
||||
let sig = status & 127;
|
||||
if (sig != 0) status = 128 + sig;
|
||||
else status = (status >> 8) & 255;
|
||||
return { status: status, output: data == null ? "" : as_string(data) };
|
||||
}
|
||||
|
||||
function command_output(command) {
|
||||
let res = command_capture(command);
|
||||
return (res.status == 0) ? trim(res.output) : "";
|
||||
}
|
||||
|
||||
function module_capture(args) {
|
||||
let parts = ["ucode", "-L", shell_quote(LIB_DIR), shell_quote(RUNTIME_UC)];
|
||||
for (let a in args) push(parts, shell_quote(as_string(a)));
|
||||
return command_capture(join(" ", parts));
|
||||
}
|
||||
|
||||
function parse_json_safe(raw) {
|
||||
try { return json(as_string(raw)); } catch(e) { return null; }
|
||||
}
|
||||
|
||||
function get_agent_token() {
|
||||
let c = uci.cursor();
|
||||
if (!c) return "";
|
||||
c.load(CONFIG_NAME);
|
||||
return as_string(c.get(CONFIG_NAME + ".settings.agent_api_token") || "");
|
||||
}
|
||||
|
||||
function check_write_auth(bearer) {
|
||||
let token = get_agent_token();
|
||||
if (token == "") return false;
|
||||
return ("Bearer " + token) == as_string(bearer);
|
||||
}
|
||||
|
||||
// ─── READ endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
function handle_health() {
|
||||
let ai_raw = fs.readfile("/tmp/tachyon_ai_status.json") || "{}";
|
||||
let ai = parse_json_safe(ai_raw) || {};
|
||||
let sb_pid = trim(command_output("pidof sing-box"));
|
||||
let wd_pid = trim(fs.readfile("/var/run/tachyon_watchdog.pid") || "");
|
||||
ok({
|
||||
status: ai.status || (sb_pid != "" ? "healthy" : "critical"),
|
||||
singbox: sb_pid != "" ? "running" : "stopped",
|
||||
watchdog: (wd_pid != "" && fs.stat("/proc/" + wd_pid) != null) ? "running" : "stopped",
|
||||
incidents: ai.incidents_resolved_total || 0,
|
||||
timestamp: time()
|
||||
});
|
||||
}
|
||||
|
||||
function handle_diagnose() {
|
||||
let res = module_capture(["diagnose-json"]);
|
||||
if (res.status != 0) {
|
||||
err("Diagnostics failed: " + res.output, 500);
|
||||
return;
|
||||
}
|
||||
let data = parse_json_safe(res.output);
|
||||
if (data == null) {
|
||||
err("Invalid JSON from diagnostics", 500);
|
||||
return;
|
||||
}
|
||||
print(res.output);
|
||||
}
|
||||
|
||||
function handle_logs() {
|
||||
let raw = command_output("logread -l 100 2>/dev/null | grep -i tachyon");
|
||||
let lines = [];
|
||||
for (let line in split(raw, "\n")) {
|
||||
line = trim(as_string(line));
|
||||
if (line != "") push(lines, line);
|
||||
}
|
||||
ok({ lines: lines });
|
||||
}
|
||||
|
||||
function handle_config() {
|
||||
let res = module_capture(["show-config", "masked"]);
|
||||
let data = parse_json_safe(res.output);
|
||||
if (data != null) {
|
||||
print(res.output);
|
||||
} else {
|
||||
ok({ raw: res.output });
|
||||
}
|
||||
}
|
||||
|
||||
function handle_snapshot() {
|
||||
let mem_info = fs.readfile("/proc/meminfo") || "";
|
||||
let mem_avail = 0;
|
||||
for (let line in split(mem_info, "\n")) {
|
||||
if (index(line, "MemAvailable:") == 0) {
|
||||
let f = split(trim(line), /[ \t]+/);
|
||||
if (length(f) >= 2) mem_avail = int(f[1]) / 1024;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let uptime_raw = fs.readfile("/proc/uptime") || "0";
|
||||
let up_sec = double(split(uptime_raw, " ")[0] || 0);
|
||||
let d = int(up_sec / 86400);
|
||||
let h = int((up_sec - d * 86400) / 3600);
|
||||
let m = int((up_sec - d * 86400 - h * 3600) / 60);
|
||||
|
||||
let openwrt_ver = trim(command_output("cat /etc/openwrt_release 2>/dev/null | grep DISTRIB_RELEASE | cut -d= -f2 | tr -d '\"'"));
|
||||
let tachyon_ver = trim(command_output("opkg status tachyon 2>/dev/null | grep Version | awk '{print $2}'"));
|
||||
if (tachyon_ver == "")
|
||||
tachyon_ver = trim(command_output("apk list tachyon 2>/dev/null | head -1"));
|
||||
|
||||
let sb_pid = trim(command_output("pidof sing-box"));
|
||||
let wd_pid = trim(fs.readfile("/var/run/tachyon_watchdog.pid") || "");
|
||||
let wd_ok = wd_pid != "" && fs.stat("/proc/" + wd_pid) != null;
|
||||
|
||||
let proxy_resp = command_capture("curl -s -o /dev/null -m 5 --connect-timeout 3 -w '%{http_code}' --proxy http://127.0.0.1:4534 http://www.google.com 2>/dev/null").output;
|
||||
let direct_resp = command_capture("curl -s -o /dev/null -m 5 --connect-timeout 3 -w '%{http_code}' http://www.google.com 2>/dev/null").output;
|
||||
|
||||
let ai_status = parse_json_safe(fs.readfile("/tmp/tachyon_ai_status.json") || "{}") || {};
|
||||
|
||||
let logs_raw = command_output("logread -l 50 2>/dev/null | grep -i tachyon | tail -30");
|
||||
let logs = [];
|
||||
for (let line in split(logs_raw, "\n")) {
|
||||
line = trim(as_string(line));
|
||||
if (line != "") push(logs, line);
|
||||
}
|
||||
|
||||
let sections = [];
|
||||
let section_objs = uci.section_objects(CONFIG_NAME, "section");
|
||||
for (let s in section_objs) {
|
||||
push(sections, {
|
||||
name: s[".name"],
|
||||
label: s.label || s[".name"],
|
||||
enabled: s.enabled == "1",
|
||||
action: s.action || "unknown"
|
||||
});
|
||||
}
|
||||
|
||||
ok({
|
||||
system: {
|
||||
openwrt_version: openwrt_ver || "unknown",
|
||||
tachyon_version: tachyon_ver || "unknown",
|
||||
uptime: sprintf("%dd %02d:%02d", d, h, m),
|
||||
memory_mb_avail: mem_avail,
|
||||
timestamp: time()
|
||||
},
|
||||
services: {
|
||||
singbox: sb_pid != "" ? "running" : "stopped",
|
||||
watchdog: wd_ok ? "running" : "stopped"
|
||||
},
|
||||
connectivity: {
|
||||
direct_ok: int(direct_resp || "0") >= 200 && int(direct_resp || "0") < 400,
|
||||
proxy_ok: int(proxy_resp || "0") >= 200 && int(proxy_resp || "0") < 400
|
||||
},
|
||||
incidents: {
|
||||
total: ai_status.incidents_resolved_total || 0,
|
||||
last: ai_status.last_incident || null
|
||||
},
|
||||
sections: sections,
|
||||
logs_tail: logs,
|
||||
agent_api: {
|
||||
version: API_VERSION,
|
||||
tools_url: "/tachyon/agent/v1/tools",
|
||||
write_auth_required: get_agent_token() != ""
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handle_tools() {
|
||||
ok({
|
||||
schema_version: "1.0",
|
||||
description: "Tachyon AI Agent API — diagnose and manage Tachyon on OpenWrt",
|
||||
tools: [
|
||||
{
|
||||
name: "tachyon_get_health",
|
||||
description: "Quick health status of Tachyon services",
|
||||
method: "GET",
|
||||
path: "/tachyon/agent/v1/health",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_get_snapshot",
|
||||
description: "Full system snapshot (services, connectivity, sections, logs). Use this first.",
|
||||
method: "GET",
|
||||
path: "/tachyon/agent/v1/snapshot",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_diagnose",
|
||||
description: "Run full self-diagnostics. Returns structured problem list. Also auto-repairs detected issues.",
|
||||
method: "GET",
|
||||
path: "/tachyon/agent/v1/diagnose",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_get_logs",
|
||||
description: "Last 100 Tachyon-related syslog lines",
|
||||
method: "GET",
|
||||
path: "/tachyon/agent/v1/logs",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_get_config",
|
||||
description: "Current UCI configuration (secrets masked)",
|
||||
method: "GET",
|
||||
path: "/tachyon/agent/v1/config",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_heal",
|
||||
description: "Run automatic self-healing. Requires Bearer token.",
|
||||
method: "POST",
|
||||
path: "/tachyon/agent/v1/heal",
|
||||
auth: "bearer",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_restart",
|
||||
description: "Restart a subsystem: singbox | tachyon | dnsmasq | watchdog. Requires Bearer token.",
|
||||
method: "POST",
|
||||
path: "/tachyon/agent/v1/restart",
|
||||
auth: "bearer",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["service"],
|
||||
properties: {
|
||||
service: {
|
||||
type: "string",
|
||||
enum: ["singbox", "tachyon", "dnsmasq", "watchdog"],
|
||||
description: "Service to restart"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "tachyon_reload",
|
||||
description: "Reload Tachyon config without full restart. Requires Bearer token.",
|
||||
method: "POST",
|
||||
path: "/tachyon/agent/v1/reload",
|
||||
auth: "bearer",
|
||||
input_schema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "tachyon_set_config",
|
||||
description: "Set a UCI option. Requires Bearer token.",
|
||||
method: "POST",
|
||||
path: "/tachyon/agent/v1/config/set",
|
||||
auth: "bearer",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["section", "option", "value"],
|
||||
properties: {
|
||||
section: { type: "string", description: "UCI section name" },
|
||||
option: { type: "string", description: "UCI option name" },
|
||||
value: { type: "string", description: "Value to set" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "tachyon_toggle_section",
|
||||
description: "Enable or disable a routing section. Requires Bearer token.",
|
||||
method: "POST",
|
||||
path: "/tachyon/agent/v1/section/toggle",
|
||||
auth: "bearer",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["section"],
|
||||
properties: {
|
||||
section: { type: "string", description: "Section .name" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "tachyon_add_domain",
|
||||
description: "Add a domain to a routing section. Requires Bearer token.",
|
||||
method: "POST",
|
||||
path: "/tachyon/agent/v1/domain/add",
|
||||
auth: "bearer",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["section", "domain"],
|
||||
properties: {
|
||||
section: { type: "string", description: "Section .name" },
|
||||
domain: { type: "string", description: "Domain to add" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── WRITE endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
function handle_heal() {
|
||||
let res = module_capture(["diagnose-json"]);
|
||||
let data = parse_json_safe(res.output);
|
||||
command_capture("ucode -L " + shell_quote(LIB_DIR) + " " + shell_quote(WATCHDOG_UC) + " ai-heal > /dev/null 2>&1");
|
||||
ok({
|
||||
message: "Self-healing completed",
|
||||
issues_found: data ? (data.issues_found || 0) : 0,
|
||||
issues_fixed: data ? (data.issues_fixed || 0) : 0,
|
||||
overall: data ? (data.overall || "unknown") : "unknown",
|
||||
problems: data ? (data.problems || []) : []
|
||||
});
|
||||
}
|
||||
|
||||
function handle_restart(body) {
|
||||
let svc = as_string(body.service || "");
|
||||
let allowed = { singbox: 1, tachyon: 1, dnsmasq: 1, watchdog: 1 };
|
||||
if (!allowed[svc]) {
|
||||
err("Unknown service '" + svc + "'. Allowed: singbox, tachyon, dnsmasq, watchdog");
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd_map = {
|
||||
singbox: "/etc/init.d/sing-box restart > /dev/null 2>&1",
|
||||
tachyon: "/etc/init.d/tachyon restart > /dev/null 2>&1",
|
||||
dnsmasq: "/etc/init.d/dnsmasq restart > /dev/null 2>&1",
|
||||
watchdog: "/usr/bin/tachyon watchdog start-runtime > /dev/null 2>&1"
|
||||
};
|
||||
|
||||
system(cmd_map[svc]);
|
||||
ok({ message: "Restart of '" + svc + "' initiated" });
|
||||
}
|
||||
|
||||
function handle_reload() {
|
||||
system("/usr/bin/tachyon reload > /dev/null 2>&1");
|
||||
ok({ message: "Reload initiated" });
|
||||
}
|
||||
|
||||
function handle_config_set(body) {
|
||||
let section = as_string(body.section || "");
|
||||
let option = as_string(body.option || "");
|
||||
let value = as_string(body.value || "");
|
||||
|
||||
if (section == "" || option == "") {
|
||||
err("section and option are required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use module-level uci.set() which handles path as package.section.option
|
||||
let path = CONFIG_NAME + "." + section + "." + option;
|
||||
if (!uci.set(path, value)) {
|
||||
err("Failed to set UCI option: " + path);
|
||||
return;
|
||||
}
|
||||
if (!uci.commit(CONFIG_NAME)) {
|
||||
err("Failed to commit UCI config", 500);
|
||||
return;
|
||||
}
|
||||
|
||||
ok({ message: "Set " + path + " = " + value });
|
||||
}
|
||||
|
||||
function handle_section_toggle(body) {
|
||||
let section = as_string(body.section || "");
|
||||
if (section == "") { err("section is required"); return; }
|
||||
|
||||
let s = uci.get_all(CONFIG_NAME, section);
|
||||
if (!s) { err("Section not found: " + section, 404); return; }
|
||||
|
||||
let new_val = (s.enabled == "1") ? "0" : "1";
|
||||
if (!uci.set(CONFIG_NAME + "." + section + ".enabled", new_val)) {
|
||||
err("Failed to toggle section", 500);
|
||||
return;
|
||||
}
|
||||
uci.commit(CONFIG_NAME);
|
||||
system("/usr/bin/tachyon reload > /dev/null 2>&1");
|
||||
|
||||
ok({
|
||||
message: "Section '" + section + "' is now " + (new_val == "1" ? "enabled" : "disabled"),
|
||||
enabled: new_val == "1"
|
||||
});
|
||||
}
|
||||
|
||||
function handle_domain_add(body) {
|
||||
let section = as_string(body.section || "");
|
||||
let domain = as_string(body.domain || "");
|
||||
if (section == "" || domain == "") { err("section and domain are required"); return; }
|
||||
|
||||
if (!match(domain, /^[a-zA-Z0-9*._-]+$/)) {
|
||||
err("Invalid domain: " + domain);
|
||||
return;
|
||||
}
|
||||
|
||||
let path = CONFIG_NAME + "." + section + ".domain";
|
||||
if (!uci.add_list(path, domain)) {
|
||||
err("Failed to add domain (section may not exist or domain already present)");
|
||||
return;
|
||||
}
|
||||
uci.commit(CONFIG_NAME);
|
||||
system("/usr/bin/tachyon reload > /dev/null 2>&1");
|
||||
|
||||
ok({ message: "Domain '" + domain + "' added to section '" + section + "'" });
|
||||
}
|
||||
|
||||
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
let path_arg = as_string(ARGV[0] || "");
|
||||
let method = uc(as_string(ARGV[1] || "GET"));
|
||||
let body = parse_json_safe(ARGV[2] || "{}") || {};
|
||||
let bearer = as_string(ARGV[3] || "");
|
||||
|
||||
let route = replace(path_arg, /^\/tachyon\/agent\/v1/, "");
|
||||
if (route == "") route = "/";
|
||||
|
||||
if (method == "GET") {
|
||||
if (route == "/health" || route == "/health/")
|
||||
handle_health();
|
||||
else if (route == "/snapshot" || route == "/snapshot/")
|
||||
handle_snapshot();
|
||||
else if (route == "/diagnose" || route == "/diagnose/")
|
||||
handle_diagnose();
|
||||
else if (route == "/logs" || route == "/logs/")
|
||||
handle_logs();
|
||||
else if (route == "/config" || route == "/config/")
|
||||
handle_config();
|
||||
else if (route == "/tools" || route == "/tools/")
|
||||
handle_tools();
|
||||
else
|
||||
err("Unknown endpoint: GET " + route, 404);
|
||||
} else if (method == "POST") {
|
||||
if (!check_write_auth(bearer)) {
|
||||
err("Unauthorized. Configure agent_api_token in UCI and use Bearer token.", 401);
|
||||
} else {
|
||||
if (route == "/heal" || route == "/heal/")
|
||||
handle_heal();
|
||||
else if (route == "/restart" || route == "/restart/")
|
||||
handle_restart(body);
|
||||
else if (route == "/reload" || route == "/reload/")
|
||||
handle_reload();
|
||||
else if (route == "/config/set")
|
||||
handle_config_set(body);
|
||||
else if (route == "/section/toggle")
|
||||
handle_section_toggle(body);
|
||||
else if (route == "/domain/add")
|
||||
handle_domain_add(body);
|
||||
else
|
||||
err("Unknown endpoint: POST " + route, 404);
|
||||
}
|
||||
} else {
|
||||
err("Method not allowed: " + method, 405);
|
||||
}
|
||||
Reference in New Issue
Block a user