Files
NeoTachyon/tachyon/files/usr/lib/service/snapshot.uc
T
Dushnilin 0964b79171 fix(ai-chat): fix AI Chat and Assistant to work with LM Studio
- Fix response parsing: check report field, not just message
- Pass user query text to LLM instead of ignoring it
- Append user question to diagnostic prompt with system context
- Increase timeout to 120s for LM Studio responses
- Add command_status_from_args to core/common.uc exports

Closes #38
2026-08-19 15:57:42 +03:00

306 lines
11 KiB
Ucode

#!/usr/bin/env ucode
// Config snapshots: save / list / restore / delete working configs so the user
// can roll back from LuCI's advanced settings. A snapshot is a tar.gz of
// config/<name> + <name>/ under the config root — the same member layout as
// the Telegram backup, so snapshot archives can be restored there too.
let fs = require("fs");
let common = require("core.common");
let as_string = common.as_string;
function env(name, fallback) {
let value = getenv(name);
return value == null ? as_string(fallback) : as_string(value);
}
let shell_quote = common.shell_quote;
let command_from_args = common.command_from_args;
function normalize_status(status) {
status = int(status);
if (status == -1)
return 255;
let signal = status & 127;
if (signal != 0)
return 128 + signal;
return (status >> 8) & 255;
}
let command_status = common.command_status;
function command_success_from_args(args) {
return command_status(command_from_args(args)) == 0;
}
let command_output = common.command_output;
let command_output_from_args = common.command_output_from_args;
function log_message(message) {
system(command_from_args([ "logger", "-t", CONFIG_NAME, "[info] " + message ]) + " >/dev/null 2>&1");
}
function path_basename(path) {
let parts = split(as_string(path), "/");
return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : "";
}
function fail(message) {
print(sprintf("%J\n", { success: false, message: message }));
return 1;
}
const CONFIG_NAME = env("TACHYON_CONFIG_NAME", "tachyon");
const BIN_PATH = env("TACHYON_BIN", "/usr/bin/tachyon");
const CONFIG_PATH = env("TACHYON_CONFIG_PATH", "/etc/config/" + CONFIG_NAME);
const PERSISTENT_DIR = env("TACHYON_PERSISTENT_DIR", "/etc/" + CONFIG_NAME);
const CONFIG_ROOT = env("TACHYON_CONFIG_ROOT", "/etc");
const SNAPSHOTS_DIR = env("TACHYON_SNAPSHOTS_DIR", "/etc/.tachyon/snapshots");
const SNAPSHOT_LIMIT = int(env("TACHYON_SNAPSHOT_LIMIT", "10"));
const VALIDATE_BIN = env("TACHYON_SNAPSHOT_VALIDATE_BIN", "/sbin/uci");
function snapshot_stamp() {
return trim(command_output_from_args([ "date", "+%Y%m%d-%H%M%S" ]));
}
function snapshot_name_safe(name) {
name = as_string(name);
let cleaned = "";
for (let i = 0; i < length(name); i++) {
let ch = substr(name, i, 1);
if (match(ch, /[A-Za-z0-9_\-]/))
cleaned += ch;
}
if (cleaned == "" || length(cleaned) > 64)
return null;
return cleaned;
}
function snapshot_file_valid(file) {
return match(as_string(file), /^[A-Za-z0-9_\-]+-[0-9]{8}-[0-9]{6}\.tar\.gz$/) != null;
}
function backup_archive_safe(members) {
let config_member = "config/" + CONFIG_NAME;
let data_prefix = CONFIG_NAME + "/";
for (let m in members) {
if (substr(m, 0, 1) == "/") return { ok: false, reason: "absolute path: " + m };
if (match(m, /\.\./)) return { ok: false, reason: "path traversal: " + m };
if (m != config_member && substr(m, 0, length(data_prefix)) != data_prefix)
return { ok: false, reason: "unexpected member: " + m };
}
return { ok: true };
}
function config_sane_preview(path) {
let data = fs.readfile(path);
if (data == null) return false;
return match(data, /(^|\n)[ \t]*config[ \t]+[A-Za-z0-9_-]+/) != null;
}
function restore_config_from_backup(backup_path) {
if (backup_path && fs.stat(backup_path)) {
let tmp = CONFIG_PATH + ".restore-tmp";
command_status(command_from_args([ "cp", "-a", backup_path, tmp ]));
command_status(command_from_args([ "mv", "-f", tmp, CONFIG_PATH ]));
}
}
function prune_snapshots() {
let out = command_output_from_args([ "ls", "-t", SNAPSHOTS_DIR ]);
let kept = 0;
for (let line in split(out, "\n")) {
line = trim(line);
if (line == "" || !snapshot_file_valid(line))
continue;
kept++;
if (kept > SNAPSHOT_LIMIT)
command_success_from_args([ "rm", "-f", SNAPSHOTS_DIR + "/" + line ]);
}
}
function snapshot_save(name) {
let safe = snapshot_name_safe(name);
if (safe == null)
return fail("invalid snapshot name");
if (!command_success_from_args([ "mkdir", "-p", SNAPSHOTS_DIR ]))
return fail("unable to create snapshots directory");
if (fs.stat(CONFIG_PATH) == null)
return fail("config file not found");
let file = SNAPSHOTS_DIR + "/" + safe + "-" + snapshot_stamp() + ".tar.gz";
if (!command_success_from_args([ "tar", "-czf", file, "-C", CONFIG_ROOT, "config/" + CONFIG_NAME, CONFIG_NAME ])) {
command_success_from_args([ "rm", "-f", file ]);
return fail("unable to create snapshot archive");
}
command_success_from_args([ "chmod", "600", file ]);
prune_snapshots();
log_message("Config snapshot saved: " + safe);
print(sprintf("%J\n", { success: true, file: path_basename(file) }));
return 0;
}
function snapshot_list() {
let entries = [];
if (fs.stat(SNAPSHOTS_DIR) == null)
return entries;
let out = command_output_from_args([ "ls", "-t", SNAPSHOTS_DIR ]);
for (let line in split(out, "\n")) {
line = trim(line);
if (line == "" || !snapshot_file_valid(line))
continue;
let full = SNAPSHOTS_DIR + "/" + line;
if (fs.stat(full) == null)
continue;
let size = int(command_output_from_args([ "stat", "-c", "%s", full ]));
let base = substr(line, 0, length(line) - length(".tar.gz"));
let last_dash = rindex(base, "-");
let mid_dash = last_dash >= 0 ? rindex(substr(base, 0, last_dash), "-") : -1;
let name = mid_dash >= 0 ? substr(base, 0, mid_dash) : base;
let stamp = mid_dash >= 0 ? substr(base, mid_dash + 1) : "";
push(entries, { file: line, name: name, stamp: stamp, size: size });
}
return entries;
}
function snapshot_delete(file) {
if (!snapshot_file_valid(file))
return fail("invalid snapshot name");
let path = SNAPSHOTS_DIR + "/" + file;
if (fs.stat(path) == null)
return fail("snapshot not found");
if (!command_success_from_args([ "rm", "-f", path ]))
return fail("unable to remove snapshot");
log_message("Config snapshot deleted: " + file);
print(sprintf("%J\n", { success: true }));
return 0;
}
function snapshot_restore(file) {
if (!snapshot_file_valid(file))
return fail("invalid snapshot name");
let archive = SNAPSHOTS_DIR + "/" + file;
if (fs.stat(archive) == null)
return fail("snapshot not found");
let members_out = command_output_from_args([ "tar", "-tzf", archive ]);
let members = [];
for (let line in split(members_out, "\n")) {
line = trim(line);
if (line != "")
push(members, line);
}
let safety = backup_archive_safe(members);
if (!safety.ok)
return fail("unsafe archive: " + safety.reason);
let stage = trim(command_output_from_args([ "mktemp", "-d", "/tmp/tachyon-snapshot-restore.XXXXXX" ]));
if (stage == "")
return fail("unable to create staging directory");
if (!command_success_from_args([ "tar", "-xzf", archive, "-C", stage ])) {
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("unable to extract snapshot archive");
}
let staged_config = stage + "/config/" + CONFIG_NAME;
let had_config = fs.stat(staged_config) != null;
if (had_config && !config_sane_preview(staged_config)) {
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("snapshot config does not look like a valid UCI file");
}
let staged_data = stage + "/" + CONFIG_NAME;
let had_data = fs.stat(staged_data) != null;
if (!had_config && !had_data) {
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("snapshot contains neither config nor data");
}
let stamp = as_string(time()) + "-" + sprintf("%04x", clock()[1] & 0xFFFF);
let cfg_backup = SNAPSHOTS_DIR + "/restore_cfg_backup." + stamp;
let data_backup = SNAPSHOTS_DIR + "/restore_data_backup." + stamp;
if (fs.stat(CONFIG_PATH) &&
!command_success_from_args([ "cp", "-a", CONFIG_PATH, cfg_backup ])) {
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("unable to back up current config");
}
if (fs.stat(PERSISTENT_DIR) &&
!command_success_from_args([ "cp", "-a", PERSISTENT_DIR, data_backup ])) {
command_status(command_from_args([ "rm", "-f", cfg_backup ]));
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("unable to back up current data");
}
if (had_config) {
let tmp = CONFIG_PATH + ".restored";
if (!command_success_from_args([ "cp", "-a", staged_config, tmp ]) ||
!command_success_from_args([ "mv", "-f", tmp, CONFIG_PATH ])) {
restore_config_from_backup(cfg_backup);
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("unable to write restored config");
}
command_success_from_args([ "chmod", "600", CONFIG_PATH ]);
if (!command_success_from_args([ VALIDATE_BIN, "show", CONFIG_NAME ])) {
restore_config_from_backup(cfg_backup);
command_status(command_from_args([ "rm", "-rf", stage, data_backup ]));
command_status(command_from_args([ "rm", "-f", cfg_backup ]));
return fail("restored config failed validation; rolled back");
}
}
if (had_data) {
command_status(command_from_args([ "rm", "-rf", PERSISTENT_DIR ]));
if (!command_success_from_args([ "cp", "-a", staged_data, PERSISTENT_DIR ])) {
command_status(command_from_args([ "rm", "-rf", PERSISTENT_DIR ]));
if (fs.stat(data_backup))
command_status(command_from_args([ "mv", data_backup, PERSISTENT_DIR ]));
restore_config_from_backup(cfg_backup);
command_status(command_from_args([ "rm", "-rf", stage ]));
return fail("unable to write restored data; rolled back");
}
}
command_status(command_from_args([ "rm", "-rf", stage ]));
command_status(command_from_args([ "rm", "-f", cfg_backup ]));
command_status(command_from_args([ "rm", "-rf", data_backup ]));
log_message("Config snapshot restored: " + file);
print(sprintf("%J\n", { success: true }));
command_success_from_args([ BIN_PATH, "restart" ]);
return 0;
}
let mode = as_string(ARGV[0]);
if (mode == "snapshot-save") {
let name = as_string(ARGV[1]);
if (name == "")
exit(fail("snapshot name required"));
exit(snapshot_save(name));
}
if (mode == "snapshot-list") {
print(sprintf("%J\n", { success: true, snapshots: snapshot_list() }));
exit(0);
}
if (mode == "snapshot-restore") {
let file = as_string(ARGV[1]);
if (file == "")
exit(fail("snapshot file required"));
exit(snapshot_restore(file));
}
if (mode == "snapshot-delete") {
let file = as_string(ARGV[1]);
if (file == "")
exit(fail("snapshot file required"));
exit(snapshot_delete(file));
}
warn("Usage: service/snapshot.uc snapshot-list|snapshot-save <name>|snapshot-restore <file>|snapshot-delete <file>\n");
exit(1);