Initial commit

This commit is contained in:
zazawowow
2026-01-24 22:01:51 +00:00
commit 64cc3bc7fb
56 changed files with 4584 additions and 0 deletions

48
apps/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Archipelago App Manifests
This directory contains app manifest definitions for containerized applications in the Archipelago Bitcoin Node OS.
## App Categories
### Bitcoin & Lightning
- `bitcoin-core/` - Bitcoin Core full node
- `lnd/` - Lightning Network Daemon
- `core-lightning/` - Core Lightning (CLN)
- `btcpay-server/` - BTCPay Server payment processor
- `mempool/` - Mempool blockchain explorer
### Web5 & Decentralized Protocols
- `nostr-rs-relay/` - High-performance Nostr relay (Rust)
- `strfry/` - Nostr relay (C++)
- `web5-dwn/` - Decentralized Web Node
- `did-wallet/` - Web5 wallet with DID support
### Mesh Networking & Routing
- `meshtastic/` - Meshtastic LoRa mesh networking
- `router/` - Mesh routing and local network management
- `cjdns/` - Encrypted mesh networking (cjdns)
### Self-Hosted Services
- `homeassistant/` - Home automation
- `grafana/` - Monitoring and dashboards
- `searxng/` - Privacy-respecting search engine
- `onlyoffice/` - Office suite
- `ollama/` - Local AI models
- `penpot/` - Design tool
### Other
- `fedimint/` - Federated e-cash mint
- `morphos-server/` - MorphOS server
- `a-b/` - A to B protocol
## Manifest Format
Each app has a `manifest.yml` file defining:
- Container image and version
- Resource requirements
- Dependencies
- Security policies
- Health checks
- Network configuration
See `docs/app-manifest-spec.md` for the complete specification.

View File

@@ -0,0 +1,58 @@
app:
id: bitcoin-core
name: Bitcoin Core
version: 26.0.0
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
container:
image: bitcoin/bitcoin:26.0
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- storage: 500Gi # Minimum disk space for mainnet
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 500Gi
security:
capabilities: [] # No special capabilities needed
readonly_root: true
network_policy: isolated
apparmor_profile: bitcoin-core
ports:
- host: 8332
container: 8332
protocol: tcp # RPC
- host: 8333
container: 8333
protocol: tcp # P2P
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- NETWORK=mainnet
- RPC_USER=${BITCOIN_RPC_USER}
- RPC_PASSWORD=${BITCOIN_RPC_PASSWORD}
- PRUNE=0 # Full node (set to 550 for pruned)
health_check:
type: http
endpoint: http://localhost:8332
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: true
pruning_support: true

View File

@@ -0,0 +1,63 @@
app:
id: btcpay-server
name: BTCPay Server
version: 1.12.0
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: btcpayserver/btcpayserver:1.12.0
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: lnd
version: ">=0.18.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
network_policy: isolated
apparmor_profile: btcpay
ports:
- host: 80
container: 80
protocol: tcp
- host: 443
container: 443
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/btcpay
target: /datadir
options: [rw]
environment:
- BTCPAY_NETWORK=mainnet
- BTCPAY_CHAIN=btc
- BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332
- BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true
health_check:
type: http
endpoint: http://localhost
path: /health
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
lightning_integration:
payment_processing: true
invoice_management: true

View File

@@ -0,0 +1,60 @@
app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
image: elementsproject/lightningd:v23.08.2
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
network_policy: isolated
apparmor_profile: core-lightning
ports:
- host: 9735
container: 9735
protocol: tcp # P2P
- host: 9835
container: 9835
protocol: tcp # gRPC
volumes:
- type: bind
source: /var/lib/archipelago/core-lightning
target: /home/clightning/.lightning
options: [rw]
environment:
- BITCOIND_RPCURL=http://bitcoin-core:8332
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=bitcoin
health_check:
type: exec
endpoint: lightning-cli getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true

View File

@@ -0,0 +1,54 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: tbd/web5-wallet:latest
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- app_id: web5-dwn
version: ">=1.0.0"
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8080
container: 8080
protocol: tcp # Web UI
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- DWN_ENDPOINT=http://web5-dwn:3000
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://localhost:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true

64
apps/lnd/manifest.yml Normal file
View File

@@ -0,0 +1,64 @@
app:
id: lnd
name: Lightning Network Daemon
version: 0.18.0
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: lightninglabs/lnd:v0.18.0
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
network_policy: isolated
apparmor_profile: lnd
ports:
- host: 9735
container: 9735
protocol: tcp # P2P
- host: 10009
container: 10009
protocol: tcp # gRPC
- host: 8080
container: 8080
protocol: tcp # REST
volumes:
- type: bind
source: /var/lib/archipelago/lnd
target: /root/.lnd
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=mainnet
health_check:
type: http
endpoint: http://localhost:8080
path: /v1/getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true

View File

@@ -0,0 +1,54 @@
app:
id: meshtastic
name: Meshtastic
version: 2.5.0
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
container:
image: meshtastic/meshtastic:latest
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- storage: 1Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: [NET_ADMIN, SYS_ADMIN] # Required for LoRa radio access
readonly_root: false # Needs write access for device management
network_policy: host # Requires host network for radio access
apparmor_profile: meshtastic
ports:
- 4403:4403 # HTTP API
- 1883:1883 # MQTT (optional)
devices:
- /dev/ttyUSB0 # LoRa radio device (if connected)
- /dev/ttyACM0 # Alternative device path
volumes:
- type: bind
source: /var/lib/archipelago/meshtastic
target: /app/data
options: [rw]
environment:
- MESHTASTIC_PORT=/dev/ttyUSB0
- MESHTASTIC_SERIAL=true
health_check:
type: http
endpoint: http://localhost:4403
path: /health
interval: 30s
timeout: 5s
retries: 3
networking:
mesh_enabled: true
local_network_access: true

View File

@@ -0,0 +1,52 @@
app:
id: nostr-rs-relay
name: Nostr Relay (Rust)
version: 0.8.0
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container:
image: scsibug/nostr-rs-relay:latest
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- storage: 10Gi # For event storage
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
apparmor_profile: nostr-relay
ports:
- 8080:8080 # HTTP/WebSocket
volumes:
- type: bind
source: /var/lib/archipelago/nostr-relay
target: /app/db
options: [rw]
environment:
- RELAY_NAME=Archipelago Nostr Relay
- RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago
- MAX_EVENTS=1000000
- MAX_SUBSCRIPTIONS=100
health_check:
type: http
endpoint: http://localhost:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
nostr_integration:
relay_type: public
monetization_enabled: true # Earn networking profits
event_storage: sqlite

58
apps/router/manifest.yml Normal file
View File

@@ -0,0 +1,58 @@
app:
id: router
name: Mesh Router
version: 1.0.0
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container:
image: archipelago/router:latest
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- storage: 500Mi
resources:
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 500Mi
security:
capabilities: [NET_ADMIN, NET_RAW] # Required for network management
readonly_root: true
network_policy: host # Requires host network for routing
apparmor_profile: router
ports:
- 8080:8080 # Web UI
- 5353:5353 # mDNS/Bonjour
- 1900:1900 # SSDP
volumes:
- type: bind
source: /var/lib/archipelago/router
target: /app/data
options: [rw]
- type: bind
source: /var/run/dbus
target: /var/run/dbus
options: [ro]
environment:
- NETWORK_INTERFACE=eth0
- MESH_ENABLED=true
- DEVICE_DISCOVERY=true
health_check:
type: http
endpoint: http://localhost:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
networking:
mesh_enabled: true
local_network_access: true
device_discovery: true
routing_protocols: [olsr, babel]

48
apps/strfry/manifest.yml Normal file
View File

@@ -0,0 +1,48 @@
app:
id: strfry
name: Strfry Nostr Relay
version: 0.9.0
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container:
image: strfry/strfry:latest
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
apparmor_profile: nostr-relay
ports:
- 8080:8080 # HTTP/WebSocket
volumes:
- type: bind
source: /var/lib/archipelago/strfry
target: /strfry
options: [rw]
environment:
- RELAY_NAME=Archipelago Strfry Relay
health_check:
type: http
endpoint: http://localhost:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
nostr_integration:
relay_type: public
monetization_enabled: true

View File

@@ -0,0 +1,52 @@
app:
id: web5-dwn
name: Decentralized Web Node
version: 1.0.0
description: Personal data store for Web5. Store and sync your decentralized data across devices.
container:
image: tbd/web5-dwn:latest
image_signature: cosign://...
pull_policy: verify-signature
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
apparmor_profile: web5-dwn
ports:
- host: 3000
container: 3000
protocol: tcp # HTTP API
volumes:
- type: bind
source: /var/lib/archipelago/web5-dwn
target: /app/data
options: [rw]
environment:
- DWN_STORAGE_PATH=/app/data
- DID_METHOD=key
health_check:
type: http
endpoint: http://localhost:3000
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
dwn_protocol: true
sync_enabled: true

24
core/container/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "archipelago-container"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
thiserror = "1.0"
anyhow = "1.0"
async-trait = "0.1"
futures = "0.3"
indexmap = { version = "2.0", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }
log = "0.4"
tracing = "0.1"
[lib]
name = "archipelago_container"
path = "src/lib.rs"

View File

@@ -0,0 +1,255 @@
use crate::manifest::{AppManifest, Dependency};
use indexmap::IndexMap;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DependencyError {
#[error("Circular dependency detected: {0}")]
CircularDependency(String),
#[error("Missing dependency: {0}")]
MissingDependency(String),
#[error("Version conflict: {0}")]
VersionConflict(String),
}
pub struct DependencyResolver {
manifests: IndexMap<String, AppManifest>,
}
impl DependencyResolver {
pub fn new() -> Self {
Self {
manifests: IndexMap::new(),
}
}
pub fn add_manifest(&mut self, manifest: AppManifest) {
self.manifests.insert(manifest.app.id.clone(), manifest);
}
pub fn resolve_dependencies(&self, app_id: &str) -> Result<Vec<String>, DependencyError> {
let mut visited = HashSet::new();
let mut visiting = HashSet::new();
let mut result = Vec::new();
self.resolve_recursive(app_id, &mut visited, &mut visiting, &mut result)?;
// Reverse to get installation order (dependencies first)
result.reverse();
Ok(result)
}
fn resolve_recursive(
&self,
app_id: &str,
visited: &mut HashSet<String>,
visiting: &mut HashSet<String>,
result: &mut Vec<String>,
) -> Result<(), DependencyError> {
if visited.contains(app_id) {
return Ok(());
}
if visiting.contains(app_id) {
return Err(DependencyError::CircularDependency(
format!("Circular dependency detected involving: {}", app_id)
));
}
visiting.insert(app_id.to_string());
let manifest = self.manifests.get(app_id)
.ok_or_else(|| DependencyError::MissingDependency(
format!("App not found: {}", app_id)
))?;
// Resolve all dependencies first
for dep in &manifest.app.dependencies {
match dep {
Dependency::App { app_id: dep_id, version: _ } => {
self.resolve_recursive(dep_id, visited, visiting, result)?;
}
Dependency::Storage { storage: _ } => {
// Storage dependencies are checked but don't require other apps
}
Dependency::Simple(dep_id) => {
self.resolve_recursive(dep_id, visited, visiting, result)?;
}
}
}
visiting.remove(app_id);
visited.insert(app_id.to_string());
if !result.contains(&app_id.to_string()) {
result.push(app_id.to_string());
}
Ok(())
}
pub fn check_conflicts(&self, app_id: &str) -> Result<(), DependencyError> {
let manifest = self.manifests.get(app_id)
.ok_or_else(|| DependencyError::MissingDependency(
format!("App not found: {}", app_id)
))?;
// Check for port conflicts
let mut port_usage: HashMap<u16, String> = HashMap::new();
for (id, m) in &self.manifests {
if id == app_id {
continue;
}
for port in &m.app.ports {
if let Some(existing) = port_usage.get(&port.host) {
return Err(DependencyError::VersionConflict(
format!("Port {} already used by {}", port.host, existing)
));
}
port_usage.insert(port.host, id.clone());
}
}
// Check for new app's ports
for port in &manifest.app.ports {
if let Some(existing) = port_usage.get(&port.host) {
return Err(DependencyError::VersionConflict(
format!("Port {} already used by {}", port.host, existing)
));
}
}
Ok(())
}
pub fn calculate_resources(&self, app_ids: &[String]) -> ResourceRequirements {
let mut total = ResourceRequirements {
cpu: 0,
memory_mb: 0,
disk_gb: 0,
};
for app_id in app_ids {
if let Some(manifest) = self.manifests.get(app_id) {
if let Some(cpu) = manifest.app.resources.cpu_limit {
total.cpu += cpu;
}
if let Some(memory) = &manifest.app.resources.memory_limit {
// Parse memory string (e.g., "1Gi", "512Mi")
if let Ok(mb) = parse_memory(memory) {
total.memory_mb += mb;
}
}
if let Some(disk) = &manifest.app.resources.disk_limit {
// Parse disk string (e.g., "10Gi", "500Mi")
if let Ok(gb) = parse_disk(disk) {
total.disk_gb += gb;
}
}
}
}
total
}
}
fn parse_memory(s: &str) -> Result<u32, ()> {
let s = s.trim().to_lowercase();
if s.ends_with("gi") {
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
Ok((num * 1024.0) as u32)
} else if s.ends_with("mi") {
let num: f64 = s.trim_end_matches("mi").parse().map_err(|_| ())?;
Ok(num as u32)
} else {
Err(())
}
}
fn parse_disk(s: &str) -> Result<u32, ()> {
let s = s.trim().to_lowercase();
if s.ends_with("gi") {
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
Ok(num as u32)
} else if s.ends_with("ti") {
let num: f64 = s.trim_end_matches("ti").parse().map_err(|_| ())?;
Ok((num * 1024.0) as u32)
} else {
Err(())
}
}
#[derive(Debug, Clone)]
pub struct ResourceRequirements {
pub cpu: u32,
pub memory_mb: u32,
pub disk_gb: u32,
}
impl Default for DependencyResolver {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{AppManifest, AppDefinition, ContainerConfig};
fn create_test_manifest(id: &str, deps: Vec<Dependency>) -> AppManifest {
AppManifest {
app: AppDefinition {
id: id.to_string(),
name: format!("Test {}", id),
version: "1.0.0".to_string(),
description: None,
container: ContainerConfig {
image: format!("test/{}:latest", id),
image_signature: None,
pull_policy: "if-not-present".to_string(),
},
dependencies: deps,
resources: Default::default(),
security: Default::default(),
ports: vec![],
volumes: vec![],
environment: vec![],
health_check: None,
devices: vec![],
extensions: Default::default(),
},
}
}
#[test]
fn test_simple_dependency() {
let mut resolver = DependencyResolver::new();
resolver.add_manifest(create_test_manifest("app1", vec![]));
resolver.add_manifest(create_test_manifest("app2", vec![
Dependency::Simple("app1".to_string())
]));
let deps = resolver.resolve_dependencies("app2").unwrap();
assert_eq!(deps, vec!["app1", "app2"]);
}
#[test]
fn test_circular_dependency() {
let mut resolver = DependencyResolver::new();
resolver.add_manifest(create_test_manifest("app1", vec![
Dependency::Simple("app2".to_string())
]));
resolver.add_manifest(create_test_manifest("app2", vec![
Dependency::Simple("app1".to_string())
]));
let result = resolver.resolve_dependencies("app1");
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,189 @@
use crate::manifest::HealthCheck;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::{interval, sleep};
use tracing::{error, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum HealthStatus {
Healthy,
Unhealthy,
Unknown,
Starting,
}
pub struct HealthMonitor {
container_name: String,
health_check: Option<HealthCheck>,
}
impl HealthMonitor {
pub fn new(container_name: String, health_check: Option<HealthCheck>) -> Self {
Self {
container_name,
health_check,
}
}
pub async fn check_health(&self) -> Result<HealthStatus> {
if let Some(ref check) = self.health_check {
match check.check_type.as_str() {
"http" => self.check_http_health(check).await,
"exec" => self.check_exec_health(check).await,
_ => {
warn!("Unknown health check type: {}", check.check_type);
Ok(HealthStatus::Unknown)
}
}
} else {
// No health check defined, assume healthy if container is running
Ok(HealthStatus::Unknown)
}
}
async fn check_http_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
let endpoint = check.endpoint.as_ref()
.ok_or_else(|| anyhow::anyhow!("HTTP health check missing endpoint"))?;
let url = if let Some(path) = &check.path {
format!("{}{}", endpoint, path)
} else {
endpoint.clone()
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.context("Failed to create HTTP client")?;
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
Ok(HealthStatus::Healthy)
} else {
Ok(HealthStatus::Unhealthy)
}
}
Err(e) => {
warn!("Health check failed for {}: {}", self.container_name, e);
Ok(HealthStatus::Unhealthy)
}
}
}
async fn check_exec_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
// Execute health check command in container
let endpoint = check.endpoint.as_ref()
.ok_or_else(|| anyhow::anyhow!("Exec health check missing endpoint"))?;
use tokio::process::Command;
let output = Command::new("podman")
.arg("exec")
.arg(&self.container_name)
.arg("sh")
.arg("-c")
.arg(endpoint)
.output()
.await
.context("Failed to execute health check")?;
if output.status.success() {
Ok(HealthStatus::Healthy)
} else {
Ok(HealthStatus::Unhealthy)
}
}
pub async fn monitor_health(
&self,
mut shutdown: tokio::sync::broadcast::Receiver<()>,
on_status_change: impl Fn(HealthStatus) + Send + 'static,
) -> Result<()> {
let check = self.health_check.clone();
let interval_duration = if let Some(ref check) = check {
parse_duration(&check.interval).unwrap_or(Duration::from_secs(30))
} else {
Duration::from_secs(30)
};
let mut interval = interval(interval_duration);
let mut consecutive_failures = 0;
let max_failures = check.as_ref()
.map(|c| c.retries)
.unwrap_or(3);
let mut last_status = HealthStatus::Unknown;
loop {
tokio::select! {
_ = interval.tick() => {
match self.check_health().await {
Ok(status) => {
if status != last_status {
info!("Health status changed for {}: {:?} -> {:?}",
self.container_name, last_status, status);
on_status_change(status.clone());
last_status = status.clone();
}
match status {
HealthStatus::Healthy => {
consecutive_failures = 0;
}
HealthStatus::Unhealthy => {
consecutive_failures += 1;
if consecutive_failures >= max_failures {
error!("Container {} is unhealthy after {} failures",
self.container_name, consecutive_failures);
// TODO: Trigger auto-restart or alert
}
}
_ => {}
}
}
Err(e) => {
error!("Health check error for {}: {}", self.container_name, e);
consecutive_failures += 1;
}
}
}
_ = shutdown.recv() => {
info!("Health monitoring stopped for {}", self.container_name);
break;
}
}
}
Ok(())
}
}
fn parse_duration(s: &str) -> Option<Duration> {
let s = s.trim().to_lowercase();
if s.ends_with('s') {
let secs: u64 = s.trim_end_matches('s').parse().ok()?;
Some(Duration::from_secs(secs))
} else if s.ends_with('m') {
let mins: u64 = s.trim_end_matches('m').parse().ok()?;
Some(Duration::from_secs(mins * 60))
} else if s.ends_with('h') {
let hours: u64 = s.trim_end_matches('h').parse().ok()?;
Some(Duration::from_secs(hours * 3600))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
}
}

View File

@@ -0,0 +1,9 @@
pub mod manifest;
pub mod podman_client;
pub mod dependency_resolver;
pub mod health_monitor;
pub use manifest::{AppManifest, Dependency, ResourceLimits, SecurityPolicy, HealthCheck};
pub use podman_client::PodmanClient;
pub use dependency_resolver::DependencyResolver;
pub use health_monitor::HealthMonitor;

View File

@@ -0,0 +1,228 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("Invalid manifest: {0}")]
Invalid(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yaml::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
pub app: AppDefinition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppDefinition {
pub id: String,
pub name: String,
pub version: String,
pub description: Option<String>,
#[serde(default)]
pub container: ContainerConfig,
#[serde(default)]
pub dependencies: Vec<Dependency>,
#[serde(default)]
pub resources: ResourceLimits,
#[serde(default)]
pub security: SecurityPolicy,
#[serde(default)]
pub ports: Vec<PortMapping>,
#[serde(default)]
pub volumes: Vec<Volume>,
#[serde(default)]
pub environment: Vec<String>,
#[serde(default)]
pub health_check: Option<HealthCheck>,
#[serde(default)]
pub devices: Vec<String>,
#[serde(flatten)]
pub extensions: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContainerConfig {
pub image: String,
#[serde(default)]
pub image_signature: Option<String>,
#[serde(default = "default_pull_policy")]
pub pull_policy: String,
}
fn default_pull_policy() -> String {
"if-not-present".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Dependency {
Storage { storage: String },
App { app_id: String, version: Option<String> },
Simple(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ResourceLimits {
#[serde(default)]
pub cpu_limit: Option<u32>,
#[serde(default)]
pub memory_limit: Option<String>,
#[serde(default)]
pub disk_limit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecurityPolicy {
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default = "default_true")]
pub readonly_root: bool,
#[serde(default = "default_network_policy")]
pub network_policy: String,
#[serde(default)]
pub apparmor_profile: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_network_policy() -> String {
"isolated".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortMapping {
pub host: u16,
pub container: u16,
#[serde(default)]
pub protocol: String,
}
impl From<(u16, u16)> for PortMapping {
fn from((host, container): (u16, u16)) -> Self {
PortMapping {
host,
container,
protocol: "tcp".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Volume {
#[serde(rename = "type")]
pub volume_type: String,
pub source: String,
pub target: String,
#[serde(default)]
pub options: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthCheck {
#[serde(rename = "type")]
pub check_type: String,
pub endpoint: Option<String>,
pub path: Option<String>,
#[serde(default = "default_interval")]
pub interval: String,
#[serde(default = "default_timeout")]
pub timeout: String,
#[serde(default = "default_retries")]
pub retries: u32,
}
fn default_interval() -> String {
"30s".to_string()
}
fn default_timeout() -> String {
"5s".to_string()
}
fn default_retries() -> u32 {
3
}
impl AppManifest {
pub fn from_file(path: &std::path::Path) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
Self::from_str(&content)
}
pub fn from_str(content: &str) -> Result<Self, ManifestError> {
let manifest: AppManifest = serde_yaml::from_str(content)?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.app.id.is_empty() {
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
}
if self.app.container.image.is_empty() {
return Err(ManifestError::Invalid("container.image cannot be empty".to_string()));
}
// Validate version format (semantic versioning)
if !self.app.version.chars().any(|c| c.is_ascii_digit()) {
return Err(ManifestError::Invalid("app.version must contain at least one digit".to_string()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_parse() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:latest
"#;
let manifest = AppManifest::from_str(yaml).unwrap();
assert_eq!(manifest.app.id, "test-app");
assert_eq!(manifest.app.name, "Test App");
assert_eq!(manifest.app.version, "1.0.0");
}
#[test]
fn test_manifest_validation() {
let yaml = r#"
app:
id: ""
name: Test
version: 1.0.0
container:
image: test/image:latest
"#;
let result = AppManifest::from_str(yaml);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,334 @@
use crate::manifest::{AppManifest, PortMapping, Volume};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::{Command, Stdio};
use thiserror::Error;
use tokio::process::Command as TokioCommand;
#[derive(Debug, Error)]
pub enum PodmanError {
#[error("Podman command failed: {0}")]
CommandFailed(String),
#[error("Container not found: {0}")]
NotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerStatus {
pub id: String,
pub name: String,
pub state: ContainerState,
pub image: String,
pub created: String,
pub ports: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ContainerState {
Created,
Running,
Stopped,
Exited,
Paused,
Unknown(String),
}
impl From<&str> for ContainerState {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"created" => ContainerState::Created,
"running" => ContainerState::Running,
"stopped" => ContainerState::Stopped,
"exited" => ContainerState::Exited,
"paused" => ContainerState::Paused,
other => ContainerState::Unknown(other.to_string()),
}
}
}
pub struct PodmanClient {
user: String,
rootless: bool,
}
impl PodmanClient {
pub fn new(user: String) -> Self {
Self {
user,
rootless: true,
}
}
fn podman_command(&self) -> Command {
let mut cmd = Command::new("podman");
if self.rootless {
// Run as the specified user
cmd.env("HOME", format!("/home/{}", self.user));
}
cmd
}
fn podman_async(&self) -> TokioCommand {
let mut cmd = TokioCommand::new("podman");
if self.rootless {
cmd.env("HOME", format!("/home/{}", self.user));
}
cmd
}
pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
let mut cmd = self.podman_async();
cmd.arg("pull").arg(image);
if let Some(sig) = signature {
// Verify signature with cosign if provided
cmd.arg("--signature-policy").arg("default");
// TODO: Implement cosign verification
log::warn!("Signature verification not yet implemented: {}", sig);
}
let output = cmd
.output()
.await
.context("Failed to execute podman pull")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
}
Ok(())
}
pub async fn create_container(
&self,
manifest: &AppManifest,
name: &str,
) -> Result<String> {
let mut cmd = self.podman_async();
cmd.arg("create");
// Container name
cmd.arg("--name").arg(name);
// Read-only root filesystem
if manifest.app.security.readonly_root {
cmd.arg("--read-only");
}
// Network policy
match manifest.app.security.network_policy.as_str() {
"host" => {
cmd.arg("--network").arg("host");
}
"isolated" => {
// Create isolated network (default)
}
_ => {
cmd.arg("--network").arg(&manifest.app.security.network_policy);
}
}
// Port mappings
for port in &manifest.app.ports {
cmd.arg("-p").arg(format!("{}:{}", port.host, port.container));
}
// Volumes
for volume in &manifest.app.volumes {
let mut mount = format!("{}:{}", volume.source, volume.target);
if !volume.options.is_empty() {
mount.push_str(&format!(":{}", volume.options.join(",")));
}
cmd.arg("-v").arg(mount);
}
// Devices
for device in &manifest.app.devices {
cmd.arg("--device").arg(device);
}
// Environment variables
for env in &manifest.app.environment {
cmd.arg("-e").arg(env);
}
// Resource limits
if let Some(cpu) = manifest.app.resources.cpu_limit {
cmd.arg("--cpus").arg(cpu.to_string());
}
if let Some(memory) = &manifest.app.resources.memory_limit {
cmd.arg("--memory").arg(memory);
}
// Capabilities (drop all, add specified)
cmd.arg("--cap-drop").arg("ALL");
for cap in &manifest.app.security.capabilities {
cmd.arg("--cap-add").arg(cap);
}
// Image
cmd.arg(&manifest.app.container.image);
let output = cmd
.output()
.await
.context("Failed to create container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
}
let container_id = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
Ok(container_id)
}
pub async fn start_container(&self, name: &str) -> Result<()> {
let mut cmd = self.podman_async();
cmd.arg("start").arg(name);
let output = cmd
.output()
.await
.context("Failed to start container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
Ok(())
}
pub async fn stop_container(&self, name: &str) -> Result<()> {
let mut cmd = self.podman_async();
cmd.arg("stop").arg(name);
let output = cmd
.output()
.await
.context("Failed to stop container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
}
Ok(())
}
pub async fn remove_container(&self, name: &str) -> Result<()> {
let mut cmd = self.podman_async();
cmd.arg("rm").arg("-f").arg(name);
let output = cmd
.output()
.await
.context("Failed to remove container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to remove container: {}", stderr));
}
Ok(())
}
pub async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
let mut cmd = self.podman_async();
cmd.arg("inspect")
.arg("--format")
.arg("{{.Id}}|{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{.Created}}|{{.NetworkSettings.Ports}}")
.arg(name);
let output = cmd
.output()
.await
.context("Failed to inspect container")?;
if !output.status.success() {
return Err(anyhow::anyhow!("Container not found: {}", name));
}
let info = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = info.trim().split('|').collect();
if parts.len() < 5 {
return Err(anyhow::anyhow!("Invalid container inspect output"));
}
Ok(ContainerStatus {
id: parts[0].to_string(),
name: parts[1].to_string(),
state: ContainerState::from(parts[2]),
image: parts[3].to_string(),
created: parts[4].to_string(),
ports: vec![], // TODO: Parse ports from parts[5]
})
}
pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
let mut cmd = self.podman_async();
cmd.arg("logs")
.arg("--tail")
.arg(lines.to_string())
.arg(name);
let output = cmd
.output()
.await
.context("Failed to get container logs")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to get logs: {}", stderr));
}
let logs = String::from_utf8_lossy(&output.stdout);
Ok(logs.lines().map(|s| s.to_string()).collect())
}
pub async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
let mut cmd = self.podman_async();
cmd.arg("ps")
.arg("-a")
.arg("--format")
.arg("json");
let output = cmd
.output()
.await
.context("Failed to list containers")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to list containers: {}", stderr));
}
let json = String::from_utf8_lossy(&output.stdout);
let containers: Vec<serde_json::Value> = serde_json::from_str(&json)
.context("Failed to parse container list")?;
let mut result = Vec::new();
for container in containers {
result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(),
name: container["Names"][0].as_str().unwrap_or("").to_string(),
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(),
ports: vec![],
});
}
Ok(result)
}
}

18
core/parmanode/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "archipelago-parmanode"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
anyhow = "1.0"
thiserror = "1.0"
archipelago-container = { path = "../container" }
log = "0.4"
tracing = "0.1"
[lib]
name = "archipelago_parmanode"
path = "src/lib.rs"

View File

@@ -0,0 +1,101 @@
// Parmanode to App Manifest converter
// Converts Parmanode module structure to Archipelago app manifest format
use archipelago_container::AppManifest;
use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::fs;
use tracing::info;
pub struct ParmanodeConverter;
impl ParmanodeConverter {
pub fn new() -> Self {
Self
}
/// Convert a Parmanode module directory to an App Manifest
pub async fn convert_to_manifest(&self, module_path: &PathBuf) -> Result<AppManifest> {
info!("Converting Parmanode module to manifest: {:?}", module_path);
// Read Parmanode module metadata if available
let module_name = module_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
// Try to detect what the module installs
let install_script = module_path.join("install.sh");
let script_content = if install_script.exists() {
fs::read_to_string(&install_script).await.ok()
} else {
None
};
// Infer app details from script content
let (app_id, image) = self.infer_from_script(&script_content)?;
// Create a basic manifest
let manifest_yaml = format!(
r#"
app:
id: {}
name: {}
version: 1.0.0
description: Converted from Parmanode module
container:
image: {}
pull_policy: if-not-present
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
"#,
app_id, module_name, image
);
AppManifest::from_str(&manifest_yaml)
.context("Failed to create manifest from Parmanode module")
}
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
let content = script_content.as_deref().unwrap_or("");
// Try to detect Bitcoin Core
if content.contains("bitcoind") || content.contains("bitcoin-core") {
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:latest".to_string()));
}
// Try to detect LND
if content.contains("lnd") && !content.contains("lightning") {
return Ok(("lnd".to_string(), "lightninglabs/lnd:latest".to_string()));
}
// Try to detect Core Lightning
if content.contains("clightning") || content.contains("core-lightning") {
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:latest".to_string()));
}
// Try to detect Electrs
if content.contains("electrs") {
return Ok(("electrs".to_string(), "romanz/electrs:latest".to_string()));
}
// Default fallback
Ok(("parmanode-module".to_string(), "alpine:latest".to_string()))
}
}
impl Default for ParmanodeConverter {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,5 @@
pub mod script_runner;
pub mod converter;
pub use script_runner::ParmanodeScriptRunner;
pub use converter::ParmanodeConverter;

View File

@@ -0,0 +1,128 @@
// Parmanode script runner - executes Parmanode installation scripts in containers
// Provides compatibility layer for existing Parmanode modules
use archipelago_container::{PodmanClient, AppManifest};
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
use tokio::fs;
use tracing::{info, warn};
pub struct ParmanodeScriptRunner {
podman: PodmanClient,
scripts_dir: PathBuf,
}
impl ParmanodeScriptRunner {
pub fn new(scripts_dir: PathBuf) -> Self {
Self {
podman: PodmanClient::new("archipelago".to_string()),
scripts_dir,
}
}
/// Detect if a path contains a Parmanode script
pub fn is_parmanode_script(&self, path: &PathBuf) -> bool {
// Check for common Parmanode script patterns
path.file_name()
.and_then(|name| name.to_str())
.map(|name| {
name.ends_with(".sh") && (
name.contains("parmanode") ||
name.contains("bitcoin") ||
name.contains("lightning") ||
name.contains("electrs")
)
})
.unwrap_or(false)
}
/// Run a Parmanode script in an isolated container
pub async fn run_script(&self, script_path: &PathBuf) -> Result<()> {
info!("Running Parmanode script: {:?}", script_path);
// Create a temporary container manifest for the script
let script_name = script_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("parmanode-script");
// Create a minimal container to run the script
let container_name = format!("parmanode-{}", script_name);
// Copy script to a location accessible by containers
let script_content = fs::read_to_string(script_path).await
.context("Failed to read Parmanode script")?;
// Create a wrapper script that runs in Alpine
let wrapper_script = format!(
r#"#!/bin/sh
set -e
{}
"#,
script_content
);
// Write wrapper to temp location
let temp_script = format!("/tmp/parmanode-{}.sh", script_name);
fs::write(&temp_script, wrapper_script).await
.context("Failed to write wrapper script")?;
// Make executable
Command::new("chmod")
.arg("+x")
.arg(&temp_script)
.output()
.context("Failed to make script executable")?;
// Run script in a temporary Alpine container
let output = Command::new("podman")
.arg("run")
.arg("--rm")
.arg("--volume")
.arg(format!("{}:/script.sh:ro", temp_script))
.arg("alpine:latest")
.arg("sh")
.arg("/script.sh")
.output()
.context("Failed to execute Parmanode script in container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Parmanode script failed: {}", stderr));
}
info!("Parmanode script completed successfully");
Ok(())
}
/// Install a Parmanode module (runs script and sets up container)
pub async fn install_module(&self, module_path: &PathBuf) -> Result<String> {
// Find the main installation script
let install_script = module_path.join("install.sh");
if !install_script.exists() {
return Err(anyhow::anyhow!("No install.sh found in Parmanode module"));
}
// Run the installation script
self.run_script(&install_script).await?;
// Try to convert to app manifest for future management
let converter = crate::converter::ParmanodeConverter::new();
match converter.convert_to_manifest(module_path).await {
Ok(manifest) => {
info!("Converted Parmanode module to app manifest");
// TODO: Save manifest for future use
Ok(manifest.app.id)
}
Err(e) => {
warn!("Failed to convert Parmanode module: {}", e);
// Return a generic ID
Ok(format!("parmanode-{}",
module_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("module")))
}
}
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "archipelago-performance"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0"
thiserror = "1.0"
log = "0.4"
tracing = "0.1"
[lib]
name = "archipelago_performance"
path = "src/lib.rs"

View File

@@ -0,0 +1,3 @@
pub mod resource_manager;
pub use resource_manager::ResourceManager;

View File

@@ -0,0 +1,89 @@
// Resource management and optimization for containers
// Handles CPU, memory, and disk I/O limits and optimization
use anyhow::Result;
use std::collections::HashMap;
use tracing::{info, warn};
#[derive(Debug, Clone)]
pub struct ResourceLimits {
pub cpu_cores: f64,
pub memory_mb: u32,
pub disk_io_read_mbps: Option<u32>,
pub disk_io_write_mbps: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct SystemResources {
pub total_cpu_cores: u32,
pub total_memory_mb: u32,
pub available_disk_gb: u32,
}
pub struct ResourceManager {
system_resources: SystemResources,
allocated_resources: HashMap<String, ResourceLimits>,
}
impl ResourceManager {
pub fn new(system_resources: SystemResources) -> Self {
Self {
system_resources,
allocated_resources: HashMap::new(),
}
}
/// Check if resources are available for a new container
pub fn can_allocate(&self, requested: &ResourceLimits) -> Result<bool> {
let mut used_cpu = 0.0;
let mut used_memory = 0;
for limits in self.allocated_resources.values() {
used_cpu += limits.cpu_cores;
used_memory += limits.memory_mb;
}
let available_cpu = self.system_resources.total_cpu_cores as f64 - used_cpu;
let available_memory = self.system_resources.total_memory_mb - used_memory;
if requested.cpu_cores > available_cpu {
return Ok(false);
}
if requested.memory_mb > available_memory {
return Ok(false);
}
Ok(true)
}
/// Allocate resources for a container
pub fn allocate(&mut self, container_id: String, limits: ResourceLimits) -> Result<()> {
if !self.can_allocate(&limits)? {
return Err(anyhow::anyhow!("Insufficient resources"));
}
self.allocated_resources.insert(container_id, limits);
info!("Allocated resources for container");
Ok(())
}
/// Release resources for a container
pub fn release(&mut self, container_id: &str) {
self.allocated_resources.remove(container_id);
info!("Released resources for container: {}", container_id);
}
/// Get current resource usage
pub fn get_usage(&self) -> (f64, u32) {
let cpu: f64 = self.allocated_resources.values().map(|r| r.cpu_cores).sum();
let memory: u32 = self.allocated_resources.values().map(|r| r.memory_mb).sum();
(cpu, memory)
}
/// Optimize resource allocation (reduce limits for low-priority containers)
pub fn optimize_allocation(&mut self) {
// TODO: Implement dynamic resource adjustment based on usage
info!("Optimizing resource allocation");
}
}

18
core/security/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "archipelago-security"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0"
thiserror = "1.0"
log = "0.4"
tracing = "0.1"
uuid = { version = "1.0", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
[lib]
name = "archipelago_security"
path = "src/lib.rs"

View File

@@ -0,0 +1,74 @@
// AppArmor/SELinux policy generator for containers
// Creates security profiles for each containerized app
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;
pub struct ContainerPolicyGenerator {
policies_dir: PathBuf,
}
impl ContainerPolicyGenerator {
pub fn new(policies_dir: PathBuf) -> Self {
Self { policies_dir }
}
/// Generate AppArmor profile for a container
pub async fn generate_apparmor_profile(
&self,
app_id: &str,
capabilities: &[String],
readonly: bool,
) -> Result<PathBuf> {
let profile_path = self.policies_dir.join(format!("{}.apparmor", app_id));
let mut profile = String::from("# AppArmor profile for Archipelago container\n");
profile.push_str(&format!("profile archipelago-{} flags=(attach_disconnected,mediate_deleted) {{\n", app_id));
// Base includes
profile.push_str(" #include <abstractions/base>\n");
// Capabilities
if capabilities.is_empty() {
profile.push_str(" capability,\n");
} else {
for cap in capabilities {
profile.push_str(&format!(" capability {},\n", cap));
}
}
// Filesystem access
if readonly {
profile.push_str(" deny / rw,\n");
profile.push_str(&format!(" /var/lib/archipelago/{} rw,\n", app_id));
} else {
profile.push_str(" / r,\n");
profile.push_str(&format!(" /var/lib/archipelago/{} rw,\n", app_id));
}
// Network
profile.push_str(" network,\n");
profile.push_str("}\n");
fs::write(&profile_path, profile).await?;
Ok(profile_path)
}
/// Apply AppArmor profile to a container
pub async fn apply_profile(&self, container_name: &str, profile_path: &PathBuf) -> Result<()> {
// Load the profile
tokio::process::Command::new("apparmor_parser")
.arg("-r")
.arg(profile_path)
.output()
.await?;
// TODO: Configure Podman to use the profile
// This requires Podman configuration changes
Ok(())
}
}

View File

@@ -0,0 +1,90 @@
// Container image signature verification using Cosign
// Verifies that container images are signed and trusted
use anyhow::{Context, Result};
use std::process::Command;
use tracing::{info, warn};
pub struct ImageVerifier {
cosign_public_key: Option<String>, // Public key for verification
}
impl ImageVerifier {
pub fn new(cosign_public_key: Option<String>) -> Self {
Self { cosign_public_key }
}
/// Verify a container image signature
pub async fn verify_image(&self, image: &str, signature: Option<&str>) -> Result<bool> {
if signature.is_none() && self.cosign_public_key.is_none() {
warn!("No signature provided for image: {}", image);
return Ok(false);
}
// Check if cosign is available
let cosign_available = Command::new("cosign")
.arg("version")
.output()
.is_ok();
if !cosign_available {
warn!("Cosign not available, skipping signature verification");
return Ok(false);
}
// If public key is provided, use it for verification
if let Some(ref public_key) = self.cosign_public_key {
let output = Command::new("cosign")
.arg("verify")
.arg("--key")
.arg(public_key)
.arg(image)
.output()
.context("Failed to run cosign verify")?;
if output.status.success() {
info!("Image signature verified: {}", image);
return Ok(true);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
}
}
// If signature URL is provided, verify using that
if let Some(sig_url) = signature {
if sig_url.starts_with("cosign://") {
// Extract signature reference
let sig_ref = sig_url.strip_prefix("cosign://").unwrap();
let output = Command::new("cosign")
.arg("verify")
.arg("--signature")
.arg(sig_ref)
.arg(image)
.output()
.context("Failed to run cosign verify")?;
if output.status.success() {
info!("Image signature verified: {}", image);
return Ok(true);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
}
}
}
Ok(false)
}
/// Check if an image has a signature
pub async fn has_signature(&self, image: &str) -> bool {
// Try to find signature in registry
let output = Command::new("cosign")
.arg("triangulate")
.arg(image)
.output();
output.is_ok() && output.unwrap().status.success()
}
}

7
core/security/src/lib.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod container_policies;
pub mod secrets_manager;
pub mod image_verifier;
pub use container_policies::ContainerPolicyGenerator;
pub use secrets_manager::SecretsManager;
pub use image_verifier::ImageVerifier;

View File

@@ -0,0 +1,98 @@
// Encrypted secrets management for containers
// Stores secrets securely and injects them at runtime
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;
use uuid::Uuid;
pub struct SecretsManager {
secrets_dir: PathBuf,
encryption_key: Vec<u8>, // In production, derive from user password
}
impl SecretsManager {
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Self {
Self {
secrets_dir,
encryption_key,
}
}
/// Store a secret for an app
pub async fn store_secret(
&self,
app_id: &str,
key: &str,
value: &str,
) -> Result<String> {
let secret_id = Uuid::new_v4().to_string();
let secret_path = self.secrets_dir
.join(app_id)
.join(format!("{}.secret", secret_id));
fs::create_dir_all(secret_path.parent().unwrap()).await?;
// TODO: Encrypt the secret value
// For now, store as plaintext (MUST be encrypted in production)
fs::write(&secret_path, value).await
.context("Failed to write secret")?;
// Set restrictive permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&secret_path).await?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&secret_path, perms).await?;
}
Ok(secret_id)
}
/// Retrieve a secret (returns the secret ID path for volume mounting)
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
self.secrets_dir
.join(app_id)
.join(format!("{}.secret", secret_id))
}
/// List secrets for an app
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
let app_secrets_dir = self.secrets_dir.join(app_id);
if !app_secrets_dir.exists() {
return Ok(vec![]);
}
let mut secrets = Vec::new();
let mut entries = fs::read_dir(&app_secrets_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
if let Some(secret_id) = path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string()) {
secrets.push(secret_id);
}
}
}
Ok(secrets)
}
/// Delete a secret
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
let secret_path = self.secrets_dir
.join(app_id)
.join(format!("{}.secret", secret_id));
if secret_path.exists() {
fs::remove_file(&secret_path).await?;
}
Ok(())
}
}

126
docs/app-manifest-spec.md Normal file
View File

@@ -0,0 +1,126 @@
# App Manifest Specification
## Overview
App manifests define containerized applications in Archipelago. They use YAML format and specify container configuration, dependencies, resources, security policies, and integration metadata.
## File Location
App manifests are stored in `apps/{app-id}/manifest.yml`
## Schema
### Required Fields
```yaml
app:
id: string # Unique app identifier (lowercase, kebab-case)
name: string # Human-readable name
version: string # Semantic version (e.g., "1.0.0")
container:
image: string # Container image (e.g., "bitcoin/bitcoin:26.0")
```
### Optional Fields
```yaml
app:
description: string # App description
container:
image_signature: string # Cosign signature URL (e.g., "cosign://...")
pull_policy: string # "if-not-present" | "always" | "never"
dependencies:
- storage: string # Minimum disk space (e.g., "500Gi")
- app_id: string # Required app dependency
version: string # Version constraint (e.g., ">=26.0")
- string # Simple app dependency
resources:
cpu_limit: number # CPU cores (e.g., 2)
memory_limit: string # Memory limit (e.g., "2Gi", "512Mi")
disk_limit: string # Disk limit (e.g., "500Gi")
security:
capabilities: [string] # Linux capabilities (e.g., ["NET_BIND_SERVICE"])
readonly_root: boolean # Read-only root filesystem (default: true)
network_policy: string # "isolated" | "host" | network name
apparmor_profile: string # AppArmor profile name
ports:
- host: number # Host port
container: number # Container port
protocol: string # "tcp" | "udp" (default: "tcp")
volumes:
- type: string # "bind" | "tmpfs" | "volume"
source: string # Host path
target: string # Container path
options: [string] # Mount options (e.g., ["rw", "noexec"])
environment:
- string # Environment variable (e.g., "NETWORK=mainnet")
devices:
- string # Device path (e.g., "/dev/ttyUSB0")
health_check:
type: string # "http" | "exec"
endpoint: string # HTTP URL or command
path: string # HTTP path (for http type)
interval: string # Check interval (e.g., "30s")
timeout: string # Timeout (e.g., "5s")
retries: number # Failure retries (default: 3)
# Integration-specific metadata
bitcoin_integration:
rpc_access: string # "admin" | "read-only"
sync_required: boolean # Requires synced node
testnet_support: boolean
pruning_support: boolean
lightning_integration:
channel_management: boolean
payment_routing: boolean
nostr_integration:
relay_type: string # "public" | "private"
monetization_enabled: boolean
event_storage: string # "sqlite" | "postgres"
web5_integration:
did_support: boolean
dwn_protocol: boolean
sync_enabled: boolean
networking:
mesh_enabled: boolean
local_network_access: boolean
device_discovery: boolean
routing_protocols: [string] # e.g., ["olsr", "babel"]
```
## Examples
See `apps/` directory for complete examples:
- `apps/bitcoin-core/manifest.yml`
- `apps/lnd/manifest.yml`
- `apps/nostr-rs-relay/manifest.yml`
- `apps/meshtastic/manifest.yml`
## Validation
Manifests are validated on installation:
- Required fields present
- Version format valid
- Resource limits reasonable
- Port conflicts detected
- Dependency cycles prevented
## Versioning
- Use semantic versioning (MAJOR.MINOR.PATCH)
- Breaking changes increment MAJOR
- New features increment MINOR
- Bug fixes increment PATCH

165
docs/architecture.md Normal file
View File

@@ -0,0 +1,165 @@
# Archipelago Bitcoin Node OS - Architecture Documentation
## Overview
Archipelago is a next-generation Bitcoin Node OS built on Alpine Linux with Podman containerization, combining the modularity of Parmanode with the security and efficiency of a minimal server OS.
## System Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Alpine Linux Base (130MB) │
│ - Minimal kernel │
│ - Hardened security │
│ - Read-only root filesystem │
└─────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
│ │ │
┌───────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐
│ Podman │ │ Rust Backend│ │ Vue.js UI │
│ (rootless) │ │ (core/) │ │ (neode-ui/) │
└───────┬──────┘ └──────┬──────┘ └─────────────┘
│ │
└───────┬───────┘
┌───────────▼───────────┐
│ Container Orchestration│
│ Layer (new) │
│ - Manifest parser │
│ - Podman client │
│ - Dependency resolver │
│ - Health monitor │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Containerized Apps │
│ - Bitcoin Core │
│ - LND / CLN │
│ - BTCPay Server │
│ - Nostr Relays │
│ - Meshtastic │
│ - Web5 DWN │
└───────────────────────┘
```
## Key Components
### 1. Alpine Linux Base
- **Size**: ~130MB (vs 1.5GB+ for Umbrel/StartOS)
- **Security**: Hardened kernel, minimal attack surface
- **Multi-arch**: ARM64 (Raspberry Pi) and x86_64 support
### 2. Container Orchestration Layer
Located in `core/container/`:
- **manifest.rs**: Parses YAML app manifests
- **podman_client.rs**: Wraps Podman API for container management
- **dependency_resolver.rs**: Resolves app dependencies and conflicts
- **health_monitor.rs**: Monitors container health and auto-restarts
### 3. Backend API Extensions
New RPC endpoints in `core/startos/src/container/`:
- `container-install`: Install app from manifest
- `container-start/stop/remove`: Container lifecycle
- `container-status/logs`: Status and debugging
- `container-list`: List all containers
- `container-health`: Health status aggregation
### 4. Vue.js UI Integration
New components in `neode-ui/`:
- **ContainerApps.vue**: List of containerized apps
- **ContainerAppDetails.vue**: Detailed app view with logs
- **ContainerStatus.vue**: Status indicator component
- **container-client.ts**: API client for container operations
- **container.ts**: Pinia store for container state
### 5. App Manifest System
Standardized YAML format in `apps/`:
- Defines container image, resources, dependencies
- Security policies and health checks
- Bitcoin/Lightning/Web5 integration metadata
### 6. Parmanode Compatibility
Located in `core/parmanode/`:
- **script_runner.rs**: Executes Parmanode scripts in containers
- **converter.rs**: Converts Parmanode modules to app manifests
- **parmanode-wrapper.sh**: Shell wrapper for direct script execution
### 7. Security Modules
Located in `core/security/`:
- **container_policies.rs**: Generates AppArmor/SELinux profiles
- **secrets_manager.rs**: Encrypted secrets storage
- **image_verifier.rs**: Cosign signature verification
### 8. Performance Optimization
Located in `core/performance/`:
- **resource_manager.rs**: CPU/memory/disk allocation
- **optimize-alpine.sh**: OS-level optimizations
## App Categories
### Bitcoin & Lightning
- Bitcoin Core (full node)
- LND (Lightning Network Daemon)
- Core Lightning (CLN)
- BTCPay Server
- Mempool (blockchain explorer)
### Web5 & Decentralized Protocols
- Nostr relays (nostr-rs-relay, strfry)
- Web5 DWN (Decentralized Web Node)
- DID Wallet
- Bitcoin Domain Names
### Mesh Networking & Routing
- Meshtastic (LoRa mesh networking)
- Router (mesh routing, device discovery)
- Local network management
### Self-Hosted Services
- Home Assistant
- Grafana
- SearXNG
- OnlyOffice
- Ollama (local AI)
- Penpot
## Security Model
1. **OS Level**: Hardened Alpine, read-only root, minimal kernel
2. **Container Level**: Rootless Podman, capability dropping, network isolation
3. **Secrets**: Encrypted storage, runtime injection only
4. **Supply Chain**: Signed images (Cosign), SBOM generation
5. **Network**: Firewall, rate limiting, Tor integration
6. **Audit**: Immutable logs, configuration tracking
## Networking
- **Isolated Networks**: Each app on separate bridge network by default
- **Bitcoin Core**: Isolated network, explicit RPC access
- **Lightning Nodes**: Separate network, gRPC/REST exposed
- **Tor Integration**: Optional, default for privacy-sensitive apps
- **Mesh Networking**: Meshtastic and router support for decentralized communication
## Data Persistence
- **App Data**: `/var/lib/archipelago/{app-id}/`
- **Secrets**: `/var/lib/archipelago/secrets/{app-id}/` (encrypted)
- **Logs**: `/var/lib/archipelago/logs/{app-id}/`
- **Backups**: `/var/lib/archipelago/backups/`
## Future Enhancements
- Time-travel snapshots (ZFS/BTRFS)
- Decentralized app marketplace (IPFS + Nostr)
- Multi-node clustering
- Hardware attestation (TPM 2.0)
- Protocol-agnostic design (multi-chain support)

2
image-recipe/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
results/
*.deb

View File

@@ -0,0 +1,93 @@
# Alpine Linux Base Image for Archipelago Bitcoin Node OS
# Multi-arch support: ARM64 (Raspberry Pi) and x86_64
ARG ALPINE_VERSION=3.19
FROM alpine:${ALPINE_VERSION}
# Install essential packages
RUN apk add --no-cache \
bash \
curl \
wget \
ca-certificates \
openssl \
sudo \
shadow \
systemd \
systemd-openrc \
dbus \
udev \
util-linux \
e2fsprogs \
dosfstools \
parted \
gptfdisk \
rsync \
git \
vim \
nano \
htop \
iotop \
net-tools \
iproute2 \
iputils \
tcpdump \
tzdata \
logrotate \
fail2ban \
ufw \
&& rm -rf /var/cache/apk/*
# Install Podman and dependencies
RUN apk add --no-cache \
podman \
podman-compose \
crun \
fuse-overlayfs \
slirp4netns \
&& rm -rf /var/cache/apk/*
# Create archipelago user for rootless containers
RUN adduser -D -s /bin/bash archipelago && \
echo "archipelago ALL=(ALL) NOPASSWD: /usr/bin/podman, /usr/bin/podman-compose" >> /etc/sudoers
# Configure Podman for rootless operation
RUN mkdir -p /home/archipelago/.config/containers && \
echo 'driver = "overlay"' > /home/archipelago/.config/containers/storage.conf && \
echo 'rootless_storage_path = "/home/archipelago/.local/share/containers/storage"' >> /home/archipelago/.config/containers/storage.conf
# Set up systemd for container management
RUN systemctl enable systemd-resolved && \
systemctl enable dbus
# Create necessary directories
RUN mkdir -p \
/var/lib/archipelago \
/var/lib/archipelago/apps \
/var/lib/archipelago/secrets \
/var/lib/archipelago/logs \
/var/lib/archipelago/backups \
/etc/archipelago
# Copy hardening scripts
COPY scripts/harden-alpine.sh /usr/local/bin/
COPY scripts/install-podman.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/harden-alpine.sh /usr/local/bin/install-podman.sh
# Run hardening script
RUN /usr/local/bin/harden-alpine.sh
# Set timezone to UTC
RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime
# Configure log rotation
COPY configs/logrotate.conf /etc/logrotate.d/archipelago
# Set up firewall defaults (will be configured on first boot)
RUN ufw --force enable || true
# Expose common ports (will be managed by firewall rules)
EXPOSE 22 80 443 8332 8333 9735 10009 8080 8443
# Default command
CMD ["/bin/bash"]

23
image-recipe/README.md Normal file
View File

@@ -0,0 +1,23 @@
# StartOS Image Recipes
Code and `debos` recipes that are used to create the StartOS live and installer
images.
If you want to build a local image in the exact same environment used to build
official StartOS images, you can use the `run-local-build.sh` helper script:
```bash
# Prerequisites
sudo apt-get install -y debspawn
sudo mkdir -p /etc/debspawn/ && echo "AllowUnsafePermissions=true" | sudo tee /etc/debspawn/global.toml
# Get dpkg
mkdir -p overlays/startos/root
wget -O overlays/startos/root/startos_0.3.x-1_amd64.deb <dpkg_url>
# Build image
./run-local-build.sh
```
In order for the build to work properly, you will need debspawn >= 0.5.1, the
build may fail with prior versions.

41
image-recipe/build-alpine.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Build script for Alpine Linux base image
# Supports multi-arch: ARM64 (aarch64) and x86_64
set -e
ARCH="${ARCH:-$(uname -m)}"
ALPINE_VERSION="${ALPINE_VERSION:-3.19}"
IMAGE_NAME="archipelago/alpine-base"
TAG="${ALPINE_VERSION}-${ARCH}"
echo "🔨 Building Alpine Linux base image for ${ARCH}..."
# Map architecture names
case "$ARCH" in
aarch64|arm64)
BUILD_ARCH="arm64"
PLATFORM="linux/arm64"
;;
x86_64|amd64)
BUILD_ARCH="amd64"
PLATFORM="linux/amd64"
;;
*)
echo "❌ Unsupported architecture: $ARCH"
exit 1
;;
esac
# Build the image
docker buildx build \
--platform "$PLATFORM" \
--file image-recipe/Dockerfile.alpine-base \
--tag "${IMAGE_NAME}:${TAG}" \
--tag "${IMAGE_NAME}:latest-${BUILD_ARCH}" \
--load \
.
echo "✅ Alpine base image built successfully!"
echo " Image: ${IMAGE_NAME}:${TAG}"
echo " Platform: ${PLATFORM}"

355
image-recipe/build.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/bin/bash
set -e
MAX_IMG_SECTORS=7217792 # 4GB
echo "==== StartOS Image Build ===="
echo "Building for architecture: $IB_TARGET_ARCH"
base_dir="$(dirname "$(readlink -f "$0")")"
prep_results_dir="$base_dir/images-prep"
if systemd-detect-virt -qc; then
RESULTS_DIR="/srv/artifacts"
else
RESULTS_DIR="$base_dir/results"
fi
echo "Saving results in: $RESULTS_DIR"
IMAGE_BASENAME=startos-${VERSION_FULL}_${IB_TARGET_PLATFORM}
mkdir -p $prep_results_dir
cd $prep_results_dir
QEMU_ARCH=${IB_TARGET_ARCH}
BOOTLOADERS=grub-efi,syslinux
if [ "$QEMU_ARCH" = 'amd64' ]; then
QEMU_ARCH=x86_64
elif [ "$QEMU_ARCH" = 'arm64' ]; then
QEMU_ARCH=aarch64
BOOTLOADERS=grub-efi
fi
NON_FREE=
if [[ "${IB_TARGET_PLATFORM}" =~ -nonfree$ ]] || [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
NON_FREE=1
fi
IMAGE_TYPE=iso
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ] || [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then
IMAGE_TYPE=img
fi
ARCHIVE_AREAS="main contrib"
if [ "$NON_FREE" = 1 ]; then
if [ "$IB_SUITE" = "bullseye" ]; then
ARCHIVE_AREAS="$ARCHIVE_AREAS non-free"
elif [ "$IB_SUITE" = "bookworm" ]; then
ARCHIVE_AREAS="$ARCHIVE_AREAS non-free-firmware"
fi
fi
PLATFORM_CONFIG_EXTRAS=
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --firmware-binary false"
PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --firmware-chroot false"
# BEGIN stupid ugly hack
# The actual name of the package is `raspberrypi-kernel`
# live-build determines thte name of the package for the kernel by combining the `linux-packages` flag, with the `linux-flavours` flag
# the `linux-flavours` flag defaults to the architecture, so there's no way to remove the suffix.
# So we're doing this, cause thank the gods our package name contains a hypen. Cause if it didn't we'd be SOL
PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --linux-packages raspberrypi"
PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --linux-flavours kernel"
# END stupid ugly hack
elif [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then
PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --linux-flavours rockchip64"
fi
cat > /etc/wgetrc << EOF
retry_connrefused = on
tries = 100
EOF
lb config \
--iso-application "StartOS v${VERSION_FULL} ${IB_TARGET_ARCH}" \
--iso-volume "StartOS v${VERSION} ${IB_TARGET_ARCH}" \
--iso-preparer "START9 LABS; HTTPS://START9.COM" \
--iso-publisher "START9 LABS; HTTPS://START9.COM" \
--backports true \
--bootappend-live "boot=live noautologin" \
--bootloaders $BOOTLOADERS \
--mirror-bootstrap "https://deb.debian.org/debian/" \
--mirror-chroot "https://deb.debian.org/debian/" \
--mirror-chroot-security "https://security.debian.org/debian-security" \
-d ${IB_SUITE} \
-a ${IB_TARGET_ARCH} \
--bootstrap-qemu-arch ${IB_TARGET_ARCH} \
--bootstrap-qemu-static /usr/bin/qemu-${QEMU_ARCH}-static \
--archive-areas "${ARCHIVE_AREAS}" \
$PLATFORM_CONFIG_EXTRAS
# Overlays
mkdir -p config/includes.chroot/deb
cp $base_dir/deb/${IMAGE_BASENAME}.deb config/includes.chroot/deb/
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
cp -r $base_dir/raspberrypi/squashfs/* config/includes.chroot/
fi
mkdir -p config/includes.chroot/etc
echo start > config/includes.chroot/etc/hostname
cat > config/includes.chroot/etc/hosts << EOT
127.0.0.1 localhost start
::1 localhost start ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
EOT
# Bootloaders
rm -rf config/bootloaders
cp -r /usr/share/live/build/bootloaders config/bootloaders
cat > config/bootloaders/syslinux/syslinux.cfg << EOF
include menu.cfg
default vesamenu.c32
prompt 0
timeout 50
EOF
cat > config/bootloaders/isolinux/isolinux.cfg << EOF
include menu.cfg
default vesamenu.c32
prompt 0
timeout 50
EOF
rm config/bootloaders/syslinux_common/splash.svg
cp $base_dir/splash.png config/bootloaders/syslinux_common/splash.png
cp $base_dir/splash.png config/bootloaders/isolinux/splash.png
cp $base_dir/splash.png config/bootloaders/grub-pc/splash.png
sed -i -e '2i set timeout=5' config/bootloaders/grub-pc/config.cfg
# Archives
mkdir -p config/archives
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
curl -fsSL https://archive.raspberrypi.org/debian/raspberrypi.gpg.key | gpg --dearmor -o config/archives/raspi.key
echo "deb https://archive.raspberrypi.org/debian/ bullseye main" > config/archives/raspi.list
fi
if [ "${IB_SUITE}" = "bullseye" ]; then
cat > config/archives/backports.pref <<- EOF
Package: *
Pin: release a=bullseye-backports
Pin-Priority: 500
EOF
fi
if [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then
curl -fsSL https://apt.armbian.com/armbian.key | gpg --dearmor -o config/archives/armbian.key
echo "deb https://apt.armbian.com/ ${IB_SUITE} main" > config/archives/armbian.list
fi
curl -fsSL https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc > config/archives/tor.key
echo "deb [arch=${IB_TARGET_ARCH} signed-by=/etc/apt/trusted.gpg.d/tor.key.gpg] https://deb.torproject.org/torproject.org ${IB_SUITE} main" > config/archives/tor.list
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o config/archives/docker.key
echo "deb [arch=${IB_TARGET_ARCH} signed-by=/etc/apt/trusted.gpg.d/docker.key.gpg] https://download.docker.com/linux/debian ${IB_SUITE} stable" > config/archives/docker.list
echo "deb http://deb.debian.org/debian/ trixie main contrib" > config/archives/trixie.list
cat > config/archives/trixie.pref <<- EOF
Package: *
Pin: release n=trixie
Pin-Priority: 100
Package: podman
Pin: release n=trixie
Pin-Priority: 600
EOF
# Dependencies
## Base dependencies
dpkg-deb --fsys-tarfile $base_dir/deb/${IMAGE_BASENAME}.deb | tar --to-stdout -xvf - ./usr/lib/startos/depends > config/package-lists/embassy-depends.list.chroot
## Firmware
if [ "$NON_FREE" = 1 ]; then
echo 'firmware-iwlwifi firmware-misc-nonfree firmware-brcm80211 firmware-realtek firmware-atheros firmware-libertas firmware-amd-graphics' > config/package-lists/nonfree.list.chroot
fi
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
echo 'raspberrypi-bootloader rpi-update parted' > config/package-lists/bootloader.list.chroot
else
echo 'grub-efi grub2-common' > config/package-lists/bootloader.list.chroot
fi
if [ "${IB_TARGET_ARCH}" = "amd64" ] || [ "${IB_TARGET_ARCH}" = "i386" ]; then
echo 'grub-pc-bin' >> config/package-lists/bootloader.list.chroot
fi
cat > config/hooks/normal/9000-install-startos.hook.chroot << EOF
#!/bin/bash
set -e
apt-get install -y /deb/${IMAGE_BASENAME}.deb
rm -rf /deb
if [ "${IB_SUITE}" = bookworm ]; then
echo 'deb https://deb.debian.org/debian/ bullseye main' > /etc/apt/sources.list.d/bullseye.list
apt-get update
apt-get install -y postgresql-13
rm /etc/apt/sources.list.d/bullseye.list
apt-get update
fi
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
for f in /usr/lib/modules/*; do
v=\${f#/usr/lib/modules/}
echo "Configuring raspi kernel '\$v'"
extract-ikconfig "/usr/lib/modules/\$v/kernel/kernel/configs.ko.xz" > /boot/config-\$v
update-initramfs -c -k \$v
done
ln -sf /usr/bin/pi-beep /usr/local/bin/beep
fi
useradd --shell /bin/bash -G embassy -m start9
echo start9:embassy | chpasswd
usermod -aG sudo start9
echo "start9 ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "/etc/sudoers.d/010_start9-nopasswd"
if [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then
/usr/lib/startos/scripts/enable-kiosk
fi
if ! [[ "${IB_OS_ENV}" =~ (^|-)dev($|-) ]]; then
passwd -l start9
fi
EOF
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(date '+%s')}"
lb bootstrap
lb chroot
lb installer
lb binary_chroot
lb chroot_prep install all mode-apt-install-binary mode-archives-chroot
ln -sf /run/systemd/resolve/stub-resolv.conf chroot/chroot/etc/resolv.conf
lb binary_rootfs
cp $prep_results_dir/binary/live/filesystem.squashfs $RESULTS_DIR/$IMAGE_BASENAME.squashfs
if [ "${IMAGE_TYPE}" = iso ]; then
lb binary_manifest
lb binary_package-lists
lb binary_linux-image
lb binary_memtest
lb binary_grub-legacy
lb binary_grub-pc
lb binary_grub_cfg
lb binary_syslinux
lb binary_disk
lb binary_loadlin
lb binary_win32-loader
lb binary_includes
lb binary_grub-efi
lb binary_hooks
lb binary_checksums
find binary -newermt "$(date -d@${SOURCE_DATE_EPOCH} '+%Y-%m-%d %H:%M:%S')" -printf "%y %p\n" -exec touch '{}' -d@${SOURCE_DATE_EPOCH} --no-dereference ';' > binary.modified_timestamps
lb binary_iso
lb binary_onie
lb binary_netboot
lb binary_tar
lb binary_hdd
lb binary_zsync
lb chroot_prep remove all mode-archives-chroot
lb source
mv $prep_results_dir/live-image-${IB_TARGET_ARCH}.hybrid.iso $RESULTS_DIR/$IMAGE_BASENAME.iso
elif [ "${IMAGE_TYPE}" = img ]; then
function partition_for () {
if [[ "$1" =~ [0-9]+$ ]]; then
echo "$1p$2"
else
echo "$1$2"
fi
}
ROOT_PART_END=$MAX_IMG_SECTORS
TARGET_NAME=$prep_results_dir/${IMAGE_BASENAME}.img
TARGET_SIZE=$[($ROOT_PART_END+1)*512]
truncate -s $TARGET_SIZE $TARGET_NAME
(
echo o
echo x
echo i
echo "0xcb15ae4d"
echo r
echo n
echo p
echo 1
echo 2048
echo 526335
echo t
echo c
echo n
echo p
echo 2
echo 526336
echo $ROOT_PART_END
echo a
echo 1
echo w
) | fdisk $TARGET_NAME
OUTPUT_DEVICE=$(losetup --show -fP $TARGET_NAME)
mkfs.ext4 `partition_for ${OUTPUT_DEVICE} 2`
mkfs.vfat `partition_for ${OUTPUT_DEVICE} 1`
TMPDIR=$(mktemp -d)
mount `partition_for ${OUTPUT_DEVICE} 2` $TMPDIR
mkdir $TMPDIR/boot
mount `partition_for ${OUTPUT_DEVICE} 1` $TMPDIR/boot
unsquashfs -f -d $TMPDIR $prep_results_dir/binary/live/filesystem.squashfs
if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
sed -i 's| boot=embassy| init=/usr/lib/startos/scripts/init_resize\.sh|' $TMPDIR/boot/cmdline.txt
rsync -a $base_dir/raspberrypi/img/ $TMPDIR/
fi
umount $TMPDIR/boot
umount $TMPDIR
e2fsck -fy `partition_for ${OUTPUT_DEVICE} 2`
resize2fs -M `partition_for ${OUTPUT_DEVICE} 2`
BLOCK_COUNT=$(dumpe2fs -h `partition_for ${OUTPUT_DEVICE} 2` | awk '/^Block count:/ { print $3 }')
BLOCK_SIZE=$(dumpe2fs -h `partition_for ${OUTPUT_DEVICE} 2` | awk '/^Block size:/ { print $3 }')
SECTOR_LEN=$[$BLOCK_COUNT*$BLOCK_SIZE/512]
losetup -d $OUTPUT_DEVICE
(
echo d
echo 2
echo n
echo p
echo 2
echo 526336
echo +$SECTOR_LEN
echo w
) | fdisk $TARGET_NAME
ROOT_PART_END=$[526336+$SECTOR_LEN]
TARGET_SIZE=$[($ROOT_PART_END+1)*512]
truncate -s $TARGET_SIZE $TARGET_NAME
mv $TARGET_NAME $RESULTS_DIR/$IMAGE_BASENAME.img
fi

View File

@@ -0,0 +1,24 @@
# Log rotation configuration for Archipelago
/var/log/archipelago/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0644 root root
sharedscripts
postrotate
/usr/bin/systemctl reload archipelago > /dev/null 2>&1 || true
endscript
}
/var/lib/archipelago/logs/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0644 archipelago archipelago
}

24
image-recipe/prepare.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -e
set -x
export DEBIAN_FRONTEND=noninteractive
apt-get install -yq \
live-build \
procps \
systemd \
binfmt-support \
qemu-utils \
qemu-user-static \
qemu-system-x86 \
qemu-system-aarch64 \
xorriso \
isolinux \
ca-certificates \
curl \
gpg \
fdisk \
dosfstools \
e2fsprogs \
squashfs-tools \
rsync

View File

@@ -0,0 +1,2 @@
/dev/mmcblk0p1 /boot vfat umask=0077 0 2
/dev/mmcblk0p2 / ext4 defaults 0 1

View File

@@ -0,0 +1,129 @@
#!/bin/bash
get_variables () {
ROOT_PART_DEV=$(findmnt / -o source -n)
ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3)
ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4)
ROOT_DEV="/dev/${ROOT_DEV_NAME}"
ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition")
BOOT_PART_DEV=$(findmnt /boot -o source -n)
BOOT_PART_NAME=$(echo "$BOOT_PART_DEV" | cut -d "/" -f 3)
BOOT_DEV_NAME=$(echo /sys/block/*/"${BOOT_PART_NAME}" | cut -d "/" -f 4)
BOOT_PART_NUM=$(cat "/sys/block/${BOOT_DEV_NAME}/${BOOT_PART_NAME}/partition")
OLD_DISKID=$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p')
ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size")
if [ "$ROOT_DEV_SIZE" -le 67108864 ]; then
TARGET_END=$((ROOT_DEV_SIZE - 1))
else
TARGET_END=$((33554432 - 1))
DATA_PART_START=33554432
DATA_PART_END=$((ROOT_DEV_SIZE - 1))
fi
PARTITION_TABLE=$(parted -m "$ROOT_DEV" unit s print | tr -d 's')
LAST_PART_NUM=$(echo "$PARTITION_TABLE" | tail -n 1 | cut -d ":" -f 1)
ROOT_PART_LINE=$(echo "$PARTITION_TABLE" | grep -e "^${ROOT_PART_NUM}:")
ROOT_PART_START=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 2)
ROOT_PART_END=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 3)
}
check_variables () {
if [ "$BOOT_DEV_NAME" != "$ROOT_DEV_NAME" ]; then
FAIL_REASON="Boot and root partitions are on different devices"
return 1
fi
if [ "$ROOT_PART_NUM" -ne "$LAST_PART_NUM" ]; then
FAIL_REASON="Root partition should be last partition"
return 1
fi
if [ "$ROOT_PART_END" -gt "$TARGET_END" ]; then
FAIL_REASON="Root partition runs past the end of device"
return 1
fi
if [ ! -b "$ROOT_DEV" ] || [ ! -b "$ROOT_PART_DEV" ] || [ ! -b "$BOOT_PART_DEV" ] ; then
FAIL_REASON="Could not determine partitions"
return 1
fi
}
main () {
get_variables
if ! check_variables; then
return 1
fi
# if [ "$ROOT_PART_END" -eq "$TARGET_END" ]; then
# reboot_pi
# fi
if ! echo Yes | parted -m --align=optimal "$ROOT_DEV" ---pretend-input-tty u s resizepart "$ROOT_PART_NUM" "$TARGET_END" ; then
FAIL_REASON="Root partition resize failed"
return 1
fi
if [ -n "$DATA_PART_START" ]; then
if ! parted -ms --align=optimal "$ROOT_DEV" u s mkpart primary "$DATA_PART_START" "$DATA_PART_END"; then
FAIL_REASON="Data partition creation failed"
return 1
fi
fi
(
echo x
echo i
echo "0xcb15ae4d"
echo r
echo w
) | fdisk $ROOT_DEV
mount / -o remount,rw
resize2fs $ROOT_PART_DEV
if ! systemd-machine-id-setup; then
FAIL_REASON="systemd-machine-id-setup failed"
return 1
fi
if ! ssh-keygen -A; then
FAIL_REASON="ssh host key generation failed"
return 1
fi
echo start > /etc/hostname
return 0
}
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t tmpfs tmp /run
mkdir -p /run/systemd
mount /boot
mount / -o remount,ro
beep
if main; then
sed -i 's| init=/usr/lib/startos/scripts/init_resize\.sh| boot=embassy|' /boot/cmdline.txt
echo "Resized root filesystem. Rebooting in 5 seconds..."
sleep 5
else
echo -e "Could not expand filesystem.\n${FAIL_REASON}"
sleep 5
fi
sync
umount /boot
reboot -f

View File

@@ -0,0 +1 @@
usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u console=serial0,115200 console=tty1 root=PARTUUID=cb15ae4d-02 rootfstype=ext4 fsck.repair=yes rootwait cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory quiet boot=embassy

View File

@@ -0,0 +1,86 @@
# For more options and information see
# http://rpf.io/configtxt
# Some settings may impact device functionality. See link above for details
# uncomment if you get no picture on HDMI for a default "safe" mode
#hdmi_safe=1
# uncomment the following to adjust overscan. Use positive numbers if console
# goes off screen, and negative if there is too much border
#overscan_left=16
#overscan_right=16
#overscan_top=16
#overscan_bottom=16
# uncomment to force a console size. By default it will be display's size minus
# overscan.
#framebuffer_width=1280
#framebuffer_height=720
# uncomment if hdmi display is not detected and composite is being output
#hdmi_force_hotplug=1
# uncomment to force a specific HDMI mode (this will force VGA)
#hdmi_group=1
#hdmi_mode=1
# uncomment to force a HDMI mode rather than DVI. This can make audio work in
# DMT (computer monitor) modes
#hdmi_drive=2
# uncomment to increase signal to HDMI, if you have interference, blanking, or
# no display
#config_hdmi_boost=4
# uncomment for composite PAL
#sdtv_mode=2
#uncomment to overclock the arm. 700 MHz is the default.
#arm_freq=800
# Uncomment some or all of these to enable the optional hardware interfaces
#dtparam=i2c_arm=on
#dtparam=i2s=on
#dtparam=spi=on
# Uncomment this to enable infrared communication.
#dtoverlay=gpio-ir,gpio_pin=17
#dtoverlay=gpio-ir-tx,gpio_pin=18
# Additional overlays and parameters are documented /boot/overlays/README
# Enable audio (loads snd_bcm2835)
dtparam=audio=on
# Automatically load overlays for detected cameras
camera_auto_detect=1
# Automatically load overlays for detected DSI displays
display_auto_detect=1
# Enable DRM VC4 V3D driver
dtoverlay=vc4-kms-v3d
max_framebuffers=2
# Run in 64-bit mode
arm_64bit=1
# Disable compensation for displays with overscan
disable_overscan=1
[cm4]
# Enable host mode on the 2711 built-in XHCI USB controller.
# This line should be removed if the legacy DWC2 controller is required
# (e.g. for USB device mode) or if USB support is not required.
otg_mode=1
[all]
[pi4]
# Run as fast as firmware / board allows
arm_boost=1
[all]
gpu_mem=16
dtoverlay=pwm-2chan,disable-bt
initramfs initrd.img-6.1.21-v8+

View File

@@ -0,0 +1,6 @@
os-partitions:
boot: /dev/mmcblk0p1
root: /dev/mmcblk0p2
ethernet-interface: end0
wifi-interface: wlan0
disable-encryption: true

View File

@@ -0,0 +1 @@
options cfg80211 ieee80211_regdom=US

View File

@@ -0,0 +1,69 @@
#!/bin/sh
# ----------------------------------------------------------------------
# extract-ikconfig - Extract the .config file from a kernel image
#
# This will only work when the kernel was compiled with CONFIG_IKCONFIG.
#
# The obscure use of the "tr" filter is to work around older versions of
# "grep" that report the byte offset of the line instead of the pattern.
#
# (c) 2009,2010 Dick Streefland <dick@streefland.net>
# Licensed under the terms of the GNU General Public License.
# ----------------------------------------------------------------------
cf1='IKCFG_ST\037\213\010'
cf2='0123456789'
dump_config()
{
if pos=`tr "$cf1\n$cf2" "\n$cf2=" < "$1" | grep -abo "^$cf2"`
then
pos=${pos%%:*}
tail -c+$(($pos+8)) "$1" | zcat > $tmp1 2> /dev/null
if [ $? != 1 ]
then # exit status must be 0 or 2 (trailing garbage warning)
cat $tmp1
exit 0
fi
fi
}
try_decompress()
{
for pos in `tr "$1\n$2" "\n$2=" < "$img" | grep -abo "^$2"`
do
pos=${pos%%:*}
tail -c+$pos "$img" | $3 > $tmp2 2> /dev/null
dump_config $tmp2
done
}
# Check invocation:
me=${0##*/}
img=$1
if [ $# -ne 1 -o ! -s "$img" ]
then
echo "Usage: $me <kernel-image>" >&2
exit 2
fi
# Prepare temp files:
tmp1=/tmp/ikconfig$$.1
tmp2=/tmp/ikconfig$$.2
trap "rm -f $tmp1 $tmp2" 0
# Initial attempt for uncompressed images or objects:
dump_config "$img"
# That didn't work, so retry after decompression.
try_decompress '\037\213\010' xy gunzip
try_decompress '\3757zXZ\000' abcde unxz
try_decompress 'BZh' xy bunzip2
try_decompress '\135\0\0\0' xxx unlzma
try_decompress '\211\114\132' xy 'lzop -d'
try_decompress '\002\041\114\030' xyy 'lz4 -d -l'
try_decompress '\050\265\057\375' xxx unzstd
# Bail out:
echo "$me: Cannot find kernel config." >&2
exit 1

88
image-recipe/run-local-build.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
set -e
DEB_PATH="$(realpath $1)"
cd "$(dirname "${BASH_SOURCE[0]}")"/..
BASEDIR="$(pwd -P)"
VERSION="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/VERSION.txt)"
GIT_HASH="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/GIT_HASH.txt)"
if [[ "$GIT_HASH" =~ ^@ ]]; then
GIT_HASH="unknown"
else
GIT_HASH="$(echo -n "$GIT_HASH" | head -c 7)"
fi
STARTOS_ENV="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/ENVIRONMENT.txt)"
PLATFORM="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/PLATFORM.txt)"
if [ -z "$1" ]; then
PLATFORM="$(uname -m)"
fi
if [ "$PLATFORM" = "x86_64" ] || [ "$PLATFORM" = "x86_64-nonfree" ]; then
ARCH=amd64
QEMU_ARCH=x86_64
elif [ "$PLATFORM" = "aarch64" ] || [ "$PLATFORM" = "aarch64-nonfree" ] || [ "$PLATFORM" = "raspberrypi" ] || [ "$PLATFORM" = "rockchip64" ]; then
ARCH=arm64
QEMU_ARCH=aarch64
else
ARCH="$PLATFORM"
QEMU_ARCH="$PLATFORM"
fi
SUITE=bookworm
debspawn list | grep $SUITE || debspawn create $SUITE
VERSION_FULL="${VERSION}-${GIT_HASH}"
if [ -n "$STARTOS_ENV" ]; then
VERSION_FULL="$VERSION_FULL~${STARTOS_ENV}"
fi
if [ -z "$DSNAME" ]; then
DSNAME="$SUITE"
fi
if [ "$QEMU_ARCH" != "$(uname -m)" ]; then
sudo update-binfmts --import qemu-$QEMU_ARCH
fi
imgbuild_fname="$(mktemp /tmp/exec-mkimage.XXXXXX)"
cat > $imgbuild_fname <<END
#!/bin/sh
export IB_SUITE=${SUITE}
export IB_TARGET_ARCH=${ARCH}
export IB_TARGET_PLATFORM=${PLATFORM}
export IB_OS_ENV=${STARTOS_ENV}
export VERSION=${VERSION}
export VERSION_FULL=${VERSION_FULL}
exec ./build.sh
END
prepare_hash=$(sha1sum ${BASEDIR}/image-recipe/prepare.sh | head -c 7)
mkdir -p ${BASEDIR}/image-recipe/deb
cp $DEB_PATH ${BASEDIR}/image-recipe/deb/
mkdir -p ${BASEDIR}/results
set +e
debspawn run \
-x \
--allow=read-kmods,kvm,full-dev \
--cachekey="${SUITE}-${prepare_hash}-mkimage" \
--init-command="${BASEDIR}/image-recipe/prepare.sh" \
--build-dir="${BASEDIR}/image-recipe" \
--artifacts-out="${BASEDIR}/results" \
--header="StartOS Image Build" \
--suite=${SUITE} \
${DSNAME} \
${imgbuild_fname}
retval=$?
rm $imgbuild_fname
if [ $retval -ne 0 ]; then
exit $retval
fi
exit 0

View File

@@ -0,0 +1,118 @@
#!/bin/bash
# Alpine Linux Hardening Script for Archipelago Bitcoin Node OS
# This script applies security hardening to the Alpine base image
set -e
echo "🔒 Starting Alpine Linux hardening..."
# Disable unnecessary services
systemctl disable bluetooth || true
systemctl disable avahi-daemon || true
# Configure kernel parameters for security
cat >> /etc/sysctl.conf <<EOF
# Archipelago Security Hardening
# Disable IP forwarding
net.ipv4.ip_forward = 0
net.ipv6.conf.all.forwarding = 0
# Enable SYN flood protection
net.ipv4.tcp_syncookies = 1
# Disable source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
# Disable send redirects
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Log martian packets
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# Ignore ICMP ping requests (can be enabled if needed)
# net.ipv4.icmp_echo_ignore_all = 1
# Ignore ICMP ping broadcasts
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Ignore bogus ICMP errors
net.ipv4.icmp_ignore_bogus_error_responses = 1
# Enable RFC-recommended source validation
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Disable IPv6 if not needed (uncomment if IPv6 not required)
# net.ipv6.conf.all.disable_ipv6 = 1
# net.ipv6.conf.default.disable_ipv6 = 1
EOF
# Configure SSH (if installed)
if [ -f /etc/ssh/sshd_config ]; then
sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config || true
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true
sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
fi
# Set up fail2ban basic configuration
if [ -f /etc/fail2ban/jail.conf ]; then
cat > /etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
destemail = root@localhost
sendername = Fail2Ban
action = %(action_)s
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
EOF
fi
# Configure automatic security updates
cat > /etc/periodic/daily/archipelago-security-updates <<'EOF'
#!/bin/sh
# Automatic security updates for Archipelago
apk update && apk upgrade -u || true
EOF
chmod +x /etc/periodic/daily/archipelago-security-updates
# Set restrictive file permissions
chmod 700 /var/lib/archipelago/secrets
chmod 755 /var/lib/archipelago/apps
chmod 755 /var/lib/archipelago/logs
# Create log directory with proper permissions
mkdir -p /var/log/archipelago
chmod 755 /var/log/archipelago
# Configure log rotation for archipelago logs
cat > /etc/logrotate.d/archipelago <<EOF
/var/log/archipelago/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0644 root root
}
EOF
echo "✅ Alpine Linux hardening complete!"

View File

@@ -0,0 +1,59 @@
#!/bin/bash
# Podman Installation and Configuration Script for Archipelago
# Configures Podman for rootless operation
set -e
echo "🐳 Configuring Podman for rootless operation..."
# Ensure archipelago user exists
if ! id "archipelago" &>/dev/null; then
echo "Creating archipelago user..."
adduser -D -s /bin/bash archipelago
fi
# Create Podman configuration directories
mkdir -p /home/archipelago/.config/containers
mkdir -p /home/archipelago/.local/share/containers/storage
# Configure storage
cat > /home/archipelago/.config/containers/storage.conf <<EOF
[storage]
driver = "overlay"
runroot = "/run/user/$(id -u archipelago)/containers"
graphroot = "/home/archipelago/.local/share/containers/storage"
EOF
# Configure registries (use Docker Hub and quay.io)
mkdir -p /home/archipelago/.config/containers/registries.conf.d
cat > /home/archipelago/.config/containers/registries.conf.d/000-shortnames.conf <<EOF
[registries.search]
registries = ['docker.io', 'quay.io', 'ghcr.io']
[registries.insecure]
registries = []
[registries.block]
registries = []
EOF
# Set up subuid and subgid for rootless containers
if ! grep -q "^archipelago:" /etc/subuid; then
echo "archipelago:100000:65536" >> /etc/subuid
fi
if ! grep -q "^archipelago:" /etc/subgid; then
echo "archipelago:100000:65536" >> /etc/subgid
fi
# Create systemd user service directory
mkdir -p /home/archipelago/.config/systemd/user
# Enable lingering for archipelago user (allows user services to run without login)
loginctl enable-linger archipelago || true
# Set proper permissions
chown -R archipelago:archipelago /home/archipelago/.config
chown -R archipelago:archipelago /home/archipelago/.local
echo "✅ Podman configuration complete!"

BIN
image-recipe/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,103 @@
// Container management API client
// Extends RPC client with container-specific methods
import { rpcClient } from './rpc-client'
export interface ContainerStatus {
id: string
name: string
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
image: string
created: string
ports: string[]
}
export interface ContainerAppInfo {
id: string
name: string
version: string
status: ContainerStatus
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
export const containerClient = {
/**
* Install a container app from a manifest file
*/
async installApp(manifestPath: string): Promise<string> {
return rpcClient.call<string>({
method: 'container-install',
params: { manifest_path: manifestPath },
})
},
/**
* Start a container
*/
async startContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-start',
params: { app_id: appId },
})
},
/**
* Stop a container
*/
async stopContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-stop',
params: { app_id: appId },
})
},
/**
* Remove a container
*/
async removeContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-remove',
params: { app_id: appId },
})
},
/**
* Get container status
*/
async getContainerStatus(appId: string): Promise<ContainerStatus> {
return rpcClient.call<ContainerStatus>({
method: 'container-status',
params: { app_id: appId },
})
},
/**
* Get container logs
*/
async getContainerLogs(appId: string, lines: number = 100): Promise<string[]> {
return rpcClient.call<string[]>({
method: 'container-logs',
params: { app_id: appId, lines },
})
},
/**
* List all containers
*/
async listContainers(): Promise<ContainerStatus[]> {
return rpcClient.call<ContainerStatus[]>({
method: 'container-list',
params: {},
})
},
/**
* Get health status for all containers
*/
async getHealthStatus(): Promise<Record<string, 'healthy' | 'unhealthy' | 'unknown' | 'starting'>> {
return rpcClient.call<Record<string, string>>({
method: 'container-health',
params: {},
})
},
}

View File

@@ -0,0 +1,116 @@
<template>
<div class="flex items-center gap-2">
<!-- Status Indicator -->
<div class="relative">
<div
class="w-3 h-3 rounded-full transition-colors"
:class="statusClass"
></div>
<div
v-if="isRunning"
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
:class="statusClass"
></div>
</div>
<!-- Status Text -->
<span class="text-sm font-medium" :class="textClass">
{{ statusText }}
</span>
<!-- Health Badge (if running) -->
<span
v-if="isRunning && health"
class="px-2 py-0.5 rounded text-xs font-medium"
:class="healthClass"
>
{{ healthText }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
const props = withDefaults(defineProps<Props>(), {
health: 'unknown',
})
const isRunning = computed(() => props.state === 'running')
const statusClass = computed(() => {
switch (props.state) {
case 'running':
return 'bg-green-400'
case 'stopped':
case 'exited':
return 'bg-gray-400'
case 'paused':
return 'bg-yellow-400'
default:
return 'bg-red-400'
}
})
const textClass = computed(() => {
switch (props.state) {
case 'running':
return 'text-green-400'
case 'stopped':
case 'exited':
return 'text-gray-400'
case 'paused':
return 'text-yellow-400'
default:
return 'text-red-400'
}
})
const statusText = computed(() => {
switch (props.state) {
case 'running':
return 'Running'
case 'stopped':
return 'Stopped'
case 'exited':
return 'Exited'
case 'paused':
return 'Paused'
case 'created':
return 'Created'
default:
return 'Unknown'
}
})
const healthClass = computed(() => {
switch (props.health) {
case 'healthy':
return 'bg-green-500/20 text-green-400 border border-green-500/30'
case 'unhealthy':
return 'bg-red-500/20 text-red-400 border border-red-500/30'
case 'starting':
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
default:
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
}
})
const healthText = computed(() => {
switch (props.health) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'starting':
return 'Starting'
default:
return 'Unknown'
}
})
</script>

View File

@@ -0,0 +1,139 @@
// Pinia store for container management
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { containerClient, type ContainerStatus, type ContainerAppInfo } from '@/api/container-client'
export const useContainerStore = defineStore('container', () => {
// State
const containers = ref<ContainerStatus[]>([])
const healthStatus = ref<Record<string, string>>({})
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const runningContainers = computed(() =>
containers.value.filter(c => c.state === 'running')
)
const stoppedContainers = computed(() =>
containers.value.filter(c => c.state === 'stopped' || c.state === 'exited')
)
const getContainerById = computed(() => (id: string) =>
containers.value.find(c => c.name.includes(id))
)
const getHealthStatus = computed(() => (appId: string) =>
healthStatus.value[appId] || 'unknown'
)
// Actions
async function fetchContainers() {
loading.value = true
error.value = null
try {
containers.value = await containerClient.listContainers()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch containers'
console.error('Failed to fetch containers:', e)
} finally {
loading.value = false
}
}
async function fetchHealthStatus() {
try {
healthStatus.value = await containerClient.getHealthStatus()
} catch (e) {
console.error('Failed to fetch health status:', e)
}
}
async function installApp(manifestPath: string) {
loading.value = true
error.value = null
try {
const containerName = await containerClient.installApp(manifestPath)
await fetchContainers()
return containerName
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to install app'
throw e
} finally {
loading.value = false
}
}
async function startContainer(appId: string) {
loading.value = true
error.value = null
try {
await containerClient.startContainer(appId)
await fetchContainers()
await fetchHealthStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start container'
throw e
} finally {
loading.value = false
}
}
async function stopContainer(appId: string) {
loading.value = true
error.value = null
try {
await containerClient.stopContainer(appId)
await fetchContainers()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to stop container'
throw e
} finally {
loading.value = false
}
}
async function removeContainer(appId: string) {
loading.value = true
error.value = null
try {
await containerClient.removeContainer(appId)
await fetchContainers()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to remove container'
throw e
} finally {
loading.value = false
}
}
async function getContainerLogs(appId: string, lines: number = 100) {
try {
return await containerClient.getContainerLogs(appId, lines)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to get logs'
throw e
}
}
return {
// State
containers,
healthStatus,
loading,
error,
// Getters
runningContainers,
stoppedContainers,
getContainerById,
getHealthStatus,
// Actions
fetchContainers,
fetchHealthStatus,
installApp,
startContainer,
stopContainer,
removeContainer,
getContainerLogs,
}
})

View File

@@ -0,0 +1,260 @@
<template>
<div class="p-6">
<div class="mb-6">
<button
@click="$router.back()"
class="mb-4 flex items-center gap-2 text-white/70 hover:text-white transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
<p class="text-white/70">Container details and management</p>
</div>
<ContainerStatus
v-if="container"
:state="container.state as any"
:health="healthStatus as any"
/>
</div>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
</div>
<div v-else-if="error" class="glass-card p-6">
<div class="flex items-center gap-3 text-red-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
</div>
<div v-else-if="container" class="space-y-6">
<!-- Container Info Card -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span class="text-sm text-white/60">Container ID</span>
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
</div>
<div>
<span class="text-sm text-white/60">Image</span>
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
</div>
<div>
<span class="text-sm text-white/60">State</span>
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
</div>
<div>
<span class="text-sm text-white/60">Created</span>
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
</div>
</div>
</div>
<!-- Actions Card -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-4">Actions</h2>
<div class="flex gap-4">
<button
v-if="container.state !== 'running'"
@click="handleStart"
:disabled="actionLoading"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Start Container
</button>
<button
v-else
@click="handleStop"
:disabled="actionLoading"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Stop Container
</button>
<button
@click="handleRestart"
:disabled="actionLoading || container.state !== 'running'"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Restart
</button>
<button
@click="handleRemove"
:disabled="actionLoading"
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
>
Remove
</button>
</div>
</div>
<!-- Logs Card -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white">Logs</h2>
<button
@click="refreshLogs"
:disabled="logsLoading"
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Refresh
</button>
</div>
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
<div v-if="logsLoading" class="text-center py-4 text-white/60">
Loading logs...
</div>
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
No logs available
</div>
<div v-else>
<div v-for="(log, index) in logs" :key="index" class="mb-1">
{{ log }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useContainerStore } from '@/stores/container'
import ContainerStatus from '@/components/ContainerStatus.vue'
const route = useRoute()
const router = useRouter()
const store = useContainerStore()
const appId = computed(() => route.params.id as string)
const appName = computed(() => {
return appId.value
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
})
const container = ref<any>(null)
const logs = ref<string[]>([])
const loading = ref(false)
const logsLoading = ref(false)
const actionLoading = ref(false)
const error = ref<string | null>(null)
const healthStatus = ref<string>('unknown')
onMounted(async () => {
await loadContainer()
await loadLogs()
await loadHealthStatus()
})
async function loadContainer() {
loading.value = true
error.value = null
try {
const status = await store.getContainerStatus(appId.value)
container.value = status
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load container'
} finally {
loading.value = false
}
}
async function loadLogs() {
logsLoading.value = true
try {
logs.value = await store.getContainerLogs(appId.value, 100)
} catch (e) {
console.error('Failed to load logs:', e)
} finally {
logsLoading.value = false
}
}
async function loadHealthStatus() {
await store.fetchHealthStatus()
healthStatus.value = store.getHealthStatus(appId.value)
}
async function refreshLogs() {
await loadLogs()
}
async function handleStart() {
actionLoading.value = true
try {
await store.startContainer(appId.value)
await loadContainer()
await loadHealthStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start container'
} finally {
actionLoading.value = false
}
}
async function handleStop() {
actionLoading.value = true
try {
await store.stopContainer(appId.value)
await loadContainer()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to stop container'
} finally {
actionLoading.value = false
}
}
async function handleRestart() {
actionLoading.value = true
try {
await store.stopContainer(appId.value)
await new Promise(resolve => setTimeout(resolve, 1000))
await store.startContainer(appId.value)
await loadContainer()
await loadHealthStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to restart container'
} finally {
actionLoading.value = false
}
}
async function handleRemove() {
if (!confirm(`Are you sure you want to remove ${appName.value}? This will delete the container and all its data.`)) {
return
}
actionLoading.value = true
try {
await store.removeContainer(appId.value)
router.push('/dashboard/containers')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to remove container'
} finally {
actionLoading.value = false
}
}
function formatDate(dateString: string): string {
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<div class="p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Container Apps</h1>
<p class="text-white/70">Manage containerized applications running on your Archipelago node</p>
</div>
<!-- Loading State -->
<div v-if="store.loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
</div>
<!-- Error State -->
<div v-else-if="store.error" class="glass-card p-6 mb-6">
<div class="flex items-center gap-3 text-red-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ store.error }}</span>
</div>
</div>
<!-- Container List -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="container in store.containers"
:key="container.id"
class="glass-card p-6 hover:bg-white/5 transition-colors cursor-pointer"
@click="$router.push(`/dashboard/containers/${extractAppId(container.name)}`)"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
{{ extractAppName(container.name) }}
</h3>
<p class="text-sm text-white/60">{{ container.image }}</p>
</div>
<ContainerStatus
:state="container.state as any"
:health="store.getHealthStatus(extractAppId(container.name)) as any"
/>
</div>
<div class="space-y-2 mb-4">
<div class="flex items-center justify-between text-sm">
<span class="text-white/60">Container ID</span>
<span class="text-white/80 font-mono text-xs">{{ container.id.substring(0, 12) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-white/60">Created</span>
<span class="text-white/80 text-xs">{{ formatDate(container.created) }}</span>
</div>
</div>
<div class="flex gap-2">
<button
v-if="container.state !== 'running'"
@click.stop="handleStart(extractAppId(container.name))"
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
>
Start
</button>
<button
v-else
@click.stop="handleStop(extractAppId(container.name))"
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
>
Stop
</button>
<button
@click.stop="handleRemove(extractAppId(container.name))"
class="px-4 py-2 glass-button rounded text-sm font-medium text-red-400/90 hover:text-red-400 transition-colors"
>
Remove
</button>
</div>
</div>
<!-- Empty State -->
<div v-if="store.containers.length === 0" class="col-span-full glass-card p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 class="text-xl font-semibold text-white mb-2">No containers found</h3>
<p class="text-white/60 mb-6">Install your first container app to get started</p>
<button
@click="$router.push('/dashboard/marketplace')"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors"
>
Browse Marketplace
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useContainerStore } from '@/stores/container'
import ContainerStatus from '@/components/ContainerStatus.vue'
const store = useContainerStore()
onMounted(async () => {
await store.fetchContainers()
await store.fetchHealthStatus()
// Refresh every 30 seconds
setInterval(async () => {
await store.fetchContainers()
await store.fetchHealthStatus()
}, 30000)
})
function extractAppId(containerName: string): string {
// Extract app ID from container name like "archipelago-bitcoin-core"
return containerName.replace('archipelago-', '')
}
function extractAppName(containerName: string): string {
const appId = extractAppId(containerName)
// Convert kebab-case to Title Case
return appId
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function formatDate(dateString: string): string {
try {
const date = new Date(dateString)
return date.toLocaleDateString()
} catch {
return dateString
}
}
async function handleStart(appId: string) {
try {
await store.startContainer(appId)
} catch (e) {
console.error('Failed to start container:', e)
}
}
async function handleStop(appId: string) {
try {
await store.stopContainer(appId)
} catch (e) {
console.error('Failed to stop container:', e)
}
}
async function handleRemove(appId: string) {
if (!confirm(`Are you sure you want to remove ${appId}? This will delete the container.`)) {
return
}
try {
await store.removeContainer(appId)
} catch (e) {
console.error('Failed to remove container:', e)
}
}
</script>

54
scripts/optimize-alpine.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Alpine Linux optimization script for Archipelago
# Optimizes system settings for container workloads
set -e
echo "⚡ Optimizing Alpine Linux for container workloads..."
# CPU Governor - set to performance for better container performance
if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then
echo "performance" > /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null || true
fi
# I/O Scheduler - use deadline or none for SSDs
if command -v lsblk >/dev/null 2>&1; then
for disk in $(lsblk -d -o NAME -n); do
if [ -f "/sys/block/$disk/queue/scheduler" ]; then
# Prefer none (for NVMe) or deadline (for SATA SSD)
if grep -q "none" "/sys/block/$disk/queue/scheduler"; then
echo none > "/sys/block/$disk/queue/scheduler" 2>/dev/null || true
elif grep -q "deadline" "/sys/block/$disk/queue/scheduler"; then
echo deadline > "/sys/block/$disk/queue/scheduler" 2>/dev/null || true
fi
fi
done
fi
# Increase file descriptor limits
cat >> /etc/security/limits.conf <<EOF
* soft nofile 65536
* hard nofile 65536
root soft nofile 65536
root hard nofile 65536
EOF
# Optimize network settings for container networking
cat >> /etc/sysctl.conf <<EOF
# Container networking optimizations
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.core.netdev_max_backlog = 5000
net.ipv4.ip_local_port_range = 1024 65535
# Container storage optimizations
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
EOF
# Apply sysctl settings
sysctl -p >/dev/null 2>&1 || true
echo "✅ Alpine optimization complete!"

38
scripts/parmanode-wrapper.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Parmanode compatibility wrapper
# Allows running Parmanode scripts directly while wrapping them in container isolation
set -e
SCRIPT_PATH="$1"
MODULE_NAME="${2:-$(basename "$SCRIPT_PATH" .sh)}"
if [ -z "$SCRIPT_PATH" ]; then
echo "Usage: $0 <script-path> [module-name]"
exit 1
fi
if [ ! -f "$SCRIPT_PATH" ]; then
echo "Error: Script not found: $SCRIPT_PATH"
exit 1
fi
echo "🔧 Running Parmanode script in container: $SCRIPT_PATH"
# Create temporary container to run the script
CONTAINER_NAME="parmanode-${MODULE_NAME}-$$"
# Run script in Alpine container with necessary volumes
podman run --rm \
--name "$CONTAINER_NAME" \
--volume "$SCRIPT_PATH:/script.sh:ro" \
--volume "/var/lib/archipelago:/data:rw" \
--network host \
alpine:latest \
sh -c "
apk add --no-cache bash curl wget || true
chmod +x /script.sh
/script.sh
"
echo "✅ Parmanode script completed"