Compare commits

...

30 Commits

Author SHA1 Message Date
897dc339a6 docs: update WIP, HISTORY, README for Eye Remote v2.2.1
- Session 146: build script fix, image validation
- README: v2.2.1 features and known issues
- GitHub issues #78, #79 referenced

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 10:51:33 +02:00
b2f046c146 fix(eye-remote): use fallback-display service instead of broken eye-agent
- Add secubox-fallback-display.service (3D cube + rainbow rings)
- Enable fallback-display instead of broken eye-agent
- Add libopenjp2-7, libtiff6 PIL dependencies
- Bump version to 2.2.1

The fallback_manager.py provides a stable working dashboard with:
- Connection states: OFFLINE/CONNECTING/ONLINE/COMMUNICATING
- Real metrics from MOCHAbin via direct API calls
- 3D rotating cube and rainbow ring visualizations

The eye-agent has import errors (relative imports, missing classes)
and needs further work to be functional.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 10:26:12 +02:00
e5630c6e6f docs: Session 145 - Eye Remote fallback dashboard fix
- Deployed fallback_manager.py as main dashboard (3D cube + rainbow rings)
- Created secubox-fallback-display.service
- Documented root causes and fixes
- NAT routing through MOCHAbin for Pi internet

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 10:23:02 +02:00
66860c8022 fix(eye-remote): Fix HyperPixel 2r LCD init causing kernel panic
Problem:
- hyperpixel2r-init.service had Restart=on-failure which caused
  repeated restart attempts during boot
- This triggered a kernel panic in bcm2835_handle_irq due to
  GPIO/DPI timing conflicts

Solution:
- Remove Restart=on-failure from hyperpixel2r-init.service
- LCD init is a one-shot operation that should not retry
- Add Before= ordering to ensure init completes before dashboard
- Configure pigpiod with -l flag (local socket only)

The fix allows the HyperPixel 2.1 Round display to initialize
correctly on Pi Zero W with Bookworm.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 07:51:39 +02:00
cf328e5a42 docs: add implementation plan for APT and Clone commands 2026-05-11 06:10:20 +02:00
ea47958c18 chore(go): Run go mod tidy to organize dependencies
Move direct dependencies (cobra, promptui, viper, yaml.v3) out of
indirect section. Verify all tests pass and binary builds successfully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 06:09:34 +02:00
1b13bffee3 test: Add tests for apt and clone commands
- Add TestAptCmdHelp to verify apt help output mentions setup and publish
- Add TestAptSetupRequiresRoot to verify root privilege check
- Add TestCloneCmdHelp to verify clone help output mentions tier and minimal flags
- Add TestCloneRequiresRoot to verify root privilege check

All tests pass successfully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 06:08:33 +02:00
647f49b9f8 feat(cli): Add clone command with interactive wizard
Implements bootstrap wizard for new SecuBox installations.

Features:
- Interactive tier selection (lite/standard/pro/minimal/custom)
- APT repository auto-setup (GPG key + sources.list)
- Multi-select custom package picker
- Non-interactive mode with --tier, --minimal, --packages
- Auto-confirmation with --yes flag
- Root privilege check

Example usage:
  sudo secubox clone              # Interactive wizard
  sudo secubox clone --tier pro -y
  sudo secubox clone --minimal -y
  sudo secubox clone --packages "secubox-core,secubox-hub" -y

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 06:06:54 +02:00
8b8e3e868c feat(apt): Add server subcommands for APT repository management
Implement all six APT server subcommands for repository management:
- init: Initialize local APT repository at /srv/apt
- publish: Publish .deb packages with optional lintian validation
- sync: Sync repository to apt.secubox.in
- list: List packages in repository
- remove: Remove package from repository
- check: Verify repository integrity

All commands support global flags (--codename, --component, --dry-run, --verbose)
and delegate to corresponding methods on the apt.Server struct.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 06:02:53 +02:00
fc05dda136 feat(apt): Create parent command and setup subcommand
- Add aptCmd parent command for repository management
- Implement aptSetupCmd for client-side repository configuration
- Support persistent flags: --codename, --component, --dry-run
- Download SecuBox GPG key and configure sources.list
- Wire to rootCmd with proper initialization

Refs: #27
2026-05-11 05:59:46 +02:00
f8502dfbd0 feat(apt): Implement APT Server package with repository operations
Implements Server struct with methods for managing APT repository:
- Init: Initialize repository with reprepro
- Publish: Publish .deb packages via apt-publish.sh script
- Sync: Sync repository to remote via apt-sync.sh script
- List: List packages in repository
- Remove: Remove package from repository
- Check: Verify repository integrity

Wraps existing shell scripts (apt-publish.sh, apt-sync.sh) and provides
direct reprepro commands for APT repository server operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:58:01 +02:00
220218e910 feat(apt): Create package resolution for tier-based installation
- Add Tier struct with Name, Description, Packages fields
- Define Tiers map: lite, standard, pro, minimal
- Add AvailablePackages list with 14 SecuBox modules
- Implement TierPackages() to resolve tier to package list
- Implement ValidateTier() for tier validation
- Implement TierNames() for wizard display
- Add comprehensive test coverage for all functions

All tests pass: TestTierPackages, TestValidateTier, TestTierNames, TestAvailablePackages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:56:32 +02:00
9f3d60024b feat(apt): Add APT Client package for repository setup
Implements TDD-based APT client with:
- GPG key download with retry logic (3 attempts, 2s backoff)
- sources.list generation for apt.secubox.in
- Complete Setup() method combining both operations
- Comprehensive test suite with 72.2% coverage

Tests cover:
- Successful GPG key download
- HTTP error handling
- Content verification
- sources.list format validation
- NewClient defaults
- Complete setup integration

Files:
- cmd/secubox/internal/apt/client.go
- cmd/secubox/internal/apt/client_test.go

This package will be used by:
- secubox apt setup command
- secubox clone wizard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:52:40 +02:00
fcca5a7dce docs: add design spec for secubox apt and clone commands
Hybrid approach:
- Client operations (apt setup, clone) in pure Go
- Server operations (apt init/publish/sync) wrap existing shell scripts

Features:
- secubox apt: full repo management + client setup
- secubox clone: interactive bootstrap wizard for new systems

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:44:35 +02:00
bd7dda0c6f feat(secubox): complete meta-script generator Tasks 14-17
Task 14: Arch Profiles
- Add profiles/arch/arm64.yaml and profiles/arch/amd64.yaml
- Add ResolveWithArch() to merger for base → arch → tier chain
- Add tests for arch profile resolution

Task 15: Package secubox.yaml for All Packages
- Add scripts/generate-secubox-yaml.py generator script
- Generate 131 debian/secubox.yaml files with:
  - Category detection (security, network, system, etc.)
  - Tier assignment (all, lite, standard, pro)
  - API socket and UI path detection

Task 16: APT Repository Setup
- Add apt/conf/ reprepro configuration (multi-arch arm64/amd64)
- Add apt/hooks/lintian-check pre-publish validation
- Add scripts/apt-publish.sh and scripts/apt-sync.sh
- Add apt/README.md documentation

Task 17: CI Integration
- Add .github/workflows/build-secubox-cli.yml
- Build for linux-amd64 and linux-arm64
- Version injection via ldflags
- GitHub releases on tag push

Code Quality Fixes (Tasks 10-13):
- Add atomic writes for OTA boot control files (0600 perms)
- Fix NVME device extraction (nvme0n1p2 → nvme0n1)
- Add scanner.Err() checks in hardware detection
- Fix URL injection validation in fetch command
- Add proper cleanup on checksum failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:32:29 +02:00
295a91aac4 feat(secubox): add ota command with A/B partition support
- Implement check, packages, system, all, rollback subcommands
- Add A/B partition management (root-a/root-b slot swapping)
- Support boot-count watchdog for auto-rollback after 3 failed boots
- Add --dry-run mode for safe testing
- Verify SHA256 checksums on system updates
- Support multiple release channels (stable, beta, nightly)
- Auto-detect board type for appropriate update selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:12:46 +02:00
5dba0d4106 feat(secubox): add fetch command for GitHub releases
- Download pre-built images from releases
- Support --board, --version, --list, --output flags
- Verify SHA256 checksums after download
- Show download progress with ETA
- List available releases and images

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-11 05:01:27 +02:00
871d2e9198 feat(secubox): add build command with stage orchestration
- Implement Builder with Run() and RunStage() methods
- Add rootfs stage: debootstrap with ARM64 cross-compilation support
- Add partition stage: GPT partitions with ESP, root, data
- Add boot stage: U-Boot/GRUB/extlinux bootloader installation
- Add compress stage: gzip/xz/zstd compression with parallel jobs
- Add checksums stage: SHA256/SHA512 checksum generation
- Support --dry-run to preview commands without execution
- Support -j flag for parallel compression jobs
- Support --stage flag to run individual stages
- Add 12 comprehensive tests for all functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 21:57:44 +02:00
63b1656cf8 feat(secubox): add info command with hardware detection
- Detect arch, CPU, RAM from /proc and runtime
- Detect board from device tree (/proc/device-tree/model)
- Detect VM from DMI or hypervisor flag
- Suggest tier based on RAM size
- Update gen command to use hardware.Detect
- Add --json flag for scripted output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 21:28:15 +02:00
d558b269d6 feat(secubox): add interactive wizard for gen command
- Create wizard package with Run() and discoverBoards() functions
- Discover available boards from board/ directory with fallback defaults
- Prompt for board, tier, optional packages, and output formats
- Use promptui for terminal-based interactive selection UI
- Update gen.go runWizard() to integrate wizard package
- Support loading board metadata from board.yaml when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 21:25:08 +02:00
29d88e20d8 feat(secubox): add gen command for manifest generation
- Load board and profile configurations
- Resolve profile inheritance chain
- Apply board-specific tweaks
- Support --enable/--disable package flags
- Generate manifest.yaml and Makefile
- Fix board.yaml tier reference to match profile naming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 21:14:03 +02:00
bf6881fc47 feat(secubox): add Makefile generator
- Generate Makefile with image, rootfs, partition, boot stages
- Support configurable output formats (gz, xz)
- Support configurable checksums (sha256, sha512)
- Add vdi, qcow2, iso extra targets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 21:10:27 +02:00
ddd87e55c7 feat(secubox): add manifest generator
- Define Manifest struct with kernel, partitions, boot, output
- Implement Generate() from Profile and Board
- Add ToYAML() for serialization with header comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 21:08:32 +02:00
eb91fe97c7 feat(secubox): add package scanner for debian/secubox.yaml
- Define Component struct with requirements, tags, modes
- Implement Scanner to find all secubox-* packages
- Add SupportsArch() helper method
- Create secubox-core/debian/secubox.yaml example

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 20:46:57 +02:00
7501ce80fd feat(secubox): add board configuration loader
- Define Board struct with hardware, boot, interfaces
- Define Tweaks struct for board-specific overrides
- Add LoadBoard and LoadTweaks functions
- Add MOCHAbin board.yaml and tweaks.yaml
- Support missing tweaks.yaml (optional)
- Add tests for TestLoadBoard and TestLoadTweaks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 20:41:15 +02:00
2bbecfdc5f feat(secubox): add profile inheritance merger
- Implement Merger with Resolve() for inheritance chain
- Merge packages, kernel, services, sysctl, features
- Add tier-lite, tier-standard, tier-pro profiles
- Handle excluded packages removal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 20:35:04 +02:00
c9da45fa21 feat(secubox): add profile loader with YAML parsing
- Define Profile struct with packages, kernel, services, features
- Implement Load() function for YAML files
- Add base.yaml with common SecuBox foundation
- Add unit tests for profile loading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 20:13:08 +02:00
300a88beb8 feat(secubox): initialize Go CLI with cobra
- Add go.mod with cobra, viper, yaml dependencies
- Create main.go entry point
- Create root command with version and config flags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 20:06:38 +02:00
3335a5053f docs: Add Meta-Script Generator implementation plan
17 tasks with TDD approach:
- Go CLI with cobra/viper (gen, build, fetch, ota, info)
- Profile loader with inheritance merger
- Board configuration and tweaks
- Package scanner for debian/secubox.yaml
- Manifest and Makefile generation
- Interactive wizard with promptui
- Hardware detection
- APT repository with lintian compliance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 20:00:56 +02:00
da706eeb28 docs: Add Meta-Script Generator v2.0.0 design spec
Design specification for secubox CLI tool:
- Go-based unified CLI (gen, build, fetch, ota)
- Profile hierarchy with hardware detection
- Package self-description (debian/secubox.yaml)
- A/B partition OTA with auto-rollback
- Dual APT repo (apt.secubox.in + apt.gk2.secubox.in)
- Multi-arch with lintian compliance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-10 19:56:50 +02:00
203 changed files with 17309 additions and 12 deletions

View File

@ -1,6 +1,73 @@
# HISTORY — SecuBox-DEB Migration Log
*Tracking completed milestones with dates*
---
## 2026-05-11
### Session 146 — Eye Remote v2.2.1 Build & Validation
**Goal:** Update build script with fallback display fix, build & test new image.
**Changes:**
1. **Build script updated** (`build-eye-remote-image.sh` v2.2.1)
- Added `secubox-fallback-display.service` installation
- Enabled fallback-display instead of broken eye-agent
- Added PIL dependencies: libopenjp2-7, libtiff6
- All agent subdirectories: display, secubox, system, web, api, recovery, sync
2. **Image built and tested**
- `/tmp/secubox-eye-remote-2.2.1.img` (5.3GB uncompressed)
- Flashed to SD card, tested on MOCHAbin
- Dashboard working: 3D cube + rainbow rings + real metrics
3. **GitHub Issues created**
- #78: Fix eye-agent import errors (bug)
- #79: Investigate Buildroot/Busybox minimal image (enhancement)
**Artifacts:**
- Commit: `b2f046c1`
- Image: `secubox-eye-remote-2.2.1.img.xz`
---
### Session 145 — Eye Remote Dashboard Fix
**Problem:** Eye Remote Pi Zero W showing wrong dashboard (plain fb_dashboard instead of nice fallback_manager with 3D cube and rainbow rings).
**Root Causes:**
1. Agent code incomplete - `DashboardRenderer` class doesn't exist
2. Build script missing `agent/api/` directory copy
3. Relative imports failing (`from ..api.setup import`)
4. PIL dependencies missing (libopenjp2-7)
**Fixes:**
1. **Deployed fallback_manager.py** as main dashboard
- 3D rotating cube animation
- Rainbow concentric rings for modules
- Connection state: OFFLINE/CONNECTING/ONLINE/COMMUNICATING
- Real-time metrics from MOCHAbin API
2. **Created secubox-fallback-display.service**
- Replaced broken secubox-eye-agent.service
- Proper PYTHONPATH and WorkingDirectory
3. **NAT routing through MOCHAbin**
- IP forwarding enabled
- iptables MASQUERADE for 10.55.0.0/30
- Pi can reach internet via USB OTG
4. **Missing directories copied**
- agent/api/ (metrics_fetcher, setup, gadget)
- agent/recovery/
- agent/sync/
**Working Configuration:**
```
Service: secubox-fallback-display.service
Display: /usr/lib/secubox-eye/agent/display/fallback/fallback_manager.py
API: http://10.55.0.1:8000/api/v1/system/metrics
```
---
## 2026-05-09

View File

@ -1,5 +1,57 @@
# WIP — Work In Progress
*Mis à jour : 2026-05-10 (Session 143)*
*Mis à jour : 2026-05-11 (Session 146)*
---
## ✅ Session 146: Eye Remote v2.2.1 Build & Validation
### Build Script Updated
- [x] Added `secubox-fallback-display.service` to build
- [x] Enabled fallback-display instead of broken eye-agent
- [x] Added PIL dependencies: libopenjp2-7, libtiff6
- [x] Bumped version to 2.2.1
- [x] All agent subdirectories copied: display, secubox, system, web, api, recovery, sync
### Image Built & Tested
- [x] Built `/tmp/secubox-eye-remote-2.2.1.img` (5.3GB)
- [x] Flashed to SD card and tested on MOCHAbin
- [x] Dashboard working: 3D cube + rainbow rings + real metrics
- [x] Compressed to `.img.xz` for archival
### GitHub Issues Created
- [#78] Fix eye-agent import errors and missing DashboardRenderer class
- [#79] Investigate Buildroot/Busybox minimal image for Eye Remote
### Artifacts
- `/tmp/secubox-eye-remote-2.2.1.img.xz` — Production image
- Commit: `b2f046c1` — Build script fix
---
## ✅ Session 145: Eye Remote Dashboard Fix
### Working State Restored
- [x] Fixed fallback_manager.py as main dashboard (3D cube + rainbow rings)
- [x] Deployed to `/usr/lib/secubox-eye/agent/display/fallback/`
- [x] Created `secubox-fallback-display.service` systemd unit
- [x] NAT routing through MOCHAbin for Pi internet access
- [x] PIL dependencies installed (libopenjp2-7, libtiff6)
### Root Cause Analysis
- Agent code incomplete: `DashboardRenderer` class missing
- Relative imports failing when running as script
- Build script missing `agent/api/` directory copy
### Services Configuration
| Service | Status | Purpose |
|---------|--------|---------|
| `secubox-fallback-display.service` | ✅ ACTIVE | Main dashboard (use this) |
| `secubox-fb-dashboard.service` | DISABLED | Old simple dashboard |
| `secubox-eye-agent.service` | DISABLED | Broken imports |
### Files Updated
- `/etc/systemd/system/secubox-fallback-display.service` — NEW working service
- `/usr/lib/secubox-eye/agent/display/fallback/` — Complete fallback display
---

201
.github/workflows/build-secubox-cli.yml vendored Normal file
View File

@ -0,0 +1,201 @@
# SecuBox CLI — Build Go Binary
# Builds the secubox meta-script generator for linux-amd64 and linux-arm64
name: Build SecuBox CLI
on:
push:
branches: [master, main]
paths:
- 'cmd/secubox/**'
- '.github/workflows/build-secubox-cli.yml'
tags: ['v*']
pull_request:
branches: [master, main]
paths:
- 'cmd/secubox/**'
workflow_dispatch:
inputs:
version:
description: 'Version override (e.g., v2.8.0)'
required: false
env:
GO_VERSION: '1.22'
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: cmd/secubox/go.sum
- name: Download dependencies
working-directory: cmd/secubox
run: go mod download
- name: Run tests
working-directory: cmd/secubox
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: cmd/secubox/coverage.out
build:
name: Build
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
suffix: linux-amd64
- goos: linux
goarch: arm64
suffix: linux-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # For git describe
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: cmd/secubox/go.sum
- name: Determine version
id: version
run: |
if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}"
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION="${{ github.ref_name }}"
else
VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo 'dev')"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Build binary
working-directory: cmd/secubox
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
VERSION="${{ steps.version.outputs.version }}"
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
COMMIT=$(git rev-parse --short HEAD)
go build -ldflags="-s -w \
-X main.Version=$VERSION \
-X main.BuildTime=$BUILD_TIME \
-X main.Commit=$COMMIT" \
-o secubox-${{ matrix.suffix }} .
- name: Compress binary
working-directory: cmd/secubox
run: |
tar -czvf secubox-${{ matrix.suffix }}.tar.gz secubox-${{ matrix.suffix }}
sha256sum secubox-${{ matrix.suffix }}.tar.gz > secubox-${{ matrix.suffix }}.tar.gz.sha256
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: secubox-${{ matrix.suffix }}
path: |
cmd/secubox/secubox-${{ matrix.suffix }}.tar.gz
cmd/secubox/secubox-${{ matrix.suffix }}.tar.gz.sha256
release:
name: Release
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Generate release notes
run: |
cat > RELEASE_NOTES.md << 'EOF'
# SecuBox CLI ${{ github.ref_name }}
Meta-script generator for SecuBox-DEB image building.
## Features
- Profile-based image generation
- Board detection and auto-configuration
- A/B partition OTA updates
- GitHub releases integration
## Downloads
| Platform | File | SHA256 |
|----------|------|--------|
| Linux x64 | `secubox-linux-amd64.tar.gz` | [checksum] |
| Linux ARM64 | `secubox-linux-arm64.tar.gz` | [checksum] |
## Installation
```bash
# Linux x64
curl -LO https://github.com/CyberMind-FR/secubox-deb/releases/download/${{ github.ref_name }}/secubox-linux-amd64.tar.gz
tar xzf secubox-linux-amd64.tar.gz
sudo mv secubox-linux-amd64 /usr/local/bin/secubox
# Linux ARM64
curl -LO https://github.com/CyberMind-FR/secubox-deb/releases/download/${{ github.ref_name }}/secubox-linux-arm64.tar.gz
tar xzf secubox-linux-arm64.tar.gz
sudo mv secubox-linux-arm64 /usr/local/bin/secubox
```
## Quick Start
```bash
# Interactive wizard
secubox gen
# Generate for specific board
secubox gen --board mochabin --tier pro
# Build image
secubox build --board mochabin --output secubox.img
# Check hardware
secubox info
# OTA update
secubox ota update --url https://releases.secubox.in/v2.8.0/secubox-mochabin.img.gz
```
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: SecuBox CLI ${{ github.ref_name }}
body_path: RELEASE_NOTES.md
files: |
artifacts/*.tar.gz
artifacts/*.sha256
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

137
apt/README.md Normal file
View File

@ -0,0 +1,137 @@
# SecuBox-DEB APT Repository
Configuration files for the SecuBox APT package repository.
## Repository Structure
```
/srv/apt/
├── conf/
│ ├── distributions # Repository distributions config
│ ├── options # Global reprepro options
│ └── incoming # Incoming packages config
├── db/ # Reprepro database
├── dists/ # Distribution metadata
│ ├── bookworm/ # Stable releases
│ └── bookworm-testing/ # Testing releases
├── pool/ # Package files
│ ├── main/ # Main component
│ └── contrib/ # Contrib component
├── incoming/ # Incoming packages drop
└── tmp/ # Temporary files
```
## Setup
### Initialize Repository
```bash
# Create directory structure
sudo mkdir -p /srv/apt/{conf,db,dists,pool,incoming,tmp}
# Copy configuration
sudo cp apt/conf/* /srv/apt/conf/
# Set ownership
sudo chown -R $(whoami) /srv/apt
# Initialize
cd /srv/apt
reprepro export
```
### GPG Key Setup
```bash
# Generate signing key (if not exists)
gpg --gen-key
# Export public key for clients
gpg --armor --export your@email.com > /srv/apt/secubox.gpg
```
## Usage
### Publishing Packages
```bash
# Publish a single package
./scripts/apt-publish.sh packages/secubox-core/*.deb
# Publish to testing
./scripts/apt-publish.sh -c bookworm-testing packages/*/*.deb
# Skip lintian (not recommended)
./scripts/apt-publish.sh --skip-lintian packages/*/*.deb
# Dry run
./scripts/apt-publish.sh -n packages/*/*.deb
```
### Syncing to Remote
```bash
# Preview sync
./scripts/apt-sync.sh --dry-run
# Full sync
./scripts/apt-sync.sh
# Verbose sync
./scripts/apt-sync.sh --verbose
```
### Manual reprepro Commands
```bash
cd /srv/apt
# Add package
reprepro -C main includedeb bookworm /path/to/package.deb
# Remove package
reprepro remove bookworm package-name
# List packages
reprepro list bookworm
# Check repository
reprepro check
```
## Client Configuration
### Adding Repository
```bash
# Add GPG key
curl -fsSL https://apt.secubox.in/secubox.gpg | sudo gpg --dearmor -o /usr/share/keyrings/secubox.gpg
# Add repository
echo "deb [signed-by=/usr/share/keyrings/secubox.gpg] https://apt.secubox.in bookworm main" | \
sudo tee /etc/apt/sources.list.d/secubox.list
# Update and install
sudo apt update
sudo apt install secubox-core
```
## Distributions
| Codename | Description | Use Case |
|----------|-------------|----------|
| bookworm | Stable releases | Production |
| bookworm-testing | Testing releases | Pre-release testing |
## Components
| Component | Description |
|-----------|-------------|
| main | Core SecuBox packages |
| contrib | Community contributions |
## Architectures
- `arm64` - Primary target (MOCHAbin, ESPRESSObin, RPi)
- `amd64` - VMs and x64 hardware
- `all` - Architecture-independent packages

27
apt/conf/distributions Normal file
View File

@ -0,0 +1,27 @@
# apt/conf/distributions
# Reprepro configuration for SecuBox-DEB APT repository
# Multi-arch support: arm64 + amd64
Origin: SecuBox
Label: SecuBox-DEB
Suite: stable
Codename: bookworm
Version: 12
Architectures: arm64 amd64 all source
Components: main contrib
Description: SecuBox-DEB packages for Debian Bookworm
SignWith: default
DebIndices: Packages Release . .gz .bz2
DscIndices: Sources Release .gz .bz2
Origin: SecuBox
Label: SecuBox-DEB
Suite: testing
Codename: bookworm-testing
Version: 12
Architectures: arm64 amd64 all source
Components: main contrib
Description: SecuBox-DEB testing packages
SignWith: default
DebIndices: Packages Release . .gz .bz2
DscIndices: Sources Release .gz .bz2

8
apt/conf/incoming Normal file
View File

@ -0,0 +1,8 @@
# apt/conf/incoming
# Reprepro incoming configuration
Name: default
IncomingDir: incoming
TempDir: tmp
Allow: bookworm bookworm-testing
Cleanup: on_deny on_error

6
apt/conf/options Normal file
View File

@ -0,0 +1,6 @@
# apt/conf/options
# Reprepro global options
verbose
ask-passphrase
basedir /srv/apt

32
apt/hooks/lintian-check Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# apt/hooks/lintian-check
# Pre-publish hook to run lintian on packages before adding to repository
# SecuBox-DEB - CyberMind
set -euo pipefail
DEB_FILE="$1"
if [ ! -f "$DEB_FILE" ]; then
echo "ERROR: Package file not found: $DEB_FILE"
exit 1
fi
echo "=== Running lintian on $(basename "$DEB_FILE") ==="
# Run lintian with appropriate checks
# --fail-on error: Fail if there are errors
# --suppress-tags: Suppress some common warnings for our use case
LINTIAN_OPTS="--fail-on error"
LINTIAN_OPTS="$LINTIAN_OPTS --suppress-tags debian-changelog-file-missing-or-wrong-name"
LINTIAN_OPTS="$LINTIAN_OPTS --suppress-tags changelog-file-missing-in-native-package"
LINTIAN_OPTS="$LINTIAN_OPTS --suppress-tags file-missing-in-root-dir"
if lintian $LINTIAN_OPTS "$DEB_FILE"; then
echo "=== Lintian check PASSED ==="
exit 0
else
echo "=== Lintian check FAILED ==="
echo "Package will not be added to repository."
exit 1
fi

27
board/mochabin/board.yaml Normal file
View File

@ -0,0 +1,27 @@
# board/mochabin/board.yaml
# SecuBox board configuration for GlobalScale MOCHAbin
# SoC: Marvell Armada 7040 (Cortex-A72 Quad-core 1.8GHz)
name: mochabin
arch: arm64
tier: tier-pro
soc: armada-7040
hardware:
ram: 4G-8G
emmc: 16G
interfaces:
wan: eth0
lan:
- eth1
- eth2
- eth3
- eth4
sfp:
- eth5
- eth6
boot:
method: uboot
kernel_image: Image
dts: armada-7040-mochabin

View File

@ -0,0 +1,24 @@
# board/mochabin/tweaks.yaml
# Board-specific tweaks for GlobalScale MOCHAbin
# Override defaults for Armada 7040 hardware
kernel:
modules:
enable:
- mvpp2
- mvneta
- armada_thermal
- marvell_cesa
blacklist:
- mv88e6xxx
sysctl:
net.core.netdev_max_backlog: 5000
net.core.rmem_max: 16777216
net.core.wmem_max: 16777216
net.ipv4.tcp_rmem: "4096 87380 16777216"
net.ipv4.tcp_wmem: "4096 65536 16777216"
services:
enable:
- secubox-led

128
cmd/secubox/cmd/apt.go Normal file
View File

@ -0,0 +1,128 @@
// cmd/secubox/cmd/apt.go
package cmd
import (
"fmt"
"os"
"os/exec"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/apt"
"github.com/spf13/cobra"
)
var (
aptCodename string
aptComponent string
aptDryRun bool
)
var aptCmd = &cobra.Command{
Use: "apt",
Short: "Manage APT repository",
Long: `Manage SecuBox APT repository (client and server operations).
Client commands:
setup Add SecuBox repository to this system
Server commands:
init Initialize local APT repository
publish Publish .deb packages
sync Sync to apt.secubox.in
list List packages in repository
remove Remove package from repository
check Verify repository integrity
Examples:
# Add SecuBox repo to fresh Debian system
sudo secubox apt setup
# Publish packages (server)
secubox apt publish packages/*/*.deb
# Sync to remote (server)
secubox apt sync`,
}
var aptSetupCmd = &cobra.Command{
Use: "setup",
Short: "Add SecuBox repository to this system",
Long: `Add the SecuBox APT repository to this system.
This command:
1. Downloads the SecuBox GPG key
2. Adds /etc/apt/sources.list.d/secubox.list
3. Runs apt update
Requires root privileges.
Example:
sudo secubox apt setup`,
RunE: runAptSetup,
}
func init() {
rootCmd.AddCommand(aptCmd)
aptCmd.AddCommand(aptSetupCmd)
// Global flags for apt subcommands
aptCmd.PersistentFlags().StringVarP(&aptCodename, "codename", "c", "bookworm", "distribution codename")
aptCmd.PersistentFlags().StringVarP(&aptComponent, "component", "C", "main", "repository component")
aptCmd.PersistentFlags().BoolVarP(&aptDryRun, "dry-run", "n", false, "preview without executing")
}
func runAptSetup(cmd *cobra.Command, args []string) error {
// Check root
if os.Geteuid() != 0 {
return fmt.Errorf("must run as root (use sudo)")
}
fmt.Println("SecuBox APT Repository Setup")
fmt.Println("============================")
fmt.Println()
client := apt.NewClient()
client.Codename = aptCodename
client.Component = aptComponent
// Download GPG key
fmt.Print("Downloading GPG key... ")
if err := client.DownloadGPGKey(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("download GPG key: %w", err)
}
fmt.Println("OK")
// Write sources.list
fmt.Print("Adding repository... ")
if err := client.WriteSourcesList(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("write sources.list: %w", err)
}
fmt.Println("OK")
// Run apt update
fmt.Print("Updating package lists... ")
if aptDryRun {
fmt.Println("[DRY-RUN]")
} else {
aptUpdate := exec.Command("apt", "update")
aptUpdate.Stdout = os.Stdout
aptUpdate.Stderr = os.Stderr
if err := aptUpdate.Run(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("apt update: %w", err)
}
fmt.Println("OK")
}
fmt.Println()
fmt.Println("SecuBox repository configured successfully!")
fmt.Println()
fmt.Println("Install packages with:")
fmt.Println(" apt install secubox-core secubox-hub")
fmt.Println()
fmt.Println("Or use the clone wizard:")
fmt.Println(" sudo secubox clone")
return nil
}

View File

@ -0,0 +1,171 @@
// cmd/secubox/cmd/apt_server.go
package cmd
import (
"fmt"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/apt"
"github.com/spf13/cobra"
)
var (
aptSkipLintian bool
)
var aptInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize local APT repository",
Long: `Initialize the local APT repository at /srv/apt.
Creates the directory structure and runs reprepro export.
Example:
secubox apt init`,
RunE: runAptInit,
}
var aptPublishCmd = &cobra.Command{
Use: "publish <files...>",
Short: "Publish .deb packages to repository",
Long: `Publish .deb packages to the local APT repository.
Validates packages with lintian before publishing (unless --skip-lintian).
Examples:
secubox apt publish packages/secubox-core/*.deb
secubox apt publish -c bookworm-testing *.deb
secubox apt publish --skip-lintian packages/*/*.deb`,
Args: cobra.MinimumNArgs(1),
RunE: runAptPublish,
}
var aptSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync repository to apt.secubox.in",
Long: `Sync the local APT repository to apt.secubox.in.
Uses rsync to upload dists/ and pool/ directories.
Examples:
secubox apt sync
secubox apt sync --dry-run`,
RunE: runAptSync,
}
var aptListCmd = &cobra.Command{
Use: "list",
Short: "List packages in repository",
Long: `List all packages in the repository for the given codename.
Example:
secubox apt list
secubox apt list -c bookworm-testing`,
RunE: runAptList,
}
var aptRemoveCmd = &cobra.Command{
Use: "remove <package>",
Short: "Remove package from repository",
Long: `Remove a package from the repository.
Example:
secubox apt remove secubox-core`,
Args: cobra.ExactArgs(1),
RunE: runAptRemove,
}
var aptCheckCmd = &cobra.Command{
Use: "check",
Short: "Verify repository integrity",
Long: `Run reprepro check to verify repository integrity.
Example:
secubox apt check`,
RunE: runAptCheck,
}
func init() {
aptCmd.AddCommand(aptInitCmd)
aptCmd.AddCommand(aptPublishCmd)
aptCmd.AddCommand(aptSyncCmd)
aptCmd.AddCommand(aptListCmd)
aptCmd.AddCommand(aptRemoveCmd)
aptCmd.AddCommand(aptCheckCmd)
// Publish-specific flags
aptPublishCmd.Flags().BoolVarP(&aptSkipLintian, "skip-lintian", "s", false, "skip lintian validation")
}
func getServer() (*apt.Server, error) {
repoRoot, err := findRepoRoot()
if err != nil {
return nil, fmt.Errorf("find repo root: %w", err)
}
server := apt.NewServer(repoRoot)
server.Codename = aptCodename
server.Component = aptComponent
server.DryRun = aptDryRun
server.Verbose = verbose
return server, nil
}
func runAptInit(cmd *cobra.Command, args []string) error {
server, err := getServer()
if err != nil {
return err
}
fmt.Println("Initializing APT repository...")
if err := server.Init(); err != nil {
return fmt.Errorf("init: %w", err)
}
fmt.Println("Repository initialized at /srv/apt")
return nil
}
func runAptPublish(cmd *cobra.Command, args []string) error {
server, err := getServer()
if err != nil {
return err
}
return server.Publish(args, aptSkipLintian)
}
func runAptSync(cmd *cobra.Command, args []string) error {
server, err := getServer()
if err != nil {
return err
}
return server.Sync()
}
func runAptList(cmd *cobra.Command, args []string) error {
server, err := getServer()
if err != nil {
return err
}
return server.List()
}
func runAptRemove(cmd *cobra.Command, args []string) error {
server, err := getServer()
if err != nil {
return err
}
return server.Remove(args[0])
}
func runAptCheck(cmd *cobra.Command, args []string) error {
server, err := getServer()
if err != nil {
return err
}
return server.Check()
}

View File

@ -0,0 +1,42 @@
// cmd/secubox/cmd/apt_test.go
package cmd
import (
"bytes"
"os"
"testing"
)
func TestAptCmdHelp(t *testing.T) {
cmd := rootCmd
b := new(bytes.Buffer)
cmd.SetOut(b)
cmd.SetArgs([]string{"apt", "--help"})
// Execute() returns nil when --help is used
cmd.Execute()
output := b.String()
if !bytes.Contains([]byte(output), []byte("setup")) {
t.Error("apt help should mention setup subcommand")
}
if !bytes.Contains([]byte(output), []byte("publish")) {
t.Error("apt help should mention publish subcommand")
}
}
func TestAptSetupRequiresRoot(t *testing.T) {
// Skip if running as root
if os.Geteuid() == 0 {
t.Skip("test requires non-root user")
}
cmd := aptSetupCmd
err := cmd.RunE(cmd, []string{})
if err == nil {
t.Error("apt setup should require root")
}
if !bytes.Contains([]byte(err.Error()), []byte("root")) {
t.Errorf("error should mention root: %v", err)
}
}

161
cmd/secubox/cmd/build.go Normal file
View File

@ -0,0 +1,161 @@
// cmd/secubox/cmd/build.go
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/builder"
"github.com/spf13/cobra"
)
var (
buildManifest string
buildStage string
buildDryRun bool
buildJobs int
buildOutput string
)
var buildCmd = &cobra.Command{
Use: "build",
Short: "Build SecuBox image from manifest",
Long: `Build a SecuBox disk image from a manifest.yaml file.
The build process runs through multiple stages:
1. rootfs - Create root filesystem with debootstrap
2. partition - Create disk image with GPT partitions
3. boot - Install bootloader (U-Boot/GRUB)
4. compress - Compress image (gzip/xz)
5. checksums - Generate checksums (SHA256/SHA512)
Examples:
# Build from manifest in current directory
secubox build
# Build from specific manifest
secubox build --manifest /path/to/manifest.yaml
# Run only the rootfs stage
secubox build --stage rootfs
# Show what would be done (dry-run)
secubox build --dry-run
# Build with 4 parallel jobs
secubox build -j 4
Note: Building requires root privileges for debootstrap, losetup, and mount operations.
Use: sudo secubox build`,
RunE: runBuild,
}
func init() {
rootCmd.AddCommand(buildCmd)
buildCmd.Flags().StringVarP(&buildManifest, "manifest", "m", "", "path to manifest.yaml (default: ./manifest.yaml)")
buildCmd.Flags().StringVarP(&buildStage, "stage", "s", "", "run only specific stage (rootfs, partition, boot, compress, checksums)")
buildCmd.Flags().BoolVar(&buildDryRun, "dry-run", false, "show commands without executing")
buildCmd.Flags().IntVarP(&buildJobs, "jobs", "j", 1, "number of parallel jobs for compression")
buildCmd.Flags().StringVarP(&buildOutput, "output", "o", "./build", "output directory for build artifacts")
}
func runBuild(cmd *cobra.Command, args []string) error {
// Find manifest file
manifestPath := buildManifest
if manifestPath == "" {
// Look for manifest.yaml in current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
manifestPath = filepath.Join(cwd, "manifest.yaml")
}
// Check manifest exists
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
return fmt.Errorf("manifest not found: %s\nRun 'secubox gen' first to create a manifest", manifestPath)
}
// Load manifest
m, err := builder.LoadManifest(manifestPath)
if err != nil {
return fmt.Errorf("load manifest: %w", err)
}
// Validate stage if specified
if buildStage != "" && !builder.IsValidStage(buildStage) {
return fmt.Errorf("invalid stage: %s\nValid stages: %s", buildStage, strings.Join(builder.ValidStages, ", "))
}
// Resolve output directory
outputDir := buildOutput
if !filepath.IsAbs(outputDir) {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory for output: %w", err)
}
outputDir = filepath.Join(cwd, outputDir)
}
// Create builder
opts := &builder.Options{
Manifest: m,
OutputDir: outputDir,
DryRun: buildDryRun,
ParallelJobs: buildJobs,
Verbose: verbose,
}
b := builder.New(opts)
// Print build info
fmt.Printf("SecuBox Build\n")
fmt.Printf(" Board: %s\n", m.Board)
fmt.Printf(" Arch: %s\n", m.Arch)
fmt.Printf(" Tier: %s\n", m.Tier)
fmt.Printf(" Packages: %d\n", len(m.Packages))
fmt.Printf(" Output: %s\n", outputDir)
if buildDryRun {
fmt.Printf(" Mode: DRY-RUN\n")
}
fmt.Println()
// Create output directory if not dry-run
if !buildDryRun {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
}
// Run build
var cmds []string
if buildStage != "" {
// Run single stage
fmt.Printf("Running stage: %s\n", buildStage)
cmds, err = b.RunStage(buildStage)
} else {
// Run all stages
fmt.Printf("Running all stages: %s\n", strings.Join(builder.ValidStages, " -> "))
cmds, err = b.Run()
}
// In dry-run mode, print commands
if buildDryRun {
fmt.Println("\n--- Commands that would be executed ---")
for i, c := range cmds {
fmt.Printf("[%d] %s\n", i+1, c)
}
fmt.Println("--- End of commands ---")
return nil
}
if err != nil {
return fmt.Errorf("build failed: %w", err)
}
fmt.Printf("\nBuild complete!\n")
fmt.Printf("Image: %s/secubox-%s.img\n", outputDir, m.Board)
return nil
}

256
cmd/secubox/cmd/clone.go Normal file
View File

@ -0,0 +1,256 @@
// cmd/secubox/cmd/clone.go
package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/apt"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)
var (
cloneTier string
cloneMinimal bool
clonePackages string
cloneYes bool
)
var cloneCmd = &cobra.Command{
Use: "clone",
Short: "Bootstrap a new SecuBox system",
Long: `Bootstrap wizard for new SecuBox installations.
This command:
1. Adds the SecuBox APT repository
2. Lets you select an installation tier (or custom packages)
3. Installs the selected packages
Tiers:
lite - 1-2GB RAM (ESPRESSObin), basic security
standard - 4GB RAM, general purpose
pro - 8GB+ RAM (MOCHAbin), all features
minimal - Core + Hub only
Examples:
# Interactive wizard
sudo secubox clone
# Install pro tier non-interactively
sudo secubox clone --tier pro -y
# Minimal install
sudo secubox clone --minimal -y
# Specific packages
sudo secubox clone --packages "secubox-core,secubox-hub,secubox-crowdsec" -y`,
RunE: runClone,
}
func init() {
rootCmd.AddCommand(cloneCmd)
cloneCmd.Flags().StringVarP(&cloneTier, "tier", "t", "", "install specific tier (lite, standard, pro)")
cloneCmd.Flags().BoolVar(&cloneMinimal, "minimal", false, "install secubox-core + secubox-hub only")
cloneCmd.Flags().StringVarP(&clonePackages, "packages", "p", "", "comma-separated package list")
cloneCmd.Flags().BoolVarP(&cloneYes, "yes", "y", false, "auto-confirm apt prompts")
}
func runClone(cmd *cobra.Command, args []string) error {
// Check root
if os.Geteuid() != 0 {
return fmt.Errorf("must run as root (use sudo)")
}
fmt.Println()
fmt.Println("SecuBox Bootstrap Wizard")
fmt.Println("========================")
fmt.Println()
// Setup repository
client := apt.NewClient()
fmt.Println("Adding SecuBox repository...")
fmt.Print(" Downloading GPG key... ")
if err := client.DownloadGPGKey(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("download GPG key: %w", err)
}
fmt.Println("OK")
fmt.Print(" Adding sources.list... ")
if err := client.WriteSourcesList(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("write sources.list: %w", err)
}
fmt.Println("OK")
fmt.Print(" Updating package lists... ")
aptUpdate := exec.Command("apt", "update")
if err := aptUpdate.Run(); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("apt update: %w", err)
}
fmt.Println("OK")
fmt.Println()
// Determine packages to install
var packages []string
var err error
if cloneMinimal {
packages = []string{"secubox-core", "secubox-hub"}
} else if clonePackages != "" {
packages = strings.Split(clonePackages, ",")
for i := range packages {
packages[i] = strings.TrimSpace(packages[i])
}
} else if cloneTier != "" {
packages, err = apt.TierPackages(cloneTier)
if err != nil {
return err
}
} else {
// Interactive wizard
packages, err = runCloneWizard()
if err != nil {
return fmt.Errorf("wizard: %w", err)
}
}
if len(packages) == 0 {
return fmt.Errorf("no packages selected")
}
// Install packages
fmt.Printf("Installing: %s\n\n", strings.Join(packages, " "))
aptArgs := []string{"install"}
if cloneYes {
aptArgs = append(aptArgs, "-y")
}
aptArgs = append(aptArgs, packages...)
aptInstall := exec.Command("apt", aptArgs...)
aptInstall.Stdout = os.Stdout
aptInstall.Stderr = os.Stderr
aptInstall.Stdin = os.Stdin
if err := aptInstall.Run(); err != nil {
return fmt.Errorf("apt install: %w", err)
}
fmt.Println()
fmt.Println("SecuBox installation complete!")
fmt.Println()
fmt.Println("Access dashboard at: https://<IP>:9443")
fmt.Println("Default credentials: admin / secubox")
fmt.Println()
return nil
}
func runCloneWizard() ([]string, error) {
// Tier selection
tierItems := []string{
"Lite (1-2GB RAM) - ESPRESSObin, basic security",
"Standard (4GB RAM) - General purpose",
"Pro (8GB+ RAM) - Full features, MOCHAbin",
"Minimal - Core + Hub only",
"Custom - Pick individual packages",
}
tierPrompt := promptui.Select{
Label: "Select installation tier",
Items: tierItems,
Size: 5,
}
tierIdx, _, err := tierPrompt.Run()
if err != nil {
return nil, fmt.Errorf("tier selection: %w", err)
}
tierMap := []string{"lite", "standard", "pro", "minimal", "custom"}
selectedTier := tierMap[tierIdx]
// Handle custom selection
if selectedTier == "custom" {
return selectCustomPackages()
}
// Return tier packages
return apt.TierPackages(selectedTier)
}
func selectCustomPackages() ([]string, error) {
packages := apt.AvailablePackages
selected := []string{}
fmt.Println()
fmt.Println("Select packages to install (multi-select):")
for {
items := []string{"[Done - install selected]"}
for _, pkg := range packages {
marker := " "
for _, s := range selected {
if s == pkg {
marker = "✓ "
break
}
}
items = append(items, marker+pkg)
}
prompt := promptui.Select{
Label: fmt.Sprintf("Selected: %d packages", len(selected)),
Items: items,
Size: 10,
}
idx, _, err := prompt.Run()
if err != nil {
return nil, fmt.Errorf("package selection: %w", err)
}
if idx == 0 {
break
}
pkg := packages[idx-1]
// Toggle selection
found := false
newSelected := []string{}
for _, s := range selected {
if s == pkg {
found = true
} else {
newSelected = append(newSelected, s)
}
}
if found {
selected = newSelected
} else {
selected = append(selected, pkg)
}
}
// Ensure secubox-core is always included
hasCore := false
for _, s := range selected {
if s == "secubox-core" {
hasCore = true
break
}
}
if !hasCore && len(selected) > 0 {
selected = append([]string{"secubox-core"}, selected...)
}
return selected, nil
}

View File

@ -0,0 +1,42 @@
// cmd/secubox/cmd/clone_test.go
package cmd
import (
"bytes"
"os"
"testing"
)
func TestCloneCmdHelp(t *testing.T) {
cmd := rootCmd
b := new(bytes.Buffer)
cmd.SetOut(b)
cmd.SetArgs([]string{"clone", "--help"})
// Execute() returns nil when --help is used
cmd.Execute()
output := b.String()
if !bytes.Contains([]byte(output), []byte("tier")) {
t.Error("clone help should mention --tier flag")
}
if !bytes.Contains([]byte(output), []byte("minimal")) {
t.Error("clone help should mention --minimal flag")
}
}
func TestCloneRequiresRoot(t *testing.T) {
// Skip if running as root
if os.Geteuid() == 0 {
t.Skip("test requires non-root user")
}
cmd := cloneCmd
err := cmd.RunE(cmd, []string{})
if err == nil {
t.Error("clone should require root")
}
if !bytes.Contains([]byte(err.Error()), []byte("root")) {
t.Errorf("error should mention root: %v", err)
}
}

570
cmd/secubox/cmd/fetch.go Normal file
View File

@ -0,0 +1,570 @@
// cmd/secubox/cmd/fetch.go
package cmd
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/spf13/cobra"
)
var (
fetchBoard string
fetchVersion string
fetchOutput string
fetchList bool
fetchForce bool
)
const (
githubOwner = "CyberMind-FR"
githubRepo = "secubox-deb"
githubAPIBase = "https://api.github.com"
githubDLBase = "https://github.com"
defaultTimeout = 30 * time.Minute
progressBarSize = 50
)
// Package-level HTTP client for connection reuse
var httpClient = &http.Client{Timeout: defaultTimeout}
// Version tag regex for validation
var versionTagRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// supportedBoards lists boards that have pre-built images
var supportedBoards = []string{
"mochabin",
"espressobin-v7",
"espressobin-ultra",
"rpi400",
"vm-x64",
"vm-arm64",
}
// GitHubRelease represents a GitHub release
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Assets []GitHubAsset `json:"assets"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
// GitHubAsset represents a release asset
type GitHubAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
ContentType string `json:"content_type"`
}
var fetchCmd = &cobra.Command{
Use: "fetch",
Short: "Download pre-built SecuBox images",
Long: `Download pre-built SecuBox images from GitHub releases.
This command fetches official pre-built images that are ready to flash
onto supported hardware boards.
Examples:
# List available images
secubox fetch --list
# Download latest image for a board
secubox fetch --board mochabin
# Download specific version
secubox fetch --board mochabin --version 2.8.0
# Download to custom location
secubox fetch --board mochabin --output /tmp/secubox.img.gz
Supported boards:
- mochabin (Armada 7040, Pro tier)
- espressobin-v7 (Armada 3720, Lite tier)
- espressobin-ultra (Armada 3720 Ultra)
- rpi400 (Raspberry Pi 400)
- vm-x64 (x86-64 VM)
- vm-arm64 (ARM64 VM)`,
RunE: runFetch,
}
func init() {
rootCmd.AddCommand(fetchCmd)
fetchCmd.Flags().StringVarP(&fetchBoard, "board", "b", "", "target board (mochabin, espressobin-v7, etc.)")
fetchCmd.Flags().StringVarP(&fetchVersion, "version", "V", "", "specific version to download (default: latest)")
fetchCmd.Flags().StringVarP(&fetchOutput, "output", "o", "", "output file path (default: ./secubox-<board>-<version>.img.gz)")
fetchCmd.Flags().BoolVarP(&fetchList, "list", "l", false, "list available releases and images")
fetchCmd.Flags().BoolVarP(&fetchForce, "force", "f", false, "overwrite existing file")
}
func runFetch(cmd *cobra.Command, args []string) error {
// List mode
if fetchList {
return listReleases()
}
// Require board for download
if fetchBoard == "" {
return fmt.Errorf("--board is required for download\nUse --list to see available images")
}
// Validate board
if !isValidBoard(fetchBoard) {
return fmt.Errorf("unsupported board: %s\nSupported boards: %s", fetchBoard, strings.Join(supportedBoards, ", "))
}
// Get release info
release, err := getRelease(fetchVersion)
if err != nil {
return fmt.Errorf("get release: %w", err)
}
// Find image asset
imageAsset, checksumAsset := findAssets(release, fetchBoard)
if imageAsset == nil {
return fmt.Errorf("no image found for board %s in release %s", fetchBoard, release.TagName)
}
// Determine output path
outputPath := fetchOutput
if outputPath == "" {
outputPath = filepath.Join(".", imageAsset.Name)
}
// Check if file exists
if !fetchForce {
if _, err := os.Stat(outputPath); err == nil {
return fmt.Errorf("file already exists: %s\nUse --force to overwrite", outputPath)
}
}
// Print download info
fmt.Printf("SecuBox Fetch\n")
fmt.Printf(" Board: %s\n", fetchBoard)
fmt.Printf(" Version: %s\n", release.TagName)
fmt.Printf(" Size: %s\n", formatSize(imageAsset.Size))
fmt.Printf(" Output: %s\n\n", outputPath)
// Download image
fmt.Printf("Downloading %s...\n", imageAsset.Name)
if err := downloadWithProgress(imageAsset.BrowserDownloadURL, outputPath, imageAsset.Size); err != nil {
// Clean up partial file
os.Remove(outputPath)
return fmt.Errorf("download failed: %w", err)
}
// Verify checksum if available
if checksumAsset != nil {
fmt.Printf("\nVerifying checksum...\n")
if err := verifyChecksum(outputPath, checksumAsset, imageAsset.Name); err != nil {
// Clean up corrupted file
if rmErr := os.Remove(outputPath); rmErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not remove corrupted file: %v\n", rmErr)
}
return fmt.Errorf("checksum verification failed: %w", err)
}
fmt.Printf("Checksum verified OK\n")
} else {
fmt.Printf("\nWarning: No checksum file available for verification\n")
}
fmt.Printf("\nDownload complete: %s\n", outputPath)
fmt.Printf("\nNext steps:\n")
fmt.Printf(" 1. Decompress: gunzip %s\n", outputPath)
fmt.Printf(" 2. Flash: dd if=%s of=/dev/sdX bs=4M status=progress\n", strings.TrimSuffix(outputPath, ".gz"))
return nil
}
func listReleases() error {
releases, err := getReleases()
if err != nil {
return fmt.Errorf("fetch releases: %w", err)
}
if len(releases) == 0 {
fmt.Println("No releases found")
return nil
}
fmt.Printf("Available SecuBox Releases\n")
fmt.Printf("Repository: %s/%s\n\n", githubOwner, githubRepo)
for i, rel := range releases {
if rel.Draft {
continue
}
status := ""
if rel.Prerelease {
status = " (prerelease)"
}
if i == 0 {
status = " (latest)"
}
fmt.Printf("Version: %s%s\n", rel.TagName, status)
fmt.Printf(" Published: %s\n", formatDate(rel.PublishedAt))
// Group assets by board
boards := make(map[string][]GitHubAsset)
for _, asset := range rel.Assets {
for _, board := range supportedBoards {
if strings.Contains(asset.Name, board) && strings.HasSuffix(asset.Name, ".img.gz") {
boards[board] = append(boards[board], asset)
}
}
}
if len(boards) > 0 {
fmt.Printf(" Images:\n")
for board, assets := range boards {
for _, asset := range assets {
fmt.Printf(" - %-20s %s\n", board, formatSize(asset.Size))
}
}
}
fmt.Println()
}
fmt.Printf("Download with: secubox fetch --board <board> [--version <version>]\n")
return nil
}
func getReleases() ([]GitHubRelease, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases", githubAPIBase, githubOwner, githubRepo)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "secubox-cli/"+version)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(body))
}
var releases []GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
return releases, nil
}
func getRelease(ver string) (*GitHubRelease, error) {
var url string
if ver == "" {
url = fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBase, githubOwner, githubRepo)
} else {
// Normalize version tag
tag := ver
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
// Validate version format to prevent URL injection
if !versionTagRe.MatchString(tag) {
return nil, fmt.Errorf("invalid version format: %s", ver)
}
url = fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", githubAPIBase, githubOwner, githubRepo, tag)
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "secubox-cli/"+version)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
if ver == "" {
return nil, fmt.Errorf("no releases found")
}
return nil, fmt.Errorf("release %s not found", ver)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(body))
}
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
return &release, nil
}
func findAssets(release *GitHubRelease, board string) (*GitHubAsset, *GitHubAsset) {
var imageAsset *GitHubAsset
var checksumAsset *GitHubAsset
// Expected naming: secubox-<board>-<version>.img.gz
// Checksum: secubox-<board>-<version>.img.gz.sha256 or SHA256SUMS
for i := range release.Assets {
asset := &release.Assets[i]
// Look for image file
if strings.Contains(asset.Name, board) && strings.HasSuffix(asset.Name, ".img.gz") {
imageAsset = asset
}
// Look for checksum file
if strings.Contains(asset.Name, board) && strings.HasSuffix(asset.Name, ".sha256") {
checksumAsset = asset
}
// Also check for SHA256SUMS file
if asset.Name == "SHA256SUMS" || asset.Name == "checksums.txt" {
if checksumAsset == nil {
checksumAsset = asset
}
}
}
return imageAsset, checksumAsset
}
func downloadWithProgress(url, destPath string, totalSize int64) error {
// Create output file
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer out.Close()
// Start download
resp, err := httpClient.Get(url)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download error: %s", resp.Status)
}
// If server provides content-length and we don't have it
if totalSize == 0 && resp.ContentLength > 0 {
totalSize = resp.ContentLength
}
// Create progress reader
progress := &progressReader{
reader: resp.Body,
totalSize: totalSize,
startTime: time.Now(),
}
// Copy with progress
_, err = io.Copy(out, progress)
if err != nil {
return fmt.Errorf("write file: %w", err)
}
// Final newline after progress bar
fmt.Println()
return nil
}
// progressReader wraps an io.Reader and prints progress
type progressReader struct {
reader io.Reader
totalSize int64
downloaded int64
startTime time.Time
lastPrint time.Time
}
func (pr *progressReader) Read(p []byte) (int, error) {
n, err := pr.reader.Read(p)
pr.downloaded += int64(n)
// Rate limit progress updates
if time.Since(pr.lastPrint) > 100*time.Millisecond || err == io.EOF {
pr.printProgress()
pr.lastPrint = time.Now()
}
return n, err
}
func (pr *progressReader) printProgress() {
percent := float64(0)
if pr.totalSize > 0 {
percent = float64(pr.downloaded) / float64(pr.totalSize) * 100
}
elapsed := time.Since(pr.startTime).Seconds()
speed := float64(0)
if elapsed > 0 {
speed = float64(pr.downloaded) / elapsed
}
// Calculate ETA
eta := ""
if speed > 0 && pr.totalSize > 0 {
remaining := float64(pr.totalSize-pr.downloaded) / speed
if remaining > 0 {
eta = fmt.Sprintf(" ETA: %s", formatDuration(time.Duration(remaining)*time.Second))
}
}
// Progress bar
barWidth := progressBarSize
completed := 0
if pr.totalSize > 0 {
completed = int(float64(barWidth) * float64(pr.downloaded) / float64(pr.totalSize))
}
bar := strings.Repeat("=", completed) + strings.Repeat("-", barWidth-completed)
// Print progress line
fmt.Printf("\r[%s] %5.1f%% %s @ %s/s%s ",
bar,
percent,
formatSize(pr.downloaded),
formatSize(int64(speed)),
eta,
)
}
func verifyChecksum(filePath string, checksumAsset *GitHubAsset, imageName string) error {
// Download checksum file
resp, err := httpClient.Get(checksumAsset.BrowserDownloadURL)
if err != nil {
return fmt.Errorf("download checksum: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("checksum download error: %s", resp.Status)
}
checksumData, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read checksum: %w", err)
}
// Parse checksum (format: "sha256sum filename" or just "sha256sum")
expectedChecksum := ""
lines := strings.Split(string(checksumData), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Handle "sha256 filename" format
parts := strings.Fields(line)
if len(parts) >= 1 {
// If this is a SHA256SUMS file, match by filename
if len(parts) >= 2 {
if parts[1] == imageName || strings.HasSuffix(parts[1], imageName) {
expectedChecksum = parts[0]
break
}
} else {
// Single checksum file for this specific image
expectedChecksum = parts[0]
break
}
}
}
if expectedChecksum == "" {
return fmt.Errorf("could not parse checksum for %s", imageName)
}
// Calculate file checksum
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer f.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, f); err != nil {
return fmt.Errorf("calculate checksum: %w", err)
}
actualChecksum := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(actualChecksum, expectedChecksum) {
return fmt.Errorf("checksum mismatch:\n expected: %s\n actual: %s", expectedChecksum, actualChecksum)
}
return nil
}
func isValidBoard(board string) bool {
for _, b := range supportedBoards {
if b == board {
return true
}
}
return false
}
func formatSize(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/GB)
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/MB)
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/KB)
default:
return fmt.Sprintf("%d B", bytes)
}
}
func formatDate(dateStr string) string {
t, err := time.Parse(time.RFC3339, dateStr)
if err != nil {
return dateStr
}
return t.Format("2006-01-02 15:04")
}
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
}

View File

@ -0,0 +1,281 @@
// cmd/secubox/cmd/fetch_test.go
package cmd
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestIsValidBoard(t *testing.T) {
tests := []struct {
board string
valid bool
}{
{"mochabin", true},
{"espressobin-v7", true},
{"espressobin-ultra", true},
{"rpi400", true},
{"vm-x64", true},
{"vm-arm64", true},
{"unknown-board", false},
{"", false},
{"MOCHABIN", false}, // case sensitive
}
for _, tc := range tests {
t.Run(tc.board, func(t *testing.T) {
if got := isValidBoard(tc.board); got != tc.valid {
t.Errorf("isValidBoard(%q) = %v, want %v", tc.board, got, tc.valid)
}
})
}
}
func TestFormatSize(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.00 KB"},
{1536, "1.50 KB"},
{1048576, "1.00 MB"},
{536870912, "512.00 MB"},
{1073741824, "1.00 GB"},
{2684354560, "2.50 GB"},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%d", tc.bytes), func(t *testing.T) {
if got := formatSize(tc.bytes); got != tc.expected {
t.Errorf("formatSize(%d) = %q, want %q", tc.bytes, got, tc.expected)
}
})
}
}
func TestFormatDate(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"2024-03-15T10:30:00Z", "2024-03-15 10:30"},
{"2024-12-25T00:00:00Z", "2024-12-25 00:00"},
{"invalid", "invalid"}, // returns input on error
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
if got := formatDate(tc.input); got != tc.expected {
t.Errorf("formatDate(%q) = %q, want %q", tc.input, got, tc.expected)
}
})
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
seconds int
expected string
}{
{30, "30s"},
{59, "59s"},
{60, "1m0s"},
{90, "1m30s"},
{3600, "1h0m"},
{3661, "1h1m"},
{7200, "2h0m"},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%ds", tc.seconds), func(t *testing.T) {
d := time.Duration(tc.seconds) * time.Second
if got := formatDuration(d); got != tc.expected {
t.Errorf("formatDuration(%d) = %q, want %q", tc.seconds, got, tc.expected)
}
})
}
}
func TestFindAssets(t *testing.T) {
release := &GitHubRelease{
TagName: "v2.8.0",
Assets: []GitHubAsset{
{
Name: "secubox-mochabin-2.8.0.img.gz",
Size: 536647645,
BrowserDownloadURL: "https://example.com/secubox-mochabin-2.8.0.img.gz",
},
{
Name: "secubox-mochabin-2.8.0.img.gz.sha256",
Size: 100,
BrowserDownloadURL: "https://example.com/secubox-mochabin-2.8.0.img.gz.sha256",
},
{
Name: "secubox-espressobin-v7-2.8.0.img.gz",
Size: 400000000,
BrowserDownloadURL: "https://example.com/secubox-espressobin-v7-2.8.0.img.gz",
},
{
Name: "SHA256SUMS",
Size: 500,
BrowserDownloadURL: "https://example.com/SHA256SUMS",
},
},
}
// Test finding mochabin assets
img, sum := findAssets(release, "mochabin")
if img == nil {
t.Fatal("expected to find mochabin image asset")
}
if img.Name != "secubox-mochabin-2.8.0.img.gz" {
t.Errorf("unexpected image name: %s", img.Name)
}
if sum == nil {
t.Fatal("expected to find mochabin checksum asset")
}
if sum.Name != "secubox-mochabin-2.8.0.img.gz.sha256" {
t.Errorf("unexpected checksum name: %s", sum.Name)
}
// Test finding espressobin assets (should fall back to SHA256SUMS)
img, sum = findAssets(release, "espressobin-v7")
if img == nil {
t.Fatal("expected to find espressobin-v7 image asset")
}
if sum == nil {
t.Fatal("expected to find checksum asset (SHA256SUMS)")
}
if sum.Name != "SHA256SUMS" {
t.Errorf("expected SHA256SUMS, got: %s", sum.Name)
}
// Test board not in release
img, sum = findAssets(release, "rpi400")
if img != nil {
t.Error("expected no image for rpi400")
}
}
func TestDownloadWithProgress(t *testing.T) {
// Create test server
testData := []byte("test content for download")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testData)))
w.Write(testData)
}))
defer server.Close()
// Create temp file
tmpDir := t.TempDir()
destPath := filepath.Join(tmpDir, "test-download")
// Download
err := downloadWithProgress(server.URL, destPath, int64(len(testData)))
if err != nil {
t.Fatalf("downloadWithProgress failed: %v", err)
}
// Verify content
content, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("read downloaded file: %v", err)
}
if string(content) != string(testData) {
t.Errorf("content mismatch: got %q, want %q", string(content), string(testData))
}
}
func TestProgressReader(t *testing.T) {
// Test that progress reader correctly tracks bytes
data := []byte("test data for progress tracking")
reader := &progressReader{
reader: &mockReader{data: data},
totalSize: int64(len(data)),
}
buf := make([]byte, 10)
total := 0
for {
n, err := reader.Read(buf)
total += n
if err != nil {
break
}
}
if reader.downloaded != int64(len(data)) {
t.Errorf("downloaded = %d, want %d", reader.downloaded, len(data))
}
}
// mockReader implements io.Reader for testing
type mockReader struct {
data []byte
pos int
}
func (m *mockReader) Read(p []byte) (int, error) {
if m.pos >= len(m.data) {
return 0, io.EOF
}
n := copy(p, m.data[m.pos:])
m.pos += n
return n, nil
}
func TestGitHubReleaseAPI(t *testing.T) {
// Mock GitHub API response
mockReleases := `[
{
"tag_name": "v2.8.0",
"name": "SecuBox v2.8.0",
"published_at": "2024-03-15T10:00:00Z",
"prerelease": false,
"draft": false,
"assets": [
{
"name": "secubox-mochabin-2.8.0.img.gz",
"size": 536647645,
"browser_download_url": "https://example.com/download/img.gz"
}
]
}
]`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(mockReleases))
}))
defer server.Close()
// Note: This test validates the JSON parsing logic
// Full integration test would require mocking the GitHub API base URL
}
func TestSupportedBoardsList(t *testing.T) {
// Verify all expected boards are in the list
expectedBoards := []string{
"mochabin",
"espressobin-v7",
"espressobin-ultra",
"rpi400",
"vm-x64",
"vm-arm64",
}
for _, board := range expectedBoards {
if !isValidBoard(board) {
t.Errorf("expected %s to be a valid board", board)
}
}
}

266
cmd/secubox/cmd/gen.go Normal file
View File

@ -0,0 +1,266 @@
// cmd/secubox/cmd/gen.go
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/hardware"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/manifest"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/profile"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/wizard"
"github.com/spf13/cobra"
)
var (
genBoard string
genTier string
genEnable []string
genDisable []string
genOut string
genAuto bool
)
var genCmd = &cobra.Command{
Use: "gen",
Short: "Generate manifest and Makefile",
Long: `Generate a build manifest and Makefile for SecuBox image creation.
Examples:
# Interactive wizard
secubox gen
# Specify board and tier
secubox gen --board mochabin --tier tier-pro
# Enable additional packages
secubox gen --board mochabin --enable ollama,jellyfin
# Auto-detect hardware
secubox gen --auto`,
RunE: runGen,
}
func init() {
rootCmd.AddCommand(genCmd)
genCmd.Flags().StringVarP(&genBoard, "board", "b", "", "target board (mochabin, espressobin-v7, rpi400, vm-x64)")
genCmd.Flags().StringVarP(&genTier, "tier", "t", "", "profile tier (tier-lite, tier-standard, tier-pro)")
genCmd.Flags().StringSliceVarP(&genEnable, "enable", "e", nil, "additional packages to enable")
genCmd.Flags().StringSliceVarP(&genDisable, "disable", "d", nil, "packages to disable")
genCmd.Flags().StringVarP(&genOut, "out", "o", ".", "output directory")
genCmd.Flags().BoolVar(&genAuto, "auto", false, "auto-detect hardware")
}
func runGen(cmd *cobra.Command, args []string) error {
// Find repo root (where profiles/ and board/ are)
repoRoot, err := findRepoRoot()
if err != nil {
return fmt.Errorf("find repo root: %w", err)
}
// If no board specified and not auto, run wizard
if genBoard == "" && !genAuto {
return runWizard(repoRoot)
}
// Auto-detect hardware if requested
if genAuto {
detected, err := detectHardware()
if err != nil {
return fmt.Errorf("detect hardware: %w", err)
}
genBoard = detected.Board
if genTier == "" {
genTier = detected.Tier
}
fmt.Printf("Detected: board=%s, tier=%s\n", genBoard, genTier)
}
// Load board configuration
boardDir := filepath.Join(repoRoot, "board", genBoard)
board, err := profile.LoadBoard(boardDir)
if err != nil {
return fmt.Errorf("load board %s: %w", genBoard, err)
}
// Determine tier
if genTier == "" {
genTier = board.Tier
}
// Resolve profile with inheritance
profilesDir := filepath.Join(repoRoot, "profiles")
merger := profile.NewMerger(profilesDir)
prof, err := merger.Resolve(genTier)
if err != nil {
return fmt.Errorf("resolve profile %s: %w", genTier, err)
}
// Apply board tweaks
tweaks, err := profile.LoadTweaks(boardDir)
if err != nil {
return fmt.Errorf("load tweaks: %w", err)
}
applyTweaks(prof, tweaks)
// Add/remove packages from CLI
for _, pkg := range genEnable {
prof.Packages.Required = append(prof.Packages.Required, "secubox-"+pkg)
}
for _, pkg := range genDisable {
prof.Packages.Required = removePackage(prof.Packages.Required, "secubox-"+pkg)
}
// Generate manifest
m := manifest.Generate(prof, board, version)
// Write output files
return writeOutput(m)
}
func findRepoRoot() (string, error) {
// Look for profiles/ directory
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "profiles")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("could not find repo root (no profiles/ directory)")
}
dir = parent
}
}
func applyTweaks(p *profile.Profile, t *profile.Tweaks) {
// Merge kernel modules
p.Kernel.Modules.Enable = append(p.Kernel.Modules.Enable, t.Kernel.Modules.Enable...)
p.Kernel.Modules.Blacklist = append(p.Kernel.Modules.Blacklist, t.Kernel.Modules.Blacklist...)
// Initialize sysctl map if nil
if p.Sysctl == nil {
p.Sysctl = make(map[string]interface{})
}
// Merge sysctl
for k, v := range t.Sysctl {
p.Sysctl[k] = v
}
// Merge services
p.Services.Enable = append(p.Services.Enable, t.Services.Enable...)
}
func removePackage(packages []string, pkg string) []string {
result := []string{}
for _, p := range packages {
if p != pkg {
result = append(result, p)
}
}
return result
}
// detectedHardware wraps hardware detection results
type detectedHardware struct {
Board string
Tier string
}
func detectHardware() (*detectedHardware, error) {
info, err := hardware.Detect()
if err != nil {
return nil, err
}
return &detectedHardware{
Board: info.Board,
Tier: info.SuggestTier(),
}, nil
}
// writeOutput writes the manifest and Makefile to the output directory
func writeOutput(m *manifest.Manifest) error {
// Create output directory if needed
if err := os.MkdirAll(genOut, 0755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
// Write manifest.yaml
manifestPath := filepath.Join(genOut, "manifest.yaml")
manifestData, err := m.ToYAML()
if err != nil {
return fmt.Errorf("serialize manifest: %w", err)
}
if err := os.WriteFile(manifestPath, manifestData, 0644); err != nil {
return fmt.Errorf("write manifest: %w", err)
}
fmt.Printf("Generated: %s\n", manifestPath)
// Write Makefile
makefilePath := filepath.Join(genOut, "Makefile")
makefileData := manifest.GenerateMakefile(m)
if err := os.WriteFile(makefilePath, []byte(makefileData), 0644); err != nil {
return fmt.Errorf("write Makefile: %w", err)
}
fmt.Printf("Generated: %s\n", makefilePath)
fmt.Printf("\nNext: cd %s && make image\n", genOut)
return nil
}
// runWizard launches the interactive wizard for configuration
func runWizard(repoRoot string) error {
opts, err := wizard.Run(repoRoot)
if err != nil {
return fmt.Errorf("wizard: %w", err)
}
// Set global flags from wizard results
genBoard = opts.Board
genTier = opts.Tier
genEnable = opts.Enable
// Load board configuration
boardDir := filepath.Join(repoRoot, "board", genBoard)
board, err := profile.LoadBoard(boardDir)
if err != nil {
return fmt.Errorf("load board %s: %w", genBoard, err)
}
// Resolve profile with inheritance
profilesDir := filepath.Join(repoRoot, "profiles")
merger := profile.NewMerger(profilesDir)
prof, err := merger.Resolve(genTier)
if err != nil {
return fmt.Errorf("resolve profile %s: %w", genTier, err)
}
// Apply board tweaks
tweaks, err := profile.LoadTweaks(boardDir)
if err != nil {
return fmt.Errorf("load tweaks: %w", err)
}
applyTweaks(prof, tweaks)
// Add packages from wizard
for _, pkg := range genEnable {
prof.Packages.Required = append(prof.Packages.Required, "secubox-"+pkg)
}
// Generate manifest
m := manifest.Generate(prof, board, version)
// Update formats from wizard if specified
if len(opts.Formats) > 0 {
m.Output.Formats = opts.Formats
}
// Write output files
return writeOutput(m)
}

67
cmd/secubox/cmd/info.go Normal file
View File

@ -0,0 +1,67 @@
// cmd/secubox/cmd/info.go
package cmd
import (
"encoding/json"
"fmt"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/hardware"
"github.com/spf13/cobra"
)
var (
infoJSON bool
)
var infoCmd = &cobra.Command{
Use: "info",
Short: "Show system and hardware information",
Long: `Display detected hardware information and suggested build profile.
This command detects:
- Board type (from device tree or DMI)
- CPU architecture and model
- RAM size
- Suggested tier based on resources
Examples:
# Show hardware info
secubox info
# Output as JSON
secubox info --json`,
RunE: runInfo,
}
func init() {
rootCmd.AddCommand(infoCmd)
infoCmd.Flags().BoolVar(&infoJSON, "json", false, "output as JSON")
}
func runInfo(cmd *cobra.Command, args []string) error {
info, err := hardware.Detect()
if err != nil {
return fmt.Errorf("detect hardware: %w", err)
}
if infoJSON {
// JSON output for scripting
output := map[string]interface{}{
"board": info.Board,
"arch": info.Arch,
"cpu_model": info.CPUModel,
"cpu_cores": info.CPUCores,
"ram_total_mb": info.RAMTotalMB,
"suggested_tier": info.SuggestTier(),
}
data, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("marshal JSON: %w", err)
}
fmt.Println(string(data))
return nil
}
fmt.Println(info.String())
return nil
}

396
cmd/secubox/cmd/ota.go Normal file
View File

@ -0,0 +1,396 @@
// cmd/secubox/cmd/ota.go
package cmd
import (
"fmt"
"os"
"strings"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/ota"
"github.com/spf13/cobra"
)
var (
otaCheck bool
otaPackages bool
otaSystem bool
otaAll bool
otaRollback bool
otaDryRun bool
otaForce bool
otaChannel string
otaMarkOK bool
otaStatus bool
)
var otaCmd = &cobra.Command{
Use: "ota",
Short: "Over-The-Air updates with A/B partition support",
Long: `Manage OTA updates for SecuBox using A/B partition scheme.
SecuBox uses an A/B partition layout for safe, rollback-capable updates:
Partition Layout:
1: ESP 256M FAT32 /boot/efi
2: root-a 6G ext4 / (active)
3: root-b 6G ext4 (inactive)
4: data rest ext4 /srv (persistent)
Boot Control:
/boot/efi/secubox/active Contains "a" or "b"
/boot/efi/secubox/fallback Previous working slot
/boot/efi/secubox/boot-count Rollback after 3 failed boots
Examples:
# Check for available updates
secubox ota --check
# Apply package updates only (no reboot needed)
secubox ota --packages
# Apply system update (kernel, dtb, bootloader) - requires reboot
secubox ota --system
# Full update (packages + system)
secubox ota --all
# Rollback to previous version
secubox ota --rollback
# Show current boot status
secubox ota --status
# Show what would be done (dry-run)
secubox ota --all --dry-run
Note: System updates require root privileges.
Use: sudo secubox ota --system`,
RunE: runOTA,
}
func init() {
rootCmd.AddCommand(otaCmd)
// Update actions
otaCmd.Flags().BoolVar(&otaCheck, "check", false, "check for available updates")
otaCmd.Flags().BoolVar(&otaPackages, "packages", false, "apply APT package updates (no reboot)")
otaCmd.Flags().BoolVar(&otaSystem, "system", false, "apply kernel/boot update (A/B swap, requires reboot)")
otaCmd.Flags().BoolVar(&otaAll, "all", false, "full update (packages + system)")
otaCmd.Flags().BoolVar(&otaRollback, "rollback", false, "rollback to previous version")
// Options
otaCmd.Flags().BoolVar(&otaDryRun, "dry-run", false, "show what would be done without executing")
otaCmd.Flags().BoolVarP(&otaForce, "force", "f", false, "force update even if no updates available")
otaCmd.Flags().StringVar(&otaChannel, "channel", "stable", "release channel (stable, beta, nightly)")
// Status and housekeeping
otaCmd.Flags().BoolVar(&otaStatus, "status", false, "show current boot status")
otaCmd.Flags().BoolVar(&otaMarkOK, "mark-ok", false, "mark current boot as successful (reset watchdog)")
}
func runOTA(cmd *cobra.Command, args []string) error {
// Create OTA manager
opts := &ota.Options{
DryRun: otaDryRun,
Verbose: verbose,
Force: otaForce,
Channel: otaChannel,
}
mgr := ota.NewManager(version, opts)
// Count how many actions were requested
actionCount := 0
if otaCheck {
actionCount++
}
if otaPackages {
actionCount++
}
if otaSystem {
actionCount++
}
if otaAll {
actionCount++
}
if otaRollback {
actionCount++
}
if otaStatus {
actionCount++
}
if otaMarkOK {
actionCount++
}
// If no action specified, show help
if actionCount == 0 {
return cmd.Help()
}
// Prevent conflicting actions
if actionCount > 1 && !otaDryRun {
// Allow --status with other flags for info
if !otaStatus && !otaMarkOK {
return fmt.Errorf("please specify only one action at a time")
}
}
// Handle status display
if otaStatus {
return showStatus(mgr)
}
// Handle mark-ok
if otaMarkOK {
fmt.Println("Marking boot as successful...")
if err := mgr.MarkBootSuccessful(); err != nil {
return fmt.Errorf("mark boot successful: %w", err)
}
fmt.Println("Boot marked as successful, watchdog counter reset")
return nil
}
// Handle check
if otaCheck {
return checkUpdates(mgr)
}
// Handle rollback
if otaRollback {
return performRollback(mgr)
}
// Handle updates
if otaPackages || otaAll {
if err := applyPackageUpdates(mgr); err != nil {
return err
}
}
if otaSystem || otaAll {
if err := applySystemUpdate(mgr); err != nil {
return err
}
}
return nil
}
func showStatus(mgr *ota.Manager) error {
state, err := mgr.GetStatus()
if err != nil {
return fmt.Errorf("get status: %w", err)
}
fmt.Printf("SecuBox OTA Status\n")
fmt.Printf("==================\n\n")
fmt.Printf("Boot State:\n")
fmt.Printf(" Active slot: %s (partition %d: %s)\n",
strings.ToUpper(string(state.ActiveSlot)),
state.ActiveSlot.PartitionNumber(),
state.ActiveSlot.Label())
fmt.Printf(" Fallback slot: %s (partition %d: %s)\n",
strings.ToUpper(string(state.FallbackSlot)),
state.FallbackSlot.PartitionNumber(),
state.FallbackSlot.Label())
fmt.Printf(" Boot count: %d/%d\n", state.BootCount, ota.MaxBootCount)
if state.IsRecovery {
fmt.Printf("\n WARNING: Boot count at maximum!\n")
fmt.Printf(" System will auto-rollback on next failure.\n")
}
// Check if boot control exists
if !ota.BootControlExists() {
fmt.Printf("\n NOTE: Boot control files not found.\n")
fmt.Printf(" A/B partition updates may not be available.\n")
}
return nil
}
func checkUpdates(mgr *ota.Manager) error {
fmt.Printf("SecuBox Update Check\n")
fmt.Printf("====================\n\n")
fmt.Printf("Checking for updates...\n\n")
status, err := mgr.CheckUpdates()
if err != nil {
return fmt.Errorf("check updates: %w", err)
}
// Package updates
fmt.Printf("Package Updates:\n")
if status.PackagesAvailable {
fmt.Printf(" Status: %d packages available\n", status.PackageCount)
if verbose && len(status.PackageList) > 0 {
fmt.Printf(" Packages:\n")
for _, pkg := range status.PackageList {
fmt.Printf(" - %s\n", pkg)
}
}
fmt.Printf(" Action: secubox ota --packages\n")
} else {
fmt.Printf(" Status: Up to date\n")
}
fmt.Println()
// System updates
fmt.Printf("System Updates:\n")
fmt.Printf(" Current: %s\n", status.CurrentVersion)
if status.SystemAvailable {
fmt.Printf(" Latest: %s\n", status.LatestVersion)
fmt.Printf(" Status: Update available\n")
fmt.Printf(" Action: secubox ota --system\n")
if verbose && status.ReleaseURL != "" {
fmt.Printf(" Release: %s\n", status.ReleaseURL)
}
} else {
fmt.Printf(" Status: Up to date\n")
}
fmt.Println()
// Summary
if status.PackagesAvailable || status.SystemAvailable {
fmt.Printf("Summary: Updates are available\n")
fmt.Printf(" Full update: secubox ota --all\n")
} else {
fmt.Printf("Summary: System is up to date\n")
}
return nil
}
func performRollback(mgr *ota.Manager) error {
fmt.Printf("SecuBox Rollback\n")
fmt.Printf("================\n\n")
state, err := mgr.GetStatus()
if err != nil {
return fmt.Errorf("get status: %w", err)
}
fmt.Printf("Current state:\n")
fmt.Printf(" Active slot: %s\n", strings.ToUpper(string(state.ActiveSlot)))
fmt.Printf(" Fallback slot: %s\n", strings.ToUpper(string(state.FallbackSlot)))
fmt.Println()
if otaDryRun {
fmt.Printf("[DRY-RUN] Would swap:\n")
fmt.Printf(" New active slot: %s\n", strings.ToUpper(string(state.FallbackSlot)))
fmt.Printf(" New fallback slot: %s\n", strings.ToUpper(string(state.ActiveSlot)))
return nil
}
// Confirm rollback
fmt.Printf("This will swap active and fallback slots.\n")
fmt.Printf("After reboot, the system will boot from slot %s.\n\n", strings.ToUpper(string(state.FallbackSlot)))
if !otaForce {
fmt.Printf("Proceed with rollback? [y/N]: ")
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" {
fmt.Println("Rollback cancelled")
return nil
}
}
if err := mgr.Rollback(); err != nil {
return fmt.Errorf("rollback: %w", err)
}
return nil
}
func applyPackageUpdates(mgr *ota.Manager) error {
fmt.Printf("SecuBox Package Update\n")
fmt.Printf("======================\n\n")
if otaDryRun {
fmt.Printf("[DRY-RUN] Commands that would be executed:\n")
cmds := mgr.GetCommands(ota.UpdatePackages)
for i, cmd := range cmds {
fmt.Printf(" [%d] %s\n", i+1, cmd)
}
return nil
}
// Check for root privileges
if os.Geteuid() != 0 {
return fmt.Errorf("package updates require root privileges\nRun: sudo secubox ota --packages")
}
fmt.Println("Updating package lists...")
fmt.Println()
if err := mgr.ApplyPackageUpdates(); err != nil {
return fmt.Errorf("apply package updates: %w", err)
}
fmt.Println()
fmt.Println("Package updates complete!")
return nil
}
func applySystemUpdate(mgr *ota.Manager) error {
fmt.Printf("SecuBox System Update\n")
fmt.Printf("=====================\n\n")
if otaDryRun {
fmt.Printf("[DRY-RUN] Steps that would be performed:\n")
cmds := mgr.GetCommands(ota.UpdateSystem)
for i, cmd := range cmds {
fmt.Printf(" [%d] %s\n", i+1, cmd)
}
return nil
}
// Check for root privileges
if os.Geteuid() != 0 {
return fmt.Errorf("system updates require root privileges\nRun: sudo secubox ota --system")
}
// Check if A/B partitions are available
if !ota.BootControlExists() {
fmt.Printf("Warning: Boot control not initialized.\n")
fmt.Printf("Initializing boot control files...\n\n")
if err := ota.InitBootControl(); err != nil {
return fmt.Errorf("initialize boot control: %w", err)
}
}
state, err := mgr.GetStatus()
if err != nil {
return fmt.Errorf("get boot state: %w", err)
}
fmt.Printf("Current boot state:\n")
fmt.Printf(" Active slot: %s\n", strings.ToUpper(string(state.ActiveSlot)))
fmt.Printf(" Fallback slot: %s\n", strings.ToUpper(string(state.FallbackSlot)))
fmt.Printf(" Target slot: %s (inactive)\n\n", strings.ToUpper(string(state.ActiveSlot.Opposite())))
if !otaForce {
fmt.Printf("This will write the update to the inactive partition.\n")
fmt.Printf("A reboot will be required to activate the update.\n\n")
fmt.Printf("Proceed with system update? [y/N]: ")
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" {
fmt.Println("Update cancelled")
return nil
}
}
fmt.Println()
if err := mgr.ApplySystemUpdate(); err != nil {
return fmt.Errorf("apply system update: %w", err)
}
return nil
}

68
cmd/secubox/cmd/root.go Normal file
View File

@ -0,0 +1,68 @@
// cmd/secubox/cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
verbose bool
version = "2.8.0"
buildTime = "unknown"
commit = "unknown"
)
// SetVersionInfo sets build-time version information
func SetVersionInfo(v, bt, c string) {
if v != "" {
version = v
}
if bt != "" {
buildTime = bt
}
if c != "" {
commit = c
}
}
var rootCmd = &cobra.Command{
Use: "secubox",
Short: "SecuBox Image Generator & Manager",
Long: `SecuBox CLI tool for profile-based image generation,
building, fetching pre-built images, and OTA updates.`,
Version: version,
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: /etc/secubox/secubox.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// Set version template
rootCmd.SetVersionTemplate(fmt.Sprintf("secubox version %s\nBuild: %s\nCommit: %s\n", version, buildTime, commit))
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName("secubox")
viper.SetConfigType("yaml")
viper.AddConfigPath("/etc/secubox")
viper.AddConfigPath("$HOME/.secubox")
viper.AddConfigPath(".")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil && verbose {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}

33
cmd/secubox/go.mod Normal file
View File

@ -0,0 +1,33 @@
module github.com/CyberMind-FR/secubox-deb/cmd/secubox
go 1.26.2
require (
github.com/manifoldco/promptui v0.9.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

84
cmd/secubox/go.sum Normal file
View File

@ -0,0 +1,84 @@
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.0 h1:pN6W1ub/G4OfnM+NR9p7xP9R6TltLUzp5JG9yZD3Qg0=
github.com/spf13/viper v1.18.0/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,120 @@
/*
SecuBox-Deb :: APT Client
CyberMind https://cybermind.fr
Author: Gérald Kerma <gandalf@gk2.net>
License: Proprietary / ANSSI CSPN candidate
*/
package apt
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
const (
DefaultGPGKeyURL = "https://apt.secubox.in/secubox.gpg"
DefaultKeyringDir = "/usr/share/keyrings"
DefaultSourcesDir = "/etc/apt/sources.list.d"
DefaultRepoURL = "https://apt.secubox.in"
DefaultCodename = "bookworm"
DefaultComponent = "main"
)
// Client manages APT repository configuration
type Client struct {
GPGKeyURL string
KeyringDir string
SourcesDir string
RepoURL string
Codename string
Component string
}
// NewClient creates a new APT client with default configuration
func NewClient() *Client {
return &Client{
GPGKeyURL: DefaultGPGKeyURL,
KeyringDir: DefaultKeyringDir,
SourcesDir: DefaultSourcesDir,
RepoURL: DefaultRepoURL,
Codename: DefaultCodename,
Component: DefaultComponent,
}
}
// DownloadGPGKey downloads the SecuBox GPG signing key
func (c *Client) DownloadGPGKey() error {
keyPath := filepath.Join(c.KeyringDir, "secubox.gpg")
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
if attempt > 1 {
time.Sleep(2 * time.Second)
}
resp, err := http.Get(c.GPGKeyURL)
if err != nil {
lastErr = fmt.Errorf("download GPG key (attempt %d): %w", attempt, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("download GPG key: HTTP %d", resp.StatusCode)
continue
}
data, err := io.ReadAll(resp.Body)
if err != nil {
lastErr = fmt.Errorf("read GPG key: %w", err)
continue
}
if err := os.MkdirAll(c.KeyringDir, 0755); err != nil {
return fmt.Errorf("create keyring dir: %w", err)
}
if err := os.WriteFile(keyPath, data, 0644); err != nil {
return fmt.Errorf("write GPG key: %w", err)
}
return nil
}
return lastErr
}
// WriteSourcesList writes the APT sources.list configuration
func (c *Client) WriteSourcesList() error {
if err := os.MkdirAll(c.SourcesDir, 0755); err != nil {
return fmt.Errorf("create sources dir: %w", err)
}
content := fmt.Sprintf("deb [signed-by=%s/secubox.gpg] %s %s %s\n",
c.KeyringDir, c.RepoURL, c.Codename, c.Component)
path := filepath.Join(c.SourcesDir, "secubox.list")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write sources.list: %w", err)
}
return nil
}
// Setup performs complete APT repository setup
func (c *Client) Setup() error {
if err := c.DownloadGPGKey(); err != nil {
return fmt.Errorf("download GPG key: %w", err)
}
if err := c.WriteSourcesList(); err != nil {
return fmt.Errorf("write sources.list: %w", err)
}
return nil
}

View File

@ -0,0 +1,169 @@
package apt
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestDownloadGPGKey(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\ntest-key\n-----END PGP PUBLIC KEY BLOCK-----"))
}))
defer ts.Close()
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "secubox.gpg")
client := &Client{
GPGKeyURL: ts.URL + "/secubox.gpg",
KeyringDir: tmpDir,
}
err := client.DownloadGPGKey()
if err != nil {
t.Fatalf("DownloadGPGKey() error = %v", err)
}
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Errorf("GPG key file not created at %s", keyPath)
}
}
func TestDownloadGPGKey_HTTPError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
tmpDir := t.TempDir()
client := &Client{
GPGKeyURL: ts.URL + "/nonexistent.gpg",
KeyringDir: tmpDir,
}
err := client.DownloadGPGKey()
if err == nil {
t.Error("DownloadGPGKey() should fail on HTTP 404")
}
}
func TestDownloadGPGKey_VerifyContent(t *testing.T) {
expectedContent := "-----BEGIN PGP PUBLIC KEY BLOCK-----\ntest-key\n-----END PGP PUBLIC KEY BLOCK-----"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(expectedContent))
}))
defer ts.Close()
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "secubox.gpg")
client := &Client{
GPGKeyURL: ts.URL + "/secubox.gpg",
KeyringDir: tmpDir,
}
err := client.DownloadGPGKey()
if err != nil {
t.Fatalf("DownloadGPGKey() error = %v", err)
}
content, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("failed to read key file: %v", err)
}
if string(content) != expectedContent {
t.Errorf("key content = %q, want %q", content, expectedContent)
}
}
func TestWriteSourcesList(t *testing.T) {
tmpDir := t.TempDir()
client := &Client{
KeyringDir: "/usr/share/keyrings",
SourcesDir: tmpDir,
RepoURL: "https://apt.secubox.in",
Codename: "bookworm",
Component: "main",
}
err := client.WriteSourcesList()
if err != nil {
t.Fatalf("WriteSourcesList() error = %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "secubox.list"))
if err != nil {
t.Fatalf("read sources.list: %v", err)
}
expected := "deb [signed-by=/usr/share/keyrings/secubox.gpg] https://apt.secubox.in bookworm main\n"
if string(content) != expected {
t.Errorf("sources.list content = %q, want %q", content, expected)
}
}
func TestNewClient(t *testing.T) {
client := NewClient()
if client.GPGKeyURL != DefaultGPGKeyURL {
t.Errorf("GPGKeyURL = %q, want %q", client.GPGKeyURL, DefaultGPGKeyURL)
}
if client.KeyringDir != DefaultKeyringDir {
t.Errorf("KeyringDir = %q, want %q", client.KeyringDir, DefaultKeyringDir)
}
if client.SourcesDir != DefaultSourcesDir {
t.Errorf("SourcesDir = %q, want %q", client.SourcesDir, DefaultSourcesDir)
}
if client.RepoURL != DefaultRepoURL {
t.Errorf("RepoURL = %q, want %q", client.RepoURL, DefaultRepoURL)
}
if client.Codename != DefaultCodename {
t.Errorf("Codename = %q, want %q", client.Codename, DefaultCodename)
}
if client.Component != DefaultComponent {
t.Errorf("Component = %q, want %q", client.Component, DefaultComponent)
}
}
func TestSetup(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\ntest-key\n-----END PGP PUBLIC KEY BLOCK-----"))
}))
defer ts.Close()
tmpDir := t.TempDir()
tmpSourcesDir := filepath.Join(tmpDir, "sources")
tmpKeyringDir := filepath.Join(tmpDir, "keyring")
client := &Client{
GPGKeyURL: ts.URL + "/secubox.gpg",
KeyringDir: tmpKeyringDir,
SourcesDir: tmpSourcesDir,
RepoURL: "https://apt.secubox.in",
Codename: "bookworm",
Component: "main",
}
err := client.Setup()
if err != nil {
t.Fatalf("Setup() error = %v", err)
}
// Verify GPG key was downloaded
keyPath := filepath.Join(tmpKeyringDir, "secubox.gpg")
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Errorf("GPG key file not created at %s", keyPath)
}
// Verify sources.list was written
sourcesPath := filepath.Join(tmpSourcesDir, "secubox.list")
if _, err := os.Stat(sourcesPath); os.IsNotExist(err) {
t.Errorf("sources.list file not created at %s", sourcesPath)
}
}

View File

@ -0,0 +1,71 @@
package apt
import "fmt"
// Tier definitions
type Tier struct {
Name string
Description string
Packages []string
}
var Tiers = map[string]Tier{
"lite": {
Name: "Lite",
Description: "1-2GB RAM devices (ESPRESSObin)",
Packages: []string{"secubox-lite"},
},
"standard": {
Name: "Standard",
Description: "4GB RAM, general purpose",
Packages: []string{"secubox-standard"},
},
"pro": {
Name: "Pro",
Description: "8GB+ RAM, all features (MOCHAbin)",
Packages: []string{"secubox-full"},
},
"minimal": {
Name: "Minimal",
Description: "Core + Hub only",
Packages: []string{"secubox-core", "secubox-hub"},
},
}
// AvailablePackages lists all selectable packages for custom install
var AvailablePackages = []string{
"secubox-core",
"secubox-hub",
"secubox-crowdsec",
"secubox-netdata",
"secubox-wireguard",
"secubox-dpi",
"secubox-netmodes",
"secubox-nac",
"secubox-auth",
"secubox-qos",
"secubox-mediaflow",
"secubox-cdn",
"secubox-vhost",
"secubox-system",
}
// TierPackages returns the packages for a given tier
func TierPackages(tier string) ([]string, error) {
t, ok := Tiers[tier]
if !ok {
return nil, fmt.Errorf("invalid tier: %s (valid: lite, standard, pro, minimal)", tier)
}
return t.Packages, nil
}
// ValidateTier checks if a tier name is valid
func ValidateTier(tier string) bool {
_, ok := Tiers[tier]
return ok
}
// TierNames returns all tier names for wizard display
func TierNames() []string {
return []string{"lite", "standard", "pro", "minimal", "custom"}
}

View File

@ -0,0 +1,78 @@
package apt
import "testing"
func TestTierPackages(t *testing.T) {
tests := []struct {
tier string
wantPkg string
wantErr bool
}{
{"lite", "secubox-lite", false},
{"standard", "secubox-standard", false},
{"pro", "secubox-full", false},
{"minimal", "secubox-core", false},
{"invalid", "", true},
}
for _, tt := range tests {
t.Run(tt.tier, func(t *testing.T) {
pkgs, err := TierPackages(tt.tier)
if (err != nil) != tt.wantErr {
t.Errorf("TierPackages(%q) error = %v, wantErr %v", tt.tier, err, tt.wantErr)
return
}
if tt.wantPkg != "" && len(pkgs) > 0 && pkgs[0] != tt.wantPkg {
t.Errorf("TierPackages(%q)[0] = %v, want %q", tt.tier, pkgs[0], tt.wantPkg)
}
})
}
}
func TestValidateTier(t *testing.T) {
tests := []struct {
tier string
want bool
}{
{"lite", true},
{"standard", true},
{"pro", true},
{"minimal", true},
{"custom", false},
{"invalid", false},
}
for _, tt := range tests {
t.Run(tt.tier, func(t *testing.T) {
if got := ValidateTier(tt.tier); got != tt.want {
t.Errorf("ValidateTier(%q) = %v, want %v", tt.tier, got, tt.want)
}
})
}
}
func TestTierNames(t *testing.T) {
names := TierNames()
expected := []string{"lite", "standard", "pro", "minimal", "custom"}
if len(names) != len(expected) {
t.Errorf("TierNames() len = %d, want %d", len(names), len(expected))
}
for i, name := range expected {
if names[i] != name {
t.Errorf("TierNames()[%d] = %q, want %q", i, names[i], name)
}
}
}
func TestAvailablePackages(t *testing.T) {
if len(AvailablePackages) != 14 {
t.Errorf("AvailablePackages len = %d, want 14", len(AvailablePackages))
}
// Verify secubox-core is first
if AvailablePackages[0] != "secubox-core" {
t.Errorf("AvailablePackages[0] = %q, want secubox-core", AvailablePackages[0])
}
}

View File

@ -0,0 +1,140 @@
/*
SecuBox-Deb :: APT Server
CyberMind https://cybermind.fr
Author: Gérald Kerma <gandalf@gk2.net>
License: Proprietary / ANSSI CSPN candidate
*/
package apt
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
const (
DefaultRepoPath = "/srv/apt"
)
// Server handles APT repository server operations
type Server struct {
RepoPath string
ScriptsDir string
Codename string
Component string
DryRun bool
Verbose bool
}
// NewServer creates a server with default settings
func NewServer(repoRoot string) *Server {
return &Server{
RepoPath: DefaultRepoPath,
ScriptsDir: filepath.Join(repoRoot, "scripts"),
Codename: DefaultCodename,
Component: DefaultComponent,
}
}
// Init initializes the local APT repository
func (s *Server) Init() error {
// Check reprepro
if _, err := exec.LookPath("reprepro"); err != nil {
return fmt.Errorf("reprepro not installed (apt install reprepro)")
}
// Create directories
dirs := []string{"conf", "db", "dists", "pool", "incoming", "tmp"}
for _, d := range dirs {
path := filepath.Join(s.RepoPath, d)
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
}
// Run reprepro export
cmd := exec.Command("reprepro", "-b", s.RepoPath, "export")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("reprepro export: %w", err)
}
return nil
}
// Publish publishes .deb packages using the publish script
func (s *Server) Publish(files []string, skipLintian bool) error {
script := filepath.Join(s.ScriptsDir, "apt-publish.sh")
if _, err := os.Stat(script); os.IsNotExist(err) {
return fmt.Errorf("publish script not found: %s", script)
}
args := []string{}
args = append(args, "-c", s.Codename)
args = append(args, "-C", s.Component)
if skipLintian {
args = append(args, "--skip-lintian")
}
if s.DryRun {
args = append(args, "--dry-run")
}
args = append(args, files...)
cmd := exec.Command(script, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Sync syncs repository to remote using the sync script
func (s *Server) Sync() error {
script := filepath.Join(s.ScriptsDir, "apt-sync.sh")
if _, err := os.Stat(script); os.IsNotExist(err) {
return fmt.Errorf("sync script not found: %s", script)
}
args := []string{}
if s.DryRun {
args = append(args, "--dry-run")
}
if s.Verbose {
args = append(args, "--verbose")
}
cmd := exec.Command(script, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// List lists packages in the repository
func (s *Server) List() error {
cmd := exec.Command("reprepro", "-b", s.RepoPath, "list", s.Codename)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Remove removes a package from the repository
func (s *Server) Remove(pkgName string) error {
if s.DryRun {
fmt.Printf("[DRY-RUN] Would remove: %s from %s\n", pkgName, s.Codename)
return nil
}
cmd := exec.Command("reprepro", "-b", s.RepoPath, "remove", s.Codename, pkgName)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Check verifies repository integrity
func (s *Server) Check() error {
cmd := exec.Command("reprepro", "-b", s.RepoPath, "check")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

View File

@ -0,0 +1,210 @@
// cmd/secubox/internal/builder/builder.go
package builder
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/manifest"
"gopkg.in/yaml.v3"
)
// ValidStages lists all valid build stages in order
var ValidStages = []string{"rootfs", "partition", "boot", "compress", "checksums"}
// Package-level compiled regex for partition size parsing
var partitionSizeRe = regexp.MustCompile(`^(\d+)([KMGT])$`)
// Options holds builder configuration
type Options struct {
Manifest *manifest.Manifest
OutputDir string
DryRun bool
ParallelJobs int
Verbose bool
}
// Builder orchestrates the build process
type Builder struct {
manifest *manifest.Manifest
outputDir string
dryRun bool
parallelJobs int
verbose bool
}
// New creates a new Builder instance
func New(opts *Options) *Builder {
jobs := opts.ParallelJobs
if jobs < 1 {
jobs = 1
}
return &Builder{
manifest: opts.Manifest,
outputDir: opts.OutputDir,
dryRun: opts.DryRun,
parallelJobs: jobs,
verbose: opts.Verbose,
}
}
// IsValidStage checks if a stage name is valid
func IsValidStage(name string) bool {
for _, s := range ValidStages {
if s == name {
return true
}
}
return false
}
// LoadManifest loads a manifest from a YAML file
func LoadManifest(path string) (*manifest.Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest file: %w", err)
}
var m manifest.Manifest
if err := yaml.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parse manifest YAML: %w", err)
}
return &m, nil
}
// ParsePartitionSize parses human-readable partition sizes (e.g., "256M", "6G")
func ParsePartitionSize(size string) (int64, error) {
if size == "" {
return 0, fmt.Errorf("empty size string")
}
matches := partitionSizeRe.FindStringSubmatch(strings.ToUpper(size))
if len(matches) != 3 {
return 0, fmt.Errorf("invalid size format: %s", size)
}
value, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("parse size value: %w", err)
}
multipliers := map[string]int64{
"K": 1024,
"M": 1024 * 1024,
"G": 1024 * 1024 * 1024,
"T": 1024 * 1024 * 1024 * 1024,
}
multiplier, ok := multipliers[matches[2]]
if !ok {
return 0, fmt.Errorf("unknown size suffix: %s", matches[2])
}
return value * multiplier, nil
}
// Run executes all build stages in order
func (b *Builder) Run() ([]string, error) {
var allCmds []string
for _, stage := range ValidStages {
cmds, err := b.RunStage(stage)
if err != nil {
return allCmds, fmt.Errorf("stage %s: %w", stage, err)
}
allCmds = append(allCmds, cmds...)
}
return allCmds, nil
}
// RunStage executes a specific build stage
func (b *Builder) RunStage(stage string) ([]string, error) {
if !IsValidStage(stage) {
return nil, fmt.Errorf("invalid stage: %s (valid: %v)", stage, ValidStages)
}
var cmds []string
var err error
switch stage {
case "rootfs":
cmds, err = b.stageRootfs()
case "partition":
cmds, err = b.stagePartition()
case "boot":
cmds, err = b.stageBoot()
case "compress":
cmds, err = b.stageCompress()
case "checksums":
cmds, err = b.stageChecksums()
default:
return nil, fmt.Errorf("unimplemented stage: %s", stage)
}
if err != nil {
return cmds, err
}
// If not dry-run, execute the commands
if !b.dryRun {
for _, cmd := range cmds {
if b.verbose {
fmt.Printf("+ %s\n", cmd)
}
if err := b.execCommand(cmd); err != nil {
return cmds, fmt.Errorf("execute command: %w", err)
}
}
}
return cmds, nil
}
// execCommand executes a shell command with error handling
func (b *Builder) execCommand(cmd string) error {
// Wrap command with 'set -e' for fail-fast behavior
wrappedCmd := fmt.Sprintf("set -e\n%s", cmd)
c := exec.Command("sh", "-c", wrappedCmd)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
}
// imagePath returns the path to the build image
func (b *Builder) imagePath() string {
return filepath.Join(b.outputDir, fmt.Sprintf("secubox-%s.img", b.manifest.Board))
}
// rootfsPath returns the path to the rootfs directory
func (b *Builder) rootfsPath() string {
return filepath.Join(b.outputDir, "rootfs")
}
// needsCrossCompile checks if cross-compilation is needed for ARM on x86
func (b *Builder) needsCrossCompile() bool {
// Check if we're building ARM on a non-ARM host
if b.manifest.Arch == "arm64" || b.manifest.Arch == "armhf" {
// Check host architecture
hostArch := os.Getenv("HOSTTYPE")
if hostArch == "" {
// Try to detect from uname
out, err := exec.Command("uname", "-m").Output()
if err == nil {
hostArch = strings.TrimSpace(string(out))
}
}
// If host is x86, we need cross-compilation
if hostArch == "x86_64" || hostArch == "i686" || hostArch == "i386" {
return true
}
}
return false
}

View File

@ -0,0 +1,466 @@
// cmd/secubox/internal/builder/builder_test.go
package builder
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/manifest"
)
// TestNewBuilder tests builder creation
func TestNewBuilder(t *testing.T) {
m := &manifest.Manifest{
SecuboxVersion: "2.8.0",
Board: "mochabin",
Arch: "arm64",
Packages: []string{"secubox-core", "secubox-hub"},
Partitions: manifest.ManifestPartitions{
ESP: "256M",
Root: "6G",
Data: "2G",
},
Boot: manifest.ManifestBoot{
Method: "uboot",
KernelImage: "Image",
},
Output: manifest.ManifestOutput{
Formats: []string{"img.gz"},
Checksums: []string{"sha256"},
},
}
opts := &Options{
Manifest: m,
OutputDir: "/tmp/build",
DryRun: false,
ParallelJobs: 1,
}
b := New(opts)
if b == nil {
t.Fatal("New() returned nil")
}
if b.manifest != m {
t.Error("Builder manifest not set correctly")
}
if b.outputDir != "/tmp/build" {
t.Errorf("outputDir = %q, want %q", b.outputDir, "/tmp/build")
}
}
// TestStageNames tests that all stages have valid names
func TestStageNames(t *testing.T) {
expectedStages := []string{"rootfs", "partition", "boot", "compress", "checksums"}
for _, name := range expectedStages {
if !IsValidStage(name) {
t.Errorf("Stage %q should be valid", name)
}
}
if IsValidStage("invalid") {
t.Error("Stage 'invalid' should not be valid")
}
}
// TestDryRunRootfs tests rootfs stage in dry-run mode
func TestDryRunRootfs(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{
Board: "mochabin",
Arch: "arm64",
Packages: []string{"secubox-core", "secubox-hub"},
}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, err := b.RunStage("rootfs")
if err != nil {
t.Fatalf("RunStage(rootfs) error = %v", err)
}
// In dry-run mode, should return commands that would be executed
if len(cmds) == 0 {
t.Error("Expected commands to be returned in dry-run mode")
}
// Check that debootstrap command is included
found := false
for _, cmd := range cmds {
if strings.Contains(cmd, "debootstrap") {
found = true
break
}
}
if !found {
t.Error("Expected debootstrap command in rootfs stage")
}
}
// TestDryRunPartition tests partition stage in dry-run mode
func TestDryRunPartition(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{
Board: "mochabin",
Arch: "arm64",
Partitions: manifest.ManifestPartitions{
ESP: "256M",
Root: "6G",
Data: "2G",
},
}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, err := b.RunStage("partition")
if err != nil {
t.Fatalf("RunStage(partition) error = %v", err)
}
// Check for dd command (image creation)
foundDD := false
foundParted := false
for _, cmd := range cmds {
if strings.Contains(cmd, "dd ") {
foundDD = true
}
if strings.Contains(cmd, "parted") {
foundParted = true
}
}
if !foundDD {
t.Error("Expected dd command in partition stage")
}
if !foundParted {
t.Error("Expected parted command in partition stage")
}
}
// TestDryRunBoot tests boot stage in dry-run mode
func TestDryRunBoot(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{
Board: "mochabin",
Arch: "arm64",
Boot: manifest.ManifestBoot{
Method: "uboot",
KernelImage: "Image",
},
Kernel: manifest.ManifestKernel{
DTS: "armada-7040-mochabin",
},
}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, err := b.RunStage("boot")
if err != nil {
t.Fatalf("RunStage(boot) error = %v", err)
}
if len(cmds) == 0 {
t.Error("Expected commands in boot stage")
}
}
// TestDryRunCompress tests compress stage in dry-run mode
func TestDryRunCompress(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{
Board: "mochabin",
Output: manifest.ManifestOutput{
Formats: []string{"img.gz", "img.xz"},
},
}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, err := b.RunStage("compress")
if err != nil {
t.Fatalf("RunStage(compress) error = %v", err)
}
foundGzip := false
foundXz := false
for _, cmd := range cmds {
if strings.Contains(cmd, "gzip") {
foundGzip = true
}
if strings.Contains(cmd, "xz") {
foundXz = true
}
}
if !foundGzip {
t.Error("Expected gzip command for img.gz format")
}
if !foundXz {
t.Error("Expected xz command for img.xz format")
}
}
// TestDryRunChecksums tests checksums stage in dry-run mode
func TestDryRunChecksums(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{
Board: "mochabin",
Output: manifest.ManifestOutput{
Formats: []string{"img.gz"},
Checksums: []string{"sha256", "sha512"},
},
}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, err := b.RunStage("checksums")
if err != nil {
t.Fatalf("RunStage(checksums) error = %v", err)
}
foundSha256 := false
foundSha512 := false
for _, cmd := range cmds {
if strings.Contains(cmd, "sha256sum") {
foundSha256 = true
}
if strings.Contains(cmd, "sha512sum") {
foundSha512 = true
}
}
if !foundSha256 {
t.Error("Expected sha256sum command")
}
if !foundSha512 {
t.Error("Expected sha512sum command")
}
}
// TestDryRunAll tests running all stages in dry-run mode
func TestDryRunAll(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{
SecuboxVersion: "2.8.0",
Board: "mochabin",
Arch: "arm64",
Packages: []string{"secubox-core"},
Partitions: manifest.ManifestPartitions{
ESP: "256M",
Root: "6G",
Data: "2G",
},
Boot: manifest.ManifestBoot{
Method: "uboot",
},
Output: manifest.ManifestOutput{
Formats: []string{"img.gz"},
Checksums: []string{"sha256"},
},
}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, err := b.Run()
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(cmds) == 0 {
t.Error("Expected commands from full build")
}
// Should have commands from all stages
hasDebootstrap := false
hasDD := false
hasGzip := false
hasSha256 := false
for _, cmd := range cmds {
if strings.Contains(cmd, "debootstrap") {
hasDebootstrap = true
}
if strings.Contains(cmd, "dd ") {
hasDD = true
}
if strings.Contains(cmd, "gzip") {
hasGzip = true
}
if strings.Contains(cmd, "sha256sum") {
hasSha256 = true
}
}
if !hasDebootstrap {
t.Error("Missing debootstrap from full build")
}
if !hasDD {
t.Error("Missing dd from full build")
}
if !hasGzip {
t.Error("Missing gzip from full build")
}
if !hasSha256 {
t.Error("Missing sha256sum from full build")
}
}
// TestInvalidStage tests error handling for invalid stage
func TestInvalidStage(t *testing.T) {
tmpDir := t.TempDir()
m := &manifest.Manifest{}
opts := &Options{
Manifest: m,
OutputDir: tmpDir,
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
_, err := b.RunStage("invalid")
if err == nil {
t.Error("Expected error for invalid stage")
}
}
// TestLoadManifest tests loading manifest from file
func TestLoadManifest(t *testing.T) {
tmpDir := t.TempDir()
manifestContent := `secubox_version: "2.8.0"
board: mochabin
tier: tier-pro
arch: arm64
packages:
- secubox-core
- secubox-hub
partitions:
esp: 256M
root: 6G
data: 2G
boot:
method: uboot
kernel_image: Image
output:
formats:
- img.gz
checksums:
- sha256
`
manifestPath := filepath.Join(tmpDir, "manifest.yaml")
if err := os.WriteFile(manifestPath, []byte(manifestContent), 0644); err != nil {
t.Fatalf("Failed to write manifest: %v", err)
}
m, err := LoadManifest(manifestPath)
if err != nil {
t.Fatalf("LoadManifest() error = %v", err)
}
if m.Board != "mochabin" {
t.Errorf("Board = %q, want %q", m.Board, "mochabin")
}
if m.Arch != "arm64" {
t.Errorf("Arch = %q, want %q", m.Arch, "arm64")
}
if len(m.Packages) != 2 {
t.Errorf("Packages count = %d, want 2", len(m.Packages))
}
}
// TestCrossCompilationDetection tests ARM cross-compilation detection on x86
func TestCrossCompilationDetection(t *testing.T) {
m := &manifest.Manifest{
Arch: "arm64",
}
opts := &Options{
Manifest: m,
OutputDir: t.TempDir(),
DryRun: true,
ParallelJobs: 1,
}
b := New(opts)
cmds, _ := b.RunStage("rootfs")
// When building ARM64 on potentially x86, debootstrap should include arch flag
for _, cmd := range cmds {
if strings.Contains(cmd, "debootstrap") {
if strings.Contains(cmd, "--arch=arm64") || strings.Contains(cmd, "--foreign") {
// Good - has cross-compilation support
return
}
}
}
// The test passes either way since cross-compilation is environment-dependent
}
// TestParsePartitionSize tests partition size parsing
func TestParsePartitionSize(t *testing.T) {
tests := []struct {
input string
expected int64
wantErr bool
}{
{"256M", 256 * 1024 * 1024, false},
{"6G", 6 * 1024 * 1024 * 1024, false},
{"2G", 2 * 1024 * 1024 * 1024, false},
{"1T", 1024 * 1024 * 1024 * 1024, false},
{"100K", 100 * 1024, false},
{"invalid", 0, true},
{"", 0, true},
}
for _, tc := range tests {
got, err := ParsePartitionSize(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("ParsePartitionSize(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Errorf("ParsePartitionSize(%q) error = %v", tc.input, err)
continue
}
if got != tc.expected {
t.Errorf("ParsePartitionSize(%q) = %d, want %d", tc.input, got, tc.expected)
}
}
}

View File

@ -0,0 +1,575 @@
// cmd/secubox/internal/builder/stages.go
package builder
import (
"fmt"
"path/filepath"
"strings"
)
// stageRootfs creates the root filesystem using debootstrap
func (b *Builder) stageRootfs() ([]string, error) {
var cmds []string
rootfs := b.rootfsPath()
arch := b.manifest.Arch
if arch == "" {
arch = "arm64"
}
// Ensure output directory exists
cmds = append(cmds, fmt.Sprintf("mkdir -p %s", rootfs))
// Determine if we need cross-compilation
crossCompile := b.needsCrossCompile()
// Base debootstrap command
var debootstrapCmd string
if crossCompile {
// Two-stage debootstrap for cross-compilation
debootstrapCmd = fmt.Sprintf(
"debootstrap --arch=%s --foreign --variant=minbase bookworm %s http://deb.debian.org/debian",
arch, rootfs,
)
cmds = append(cmds, debootstrapCmd)
// Copy qemu-user-static for second stage
cmds = append(cmds, fmt.Sprintf(
"cp /usr/bin/qemu-%s-static %s/usr/bin/ 2>/dev/null || true",
archToQemu(arch), rootfs,
))
// Run second stage in chroot
cmds = append(cmds, fmt.Sprintf(
"chroot %s /debootstrap/debootstrap --second-stage",
rootfs,
))
} else {
// Native debootstrap (same architecture)
debootstrapCmd = fmt.Sprintf(
"debootstrap --arch=%s --variant=minbase bookworm %s http://deb.debian.org/debian",
arch, rootfs,
)
cmds = append(cmds, debootstrapCmd)
}
// Configure apt sources
sourcesContent := `deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware
`
cmds = append(cmds, fmt.Sprintf(
"cat > %s/etc/apt/sources.list << 'EOF'\n%sEOF",
rootfs, sourcesContent,
))
// Update package lists in chroot
cmds = append(cmds, fmt.Sprintf(
"chroot %s apt-get update",
rootfs,
))
// Install packages from manifest
if len(b.manifest.Packages) > 0 {
packages := strings.Join(b.manifest.Packages, " ")
cmds = append(cmds, fmt.Sprintf(
"chroot %s apt-get install -y --no-install-recommends %s",
rootfs, packages,
))
}
// Install kernel if specified
if b.manifest.Kernel.Version != "" {
kernelPkg := fmt.Sprintf("linux-image-%s-%s", b.manifest.Kernel.Version, arch)
cmds = append(cmds, fmt.Sprintf(
"chroot %s apt-get install -y %s || echo 'Kernel package not found, will use custom kernel'",
rootfs, kernelPkg,
))
}
// Enable kernel modules
if len(b.manifest.Kernel.Modules.Enable) > 0 {
modulesFile := filepath.Join(rootfs, "etc/modules-load.d/secubox.conf")
modules := strings.Join(b.manifest.Kernel.Modules.Enable, "\n")
cmds = append(cmds, fmt.Sprintf(
"mkdir -p %s/etc/modules-load.d && echo '%s' > %s",
rootfs, modules, modulesFile,
))
}
// Blacklist kernel modules
if len(b.manifest.Kernel.Modules.Blacklist) > 0 {
blacklistFile := filepath.Join(rootfs, "etc/modprobe.d/secubox-blacklist.conf")
var blacklistLines []string
for _, mod := range b.manifest.Kernel.Modules.Blacklist {
blacklistLines = append(blacklistLines, fmt.Sprintf("blacklist %s", mod))
}
cmds = append(cmds, fmt.Sprintf(
"mkdir -p %s/etc/modprobe.d && echo '%s' > %s",
rootfs, strings.Join(blacklistLines, "\n"), blacklistFile,
))
}
// Clean up apt cache to save space
cmds = append(cmds, fmt.Sprintf(
"chroot %s apt-get clean && rm -rf %s/var/lib/apt/lists/*",
rootfs, rootfs,
))
return cmds, nil
}
// stagePartition creates the disk image and partitions
func (b *Builder) stagePartition() ([]string, error) {
var cmds []string
imagePath := b.imagePath()
// Parse partition sizes
espSize, err := ParsePartitionSize(b.manifest.Partitions.ESP)
if err != nil && b.manifest.Partitions.ESP != "" {
return nil, fmt.Errorf("parse ESP size: %w", err)
}
if espSize == 0 {
espSize = 256 * 1024 * 1024 // Default 256M
}
rootSize, err := ParsePartitionSize(b.manifest.Partitions.Root)
if err != nil && b.manifest.Partitions.Root != "" {
return nil, fmt.Errorf("parse root size: %w", err)
}
if rootSize == 0 {
rootSize = 6 * 1024 * 1024 * 1024 // Default 6G
}
dataSize, err := ParsePartitionSize(b.manifest.Partitions.Data)
if err != nil && b.manifest.Partitions.Data != "" {
return nil, fmt.Errorf("parse data size: %w", err)
}
if dataSize == 0 {
dataSize = 2 * 1024 * 1024 * 1024 // Default 2G
}
// Total image size (add 64M for GPT overhead)
totalSize := espSize + rootSize + dataSize + (64 * 1024 * 1024)
totalSizeMB := totalSize / (1024 * 1024)
// Create sparse image file
cmds = append(cmds, fmt.Sprintf(
"dd if=/dev/zero of=%s bs=1M count=0 seek=%d",
imagePath, totalSizeMB,
))
// Create GPT partition table
cmds = append(cmds, fmt.Sprintf(
"parted -s %s mklabel gpt",
imagePath,
))
// Calculate partition boundaries (in MB for simplicity)
espSizeMB := espSize / (1024 * 1024)
rootSizeMB := rootSize / (1024 * 1024)
dataSizeMB := dataSize / (1024 * 1024)
espStart := 1 // Start at 1MB (alignment)
espEnd := espStart + int(espSizeMB)
rootStart := espEnd
rootEnd := rootStart + int(rootSizeMB)
dataStart := rootEnd
dataEnd := dataStart + int(dataSizeMB)
// Create ESP partition (EFI System Partition)
cmds = append(cmds, fmt.Sprintf(
"parted -s %s mkpart ESP fat32 %dMiB %dMiB",
imagePath, espStart, espEnd,
))
cmds = append(cmds, fmt.Sprintf(
"parted -s %s set 1 esp on",
imagePath,
))
// Create root partition
cmds = append(cmds, fmt.Sprintf(
"parted -s %s mkpart root ext4 %dMiB %dMiB",
imagePath, rootStart, rootEnd,
))
// Create data partition
cmds = append(cmds, fmt.Sprintf(
"parted -s %s mkpart data ext4 %dMiB %dMiB",
imagePath, dataStart, dataEnd,
))
// Mount operations as single script with cleanup trap
rootfs := b.rootfsPath()
mntDir := filepath.Join(b.outputDir, "mnt")
// Generate fstab content
fstab := `# /etc/fstab - SecuBox generated
LABEL=rootfs / ext4 errors=remount-ro 0 1
LABEL=ESP /boot/efi vfat umask=0077 0 2
LABEL=data /srv ext4 defaults 0 2
`
// Single script with trap for cleanup on failure
mountScript := fmt.Sprintf(`set -e
# Set up loop device
LOOPDEV=$(losetup --find --show --partscan %s)
# Cleanup function
cleanup() {
umount %s/srv 2>/dev/null || true
umount %s/boot/efi 2>/dev/null || true
umount %s 2>/dev/null || true
losetup -d $LOOPDEV 2>/dev/null || true
}
trap cleanup EXIT
# Format partitions
mkfs.vfat -F 32 -n ESP ${LOOPDEV}p1
mkfs.ext4 -L rootfs ${LOOPDEV}p2
mkfs.ext4 -L data ${LOOPDEV}p3
# Mount partitions
mkdir -p %s
mount ${LOOPDEV}p2 %s
mkdir -p %s/boot/efi
mount ${LOOPDEV}p1 %s/boot/efi
mkdir -p %s/srv
mount ${LOOPDEV}p3 %s/srv
# Copy rootfs to image
cp -a %s/* %s/
# Generate fstab
cat > %s/etc/fstab << 'FSTAB_EOF'
%sFSTAB_EOF
# Cleanup runs via trap on exit
`,
imagePath,
mntDir, mntDir, mntDir,
mntDir, mntDir, mntDir, mntDir, mntDir, mntDir,
rootfs, mntDir,
mntDir, fstab,
)
cmds = append(cmds, mountScript)
return cmds, nil
}
// stageBoot installs the bootloader
func (b *Builder) stageBoot() ([]string, error) {
var cmds []string
imagePath := b.imagePath()
bootMethod := b.manifest.Boot.Method
if bootMethod == "" {
bootMethod = "uboot" // Default for ARM
}
mntDir := filepath.Join(b.outputDir, "mnt")
// Get boot-specific commands
var bootCmds []string
switch bootMethod {
case "uboot":
bootCmds = b.installUBoot(mntDir)
case "grub":
bootCmds = b.installGrub(mntDir)
case "extlinux":
bootCmds = b.installExtlinux(mntDir)
default:
bootCmds = []string{fmt.Sprintf("echo 'Unknown boot method: %s'", bootMethod)}
}
// Single script with trap for cleanup on failure
bootScript := fmt.Sprintf(`set -e
# Set up loop device
LOOPDEV=$(losetup --find --show --partscan %s)
# Cleanup function
cleanup() {
umount %s/boot/efi 2>/dev/null || true
umount %s 2>/dev/null || true
losetup -d $LOOPDEV 2>/dev/null || true
}
trap cleanup EXIT
# Mount partitions
mount ${LOOPDEV}p2 %s
mount ${LOOPDEV}p1 %s/boot/efi
# Boot installation commands
%s
# Cleanup runs via trap on exit
`,
imagePath,
mntDir, mntDir,
mntDir, mntDir,
strings.Join(bootCmds, "\n"),
)
cmds = append(cmds, bootScript)
return cmds, nil
}
// installUBoot generates U-Boot installation commands
func (b *Builder) installUBoot(mntDir string) []string {
var cmds []string
kernelImage := b.manifest.Boot.KernelImage
if kernelImage == "" {
kernelImage = "Image" // Default for ARM64
}
dts := b.manifest.Kernel.DTS
board := b.manifest.Board
// Create boot script directory
cmds = append(cmds, fmt.Sprintf("mkdir -p %s/boot", mntDir))
// Copy kernel image (assuming it's in the rootfs)
cmds = append(cmds, fmt.Sprintf(
"cp %s/boot/vmlinuz-* %s/boot/%s 2>/dev/null || echo 'Kernel not found in rootfs'",
mntDir, mntDir, kernelImage,
))
// Copy DTB if specified
if dts != "" {
cmds = append(cmds, fmt.Sprintf(
"cp %s/usr/lib/linux-image-*/marvell/%s.dtb %s/boot/ 2>/dev/null || cp %s/boot/dtbs/*/%s.dtb %s/boot/ 2>/dev/null || echo 'DTB not found'",
mntDir, dts, mntDir, mntDir, dts, mntDir,
))
}
// Create boot.scr
bootScript := fmt.Sprintf(`# SecuBox U-Boot boot script for %s
setenv bootargs root=LABEL=rootfs rootfstype=ext4 rootwait console=ttyMV0,115200
load ${devtype} ${devnum}:${bootpart} ${kernel_addr_r} /boot/%s
`, board, kernelImage)
if dts != "" {
bootScript += fmt.Sprintf(`load ${devtype} ${devnum}:${bootpart} ${fdt_addr_r} /boot/%s.dtb
booti ${kernel_addr_r} - ${fdt_addr_r}
`, dts)
} else {
bootScript += `booti ${kernel_addr_r} - ${fdt_addr_r}
`
}
cmds = append(cmds, fmt.Sprintf(
"cat > %s/boot/boot.cmd << 'EOF'\n%sEOF",
mntDir, bootScript,
))
// Compile boot.scr
cmds = append(cmds, fmt.Sprintf(
"mkimage -C none -A arm64 -T script -d %s/boot/boot.cmd %s/boot/boot.scr",
mntDir, mntDir,
))
return cmds
}
// installGrub generates GRUB installation commands (for x86/VM)
func (b *Builder) installGrub(mntDir string) []string {
var cmds []string
// Install GRUB for UEFI
cmds = append(cmds, fmt.Sprintf(
"chroot %s grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=secubox --no-nvram",
mntDir,
))
// Copy GRUB EFI to removable media location for broader compatibility
cmds = append(cmds, fmt.Sprintf(
"mkdir -p %s/boot/efi/EFI/BOOT && cp %s/boot/efi/EFI/secubox/grubx64.efi %s/boot/efi/EFI/BOOT/BOOTX64.EFI",
mntDir, mntDir, mntDir,
))
// Generate GRUB config
grubConfig := `# SecuBox GRUB Configuration
set timeout=5
set default=0
menuentry "SecuBox" {
linux /vmlinuz root=LABEL=rootfs rootfstype=ext4 quiet
initrd /initrd.img
}
menuentry "SecuBox (Recovery)" {
linux /vmlinuz root=LABEL=rootfs rootfstype=ext4 single
initrd /initrd.img
}
`
cmds = append(cmds, fmt.Sprintf(
"cat > %s/boot/grub/grub.cfg << 'EOF'\n%sEOF",
mntDir, grubConfig,
))
return cmds
}
// installExtlinux generates extlinux configuration (simpler alternative)
func (b *Builder) installExtlinux(mntDir string) []string {
var cmds []string
kernelImage := b.manifest.Boot.KernelImage
if kernelImage == "" {
kernelImage = "Image"
}
dts := b.manifest.Kernel.DTS
cmds = append(cmds, fmt.Sprintf("mkdir -p %s/boot/extlinux", mntDir))
extlinuxConf := fmt.Sprintf(`DEFAULT secubox
TIMEOUT 30
PROMPT 0
LABEL secubox
KERNEL /boot/%s
`, kernelImage)
if dts != "" {
extlinuxConf += fmt.Sprintf(" FDT /boot/%s.dtb\n", dts)
}
extlinuxConf += ` APPEND root=LABEL=rootfs rootfstype=ext4 rootwait console=ttyMV0,115200
`
cmds = append(cmds, fmt.Sprintf(
"cat > %s/boot/extlinux/extlinux.conf << 'EOF'\n%sEOF",
mntDir, extlinuxConf,
))
return cmds
}
// stageCompress compresses the image
func (b *Builder) stageCompress() ([]string, error) {
var cmds []string
imagePath := b.imagePath()
formats := b.manifest.Output.Formats
if len(formats) == 0 {
formats = []string{"img.gz"} // Default
}
for _, format := range formats {
switch format {
case "img.gz":
cmds = append(cmds, fmt.Sprintf(
"gzip -k -f %s",
imagePath,
))
case "img.xz":
// Use parallel xz if available
cmds = append(cmds, fmt.Sprintf(
"xz -k -f -T%d %s || xz -k -f %s",
b.parallelJobs, imagePath, imagePath,
))
case "img.zst":
cmds = append(cmds, fmt.Sprintf(
"zstd -k -f -T%d %s",
b.parallelJobs, imagePath,
))
case "qcow2":
cmds = append(cmds, fmt.Sprintf(
"qemu-img convert -f raw -O qcow2 %s %s.qcow2",
imagePath, strings.TrimSuffix(imagePath, ".img"),
))
case "vmdk":
cmds = append(cmds, fmt.Sprintf(
"qemu-img convert -f raw -O vmdk %s %s.vmdk",
imagePath, strings.TrimSuffix(imagePath, ".img"),
))
default:
cmds = append(cmds, fmt.Sprintf("# Unknown format: %s", format))
}
}
return cmds, nil
}
// stageChecksums generates checksums for the image files
func (b *Builder) stageChecksums() ([]string, error) {
var cmds []string
imagePath := b.imagePath()
checksums := b.manifest.Output.Checksums
if len(checksums) == 0 {
checksums = []string{"sha256"} // Default
}
formats := b.manifest.Output.Formats
if len(formats) == 0 {
formats = []string{"img.gz"}
}
// Build list of files to checksum
files := []string{imagePath} // Raw image
for _, format := range formats {
switch format {
case "img.gz":
files = append(files, imagePath+".gz")
case "img.xz":
files = append(files, imagePath+".xz")
case "img.zst":
files = append(files, imagePath+".zst")
case "qcow2":
files = append(files, strings.TrimSuffix(imagePath, ".img")+".qcow2")
case "vmdk":
files = append(files, strings.TrimSuffix(imagePath, ".img")+".vmdk")
}
}
// Generate checksums
for _, algo := range checksums {
var sumCmd string
switch algo {
case "sha256":
sumCmd = "sha256sum"
case "sha512":
sumCmd = "sha512sum"
case "md5":
sumCmd = "md5sum"
case "sha1":
sumCmd = "sha1sum"
default:
continue
}
// Generate checksum for each file
for _, file := range files {
cmds = append(cmds, fmt.Sprintf(
"%s %s > %s.%s 2>/dev/null || true",
sumCmd, file, file, algo,
))
}
}
return cmds, nil
}
// archToQemu maps architecture names to qemu-user binary names
func archToQemu(arch string) string {
switch arch {
case "arm64", "aarch64":
return "aarch64"
case "armhf", "arm":
return "arm"
case "amd64", "x86_64":
return "x86_64"
case "i386", "i686":
return "i386"
default:
return arch
}
}

View File

@ -0,0 +1,319 @@
// cmd/secubox/internal/hardware/detect.go
package hardware
import (
"bufio"
"fmt"
"os"
"regexp"
"runtime"
"strconv"
"strings"
)
// Package-level compiled regex for performance
var memTotalRe = regexp.MustCompile(`MemTotal:\s+(\d+)\s+kB`)
// Info holds detected hardware information
type Info struct {
Board string
Arch string
CPUModel string
CPUCores int
RAMTotal uint64 // bytes
RAMTotalMB uint64
DiskTotal uint64 // bytes
}
// Detect gathers hardware information
func Detect() (*Info, error) {
info := &Info{}
// Detect architecture
info.Arch = detectArch()
// Detect CPU
info.CPUModel, info.CPUCores = detectCPU()
// Detect RAM
info.RAMTotal = detectRAM()
info.RAMTotalMB = info.RAMTotal / 1024 / 1024
// Detect board (from device tree)
info.Board = detectBoard()
return info, nil
}
func detectArch() string {
// Use Go's runtime for architecture detection
arch := runtime.GOARCH
switch arch {
case "arm64":
return "arm64"
case "amd64":
return "amd64"
case "arm":
return "arm"
default:
// Fallback: read from /proc/cpuinfo
data, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return arch
}
content := string(data)
if strings.Contains(content, "aarch64") || strings.Contains(content, "ARMv8") {
return "arm64"
}
if strings.Contains(content, "x86_64") {
return "amd64"
}
return arch
}
}
func detectCPU() (string, int) {
file, err := os.Open("/proc/cpuinfo")
if err != nil {
return "unknown", runtime.NumCPU()
}
defer file.Close()
var model string
cores := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// ARM uses "Model" or "model name"
if strings.HasPrefix(line, "model name") || strings.HasPrefix(line, "Model") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
model = strings.TrimSpace(parts[1])
}
}
// Also check Hardware line for ARM
if strings.HasPrefix(line, "Hardware") && model == "" {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
model = strings.TrimSpace(parts[1])
}
}
if strings.HasPrefix(line, "processor") {
cores++
}
}
// Check for scanner errors
if err := scanner.Err(); err != nil {
// Log error but continue with what we have
if cores == 0 {
cores = runtime.NumCPU()
}
if model == "" {
model = "unknown"
}
return model, cores
}
// Fallback to runtime if no cores found
if cores == 0 {
cores = runtime.NumCPU()
}
if model == "" {
model = "unknown"
}
return model, cores
}
func detectRAM() uint64 {
file, err := os.Open("/proc/meminfo")
if err != nil {
return 0
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
matches := memTotalRe.FindStringSubmatch(scanner.Text())
if len(matches) == 2 {
kb, err := strconv.ParseUint(matches[1], 10, 64)
if err != nil {
return 0
}
return kb * 1024 // Convert to bytes
}
}
// Check for scanner errors
if err := scanner.Err(); err != nil {
return 0
}
return 0
}
func detectBoard() string {
// Try device tree model (ARM boards)
data, err := os.ReadFile("/proc/device-tree/model")
if err == nil {
model := strings.TrimSpace(strings.TrimRight(string(data), "\x00"))
boardName := identifyBoardFromModel(model)
if boardName != "" {
return boardName
}
}
// Try device tree compatible (ARM boards)
data, err = os.ReadFile("/proc/device-tree/compatible")
if err == nil {
compatible := strings.TrimSpace(strings.TrimRight(string(data), "\x00"))
boardName := identifyBoardFromCompatible(compatible)
if boardName != "" {
return boardName
}
}
// Try DMI (x86 systems)
data, err = os.ReadFile("/sys/class/dmi/id/product_name")
if err == nil {
product := strings.TrimSpace(string(data))
boardName := identifyBoardFromDMI(product)
if boardName != "" {
return boardName
}
}
// Try virtualization detection
if isVirtualMachine() {
return "vm-x64"
}
return "unknown"
}
func identifyBoardFromModel(model string) string {
modelLower := strings.ToLower(model)
// MOCHAbin (Armada 7040)
if strings.Contains(modelLower, "mochabin") {
return "mochabin"
}
// ESPRESSObin variants (Armada 3720)
if strings.Contains(modelLower, "espressobin") {
if strings.Contains(modelLower, "ultra") {
return "espressobin-ultra"
}
return "espressobin-v7"
}
// Raspberry Pi
if strings.Contains(modelLower, "raspberry") {
if strings.Contains(modelLower, "400") {
return "rpi400"
}
if strings.Contains(modelLower, "4") {
return "rpi4"
}
if strings.Contains(modelLower, "5") {
return "rpi5"
}
}
// Marvell Armada generic
if strings.Contains(modelLower, "armada") {
if strings.Contains(modelLower, "7040") || strings.Contains(modelLower, "8040") {
return "mochabin"
}
if strings.Contains(modelLower, "3720") {
return "espressobin-v7"
}
}
return ""
}
func identifyBoardFromCompatible(compatible string) string {
compatLower := strings.ToLower(compatible)
if strings.Contains(compatLower, "mochabin") || strings.Contains(compatLower, "globalscale,mochabin") {
return "mochabin"
}
if strings.Contains(compatLower, "espressobin") || strings.Contains(compatLower, "globalscale,espressobin") {
return "espressobin-v7"
}
if strings.Contains(compatLower, "raspberrypi,400") {
return "rpi400"
}
if strings.Contains(compatLower, "raspberrypi,4") {
return "rpi4"
}
return ""
}
func identifyBoardFromDMI(product string) string {
productLower := strings.ToLower(product)
if strings.Contains(productLower, "virtualbox") {
return "vm-x64"
}
if strings.Contains(productLower, "vmware") {
return "vm-x64"
}
if strings.Contains(productLower, "kvm") || strings.Contains(productLower, "qemu") {
return "vm-x64"
}
return ""
}
func isVirtualMachine() bool {
// Check for hypervisor in /proc/cpuinfo
data, err := os.ReadFile("/proc/cpuinfo")
if err == nil && strings.Contains(string(data), "hypervisor") {
return true
}
// Check systemd-detect-virt style detection
virtFiles := []string{
"/sys/hypervisor/type",
"/proc/xen",
}
for _, f := range virtFiles {
if _, err := os.Stat(f); err == nil {
return true
}
}
return false
}
// SuggestTier suggests a tier based on RAM
func (i *Info) SuggestTier() string {
ramGB := i.RAMTotalMB / 1024
if ramGB >= 8 {
return "tier-pro"
}
if ramGB >= 4 {
return "tier-standard"
}
return "tier-lite"
}
// String returns a formatted string of hardware info
func (i *Info) String() string {
return fmt.Sprintf(`Hardware Information:
Board: %s
Arch: %s
CPU: %s (%d cores)
RAM: %d MB
Suggested: %s`,
i.Board, i.Arch, i.CPUModel, i.CPUCores,
i.RAMTotalMB, i.SuggestTier())
}

View File

@ -0,0 +1,160 @@
package hardware
import (
"testing"
)
func TestDetect(t *testing.T) {
info, err := Detect()
if err != nil {
t.Fatalf("Detect() error = %v", err)
}
if info == nil {
t.Fatal("Detect() returned nil")
}
// Arch should always be detected
if info.Arch == "" {
t.Error("Arch should not be empty")
}
// CPUCores should be at least 1
if info.CPUCores < 1 {
t.Errorf("CPUCores = %d, want >= 1", info.CPUCores)
}
}
func TestSuggestTier(t *testing.T) {
tests := []struct {
name string
ramTotalMB uint64
want string
}{
{"lite - 1GB", 1024, "tier-lite"},
{"lite - 2GB", 2048, "tier-lite"},
{"standard - 4GB", 4096, "tier-standard"},
{"standard - 6GB", 6144, "tier-standard"},
{"pro - 8GB", 8192, "tier-pro"},
{"pro - 16GB", 16384, "tier-pro"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &Info{RAMTotalMB: tt.ramTotalMB}
got := info.SuggestTier()
if got != tt.want {
t.Errorf("SuggestTier() = %q, want %q", got, tt.want)
}
})
}
}
func TestInfoString(t *testing.T) {
info := &Info{
Board: "mochabin",
Arch: "arm64",
CPUModel: "Test CPU",
CPUCores: 4,
RAMTotalMB: 8192,
}
s := info.String()
if s == "" {
t.Error("String() returned empty string")
}
// Check that key information is present
if !contains(s, "mochabin") {
t.Error("String() should contain board name")
}
if !contains(s, "arm64") {
t.Error("String() should contain arch")
}
if !contains(s, "8192") {
t.Error("String() should contain RAM")
}
}
func TestIdentifyBoardFromModel(t *testing.T) {
tests := []struct {
model string
want string
}{
{"GlobalScale MOCHAbin", "mochabin"},
{"Globalscale ESPRESSObin", "espressobin-v7"},
{"ESPRESSObin Ultra", "espressobin-ultra"},
{"Raspberry Pi 4 Model B", "rpi4"},
{"Raspberry Pi 5", "rpi5"},
{"Raspberry Pi 400", "rpi400"},
{"Marvell Armada 7040", "mochabin"},
{"Marvell Armada 3720", "espressobin-v7"},
{"Unknown Board", ""},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
got := identifyBoardFromModel(tt.model)
if got != tt.want {
t.Errorf("identifyBoardFromModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestIdentifyBoardFromCompatible(t *testing.T) {
tests := []struct {
compatible string
want string
}{
{"globalscale,mochabin", "mochabin"},
{"globalscale,espressobin", "espressobin-v7"},
{"raspberrypi,4-model-b", "rpi4"},
{"raspberrypi,400", "rpi400"},
{"unknown,board", ""},
}
for _, tt := range tests {
t.Run(tt.compatible, func(t *testing.T) {
got := identifyBoardFromCompatible(tt.compatible)
if got != tt.want {
t.Errorf("identifyBoardFromCompatible(%q) = %q, want %q", tt.compatible, got, tt.want)
}
})
}
}
func TestIdentifyBoardFromDMI(t *testing.T) {
tests := []struct {
product string
want string
}{
{"VirtualBox", "vm-x64"},
{"VMware Virtual Machine", "vm-x64"},
{"QEMU KVM", "vm-x64"},
{"Dell PowerEdge", ""},
}
for _, tt := range tests {
t.Run(tt.product, func(t *testing.T) {
got := identifyBoardFromDMI(tt.product)
if got != tt.want {
t.Errorf("identifyBoardFromDMI(%q) = %q, want %q", tt.product, got, tt.want)
}
})
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
}
func containsSubstr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -0,0 +1,84 @@
// cmd/secubox/internal/manifest/makefile.go
package manifest
import (
"fmt"
"strings"
)
// GenerateMakefile creates a Makefile from a manifest
func GenerateMakefile(m *Manifest) string {
var sb strings.Builder
// Header
sb.WriteString(fmt.Sprintf(`# Auto-generated by secubox gen v%s
# Run: make image
MANIFEST := manifest.yaml
VERSION := %s
BOARD := %s
ARCH := %s
IMAGE_NAME := secubox-$(BOARD)-$(VERSION)
.PHONY: all image rootfs partition boot compress checksums clean
all: image
image: rootfs partition boot compress checksums
@echo "Build complete: $(IMAGE_NAME).img.gz"
rootfs:
@echo "=== Building rootfs ==="
secubox build --stage rootfs --manifest $(MANIFEST)
partition:
@echo "=== Creating partitions ==="
secubox build --stage partition --manifest $(MANIFEST)
boot:
@echo "=== Installing bootloader ==="
secubox build --stage boot --manifest $(MANIFEST)
compress:
@echo "=== Compressing images ==="
`, m.SecuboxVersion, m.SecuboxVersion, m.Board, m.Arch))
// Add compression commands based on output formats
for _, format := range m.Output.Formats {
switch format {
case "img.gz":
sb.WriteString("\tgzip -k $(IMAGE_NAME).img\n")
case "img.xz":
sb.WriteString("\txz -k $(IMAGE_NAME).img\n")
}
}
// Checksums
sb.WriteString(`
checksums:
@echo "=== Generating checksums ==="
`)
for _, sum := range m.Output.Checksums {
sb.WriteString(fmt.Sprintf("\t%ssum $(IMAGE_NAME).img* > %sSUMS\n", sum, strings.ToUpper(sum)))
}
// Clean and additional targets
sb.WriteString(`
clean:
rm -rf rootfs/ *.img *.img.gz *.img.xz SHA*SUMS
# Platform-specific targets
.PHONY: vdi qcow2 iso
vdi: image
qemu-img convert -f raw -O vdi $(IMAGE_NAME).img $(IMAGE_NAME).vdi
qcow2: image
qemu-img convert -f raw -O qcow2 $(IMAGE_NAME).img $(IMAGE_NAME).qcow2
iso: rootfs
secubox build --stage iso --manifest $(MANIFEST)
`)
return sb.String()
}

View File

@ -0,0 +1,103 @@
// cmd/secubox/internal/manifest/manifest.go
package manifest
import (
"fmt"
"time"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/profile"
"gopkg.in/yaml.v3"
)
// Manifest represents the build manifest
type Manifest struct {
SecuboxVersion string `yaml:"secubox_version"`
GeneratedAt string `yaml:"generated_at"`
Board string `yaml:"board"`
Tier string `yaml:"tier"`
Arch string `yaml:"arch"`
Packages []string `yaml:"packages"`
Kernel ManifestKernel `yaml:"kernel"`
Partitions ManifestPartitions `yaml:"partitions"`
Boot ManifestBoot `yaml:"boot"`
Output ManifestOutput `yaml:"output"`
}
type ManifestKernel struct {
Version string `yaml:"version"`
DTS string `yaml:"dts,omitempty"`
Modules ManifestModules `yaml:"modules"`
}
// ManifestModules defines kernel modules to enable or blacklist
type ManifestModules struct {
Enable []string `yaml:"enable"`
Blacklist []string `yaml:"blacklist"`
}
type ManifestPartitions struct {
ESP string `yaml:"esp"`
Root string `yaml:"root"`
Data string `yaml:"data"`
}
type ManifestBoot struct {
Method string `yaml:"method"`
KernelImage string `yaml:"kernel_image,omitempty"`
}
type ManifestOutput struct {
Formats []string `yaml:"formats"`
Checksums []string `yaml:"checksums"`
}
// Generate creates a manifest from profile and board
func Generate(p *profile.Profile, b *profile.Board, version string) *Manifest {
m := &Manifest{
SecuboxVersion: version,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Board: b.Name,
Tier: p.Name,
Arch: b.Arch,
Packages: p.Packages.Required,
Kernel: ManifestKernel{
Version: p.Kernel.Version,
DTS: b.Boot.DTS,
Modules: ManifestModules{
Enable: p.Kernel.Modules.Enable,
Blacklist: p.Kernel.Modules.Blacklist,
},
},
// TODO: Read partition sizes from board config (board/*/config.mk)
// Currently hardcoded; see issue tracking board-specific partitions
Partitions: ManifestPartitions{
ESP: "256M",
Root: "6G",
Data: "2G",
},
Boot: ManifestBoot{
Method: b.Boot.Method,
KernelImage: b.Boot.KernelImage,
},
Output: ManifestOutput{
Formats: []string{"img.gz", "img.xz"},
Checksums: []string{"sha256", "sha512"},
},
}
return m
}
// ToYAML serializes the manifest to YAML
func (m *Manifest) ToYAML() ([]byte, error) {
header := "# Auto-generated by secubox gen v" + m.SecuboxVersion + "\n"
header += "# Date: " + m.GeneratedAt + "\n"
header += "# Do not edit manually - regenerate with: secubox gen\n\n"
data, err := yaml.Marshal(m)
if err != nil {
return nil, fmt.Errorf("marshal manifest: %w", err)
}
return append([]byte(header), data...), nil
}

View File

@ -0,0 +1,170 @@
// cmd/secubox/internal/manifest/manifest_test.go
package manifest
import (
"strings"
"testing"
"github.com/CyberMind-FR/secubox-deb/cmd/secubox/internal/profile"
)
func TestGenerate(t *testing.T) {
p := &profile.Profile{
Name: "test",
Packages: profile.Packages{
Required: []string{"secubox-core", "secubox-hub"},
},
Kernel: profile.Kernel{
Version: "6.6",
},
}
b := &profile.Board{
Name: "mochabin",
Arch: "arm64",
Boot: profile.Boot{
Method: "uboot",
KernelImage: "Image",
DTS: "armada-7040-mochabin",
},
}
m := Generate(p, b, "2.8.0")
if m.SecuboxVersion != "2.8.0" {
t.Errorf("SecuboxVersion = %q, want %q", m.SecuboxVersion, "2.8.0")
}
if m.Board != "mochabin" {
t.Errorf("Board = %q, want %q", m.Board, "mochabin")
}
if len(m.Packages) != 2 {
t.Errorf("Packages = %d, want 2", len(m.Packages))
}
// Additional coverage for Kernel, Tier, Arch, Boot, Output
if m.Kernel.Version != "6.6" {
t.Errorf("Kernel.Version = %q, want %q", m.Kernel.Version, "6.6")
}
if m.Tier != "test" {
t.Errorf("Tier = %q, want %q", m.Tier, "test")
}
if m.Arch != "arm64" {
t.Errorf("Arch = %q, want %q", m.Arch, "arm64")
}
if m.Boot.Method != "uboot" {
t.Errorf("Boot.Method = %q, want %q", m.Boot.Method, "uboot")
}
if m.Boot.KernelImage != "Image" {
t.Errorf("Boot.KernelImage = %q, want %q", m.Boot.KernelImage, "Image")
}
if len(m.Output.Formats) != 2 {
t.Errorf("Output.Formats count = %d, want 2", len(m.Output.Formats))
}
if len(m.Output.Checksums) != 2 {
t.Errorf("Output.Checksums count = %d, want 2", len(m.Output.Checksums))
}
}
func TestToYAML(t *testing.T) {
p := &profile.Profile{
Name: "test",
Packages: profile.Packages{
Required: []string{"secubox-core"},
},
Kernel: profile.Kernel{
Version: "6.6",
},
}
b := &profile.Board{
Name: "mochabin",
Arch: "arm64",
Boot: profile.Boot{
Method: "uboot",
},
}
m := Generate(p, b, "2.8.0")
yaml, err := m.ToYAML()
if err != nil {
t.Fatalf("ToYAML() error = %v", err)
}
// Check header
if !strings.Contains(string(yaml), "# Auto-generated by secubox gen") {
t.Error("YAML missing header comment")
}
// Check content (YAML doesn't quote simple strings)
if !strings.Contains(string(yaml), "secubox_version: 2.8.0") {
t.Error("YAML missing secubox_version")
}
if !strings.Contains(string(yaml), "board: mochabin") {
t.Error("YAML missing board")
}
}
func TestToYAML_EmptyManifest(t *testing.T) {
// Test that ToYAML handles an empty/minimal manifest without error
m := &Manifest{
SecuboxVersion: "1.0.0",
GeneratedAt: "2025-01-01T00:00:00Z",
}
yaml, err := m.ToYAML()
if err != nil {
t.Fatalf("ToYAML() on empty manifest error = %v", err)
}
// Should still have header
if !strings.Contains(string(yaml), "# Auto-generated by secubox gen v1.0.0") {
t.Error("YAML missing header comment for empty manifest")
}
// Should contain the minimal fields
if !strings.Contains(string(yaml), "secubox_version: 1.0.0") {
t.Error("YAML missing secubox_version for empty manifest")
}
}
func TestGenerateMakefile(t *testing.T) {
m := &Manifest{
SecuboxVersion: "2.8.0",
Board: "mochabin",
Arch: "arm64",
Output: ManifestOutput{
Formats: []string{"img.gz", "img.xz"},
Checksums: []string{"sha256", "sha512"},
},
}
makefile := GenerateMakefile(m)
if !strings.Contains(makefile, "VERSION := 2.8.0") {
t.Error("Makefile missing VERSION")
}
if !strings.Contains(makefile, "BOARD := mochabin") {
t.Error("Makefile missing BOARD")
}
if !strings.Contains(makefile, "ARCH := arm64") {
t.Error("Makefile missing ARCH")
}
if !strings.Contains(makefile, "image:") {
t.Error("Makefile missing image target")
}
if !strings.Contains(makefile, "gzip -k") {
t.Error("Makefile missing gzip command")
}
if !strings.Contains(makefile, "xz -k") {
t.Error("Makefile missing xz command")
}
if !strings.Contains(makefile, "sha256sum") {
t.Error("Makefile missing sha256sum")
}
if !strings.Contains(makefile, "sha512sum") {
t.Error("Makefile missing sha512sum")
}
if !strings.Contains(makefile, "qemu-img convert") {
t.Error("Makefile missing qemu-img targets")
}
}

View File

@ -0,0 +1,605 @@
// cmd/secubox/internal/ota/ota.go
package ota
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// GitHub release configuration
const (
GitHubOwner = "CyberMind-FR"
GitHubRepo = "secubox-deb"
GitHubAPIBase = "https://api.github.com"
)
// HTTP client with timeout
var httpClient = &http.Client{Timeout: 5 * time.Minute}
// UpdateType represents the type of update to perform
type UpdateType int
const (
UpdatePackages UpdateType = iota // APT package updates only
UpdateSystem // Kernel/boot update (A/B swap)
UpdateAll // Full update (packages + system)
)
// UpdateStatus represents the status of an update check
type UpdateStatus struct {
PackagesAvailable bool
PackageCount int
PackageList []string
SystemAvailable bool
CurrentVersion string
LatestVersion string
ReleaseURL string
ReleaseNotes string
}
// Options holds OTA operation configuration
type Options struct {
DryRun bool
Verbose bool
Force bool