Compare commits

..

4 Commits

Author SHA1 Message Date
CyberMind
46dfd781d3
Merge pull request #537 from CyberMind-FR/feature/536-serve-the-android-toolbox-apk-from-the-t
Some checks are pending
License Headers / check (push) Waiting to run
feat(toolbox): serve Android APK from /wg/toolbox.apk + onboard button (#536)
2026-06-12 23:46:50 +02:00
cf3aef48c8 feat(toolbox): serve Android APK from /wg/toolbox.apk + onboard button (#536)
Follow-up to #531 — one-tap APK install from the cabine.
  - api.py GET /wg/toolbox.apk: serve local APK (android content-type),
    302 → latest GitHub release asset if absent (never dead-ends).
  - /wg/onboard Android panels (inline + _install_panels): 📱 Installer
    l'app ToolBoX (1-tap) button.
  - sbin/secubox-toolbox-fetch-apk: pull latest release asset into the
    serve path (best-effort + APK magic check); postinst creates the dir
    + first fetch.
  - build-android-apk.yml: publish APK as release asset
    secubox-toolbox-android.apk on android-v* tags (contents:write).
  - changelog 2.6.13.

Live on gk2: /wg/toolbox.apk 302 → release (fallback, no release yet),
serve dir created, onboard button rendered.
2026-06-12 23:46:29 +02:00
CyberMind
198fecea11
Merge pull request #535 from CyberMind-FR/feature/531-plan-android-apk-one-tap-toolbox-r3-clie
feat(android): one-tap toolbox R3 installer scaffold + CI (#531)
2026-06-12 23:27:34 +02:00
ab67c981dd feat(android): scaffold one-tap toolbox R3 installer + CI (ref #531)
Kotlin/Jetpack-Compose app under clients/android-toolbox/ replacing the
manual Android onboarding tutorial. 5-step flow:
  discover -> install CA (KeyChain intent) -> import WG profile (handed
  to the WireGuard app via FileProvider) -> verify (/wg/r3-check) ->
  live cartographie sociale (/social/me).

  - ToolboxApi: plain HttpURLConnection client for /wg/ca.crt,
    /wg/profile/new, /wg/r3-check, /social/me (no Retrofit/OkHttp —
    minimal deps + CI).
  - MainActivity: Compose stepper UI, palette-matched (cosmos/gold/cyan),
    FR copy. Intents for CA install + WireGuard handoff + Play fallback.
  - Gradle (AGP 8.5.2 / Kotlin 1.9.24 / Compose BOM 2024.06), minSdk 26,
    targetSdk 34, package in.secubox.toolbox. No wrapper jar committed.
  - res: adaptive launcher icon (vector eye glyph), theme, file_paths,
    strings — all text/XML, no binaries.
  - .github/workflows/build-android-apk.yml: setup-android + setup-gradle
    8.9 build assembleDebug -> APK artifact (sideloadable).
  - README documents the flow, build, and the Android user-CA-trust
    constraint (MVP guides manual confirm; release signing is follow-up).

Closes the scaffold half of #531; CI produces the debug APK.
2026-06-12 18:15:46 +02:00
22 changed files with 688 additions and 0 deletions

70
.github/workflows/build-android-apk.yml vendored Normal file
View File

@ -0,0 +1,70 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Build the SecuBox Android ToolBox client APK (#531).
# No Gradle wrapper jar is committed (text-only scaffold) — setup-gradle
# provides Gradle ; setup-android provides the SDK. Produces a debug APK
# artifact (sideloadable). Release signing is a follow-up (needs a
# keystore secret).
name: build-android-apk
on:
push:
branches: [ master ]
paths: [ "clients/android-toolbox/**", ".github/workflows/build-android-apk.yml" ]
tags: [ "android-v*" ]
pull_request:
paths: [ "clients/android-toolbox/**" ]
workflow_dispatch:
permissions:
contents: write # needed to attach the APK to a release on tags
jobs:
build:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: clients/android-toolbox
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.9"
- name: Build debug APK
run: gradle :app:assembleDebug --no-daemon --stacktrace
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: secubox-toolbox-android-debug
path: clients/android-toolbox/app/build/outputs/apk/debug/*.apk
if-no-files-found: error
# On android-v* tags, publish the APK as a release asset under the
# stable name the toolbox fetch helper + /wg/toolbox.apk expect
# (#536). `latest/download/secubox-toolbox-android.apk` resolves to
# whichever release is newest.
- name: Stage release asset
if: startsWith(github.ref, 'refs/tags/android-v')
run: |
mkdir -p "$GITHUB_WORKSPACE/release"
cp app/build/outputs/apk/debug/app-debug.apk \
"$GITHUB_WORKSPACE/release/secubox-toolbox-android.apk"
- name: Publish release
if: startsWith(github.ref, 'refs/tags/android-v')
uses: softprops/action-gh-release@v2
with:
files: release/secubox-toolbox-android.apk
fail_on_unmatched_files: true

11
clients/android-toolbox/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Android / Gradle build artifacts
.gradle/
build/
app/build/
local.properties
*.apk
*.aab
*.keystore
*.jks
.idea/
captures/

View File

@ -0,0 +1,39 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# SecuBox Android ToolBox client (#531)
One-tap **R3 onboarding** for the VILLAGE3B cabine : install the CA,
import the WireGuard profile, verify the tunnel, then open the live
*cartographie sociale*. Replaces the manual Android tutorial.
## Flow
1. **Discover** — scan the kbin QR or type the booth host (`kbin.gk2.secubox.in`).
2. **Install CA** — downloads `/wg/ca.crt`, launches the Android cert-install intent (`KeyChain.createInstallIntent`).
3. **Import profile** — downloads `/wg/profile/new`, hands the `.conf` to the WireGuard app via `FileProvider` + `ACTION_VIEW`.
4. **Verify** — polls `/wg/r3-check` → "Tunnel R3 actif ✓".
5. **Live metrics** — opens `/social/me` (cartographie sociale).
## Build
No Gradle wrapper jar is committed (text-only scaffold). CI builds it:
- **GitHub Actions** `build-android-apk.yml` → debug APK artifact.
Locally (with Android SDK + Gradle 8.9 + JDK 17):
```bash
cd clients/android-toolbox
gradle :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk
```
## Constraints (MVP)
- Android 11+ restricts **user CA trust** ; the app launches the install
intent + guides the manual confirm step. Browsers on the device need
the CA trusted for the mitm R3 break — this is the known Android
limitation (documented, not yet automated).
- WireGuard profile import uses the **official WireGuard app** (no embedded
tunnel in the MVP) — most reliable, no extra native deps.
- Debug APK is self-signed (sideload). Release signing (published
fingerprint, served from the toolbox) is a follow-up needing a keystore
secret in CI.
## Tech
Kotlin + Jetpack Compose, minSdk 26 / targetSdk 34. API client is plain
`HttpURLConnection` (no Retrofit/OkHttp) to keep deps + CI minimal.
Package `in.secubox.toolbox`. License `LicenseRef-CMSD-1.0`.

View File

@ -0,0 +1,49 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "in.secubox.toolbox"
compileSdk = 34
defaultConfig {
applicationId = "in.secubox.toolbox"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
// Signed in CI with a published-fingerprint key (sideload APK,
// no Play Store). Debug builds are self-signed by the SDK.
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// No Retrofit/OkHttp — the API client uses HttpURLConnection to keep
// the dependency graph (and CI) minimal.
}

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Query the WireGuard app so we can hand it the generated profile. -->
<queries>
<package android:name="com.wireguard.android" />
</queries>
<application
android:allowBackup="false"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.SecuBoxToolBox"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider to share the downloaded CA + WG .conf with the
system cert installer / the WireGuard app. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,227 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: Android ToolBox client (#531)
// One-tap R3 onboarding : discover -> install CA -> import WG profile ->
// verify tunnel -> live cartographie sociale. Replaces the manual
// multi-step Android tutorial.
package `in`.secubox.toolbox
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.security.KeyChain
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val Cosmos = Color(0xFF0A0A0F)
private val Gold = Color(0xFFC9A84C)
private val Cyan = Color(0xFF00D4FF)
private val Matrix = Color(0xFF00FF41)
private val Cinnabar = Color(0xFFE63946)
private val TextPrimary = Color(0xFFE8E6D9)
enum class Step { Discover, InstallCa, ImportProfile, Verify, Done }
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { OnboardApp() }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnboardApp() {
val ctx = androidx.compose.ui.platform.LocalContext.current
val scope = rememberCoroutineScope()
var host by remember { mutableStateOf("kbin.gk2.secubox.in") }
var step by remember { mutableStateOf(Step.Discover) }
var status by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var onTunnel by remember { mutableStateOf(false) }
var peerIp by remember { mutableStateOf<String?>(null) }
val api = remember(host) { ToolboxApi(host) }
MaterialTheme(colorScheme = darkColorScheme(
primary = Gold, secondary = Cyan, background = Cosmos, surface = Cosmos,
onBackground = TextPrimary, onSurface = TextPrimary,
)) {
Surface(Modifier.fillMaxSize(), color = Cosmos) {
Column(
Modifier.fillMaxSize().padding(20.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("📡 VILLAGE3B", color = Gold, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text("ToolBoX — installation R3", color = TextPrimary, fontSize = 14.sp)
Spacer(Modifier.height(20.dp))
Stepper(step)
Spacer(Modifier.height(20.dp))
when (step) {
Step.Discover -> {
OutlinedTextField(
value = host, onValueChange = { host = it },
label = { Text("Borne (kbin…)") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text("Scanne le QR de la cabine ou saisis l'adresse, puis Suivant.",
color = TextPrimary, fontSize = 12.sp)
Spacer(Modifier.height(16.dp))
BigButton("Suivant", busy) {
busy = true; status = "Vérification de la borne…"
scope.launch {
val ok = withContext(Dispatchers.IO) { api.reachable() }
busy = false
if (ok) { step = Step.InstallCa; status = "" }
else status = "Borne injoignable — vérifie l'adresse / le réseau."
}
}
}
Step.InstallCa -> {
StepBody("1 · Installer le certificat (CA R3)",
"Le certificat permet l'analyse TLS de la cabine. " +
"Android te demandera de confirmer (Paramètres → Sécurité → " +
"Certificat utilisateur).")
BigButton("Installer le certificat", busy) {
busy = true
scope.launch {
try {
val ca = withContext(Dispatchers.IO) { api.downloadCa(ctx.cacheDir) }
val der = ca.readBytes()
val intent = KeyChain.createInstallIntent().apply {
putExtra(KeyChain.EXTRA_CERTIFICATE, der)
putExtra(KeyChain.EXTRA_NAME, "VILLAGE3B ToolBoX CA")
}
ctx.startActivity(intent)
status = "Confirme l'installation dans Android, puis Suivant."
} catch (e: Exception) {
status = "Échec téléchargement CA : ${e.message}"
} finally { busy = false }
}
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = {
ctx.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
}) { Text("Ouvrir Paramètres sécurité", color = Cyan) }
Spacer(Modifier.height(8.dp))
BigButton("Suivant", false) { step = Step.ImportProfile; status = "" }
}
Step.ImportProfile -> {
StepBody("2 · Importer le profil WireGuard",
"On génère un profil dédié et on l'ouvre dans l'app WireGuard. " +
"Active le tunnel dans WireGuard, puis reviens ici.")
BigButton("Importer dans WireGuard", busy) {
busy = true
scope.launch {
try {
val conf = withContext(Dispatchers.IO) { api.downloadProfile(ctx.cacheDir) }
val uri = FileProvider.getUriForFile(
ctx, "${ctx.packageName}.fileprovider", conf)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/octet-stream")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setPackage("com.wireguard.android")
}
try { ctx.startActivity(intent) }
catch (_: Exception) {
// WireGuard not installed -> open Play / generic chooser.
ctx.startActivity(Intent(Intent.ACTION_VIEW,
Uri.parse("https://play.google.com/store/apps/details?id=com.wireguard.android")))
status = "Installe l'app WireGuard puis réessaie."
}
} catch (e: Exception) {
status = "Échec profil : ${e.message}"
} finally { busy = false }
}
}
Spacer(Modifier.height(8.dp))
BigButton("Suivant", false) { step = Step.Verify; status = "" }
}
Step.Verify -> {
StepBody("3 · Vérifier le tunnel R3",
"Active le tunnel dans WireGuard, puis vérifie.")
BigButton("Vérifier", busy) {
busy = true; status = "Vérification…"
scope.launch {
val (t, ip) = withContext(Dispatchers.IO) { api.r3Check() }
busy = false; onTunnel = t; peerIp = ip
if (t) { step = Step.Done; status = "" }
else status = "Pas encore sur le tunnel — active WireGuard puis réessaie."
}
}
}
Step.Done -> {
Icon(Icons.Filled.CheckCircle, null, tint = Matrix, modifier = Modifier.size(56.dp))
Text("Tunnel R3 actif ✓", color = Matrix, fontSize = 20.sp, fontWeight = FontWeight.Bold)
peerIp?.let { Text("pair : $it", color = TextPrimary, fontSize = 12.sp) }
Spacer(Modifier.height(20.dp))
BigButton("🕸️ Voir ma cartographie sociale", false) {
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(api.socialMeUrl)))
}
}
}
if (status.isNotBlank()) {
Spacer(Modifier.height(16.dp))
Text(status, color = if (status.contains("Échec") || status.contains("injoignable")) Cinnabar else Cyan,
fontSize = 13.sp)
}
}
}
}
}
@Composable
private fun Stepper(cur: Step) {
val steps = listOf(Step.Discover, Step.InstallCa, Step.ImportProfile, Step.Verify, Step.Done)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
steps.forEach { s ->
val done = s.ordinal < cur.ordinal
val active = s == cur
Box(Modifier.size(if (active) 14.dp else 10.dp)) {
Surface(shape = MaterialTheme.shapes.small,
color = when { done -> Matrix; active -> Gold; else -> Color(0xFF333333) },
modifier = Modifier.fillMaxSize()) {}
}
}
}
}
@Composable
private fun StepBody(title: String, body: String) {
Text(title, color = Gold, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(body, color = TextPrimary, fontSize = 13.sp)
Spacer(Modifier.height(16.dp))
}
@Composable
private fun BigButton(label: String, busy: Boolean, onClick: () -> Unit) {
Button(onClick = onClick, enabled = !busy, modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = Cosmos)) {
if (busy) CircularProgressIndicator(Modifier.size(18.dp), color = Cosmos, strokeWidth = 2.dp)
else Text(label, fontWeight = FontWeight.Bold)
}
}

View File

@ -0,0 +1,71 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
package `in`.secubox.toolbox
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Minimal HTTP client for the SecuBox ToolBox R3 endpoints. Uses
* HttpURLConnection (no Retrofit/OkHttp) to keep the dependency graph
* and CI minimal. All calls are blocking invoke off the main thread.
*
* Endpoints (served on the kbin vhost, e.g. kbin.gk2.secubox.in) :
* GET /wg/ca.crt -> CA root cert (Android DER/PEM)
* GET /wg/profile/new -> wg-quick .conf (one fresh peer per call)
* GET /wg/r3-check -> {"tunnel": bool, "peer_ip": "10.99.1.x"}
* GET /social/me -> per-client cartographie sociale (web view)
*/
class ToolboxApi(rawHost: String) {
// Accept "kbin.gk2.secubox.in", "https://kbin…", trailing slashes…
val base: String = rawHost.trim()
.removePrefix("https://").removePrefix("http://")
.trim('/')
.let { "https://$it" }
val socialMeUrl: String get() = "$base/social/me"
private fun open(path: String): HttpURLConnection =
(URL("$base$path").openConnection() as HttpURLConnection).apply {
connectTimeout = 8000
readTimeout = 12000
setRequestProperty("User-Agent", "secubox-toolbox-android/0.1")
instanceFollowRedirects = true
}
/** Download a file (CA or WG profile) into the app cache, return it. */
fun download(path: String, outName: String, cacheDir: File): File {
val c = open(path)
try {
if (c.responseCode !in 200..299)
throw RuntimeException("HTTP ${c.responseCode} for $path")
val out = File(cacheDir, outName)
c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } }
return out
} finally { c.disconnect() }
}
fun downloadCa(cacheDir: File): File = download("/wg/ca.crt", "village3b-ca.crt", cacheDir)
fun downloadProfile(cacheDir: File): File = download("/wg/profile/new", "village3b-toolbox.conf", cacheDir)
/** R3 tunnel status. Returns (onTunnel, peerIp?). */
fun r3Check(): Pair<Boolean, String?> {
val c = open("/wg/r3-check")
try {
if (c.responseCode !in 200..299) return false to null
val body = c.inputStream.bufferedReader().readText()
val j = JSONObject(body)
return j.optBoolean("tunnel", false) to j.optString("peer_ip", null)
} catch (_: Exception) {
return false to null
} finally { c.disconnect() }
}
/** Cheap reachability probe for the discover step. */
fun reachable(): Boolean = try {
val c = open("/wg/r3-check"); val ok = c.responseCode in 200..499; c.disconnect(); ok
} catch (_: Exception) { false }
}

View File

@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#C9A84C"
android:pathData="M54,40c-10,0 -19,6 -24,14c5,8 14,14 24,14s19,-6 24,-14c-5,-8 -14,-14 -24,-14zM54,64a10,10 0 1,1 0,-20a10,10 0 0,1 0,20z" />
<path android:fillColor="#00D4FF"
android:pathData="M54,49a5,5 0 1,0 0,10a5,5 0 0,0 0,-10z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0A0A0F</color>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<resources>
<string name="app_name">VILLAGE3B ToolBoX</string>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<resources>
<style name="Theme.SecuBoxToolBox" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">#0A0A0F</item>
<item name="android:navigationBarColor">#0A0A0F</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<paths>
<cache-path name="cache" path="." />
</paths>

View File

@ -0,0 +1,5 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@ -0,0 +1,5 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@ -0,0 +1,18 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// SecuBox-Deb :: Android ToolBox client (#531)
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "SecuBoxToolBox"
include(":app")

View File

@ -1,3 +1,21 @@
secubox-toolbox (2.6.13-1~bookworm1) bookworm; urgency=medium
* Serve the Android ToolBox APK from the toolbox (#536, follow-up #531).
- api.py GET /wg/toolbox.apk : serves the local APK
(/var/lib/secubox/toolbox/android/village3b-toolbox.apk) with
content-type application/vnd.android.package-archive ; if absent,
302 → the latest public GitHub release asset (button never
dead-ends, offline-capable once fetched).
- /wg/onboard Android panel (both the inline + the _install_panels
variants) : new "📱 Installer l'app ToolBoX (1-tap)" button.
- sbin/secubox-toolbox-fetch-apk : pulls the latest release asset
into the serve path (best-effort, APK-magic sanity check).
- postinst : create the android serve dir + best-effort first fetch.
- build-android-apk.yml : on android-v* tags, publish the APK as a
release asset named secubox-toolbox-android.apk (contents:write).
-- Gerald KERMA <devel@cybermind.fr> Thu, 12 Jun 2026 14:30:00 +0200
secubox-toolbox (2.6.12-1~bookworm1) bookworm; urgency=medium
* fix(threat_intel) #530 — ThreatFox ingested 0 IOCs for weeks because

View File

@ -44,6 +44,14 @@ case "$1" in
# 4. Storage dir (SQLite + future PDF reports)
install -d -m 0750 -o secubox-toolbox -g secubox-toolbox /var/lib/secubox/toolbox
# #536 : Android APK serve dir + best-effort fetch of the latest
# release asset (so GET /wg/toolbox.apk serves it locally/offline).
# Non-blocking : if there's no release yet / no network, the endpoint
# falls back to redirecting to the public release.
install -d -m 0755 -o secubox-toolbox -g secubox-toolbox /var/lib/secubox/toolbox/android
if [ -x /usr/sbin/secubox-toolbox-fetch-apk ]; then
/usr/sbin/secubox-toolbox-fetch-apk 2>&1 | head -2 || true
fi
# /var/log/secubox is a SHARED parent traversed by many service users
# (the aggregator runs as `secubox` and reads waf-threats.log under
# here). It MUST be 0755 — a 0750 owned by secubox-toolbox silently

View File

@ -102,6 +102,9 @@ execute_after_dh_auto_install:
debian/secubox-toolbox/lib/systemd/system/
install -m 0644 systemd/secubox-escalate.timer \
debian/secubox-toolbox/lib/systemd/system/
# #536 : Android APK fetch helper.
install -m 0755 sbin/secubox-toolbox-fetch-apk \
debian/secubox-toolbox/usr/sbin/
install -m 0755 sbin/secubox-toolbox-wg-restore \
debian/secubox-toolbox/usr/sbin/
install -m 0644 systemd/secubox-toolbox-wg-restore.service \

View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# SecuBox-Deb :: secubox-toolbox-fetch-apk (#536)
#
# Pull the latest Android ToolBox APK (published as a GitHub release
# asset by build-android-apk.yml on android-v* tags) into the toolbox
# serve path, so GET /wg/toolbox.apk serves it locally (offline-capable
# sideload from the cabine). Best-effort : a failure leaves any existing
# APK in place ; the endpoint falls back to the public release redirect.
set -euo pipefail
readonly MODULE="secubox-toolbox-fetch-apk"
DEST_DIR="/var/lib/secubox/toolbox/android"
DEST="${DEST_DIR}/village3b-toolbox.apk"
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/latest/download/secubox-toolbox-android.apk"
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }
install -d -m 0755 -o secubox-toolbox -g secubox-toolbox "$DEST_DIR" 2>/dev/null \
|| mkdir -p "$DEST_DIR"
TMP=$(mktemp --suffix=.apk)
trap 'rm -f "$TMP"' EXIT
if command -v wget >/dev/null 2>&1; then
if wget -q --timeout=20 --tries=2 "$RELEASE_URL" -O "$TMP" && [ -s "$TMP" ]; then
# Sanity : APK is a ZIP — must start with PK\x03\x04.
if head -c 2 "$TMP" | grep -q "PK"; then
install -m 0644 "$TMP" "$DEST"
chown secubox-toolbox:secubox-toolbox "$DEST" 2>/dev/null || true
log "fetched APK -> ${DEST} ($(stat -c%s "$DEST" 2>/dev/null) bytes)"
exit 0
else
log "downloaded file is not an APK (no release asset yet?) — keeping existing"
fi
else
log "fetch failed (no release yet / network) — /wg/toolbox.apk will redirect to the release"
fi
else
log "wget missing — cannot fetch APK"
fi
exit 0

View File

@ -621,6 +621,11 @@ pre{background:#1a1a25;color:var(--phos-hot);padding:0.6rem 0.8rem;border-radius
</div>
<div class="tab-content" data-content=android>
<a href="/wg/toolbox.apk" class="btn btn-go">📱 Installer l'app ToolBoX (1-tap)</a>
<div class=warn style="margin-top:0.5rem">
<b>Le plus simple</b> : l'app fait tout (CA + tunnel + vérif) en 5 étapes.
Active « sources inconnues » à l'installation. Sinon, méthode manuelle ci-dessous :
</div>
<a href="/wg/ca.crt" class="btn btn-warn">📥 Télécharger CA (.crt format Android)</a>
<div class=warn>
Chrome ne peut PAS installer un CA directement (sécurité Android 11+).
@ -1185,6 +1190,9 @@ _ONBOARD_BODY = {
<p class=note>Si rien ne se passe : Réglages Batterie désactive le mode économie (il coupe parfois les VPN).</p>
""",
"android": """
<p><b> Le plus simple l'app ToolBoX fait tout :</b></p>
<a class=btn href="/wg/toolbox.apk">📱 Installer l'app ToolBoX (.apk, 1-tap)</a>
<p class=note>Active « sources inconnues » à l'installation. L'app installe le CA, importe le tunnel et vérifie le R3 en 5 étapes. Sinon, méthode manuelle :</p>
<ol>
<li>Installe l'app <a class=btn href="https://play.google.com/store/apps/details?id=com.wireguard.android" target=_blank rel=noopener>WireGuard</a> depuis le Play Store.</li>
<li>Dans l'app, tap "+""Scan from QR code" → scanne ton QR :<br><img src="/wg/qr.png" alt="QR code" style="width:240px;max-width:100%;margin:0.5rem 0;border-radius:6px"></li>
@ -1326,6 +1334,37 @@ async def wg_ca_der() -> Response:
)
# Android ToolBox app (#531/#536). CI publishes the APK as a GitHub
# release asset on `android-v*` tags ; secubox-toolbox-fetch-apk pulls it
# into the serve path below. If absent, we redirect to the public
# release so the button always works.
_ANDROID_APK = Path("/var/lib/secubox/toolbox/android/village3b-toolbox.apk")
_ANDROID_APK_RELEASE = (
"https://github.com/CyberMind-FR/secubox-deb/releases/latest/download/"
"secubox-toolbox-android.apk"
)
@router.get("/wg/toolbox.apk")
async def wg_toolbox_apk() -> Response:
"""Serve the Android ToolBox installer APK (#536).
Local file first (sideload from the cabine, works offline) ; if it
hasn't been fetched yet, 302 to the latest public GitHub release
asset so the onboard button never dead-ends.
"""
if _ANDROID_APK.exists() and _ANDROID_APK.stat().st_size > 0:
return Response(
content=_ANDROID_APK.read_bytes(),
media_type="application/vnd.android.package-archive",
headers={
"Content-Disposition": "attachment; filename=village3b-toolbox.apk",
"Cache-Control": "public, max-age=300",
},
)
return RedirectResponse(url=_ANDROID_APK_RELEASE, status_code=302)
@router.get("/wg/ca.mobileconfig")
async def wg_ca_mobileconfig() -> Response:
"""iOS profile that installs the mitm-wg CA in trust store."""