mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 10:08:36 +00:00
Compare commits
30 Commits
45c2b7c020
...
897dc339a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 897dc339a6 | |||
| b2f046c146 | |||
| e5630c6e6f | |||
| 66860c8022 | |||
| cf328e5a42 | |||
| ea47958c18 | |||
| 1b13bffee3 | |||
| 647f49b9f8 | |||
| 8b8e3e868c | |||
| fc05dda136 | |||
| f8502dfbd0 | |||
| 220218e910 | |||
| 9f3d60024b | |||
| fcca5a7dce | |||
| bd7dda0c6f | |||
| 295a91aac4 | |||
| 5dba0d4106 | |||
| 871d2e9198 | |||
| 63b1656cf8 | |||
| d558b269d6 | |||
| 29d88e20d8 | |||
| bf6881fc47 | |||
| ddd87e55c7 | |||
| eb91fe97c7 | |||
| 7501ce80fd | |||
| 2bbecfdc5f | |||
| c9da45fa21 | |||
| 300a88beb8 | |||
| 3335a5053f | |||
| da706eeb28 |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
201
.github/workflows/build-secubox-cli.yml
vendored
Normal 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
137
apt/README.md
Normal 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
27
apt/conf/distributions
Normal 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
8
apt/conf/incoming
Normal 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
6
apt/conf/options
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# apt/conf/options
|
||||
# Reprepro global options
|
||||
|
||||
verbose
|
||||
ask-passphrase
|
||||
basedir /srv/apt
|
||||
32
apt/hooks/lintian-check
Executable file
32
apt/hooks/lintian-check
Executable 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
27
board/mochabin/board.yaml
Normal 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
|
||||
24
board/mochabin/tweaks.yaml
Normal file
24
board/mochabin/tweaks.yaml
Normal 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
128
cmd/secubox/cmd/apt.go
Normal 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
|
||||
}
|
||||
171
cmd/secubox/cmd/apt_server.go
Normal file
171
cmd/secubox/cmd/apt_server.go
Normal 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()
|
||||
}
|
||||
42
cmd/secubox/cmd/apt_test.go
Normal file
42
cmd/secubox/cmd/apt_test.go
Normal 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
161
cmd/secubox/cmd/build.go
Normal 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
256
cmd/secubox/cmd/clone.go
Normal 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
|
||||
}
|
||||
42
cmd/secubox/cmd/clone_test.go
Normal file
42
cmd/secubox/cmd/clone_test.go
Normal 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
570
cmd/secubox/cmd/fetch.go
Normal 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)
|
||||
}
|
||||
281
cmd/secubox/cmd/fetch_test.go
Normal file
281
cmd/secubox/cmd/fetch_test.go
Normal 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
266
cmd/secubox/cmd/gen.go
Normal 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
67
cmd/secubox/cmd/info.go
Normal 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
396
cmd/secubox/cmd/ota.go
Normal 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
68
cmd/secubox/cmd/root.go
Normal 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
33
cmd/secubox/go.mod
Normal 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
84
cmd/secubox/go.sum
Normal 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=
|
||||
120
cmd/secubox/internal/apt/client.go
Normal file
120
cmd/secubox/internal/apt/client.go
Normal 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
|
||||
}
|
||||
169
cmd/secubox/internal/apt/client_test.go
Normal file
169
cmd/secubox/internal/apt/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
71
cmd/secubox/internal/apt/packages.go
Normal file
71
cmd/secubox/internal/apt/packages.go
Normal 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"}
|
||||
}
|
||||
78
cmd/secubox/internal/apt/packages_test.go
Normal file
78
cmd/secubox/internal/apt/packages_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
140
cmd/secubox/internal/apt/server.go
Normal file
140
cmd/secubox/internal/apt/server.go
Normal 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()
|
||||
}
|
||||
210
cmd/secubox/internal/builder/builder.go
Normal file
210
cmd/secubox/internal/builder/builder.go
Normal 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
|
||||
}
|
||||
466
cmd/secubox/internal/builder/builder_test.go
Normal file
466
cmd/secubox/internal/builder/builder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
575
cmd/secubox/internal/builder/stages.go
Normal file
575
cmd/secubox/internal/builder/stages.go
Normal 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
|
||||
}
|
||||
}
|
||||
319
cmd/secubox/internal/hardware/detect.go
Normal file
319
cmd/secubox/internal/hardware/detect.go
Normal 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())
|
||||
}
|
||||
160
cmd/secubox/internal/hardware/detect_test.go
Normal file
160
cmd/secubox/internal/hardware/detect_test.go
Normal 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
|
||||
}
|
||||
84
cmd/secubox/internal/manifest/makefile.go
Normal file
84
cmd/secubox/internal/manifest/makefile.go
Normal 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()
|
||||
}
|
||||
103
cmd/secubox/internal/manifest/manifest.go
Normal file
103
cmd/secubox/internal/manifest/manifest.go
Normal 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
|
||||
}
|
||||
170
cmd/secubox/internal/manifest/manifest_test.go
Normal file
170
cmd/secubox/internal/manifest/manifest_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
605
cmd/secubox/internal/ota/ota.go
Normal file
605
cmd/secubox/internal/ota/ota.go
Normal 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
|
||||
RepoURL string // Custom APT repo URL
|
||||