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
|
# HISTORY — SecuBox-DEB Migration Log
|
||||||
*Tracking completed milestones with dates*
|
*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
|
## 2026-05-09
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,57 @@
|
||||||
# WIP — Work In Progress
|
# 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,
|
||||||
|
}
|
||||||
|
|||||||